From 8a94c978739bcd39bf5853e12e761136ce86ea3d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 12 Sep 2026 07:07:48 -0700 Subject: [PATCH 001/148] feat(bridge): recognize the XBRIDGE action and its escrow roles Re-vendors the shared coin bundles with the bridge escrow addresses and the XBRIDGE gas entry. --- src/coins/BTC.js | 40 ++++++++++++++++++++++++++++++ src/coins/DOGE.js | 23 +++++++++++++++++ src/coins/LTC.js | 22 ++++++++++++++++ src/coins/consensus_pin.js | 24 +++++++++++++----- src/validator.js | 6 ++++- test/fixtures/action-manifest.json | 13 ++++++++-- 6 files changed, 119 insertions(+), 9 deletions(-) diff --git a/src/coins/BTC.js b/src/coins/BTC.js index caf3614..fcfbe95 100644 --- a/src/coins/BTC.js +++ b/src/coins/BTC.js @@ -123,6 +123,28 @@ module.exports = { DONATE2: '1Donate2LkbBrsanwCVRPWZCXAqQcvcqGz', // Community Development FEE_DESTINATION: '1FeesxM9LTEjBYVTkynK6jfDBgvksuh2WL', // native-fee destination (regtest-only env override; ignored on mainnet/testnet) REWARD: '1RewardsRQTXMAytLt4bBQvPEscKsSEXt', // validator reward pool (COLLECT) + // Cross-chain bridge escrow, one per DESTINATION coin. An XBRIDGE lock + // credits the balance here and the destination chain mints against it, so + // this address IS the backing for every unit of that asset on the other + // chain. Keyless by construction, exactly like BURN above, which is what + // makes the escrow unspendable without a bespoke table. + // + // The readable text lives in the base58 STRING ONLY, never in the decoded + // bytes. Measured 2026-09-12 by base58check-decoding the shipped literals: + // '17BridgeLtcXChainXXXXXXXXXXa5uRRy' is version byte 0x00 plus hash160 + // 012b8f14947cc310298e6b49b68ea24e2bbdcbc0, checksum valid, and those 20 + // bytes are not ASCII; BURN above decodes the same way, to hash160 + // 05b63ec8f5f45c95801e38f4fd8305e57b75c151. The trailing X run is padding + // that carries the string out to a 25-byte payload and lets the last few + // characters land a valid checksum. + // + // What actually makes them unspendable: the hash160 is whatever the chosen + // string happened to decode to, so no key ever produced it. + // Paying out of one needs a public key K with RIPEMD160(SHA256(K)) equal to + // those exact 20 bytes, which is a 160-bit preimage search on HASH160, not a + // lost-key problem. Pinned by xchain-indexer/test/unit/bridgeEscrowKeylessness.test.js. + BRIDGE_LTC: '17BridgeLtcXChainXXXXXXXXXXa5uRRy', + BRIDGE_DOGE: '17BridgeDogeXChainXXXXXXXXXVuqXcv', EXPLORER: '1Donate3GBGSZzzrS9U9gUgURYKscAE6Yn', // display-only donation; not read by indexer }, // Genesis ledger bootstrap pin (Counterparty name carry-forward). @@ -185,6 +207,11 @@ module.exports = { DONATE2: 'myBbbZ4t7BPoyNcT4sHtFwZDuiyYGDXLQM', FEE_DESTINATION: 'mfees5QurRs5BHXofdGpTG5pXB6uC6R8RU', REWARD: 'mrewards4RQFYoZ5yEv4xr12PzfjDYViks', + // Bridge escrow, see mainnet above. testnet and regtest share the + // literal because they share the pubKeyHash byte, the same way BURN + // already does; the network is what separates the two ledgers. + BRIDGE_LTC: 'mfbtcbridgeLtcXXXXXXXXXXXXXXVPqpoV', + BRIDGE_DOGE: 'mfbtcbridgedogeXXXXXXXXXXXXXUXTr4m', EXPLORER: 'n1jbLKMrhvFae7NwTj37ZtkN4uPy29o9aM', }, // Testnet launches CLEAN (genesis disabled, like LTC); namespace open. @@ -230,6 +257,9 @@ module.exports = { DONATE2: 'mkQd27aJSqsQ666z1Q4MLFmd3Ybqzy3TNw', FEE_DESTINATION: 'mfeesX6rLE6V3WPg9tsbL2fHNS7E4rDAim', REWARD: 'mrewardshQqD1ptkEBZGjPDF77L5uKJQmk', + // Bridge escrow, the testnet literals (same pubKeyHash byte). + BRIDGE_LTC: 'mfbtcbridgeLtcXXXXXXXXXXXXXXVPqpoV', + BRIDGE_DOGE: 'mfbtcbridgedogeXXXXXXXXXXXXXUXTr4m', EXPLORER: 'mrDH7rA2ZmGoh4Qx5guhDBJbZotUd6XyVH', }, // Regtest genesis is env-driven so the e2e harness can point it at a @@ -304,6 +334,16 @@ module.exports = { SWEEP_PER_ITEM: 100, CALLBACK_BASE: 5000, CALLBACK_PER_RECIPIENT: 100, + // XBRIDGE lock (v0, v3) and burn (v1, v4). One flat price for every user + // format: the work is one debit plus one credit or one supply move, and the + // validator federation's signing cost does not scale with the amount. Sized at + // SWEEP_BASE, for the same reason SWEEP_BASE exists: on LTC and DOGE the fee + // must be a real native-coin output, so the smallest possible bridge action has + // to buy one above the chain's dust threshold on its own. The mirror-injected + // settle formats (v2, v5) pay nothing, the CROSS_SETTLE precedent. ISSUE format + // 7 (bridgeability opt-in) adds no key: it is an owner edit of an existing row + // and the issuance fee is first-issuance only. + XBRIDGE_BASE: 5000, // BET (parimutuel betting, spec decision F): feed creation is duration- // metered like ORDER/SWAP/DISPENSER expiration (same free window via // UNIFIED_EXPIRATION_FEE_FREE_DAYS) but under its OWN per-day key so the diff --git a/src/coins/DOGE.js b/src/coins/DOGE.js index 0ec1a85..c466562 100644 --- a/src/coins/DOGE.js +++ b/src/coins/DOGE.js @@ -74,6 +74,11 @@ module.exports = { DONATE2: 'DDonate2o3Sg4phybp92oFpkmv8S9ZhGSV', // Community Development FEE_DESTINATION: 'DFeesjvoMoVqd9UDuwDSAxzHMF5xZFgeG9', // native-fee destination (regtest-only env override; ignored on mainnet/testnet) REWARD: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', // structural only; COLLECT/XCHAIN are BTC-only + // Cross-chain bridge escrow, one per DESTINATION coin, and under R3 + // (any chain is an origin) it also owns every row bridged FROM that + // chain onto this one. Keyless by construction, like BURN above. + BRIDGE_BTC: 'D5dogebridgebtcXXXXXXXXXXXXXVAFQt9', + BRIDGE_LTC: 'D5dogebridgeLtcXXXXXXXXXXXXXUVFLyn', EXPLORER: 'DDonate3FCoUgi1bxW5r9c2p75uKTLw9qE', // display-only donation }, // Dogeparty name-ownership injected at the DOGE mainnet start block. @@ -128,6 +133,11 @@ module.exports = { DONATE2: 'ndonate2wev8vKDgvd1DHhtJtvkRbn2usJ', FEE_DESTINATION: 'nfeesoodkv5UTFXcDeKcUU95QHFiK2Ggo7', REWARD: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + // Bridge escrow, see mainnet above. Dogecoin testnet and regtest use + // DIFFERENT pubKeyHash bytes (0x71 vs 0x6f), so unlike BTC and LTC the + // two networks carry different literals here. + BRIDGE_BTC: 'nUdogebridgebtcXXXXXXXXXXXXXWUG2kx', + BRIDGE_LTC: 'nUdogebridgeLtcXXXXXXXXXXXXXTsEz6W', EXPLORER: 'ndonate3xHD56SnmmSxbjX7UMSPfN7XmVA', }, // Testnet launches CLEAN (genesis disabled); namespace stays open. @@ -171,6 +181,9 @@ module.exports = { DONATE2: 'mmXU8RU7q3BUsyT66rtw1H6P7B2ZZd9c5Y', FEE_DESTINATION: 'mfees5pa2HwNBonk5vG23aDWkN9fuDJib4', REWARD: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + // Bridge escrow, regtest encoding (pubKeyHash 0x6f, not testnet's 0x71). + BRIDGE_BTC: 'mfdogebridgebtcXXXXXXXXXXXXXZ3agHN', + BRIDGE_LTC: 'mfdogebridgeLtcXXXXXXXXXXXXXXb2E4n', EXPLORER: 'n1AvTJLLSA1NHamHd5KFj9mRn6BEcwnVbf', }, genesis: { @@ -238,6 +251,16 @@ module.exports = { SWEEP_PER_ITEM: 100, CALLBACK_BASE: 5000, CALLBACK_PER_RECIPIENT: 100, + // XBRIDGE lock (v0, v3) and burn (v1, v4). One flat price for every user + // format: the work is one debit plus one credit or one supply move, and the + // validator federation's signing cost does not scale with the amount. Sized at + // SWEEP_BASE, for the same reason SWEEP_BASE exists: on LTC and DOGE the fee + // must be a real native-coin output, so the smallest possible bridge action has + // to buy one above the chain's dust threshold on its own. The mirror-injected + // settle formats (v2, v5) pay nothing, the CROSS_SETTLE precedent. ISSUE format + // 7 (bridgeability opt-in) adds no key: it is an owner edit of an existing row + // and the issuance fee is first-issuance only. + XBRIDGE_BASE: 5000, // BET (parimutuel betting, spec decision F): feed creation is duration- // metered like ORDER/SWAP/DISPENSER expiration (same free window via // UNIFIED_EXPIRATION_FEE_FREE_DAYS) but under its OWN per-day key so the diff --git a/src/coins/LTC.js b/src/coins/LTC.js index 3f26fe9..5458882 100644 --- a/src/coins/LTC.js +++ b/src/coins/LTC.js @@ -74,6 +74,11 @@ module.exports = { DONATE2: 'Ldonate2io846q2e7q8dUArh3TNnaq9ENb', // Community Development FEE_DESTINATION: 'Lfees7tszAx5Gqam2fuqf6biaX3LXafM4H', // native-fee destination (regtest-only env override; ignored on mainnet/testnet) REWARD: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', // structural only; COLLECT/XCHAIN are BTC-only + // Cross-chain bridge escrow, one per DESTINATION coin, and under R3 + // (any chain is an origin) it also owns every row bridged FROM that + // chain onto this one. Keyless by construction, like BURN above. + BRIDGE_BTC: 'LKLtcbridgebtcXXXXXXXXXXXXXXXA61Gk', + BRIDGE_DOGE: 'LKLtcbridgedogeXXXXXXXXXXXXXX8Aknx', EXPLORER: 'Ldonate3FfyqbYQAYxo3qjFLcu28oUdAfn', // display-only donation }, // No LTC source ledger; genesis disabled (no dumpHash on LTC). @@ -126,6 +131,10 @@ module.exports = { DONATE2: 'muKEjejjXQvLY7Lp7Ecpn29gM2TCb5BLTF', FEE_DESTINATION: 'mfeeskqGYw3wXYqMZFnUxBwGposEvjziRW', REWARD: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + // Bridge escrow, see mainnet above. testnet and regtest share the + // literal because they share the pubKeyHash byte, as BURN already does. + BRIDGE_BTC: 'mgLtcbridgebtcXXXXXXXXXXXXXXYpo2Bo', + BRIDGE_DOGE: 'mgLtcbridgedogeXXXXXXXXXXXXXZBQunc', EXPLORER: 'mzCXcxcECbY5aNSXsfWjzKQN1YwoefEcG8', }, // Airdrop keys carried explicitly and empty for the same reason as mainnet: @@ -165,6 +174,9 @@ module.exports = { DONATE2: 'n2DLJPppXUi8jC6fLiSkthZi2sc9UKiZHd', FEE_DESTINATION: 'mfeesJdVLx23zhtsCveA8EEfmHX7qSV2Ls', REWARD: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + // Bridge escrow, the testnet literals (same pubKeyHash byte). + BRIDGE_BTC: 'mgLtcbridgebtcXXXXXXXXXXXXXXYpo2Bo', + BRIDGE_DOGE: 'mgLtcbridgedogeXXXXXXXXXXXXXZBQunc', EXPLORER: 'myL7sZGPEG3LhFXn7RFCZ321r8bxgmgDBz', }, // Regtest binds genesis via env so the mechanism can be exercised on a @@ -233,6 +245,16 @@ module.exports = { SWEEP_PER_ITEM: 100, CALLBACK_BASE: 5000, CALLBACK_PER_RECIPIENT: 100, + // XBRIDGE lock (v0, v3) and burn (v1, v4). One flat price for every user + // format: the work is one debit plus one credit or one supply move, and the + // validator federation's signing cost does not scale with the amount. Sized at + // SWEEP_BASE, for the same reason SWEEP_BASE exists: on LTC and DOGE the fee + // must be a real native-coin output, so the smallest possible bridge action has + // to buy one above the chain's dust threshold on its own. The mirror-injected + // settle formats (v2, v5) pay nothing, the CROSS_SETTLE precedent. ISSUE format + // 7 (bridgeability opt-in) adds no key: it is an owner edit of an existing row + // and the issuance fee is first-issuance only. + XBRIDGE_BASE: 5000, // BET (parimutuel betting, spec decision F): feed creation is duration- // metered like ORDER/SWAP/DISPENSER expiration (same free window via // UNIFIED_EXPIRATION_FEE_FREE_DAYS) but under its OWN per-day key so the diff --git a/src/coins/consensus_pin.js b/src/coins/consensus_pin.js index 1adccc8..fe7efc5 100644 --- a/src/coins/consensus_pin.js +++ b/src/coins/consensus_pin.js @@ -77,16 +77,28 @@ module.exports = { // the new values together, and a straggler fail-closes on verifyConsensusPin() // at boot rather than forking. CONSENSUS_CONFIG_PIN.mainnet above stays null // regardless (Phase 6 arms that separate pin). + // REGENERATED 2026-09-12 (XChain bridge, base and token): every network + // block gains the ADDRESS.BRIDGE_ escrow roles (two per coin, one per + // other chain) and GAS_SCHEDULE gains XBRIDGE_BASE. consensusSubset() hashes + // the address map and the gas schedule WHOLE, so both edits move every hash by + // construction regardless of where XCHAIN_BRIDGE_ACTIVATION stands, and the + // same one-wave rollout rule as every regeneration above applies in full: every + // service bundling these ships the new values together, and a straggler + // fail-closes on verifyConsensusPin() at boot rather than forking. The escrow + // addresses themselves are inert until the activation (nothing credits them + // below it), so no pre-activation block hash moves; the pin moves because the + // BUNDLE changed, which is exactly what the pin is for. + // CONSENSUS_CONFIG_PIN.mainnet above stays null (Phase 6 arms that pin). testnet: { - BTC: 'd3c66a4fb288b2666a2a4fad85200bbeac162bb36fed8a3eddcfc7b2d4d48070', - LTC: 'ae94a951a838e64f9c36e503b978d9b9ad5ea74f7b443465baaabca8f675ea0d', - DOGE: 'b90aec4381b0ad32caba078706c8fb244cbe267390e41668fa063d9e64fb60e6', + BTC: 'fcff7c1f46a8f7a75ddb7e1e4fb30f9e0c72d72f307a75a9d9357ffad29452c0', + LTC: '57373962a5c562f8ceb98fceb482c586741ecf8dd6335965c76f9b8e61a4eb87', + DOGE: '5276c0a0fb161bbfd4e8b0acaabf38751dded4370ecce86455c57eb5de0e9bb2', }, regtest: { - BTC: '29976bd33cad1842320c57acdc849250646adea765f70a0ae5dad3f201f7d5d7', - LTC: 'bca62db9f59a6f7566620b086380c10fffac08dabe99f00a4fcc7cd038e46146', - DOGE: '816632e9f6647e726042282c37789ae8d924e8d4a1b2995ddde8d6a54a0bba54', + BTC: '63ee757834f6f815045321090fd89b446e784f442e3e7abf84b8c0fb3b479324', + LTC: 'ab30c1d1fd444ca3dca1a9ec855bd587422e87d5e5e5e6b6f9ddadd1d2fb587d', + DOGE: '34f8dafeff36f7c8ca3b327c3c915251e78e64448742860620522a92a69368a0', }, }, }; diff --git a/src/validator.js b/src/validator.js index 43cda0e..90c9fb1 100644 --- a/src/validator.js +++ b/src/validator.js @@ -144,7 +144,11 @@ const VALID_ACTION_NAMES = new Set([ 'DIVIDEND', 'EXECUTE', 'FILE', 'ISSUE', 'LINK', 'LIST', 'MESSAGE', 'MINT', 'NODEPROOF', 'ORDER', 'PRICE', 'ROLLCALL', 'SEND', 'SLASH', 'SLEEP', 'STAKE', 'SWAP', - 'SWEEP', 'UNSTAKE', 'VOTE', 'WITHDRAW' + 'SWEEP', 'UNSTAKE', 'VOTE', 'WITHDRAW', + // Bridge lock/burn. Mirrors the decoder set: the user-broadcast versions (0, 1, 3, 4) + // must clear this gate to reach a decoder at all, and the mirror-injected settle legs + // (2, 5) are never encoded here because no user may author one. + 'XBRIDGE' ]) // Short-form ACTION-name aliases, expanded to canonical form before the diff --git a/test/fixtures/action-manifest.json b/test/fixtures/action-manifest.json index ad21bd2..43a8712 100644 --- a/test/fixtures/action-manifest.json +++ b/test/fixtures/action-manifest.json @@ -20,7 +20,7 @@ "The indexer protocol_changes registry ALSO holds non-action feature-gate flags: any this.addChange(...) entry whose name is NOT a key in this manifest's 'actions' map is a feature gate, not an action, and is excluded here. As of this pass that set is CONTROLLER_GUARD, CROSS_CHAIN_DEX, ISSUANCE_FEE, ISSUANCE_FEE_EMISSION_EXEMPT, LOCK_MAX_SUPPLY_EXACT, UNIFIED_FEES, VM_ACTIONS, VM_BALANCE_TOKENINFO, VM_BANNED_ASYNC, DEPLOY_BASE64_CODE, CROSS_CHAIN_ROYALTY, ISSUE_MINT_SUPPLY_CUMULATIVE_CAP, SLEEP_RESPECTS_LOCK_SLEEP, COINPAY_EXPIRE_TOKEN_AMOUNT, UNSTAKE_COOLDOWN_COMPLETION_ACTION, DELEGATE_REVOKE_NO_REINSERT, CONTRACT_INDEX_CANONICAL, SLASH_BURNS_PENDING_STAKE, NATIVE_FEE_PRICE_TIME_GATE, DEPLOY_INIT_STRICT, but the enumeration is illustrative, not authoritative: trust the exclusion RULE above it, not this list, since new flag-days are added to the registry without a manifest update. The indexer conformance guard compares the dispatch switch, not the registry.", "UNKNOWN is the indexer/explorer catch-all sentinel, not an action; excluded. Each guard drops UNKNOWN before comparing.", "Aliases are expanded to canonical names during canonicalization - before the ACTION-name gate (so no alias appears as its own action entry) but AFTER the compiled-size gate, which therefore measures the alias/wire form, not the expanded canonical record (decoder ACTION_ALIASES / indexer actionAliases).", - "userEncodableVersions is NOT a copy of the SDK Formats keys, it is the audit those keys are checked against. Every entry was read off xchain-indexer/src/actions/.js: the handler's this.formats map is the set of versions the indexer will parse at all, and a version is user-encodable only if nothing in the handler restricts it to indexer-synthesized input. Two versions the indexer parses are therefore absent here: VOTE v2 (finalize) rejects a user broadcast outright via `if(!data['IS_SYNTHETIC'])` in vote.js, and PRICE v0 is the validator COIN/FIAT snapshot, which only validates with a PBFT quorum of Ed25519 signatures from price-capability stakes, so no wallet can author one. Adding a version here that the indexer will not accept from a user is worse than omitting one: the SDK guard would then demand a Format that builds a command guaranteed to be rejected on arrival, so re-run the audit against the handler before editing an array.", + "userEncodableVersions is NOT a copy of the SDK Formats keys, it is the audit those keys are checked against. Every entry was read off xchain-indexer/src/actions/.js: the handler's this.formats map is the set of versions the indexer will parse at all, and a version is user-encodable only if nothing in the handler restricts it to indexer-synthesized input. Two versions the indexer parses are therefore absent here: VOTE v2 (finalize) rejects a user broadcast outright via `if(!data['IS_SYNTHETIC'])` in vote.js, and PRICE v0 is the validator COIN/FIAT snapshot, which only validates with a PBFT quorum of Ed25519 signatures from price-capability stakes, so no wallet can author one. XBRIDGE is the third case and the reason one entry carries a gapped version list: v0/v1 (XCHAIN lock/burn) and v3/v4 (general-token lock/burn) are user-broadcast, while v2 and v5 are the mirror-injected settle legs the indexer synthesizes from a finalized bridge_transfers row and refuses on broadcast ('invalid: XBRIDGE v2 is system-injected'), the ATTEST precedent of one action name carrying mixed formats. Adding a version here that the indexer will not accept from a user is worse than omitting one: the SDK guard would then demand a Format that builds a command guaranteed to be rejected on arrival, so re-run the audit against the handler before editing an array.", "BET is fully rolled out as of the P8 wallet form: wireDecoded + userEncodable (decoder, encoder gate, SDK), indexerHandled + the BET_EXPIRE lifecycle entry (P4), explorerRender (P7), walletForm (P8). It was staged one flag per work package on purpose, because flipping a flag ahead of the code makes that repo's conformance guard red, which is the intended signal rather than an oversight. BET_EXPIRE gained its own explorerRender in a later pass: it owns no table, so its detail reads the bet_feed_statuses row keyed by its action_index and joins through to the feed it expired." ], "aliases": { @@ -230,7 +230,7 @@ "wireDecoded": true, "indexerHandled": true, "userEncodable": true, - "userEncodableVersions": [0, 1, 2, 3, 4, 5, 6], + "userEncodableVersions": [0, 1, 2, 3, 4, 5, 6, 7], "explorerRender": true, "walletForm": true }, @@ -414,6 +414,15 @@ "explorerRender": true, "walletForm": true }, + "XBRIDGE": { + "category": "wire-user", + "wireDecoded": true, + "indexerHandled": true, + "userEncodable": true, + "userEncodableVersions": [0, 1, 3, 4], + "explorerRender": true, + "walletForm": true + }, "XCALL": { "category": "mirror-injected", "indexerHandled": true, From da9b8029a43a5aa6bba7f4fddb595f791aa38f76 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 12 Sep 2026 11:07:05 -0700 Subject: [PATCH 002/148] chore(deps): move audited transitive packages to their patched releases Refreshes the lockfile so every package the security audit flags resolves to its patched release inside the existing version ranges. No source change. --- package-lock.json | 105 ++++++++++++++++++++++++++-------------------- 1 file changed, 60 insertions(+), 45 deletions(-) diff --git a/package-lock.json b/package-lock.json index e5d556e..aafb3a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2611,10 +2611,11 @@ ] }, "node_modules/baseline-browser-mapping": { - "version": "2.10.13", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", - "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", + "version": "2.11.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz", + "integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==", "dev": true, + "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" }, @@ -2911,9 +2912,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "dev": true, "funding": [ { @@ -2929,12 +2930,13 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -3093,9 +3095,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001784", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", - "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -3110,7 +3112,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/chai": { "version": "4.5.0", @@ -3734,10 +3737,11 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.331", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", - "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", - "dev": true + "version": "1.5.427", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", + "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==", + "dev": true, + "license": "ISC" }, "node_modules/elliptic": { "version": "6.6.1", @@ -4046,9 +4050,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "dev": true, "funding": [ { @@ -4892,9 +4896,9 @@ "dev": true }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -5413,10 +5417,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", - "dev": true + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/npm-run-path": { "version": "6.0.0", @@ -5460,6 +5468,7 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -5823,12 +5832,13 @@ "dev": true }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -6298,13 +6308,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -6316,12 +6327,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -6334,6 +6346,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -6351,6 +6364,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -6982,9 +6996,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, "funding": [ { @@ -7000,6 +7014,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" From db3619c4bdbfa767ca9bb2a573ce1007c6806108 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 12 Sep 2026 17:48:25 -0700 Subject: [PATCH 003/148] fix(encoder): close the serving-boundary and coin-node RPC contract holes The envelope-cancel path used one expiry stamp per outpoint as its ownership token, the concurrency gate released its slot on a response event Express never awaits, and the single-instance guard published ownership in two steps so a crash between them left an unowned lock. On the coin-node contract, the no-fee-estimate condition was handled in only one of its two shapes, mempool entries were coerced before type validation so malformed rows priced as free, and ancestor packages committed a root on the first of two round trips. From the 2026-09-12 review round. --- src/BlockchainConnector.js | 115 ++++++++--- src/XChainEncoder.js | 125 +++++++++--- src/api.js | 17 +- src/concurrencyGate.js | 51 ++++- src/errorSanitize.js | 41 +++- src/maintenanceWindow.js | 71 ++++++- src/singleInstanceGuard.js | 186 +++++++++++++----- ...reg-19-envelope-cancel-reservation.test.js | 92 +++++++++ test/security/concurrency-gate.test.js | 104 ++++++++++ test/unit/BlockchainConnector.test.js | 106 ++++++++++ test/unit/errorSanitize.test.js | 40 +++- test/unit/maintenanceWindow.test.js | 62 ++++++ test/unit/packageFeeSizing.test.js | 81 ++++++++ test/unit/singleInstanceGuard.test.js | 71 +++++++ 14 files changed, 1051 insertions(+), 111 deletions(-) diff --git a/src/BlockchainConnector.js b/src/BlockchainConnector.js index e9c1760..8614b84 100644 --- a/src/BlockchainConnector.js +++ b/src/BlockchainConnector.js @@ -94,16 +94,46 @@ function sanitizeRpcError(error){ // while modern Core reports `vsize` and nests the fee under `fees.base`. Either // reader returns null on a value it cannot price, which the caller treats as an // unusable package rather than as a zero-fee ancestor. +// The node's own JSON-RPC error as a compact suffix, off either response shape +// the fleet produces: BTC v28 answers HTTP 200 with an error body, while +// LTC/DOGE answer HTTP 500 and axios hangs the body off error.response. Returns +// '' when there is no error body, so a pure transport failure reads exactly as +// it did before. Must be read BEFORE sanitizeRpcError, which scrubs +// error.response down to its status. +function rpcErrorDetail(body){ + const rpcError = body && body.error + if (!rpcError || typeof rpcError !== 'object') return '' + const code = rpcError.code + const message = rpcError.message || '' + if (code === undefined && message === '') return '' + return ` (RPC error ${code === undefined ? 'unknown' : code}: ${message})` +} + +// Type before coercion: Number(null), Number('') and Number(false) are all a +// finite 0, and Number(true) is 1, so a bare Number() cast turns an unreadable +// field into a priceable value. Only real numbers, and strings that spell one, +// are priceable; anything else is null and invalidates the package. +function readNumeric(raw){ + if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null + if (typeof raw === 'string') { + const trimmed = raw.trim() + if (trimmed === '') return null + const parsed = Number(trimmed) + return Number.isFinite(parsed) ? parsed : null + } + return null +} + function entrySize(entry){ const raw = entry && (entry.vsize !== undefined ? entry.vsize : entry.size) - const size = Number(raw) - return (Number.isFinite(size) && size > 0) ? size : null + const size = readNumeric(raw) + return (size !== null && size > 0) ? size : null } function entryFee(entry){ const raw = entry && (entry.fees && entry.fees.base !== undefined ? entry.fees.base : entry.fee) - const fee = Number(raw) - return (Number.isFinite(fee) && fee >= 0) ? fee : null + const fee = readNumeric(raw) + return (fee !== null && fee >= 0) ? fee : null } class BlockchainConnector { @@ -134,10 +164,13 @@ class BlockchainConnector { if (responseData.result) { return responseData.result; } else { - throw new Error('Error getting network info'); + throw new Error('Error getting network info' + rpcErrorDetail(responseData)); } } catch (error) { - throw new Error(`Error in network request: ${error.message}`); + // Keep the node's reason: a warming node answers -28 "Loading block + // index...", which is otherwise flattened to "status code 500". + const detail = rpcErrorDetail(error.response && error.response.data); + throw new Error(`Error in network request: ${sanitizeRpcError(error)}${detail}`); } } @@ -173,10 +206,13 @@ class BlockchainConnector { this._chainNameCache = String(responseData.result.chain); return this._chainNameCache; } else { - throw new Error('Error getting blockchain info'); + throw new Error('Error getting blockchain info' + rpcErrorDetail(responseData)); } } catch (error) { - throw new Error(`Error in network request: ${error.message}`); + // Same reason as getNetworkInfo: preserve the node's own RPC code + // and message rather than the bare HTTP status. + const detail = rpcErrorDetail(error.response && error.response.data); + throw new Error(`Error in network request: ${sanitizeRpcError(error)}${detail}`); } } @@ -382,41 +418,50 @@ class BlockchainConnector { // txid -> {size, fee}. The dedupe surface for shared ancestors. const packageEntries = new Map() - const record = (txid, entry) => { + const record = (staged, txid, entry) => { const key = String(txid).toLowerCase() - if (packageEntries.has(key)) return true + if (packageEntries.has(key) || staged.has(key)) return true const size = entrySize(entry) const fee = entryFee(entry) if (size === null || fee === null) return false - packageEntries.set(key, { size, fee }) + staged.set(key, { size, fee }) return true } for (const txid of roots) { + // A root's two RPC calls are not atomic, so a block can confirm the + // root between them; the node then answers the second call with -5. + // Stage the branch and merge it only once both calls described the + // same mempool, so a confirmed root's bytes and fee never reach the + // package the child pays to accelerate. + const staged = new Map() const self = await this._mempoolRpc('getmempoolentry', [txid]) if (!self.ok) { if (self.absent) continue // already confirmed, nothing to carry return null } - if (!record(txid, self.result)) return null + if (!record(staged, txid, self.result)) return null // verbose=true: the ancestors come back as a txid-keyed map of the same // entries, so one call per input covers the whole branch above it. const ancestors = await this._mempoolRpc('getmempoolancestors', [txid, true]) if (!ancestors.ok) { + // The root confirmed mid-sequence; its ancestors confirmed at or + // before it did, so the whole staged branch is stale. Discard it. if (ancestors.absent) continue return null } const result = ancestors.result if (result && typeof result === 'object' && !Array.isArray(result)) { for (const ancestorTxid of Object.keys(result)) { - if (!record(ancestorTxid, result[ancestorTxid])) return null + if (!record(staged, ancestorTxid, result[ancestorTxid])) return null } } else { // A non-verbose (array) answer carries no size or fee, so the package // cannot be priced from it. return null } + for (const [key, entry] of staged) packageEntries.set(key, entry) } let size = 0 @@ -428,6 +473,24 @@ class BlockchainConnector { return { size, fees } } + // The "node has no fee estimate" fallback for non-mainnet chains: a multiple + // of the node's own min-relay floor, or null when it does not apply (mainnet, + // or a node that reports no usable relayfee). Null means the caller throws. + // Consulted from both shapes the condition arrives in, the feerate:-1 success + // body and an RPC error body, so the two paths cannot drift apart. + async _noEstimateRelayFallback() { + if ((await this.chainName()) === 'main') return null; + const info = await this.getNetworkInfo(); + const relayfee = Number(info && info.relayfee); + if (!(relayfee > 0)) return null; + const multiplier = noEstimateRelayMultiplier(); + const feerate = relayfee * multiplier; + console.warn('estimatesmartfee returned no estimate on a non-mainnet chain; using ' + + multiplier + 'x the node relayfee floor: ' + feerate + '/kB ' + + '(FEE_NO_ESTIMATE_RELAY_MULTIPLIER to change)'); + return feerate; + } + async getFeePerKilobyte(blocksNumber) { try { // On regtest, ALWAYS use the node's min-relay floor, never estimatesmartfee. @@ -504,18 +567,8 @@ class BlockchainConnector { // testnets it is 10 sat/vB, cheap in coin that costs nothing. // Mainnet keeps throwing: there a missing estimate means the node is // unhealthy, and paying real coin on a guess is the worse failure. - if ((await this.chainName()) !== 'main') { - const info = await this.getNetworkInfo(); - const relayfee = Number(info && info.relayfee); - if (relayfee > 0) { - const multiplier = noEstimateRelayMultiplier(); - const feerate = relayfee * multiplier; - console.warn('estimatesmartfee returned no estimate on a non-mainnet chain; using ' + - multiplier + 'x the node relayfee floor: ' + feerate + '/kB ' + - '(FEE_NO_ESTIMATE_RELAY_MULTIPLIER to change)'); - return feerate; - } - } + const fallback = await this._noEstimateRelayFallback(); + if (fallback !== null) return fallback; throw new Error('Error getting smart fee from node'); } catch (error) { // On a fresh regtest chain estimatesmartfee can error (not enough @@ -535,8 +588,18 @@ class BlockchainConnector { } return 0.00001000; } + // A node can report the same "no estimate" condition with an RPC + // error instead of feerate:-1 (LTC/DOGE answer HTTP 500 with a + // JSON-RPC error body, so axios throws before the try-block's + // fallback is reached). Run the same non-mainnet fallback here, or + // a public testnet in that state fails every fee-bearing build + // with nothing wrong at the node. Mainnet still returns null and + // rethrows, and a genuinely unreachable node makes the fallback's + // own RPCs throw, so node-down stays a hard failure. + const fallback = await this._noEstimateRelayFallback(); + if (fallback !== null) return fallback; } catch (_) { - // isRegtest() itself failed; fall through to rethrow. + // isRegtest() or the fallback's own RPC failed; fall through to rethrow. } console.error('Error:', sanitizeRpcError(error)); throw error; diff --git a/src/XChainEncoder.js b/src/XChainEncoder.js index 98b09ce..8ae5174 100644 --- a/src/XChainEncoder.js +++ b/src/XChainEncoder.js @@ -517,10 +517,15 @@ class XChainEncoder { // map: a byte-identical rebuild hashes to the same txid, and returning it // as a fresh build let a caller journal one broadcast as two successes. this.recentBuilds = new Map() - // outpoint -> the reservation expiry the envelope-cancel path itself wrote, - // its ownership stamp in outpointReservations. A cancel is deterministic - // from the recovery record, so it must be able to re-take its OWN live - // claim; a claim it did not write belongs to another build and blocks it. + // outpoint -> Map(cancel-build owner id -> that build's reservation expiry). + // A cancel is deterministic from the recovery record, so it must be able to + // re-take an outpoint its OWN path already holds; an outpoint no cancel + // owner wrote belongs to another build and blocks it. The owner SET, rather + // than one expiry stamp, is what makes overlapping cancel builds of a single + // outpoint distinguishable: the reservation is worth the highest live owner + // expiry and is dropped only when the last owner lets go, so a failed retry + // can no longer delete the reservation an earlier outstanding cancel holds. + // See _releaseEnvelopeCancelClaims and _maxLiveCancelOwnerExpiry. this.envelopeCancelClaims = new Map() // reservation ticket id -> the claims ONE successful build kept, with the // expiry stamp each of them wrote plus the recent-build record that build @@ -612,23 +617,71 @@ class XChainEncoder { } } - // Release an envelope-cancel claim and its ownership stamp together, so a + // The highest expiry any STILL-LIVE cancel owner holds on this outpoint, or + // null when no live owner remains. This is the value outpointReservations must + // carry while the cancel path holds an outpoint, and comparing it against the + // reservation is how the build guard tells "held only by this path" from + // "held by a foreign createTransaction claim". + _maxLiveCancelOwnerExpiry(key, now) { + const owners = this.envelopeCancelClaims.get(key) + if (!owners) return null + let max = null + for (const expiry of owners.values()) { + if (expiry > now && (max === null || expiry > max)) max = expiry + } + return max + } + + // Drop ONE cancel build's owner token and restate what the outpoint is now + // worth. While another live owner remains, outpointReservations is rewritten + // to that owner's expiry and the key stays reserved: this is the whole point + // of the owner set, because the releasing build's stamp is then no longer the + // map's value and every stamp-checked delete below leaves the entry alone. + // Returns true when the outpoint is still held by some other cancel owner. + _dropCancelOwner(key, ownerId, now) { + const owners = this.envelopeCancelClaims.get(key) + if (!owners) return false + owners.delete(ownerId) + const remaining = this._maxLiveCancelOwnerExpiry(key, now) + if (remaining === null) { + this.envelopeCancelClaims.delete(key) + return false + } + this.outpointReservations.set(key, remaining) + return true + } + + // Release an envelope-cancel claim and its ownership token together, so a // failed cancel build hands the commit outpoint back instead of squatting it - // for the whole TTL. Ownership-checked exactly like _releaseCallReservations. + // for the whole TTL - but only once no other cancel build still owns it. A + // retry of a lost-response cancel legitimately re-claims an outpoint the first + // build is still outstanding on, and deleting the reservation outright on the + // retry's failure freed that outpoint to createTransaction while the earlier + // unsigned cancel spent it. Ownership-checked exactly like + // _releaseCallReservations: a stamp that moved belongs to somebody else. _releaseEnvelopeCancelClaims(callReservations) { + const now = Date.now() + const handBack = [] for (const claim of callReservations) { - if (this.envelopeCancelClaims.get(claim.key) === claim.expiry){ - this.envelopeCancelClaims.delete(claim.key) - } - } - this._releaseCallReservations(callReservations) + // A claim another cancel owner still holds is withheld from the stamped + // release below, and deliberately not left to that check: two builds a + // millisecond apart share an expiry, so the stamp alone cannot tell the + // surviving owner's reservation from this one's. + if (claim.cancelOwnerId && this._dropCancelOwner(claim.key, claim.cancelOwnerId, now)) continue + handBack.push(claim) + } + this._releaseCallReservations(handBack) } - // Sweep the cancel ownership stamps alongside the reservation map, so neither - // grows unbounded in a long-lived process. + // Sweep the cancel ownership tokens alongside the reservation map, so neither + // grows unbounded in a long-lived process. An outpoint whose last owner token + // has lapsed goes with it. _evictExpiredEnvelopeCancelClaims(now) { - for (const [key, expiry] of this.envelopeCancelClaims) { - if (expiry <= now) this.envelopeCancelClaims.delete(key) + for (const [key, owners] of this.envelopeCancelClaims) { + for (const [ownerId, expiry] of owners) { + if (expiry <= now) owners.delete(ownerId) + } + if (owners.size === 0) this.envelopeCancelClaims.delete(key) } } @@ -662,7 +715,12 @@ class XChainEncoder { this._evictExpiredReservationTickets(now) const live = [] for (const claim of callReservations) { - if (this.outpointReservations.get(claim.key) === claim.expiry) live.push({ key: claim.key, expiry: claim.expiry }) + // cancelOwnerId rides onto the ticket because releaseReservation has to + // retire this build's owner token as well as its reservation; leaving it + // behind would hold the outpoint until the token's own TTL lapsed. + if (this.outpointReservations.get(claim.key) === claim.expiry) { + live.push({ key: claim.key, expiry: claim.expiry, cancelOwnerId: claim.cancelOwnerId || null }) + } } if (live.length === 0) return null const id = crypto.randomBytes(16).toString('hex') @@ -714,12 +772,17 @@ class XChainEncoder { const released = [] const retained = [] for (const claim of ticket.claims) { - if (this.outpointReservations.get(claim.key) === claim.expiry) { + // Retire this build's cancel owner token first, whatever the stamp says. + // A ticket whose stamp has moved still has to give the token up, or the + // outpoint would stay reserved at this build's expiry long after the + // build that took it over released. _dropCancelOwner restates the + // reservation at the highest expiry a surviving owner holds, which is + // also what makes the stamp check below leave that owner's entry alone. + const stillHeldByAnotherCancel = claim.cancelOwnerId + ? this._dropCancelOwner(claim.key, claim.cancelOwnerId, now) + : false + if (!stillHeldByAnotherCancel && this.outpointReservations.get(claim.key) === claim.expiry) { this.outpointReservations.delete(claim.key) - // The cancel path's stamp mirrors the reservation it wrote, so it - // has to go with it or the outpoint reads free to selection while - // the cancel path still believes it owns the claim. - if (this.envelopeCancelClaims.get(claim.key) === claim.expiry) this.envelopeCancelClaims.delete(claim.key) released.push(claim.key) } else { retained.push(claim.key) @@ -3242,8 +3305,11 @@ class XChainEncoder { const nowClaim = Date.now() this._evictExpiredReservations(nowClaim) this._evictExpiredEnvelopeCancelClaims(nowClaim) + // Reserved AND not held by this path: the reservation is worth the highest + // live cancel-owner expiry whenever a cancel build holds it, so any other + // value means a foreign createTransaction claim owns the outpoint. if (this._isOutpointReserved(outpointKey, nowClaim) && - this.envelopeCancelClaims.get(outpointKey) !== this.outpointReservations.get(outpointKey)){ + this._maxLiveCancelOwnerExpiry(outpointKey, nowClaim) !== this.outpointReservations.get(outpointKey)){ throw new OperationalError( 'ENVELOPE_CANCEL_OUTPOINT_RESERVED', `commit outpoint ${outpointKey} is reserved by a transaction built in the last ` + @@ -3257,8 +3323,21 @@ class XChainEncoder { // contract above is that a cancel rebuilds from the persisted recovery // record alone, so a lost-response retry must not be refused for five // minutes for producing the byte-identical transaction it is supposed to. + // + // Each build registers its own owner token rather than overwriting a single + // stamp, so an overlapping build's failure releases only what it took. A + // single stamp, overwritten and then deleted, hands the commit outpoint back + // to createTransaction while the first build's unsigned cancel still spends it. this._claimOutpoint(callReservations, outpointKey, nowClaim) - this.envelopeCancelClaims.set(outpointKey, callReservations[callReservations.length - 1].expiry) + const cancelClaim = callReservations[callReservations.length - 1] + const cancelOwnerId = crypto.randomBytes(16).toString('hex') + cancelClaim.cancelOwnerId = cancelOwnerId + let cancelOwners = this.envelopeCancelClaims.get(outpointKey) + if (!cancelOwners) { + cancelOwners = new Map() + this.envelopeCancelClaims.set(outpointKey, cancelOwners) + } + cancelOwners.set(cancelOwnerId, cancelClaim.expiry) // Fee-rate resolution with the same drain guards as createTransaction, // in miniature: the caller rate is clamped to the tighter of the diff --git a/src/api.js b/src/api.js index 4d2ce99..c36e60f 100644 --- a/src/api.js +++ b/src/api.js @@ -533,7 +533,12 @@ const jsonRpcController = { // code directly (the JSON-RPC catch-all routes all GETs to 200 today). Shares // the readiness gate with health() via getServeReadiness() above so // the two endpoints cannot drift apart. -app.get('/status', async (req, res) => { +// probeGate.hold, not requestGate.hold: /status is exempted from the main cap by +// `skip`, so its slot lives in the probe reserve and only that gate's hold() can +// find it. This route awaits an HTTP round-trip to the utxo-tracker, so without +// the wrapper a monitor that hangs up on a slow probe frees its reserve slot +// while the round-trip is still outstanding. +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 getServeReadiness() @@ -555,7 +560,7 @@ app.get('/status', async (req, res) => { // 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() }) -}) +})) // Machine-readable API spec (OpenRPC 1.3.2). Regenerated by docs/openrpc.build.js; // test/unit/openrpc-coverage.test.js keeps it in lockstep with jsonRpcController. @@ -582,7 +587,13 @@ app.use(makeRpcBatchGuard(parseInt(process.env.ENCODER_MAX_RPC_BATCH, 10) || 20) // that fall through to this root-mounted router get a normal JSON-RPC error // response instead of crashing the request. app.use((req, res, next) => { if (req.body === undefined) req.body = {}; next(); }); -app.use(jsonRouter({methods: jsonRpcController})) +// requestGate.hold: the router's middleware is async and awaits every method it +// dispatches, batch entries included, so ONE wrap holds the slot for the whole +// fan-out. Without it a client could start a create_tx, hang up, and get a fresh +// slot immediately while the first build was still spending node and tracker +// RPCs - the cap then admits unbounded real work with in_flight reading zero. +// A route added below this line without the wrapper sits outside the cap again. +app.use(requestGate.hold(jsonRouter({methods: jsonRpcController}))) // Start the server only when run directly (node src/api.js). When required by a diff --git a/src/concurrencyGate.js b/src/concurrencyGate.js index a0aeb99..e441275 100644 --- a/src/concurrencyGate.js +++ b/src/concurrencyGate.js @@ -50,7 +50,7 @@ function resolveLimit(rawValue, defaultLimit){ * @param {number} [options.retryAfter=1] Retry-After header value, seconds. * @param {function} [options.skip] (req) => true to exempt a request from the cap. * @param {object|function} [options.body] 429 JSON body, or (req) => body. - * @returns {function} Express middleware, with .getStats() and .limit attached. + * @returns {function} Express middleware, with .getStats(), .limit and .hold() attached. */ function createConcurrencyGate(options){ options = options || {}; @@ -63,6 +63,10 @@ function createConcurrencyGate(options){ let inFlight = 0; let shed = 0; + // Per-gate slot key. The probe reserve and the main cap are both mounted on + // one app, so a module-level key would let either gate claim the other's slot. + const SLOT = Symbol('concurrencyGateSlot'); + const middleware = function concurrencyGate(req, res, next){ if(limit <= 0 || skip(req)) return next(); @@ -80,17 +84,58 @@ function createConcurrencyGate(options){ released = true; inFlight--; }; + const slot = { release, claimed: false }; + req[SLOT] = slot; + // 'finish' fires on a fully-sent response; 'close' on a client abort or // a handler that never answers. Whichever lands first frees the slot, // and the guard makes the pair idempotent (both fire on a normal // response). Without the 'close' leg an aborted request would leak its // slot permanently and the gate would ratchet shut on a live service. - res.on('finish', release); - res.on('close', release); + // + // The socket lifetime is NOT the work lifetime, though. Express never + // awaits an async handler, so a client that hangs up mid-build frees its + // slot here while the create_tx behind it is still fanning out into node + // and tracker RPCs: abort-spam then admits work past the cap while + // in_flight reads zero, defeating the gate with the exact stampede shape + // it exists to shed. A handler wrapped in hold() claims its slot and + // answers for it itself, which leaves these legs as the anti-leak path + // for whatever nobody wrapped. + const releaseIfUnclaimed = () => { if(!slot.claimed) release(); }; + res.on('finish', releaseIfUnclaimed); + res.on('close', releaseIfUnclaimed); next(); }; + /** + * Bind a handler's slot to the WORK instead of to the socket. + * + * The wrapped handler owns its slot from entry until its promise settles, so + * an aborted request keeps counting against the cap for exactly as long as + * its upstream work is still running. A request with no slot (gate disabled + * by a cap <= 0, or exempted by `skip`) is passed straight through, so + * wrapping is safe on every route the gate may or may not have admitted. + * + * This changes only WHEN the counter is decremented. Nothing is cancelled: + * create_tx, broadcast_tx and the outpoint reservations they take are money + * paths where an aborted call could strand transaction state, so every + * request still runs to completion exactly as it did before. + * + * @param {function} handler Express handler or middleware. + * @returns {function} The handler, holding its slot until it settles. + */ + middleware.hold = (handler) => async function heldHandler(req, res, next){ + const slot = req[SLOT]; + if(!slot) return handler(req, res, next); + slot.claimed = true; + try { + return await handler(req, res, next); + } finally { + slot.release(); + } + }; + middleware.limit = limit; // Operational surface: a climbing `shed` is the signal that a stampede is // being refused, the same way sync exposes snapshots_rejected. diff --git a/src/errorSanitize.js b/src/errorSanitize.js index 79862d6..e3552cf 100644 --- a/src/errorSanitize.js +++ b/src/errorSanitize.js @@ -25,6 +25,18 @@ * are replaced with a generic fallback while genuine upstream reasons pass * through. * + * Transport CLASSIFICATION alone is not proof a message is safe. Every + * re-wrap between the socket and here (BlockchainConnector, UtxoTracker, + * XChainEncoder) builds a plain Error and drops err.code, so a connect + * failure carrying an RPC URL with embedded credentials arrives looking + * exactly like an application rejection. So the caller-safe message is + * gated twice: not a classified transport error, AND carrying none of the + * shapes that can only be the encoder's own topology or credentials. + * A message that trips the second gate is collapsed WHOLE rather than + * scrubbed: redacting the part we recognized still ships everything else + * the upstream chose to say, which is the same enumerate-the-bad mistake + * as classifying by errno. + * ********************************************************************/ // Node network errnos (plus the axios "bad response" wrapper) that indicate a @@ -42,11 +54,34 @@ function isTransportError(err) { return false } +// Shapes an upstream application rejection never needs and the encoder's own +// plumbing routinely carries. Node reasons are prose and opcode-ish tokens +// ("min relay fee not met, 0 < 110", "bad-txns-inputs-missingorspent", +// "dust"), so none of these fire on the reasons that are worth forwarding. +const INTERNAL_DETAIL_PATTERNS = [ + // Any URL, which is also the only shape that can carry user:pass@host. + /[a-z][a-z0-9+.-]*:\/\//i, + // A dotted quad, with or without a port. + /\b\d{1,3}(?:\.\d{1,3}){3}\b/, + // host:port, where the host has at least one letter so a bare clock or + // ratio ("12:30", "0 < 110") is not mistaken for an endpoint. + /[a-z0-9_-]*[a-z][a-z0-9_.-]*:\d{2,5}\b/i +] + +// True when the message carries encoder-internal topology or credentials. +function leaksInternalDetail(message) { + if (typeof message !== 'string' || message.length === 0) return false + return INTERNAL_DETAIL_PATTERNS.some((pattern) => pattern.test(message)) +} + // Return a caller-safe message: the upstream reason for application-level -// rejections, the generic fallback for transport-level failures. +// rejections, the generic fallback for a transport-level failure or for any +// message carrying internal topology or credentials. function upstreamErrorMessage(err, fallback) { if (isTransportError(err)) return fallback - return (err && err.message) || fallback + const message = (err && err.message) || '' + if (message === '' || leaksInternalDetail(message)) return fallback + return message } -module.exports = { isTransportError, upstreamErrorMessage } +module.exports = { isTransportError, upstreamErrorMessage, leaksInternalDetail } diff --git a/src/maintenanceWindow.js b/src/maintenanceWindow.js index 4f7e30f..514eb93 100644 --- a/src/maintenanceWindow.js +++ b/src/maintenanceWindow.js @@ -31,7 +31,8 @@ * * The sentinel is deliberately fail-safe in the honest direction. Every * way it can be wrong - missing, unreadable, oversized, unparseable, no - * expiry, expired, or claiming a window longer than MAX_WINDOW_MS - + * expiry, expired, a timestamp no Date can represent, or claiming a window + * longer than MAX_WINDOW_MS either in time remaining or in declared span - * resolves to "no maintenance", which puts the row back on Degraded. A * publish that crashes without cleaning up therefore stops excusing the * outage at its own declared end time rather than hiding it forever. @@ -65,10 +66,19 @@ function sentinelPath() { return process.env.ENCODER_MAINTENANCE_FILE || DEFAULT_SENTINEL } +// The largest absolute time value a JS Date can hold. A finite number past it +// is not a timestamp: new Date(v).toISOString() raises RangeError on it, which +// would break the readiness probe from inside the module whose whole job is +// making a wrong sentinel harmless. +const MAX_EPOCH_MS = 8.64e15 + // Accepts an ISO-8601 string or epoch milliseconds; returns null for anything -// else, including the NaN a malformed date parses to. +// else, including the NaN a malformed date parses to and a finite number +// outside the representable Date range. function toEpochMs(value) { - if (typeof value === 'number') return Number.isFinite(value) ? value : null + if (typeof value === 'number') { + return (Number.isFinite(value) && Math.abs(value) <= MAX_EPOCH_MS) ? value : null + } if (typeof value !== 'string') return null const ms = Date.parse(value) return Number.isFinite(ms) ? ms : null @@ -84,7 +94,11 @@ function cleanReason(value) { // Turn raw sentinel text into the window /status publishes, or null when the // file does not describe an ACTIVE, bounded, currently-open window. function parseMaintenanceWindow(text, now = Date.now()) { - if (typeof text !== 'string' || text.length === 0 || text.length > MAX_SENTINEL_BYTES) return null + // The ceiling is named in BYTES, so it is measured in bytes: String.length + // counts UTF-16 code units, which a multibyte reason undercounts by up to + // 3x against the limit it is being compared to. + if (typeof text !== 'string' || text.length === 0) return null + if (Buffer.byteLength(text, 'utf8') > MAX_SENTINEL_BYTES) return null let doc try { doc = JSON.parse(text) } catch { return null } @@ -97,10 +111,23 @@ function parseMaintenanceWindow(text, now = Date.now()) { if (until === null || until <= now) return null if (until - now > MAX_WINDOW_MS) return null - // A window may be declared ahead of time; it is not active until it opens. + // `since` is optional, but a SUPPLIED one that does not read as a timestamp + // is a wrong sentinel, not an absent start: silently demoting it to null + // would let a typo buy the unbounded-span treatment below. + const sinceSupplied = doc.since !== undefined && doc.since !== null const since = toEpochMs(doc.since) + if (sinceSupplied && since === null) return null + + // A window may be declared ahead of time; it is not active until it opens. if (since !== null && since > now) return null + // The ceiling above bounds only the time REMAINING, which a stale overlong + // declaration grows into: a 30-day window is refused for 29 days and then + // starts excusing a genuine outage through its final 24 hours. When the + // operator declared a start, the declared span is bounded too, so the same + // sentinel can never go from refused to active as its expiry nears. + if (since !== null && until - since > MAX_WINDOW_MS) return null + return { active: true, reason: cleanReason(doc.reason), @@ -113,13 +140,41 @@ function parseMaintenanceWindow(text, now = Date.now()) { // fails because a maintenance note is unreadable would be a strictly worse // probe than the one that predates this file. async function readMaintenanceWindow(now = Date.now(), filePath = sentinelPath()) { - let text + let bytes try { - text = await fs.promises.readFile(filePath, 'utf8') + bytes = await readBounded(filePath, MAX_SENTINEL_BYTES + 1) } catch { return null // absent (the normal case) or unreadable } - return parseMaintenanceWindow(text, now) + // One byte past the ceiling is enough to know the file is too big to be a + // sentinel, and it is refused before it is decoded or parsed. + if (bytes.length > MAX_SENTINEL_BYTES) return null + // Defence in depth for the promise in this comment: a formatting bug in the + // parser must degrade to "no maintenance", not take the probe down with it. + try { + return parseMaintenanceWindow(bytes.toString('utf8'), now) + } catch { + return null + } +} + +// At most `limit` bytes, so a runaway file is refused by size instead of being +// materialized on every readiness probe. Reads in a loop because a single +// read() may come back short, and closes the handle on every path. +async function readBounded(filePath, limit) { + const handle = await fs.promises.open(filePath, 'r') + try { + const buf = Buffer.alloc(limit) + let filled = 0 + while (filled < limit) { + const { bytesRead } = await handle.read(buf, filled, limit - filled, filled) + if (bytesRead === 0) break + filled += bytesRead + } + return buf.subarray(0, filled) + } finally { + await handle.close().catch(() => {}) + } } module.exports = { diff --git a/src/singleInstanceGuard.js b/src/singleInstanceGuard.js index 3f754db..45a4160 100644 --- a/src/singleInstanceGuard.js +++ b/src/singleInstanceGuard.js @@ -33,6 +33,35 @@ const fs = require('fs') const os = require('os') const path = require('path') +const crypto = require('crypto') + +// Filesystems that cannot hard-link. The atomic publication below falls back to +// the older open-'wx'-then-write on exactly these and on nothing else, because +// swallowing an unrecognized errno would silently give up the atomicity the +// whole protocol rests on. +const NO_HARDLINK_CODES = new Set(['EPERM', 'ENOSYS', 'EOPNOTSUPP', 'ENOTSUP', 'EXDEV']) + +// How many times a contender re-reads a lock whose record is not yet legible, +// and the pause between passes. A half-written record means an owner is mid +// publication, NOT that the lock is stale: an empty file parses to pid NaN, +// which skips every liveness and identity check, so judging it stale unlinks +// the lock out from under the owner still writing it. The window is bounded so +// a genuinely corrupt lock is still recoverable. +const INITIALIZING_RECHECK_PASSES = 10 +const INITIALIZING_RECHECK_MS = 10 + +// Break-and-retake passes. Two processes can judge one dead lock stale at the +// same moment; the loser's re-take throws EEXIST, and the retry loop absorbs it +// so it cannot escape acquireInstanceLock raw and kill boot with an opaque error +// in place of the guard's own conflict message. +const BREAK_PASSES = 5 + +// Synchronous pause. This is a boot path that runs before the event loop has +// any work on it, so there is nothing to yield to and no async seam to thread +// through the callers. +function sleepSync(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) +} // Refuses boot when the operator declares a horizontally scaled deploy. // ENCODER_REPLICAS is a deploy-manifest declaration (set it next to the @@ -135,76 +164,145 @@ function acquireInstanceLock(lockPath, env = process.env, deps = {}) { env.ENCODER_INSTANCE_LOCK_FILE || path.join(os.tmpdir(), 'xchain-encoder-' + (env.ENCODER_API_PORT || 'default') + '.lock') + // This acquisition's ownership token. It is what release() and the break path + // test against, so a lock that has changed hands since is left alone: the old + // release unlinked by PATH, so the first owner out deleted whatever record + // happened to sit at that name, including a successor's. + const token = crypto.randomBytes(16).toString('hex') + + // Publish ownership ATOMICALLY: write the complete record to a sibling temp + // file, then link it into place. link() either creates the name or throws + // EEXIST, and it publishes the finished record in one step, so the lock name + // is never observable as a zero-byte file that a contender reads as stale. + // openSync('wx') + a separate write cannot promise that, and the window is + // wide: self() reads /proc between the two. const tryTake = () => { - const fd = fs.openSync(file, 'wx') - fs.writeSync(fd, JSON.stringify({ pid: process.pid, cmd: self() })) - fs.closeSync(fd) + const record = JSON.stringify({ pid: process.pid, cmd: self(), token }) + const tmp = file + '.' + process.pid + '.' + token.slice(0, 8) + '.tmp' + let tmpWritten = false + try { + const fd = fs.openSync(tmp, 'wx') + try { fs.writeSync(fd, record) } finally { fs.closeSync(fd) } + tmpWritten = true + fs.linkSync(tmp, file) + } catch (err) { + if (tmpWritten && NO_HARDLINK_CODES.has(err.code)) { + // No hard links on this mount. Fall back to the original + // non-atomic publication, which is why the reader below still + // treats an illegible record as an initializing owner. + const fd = fs.openSync(file, 'wx') + try { fs.writeSync(fd, record) } finally { fs.closeSync(fd) } + return + } + throw err + } finally { + if (tmpWritten) { try { fs.unlinkSync(tmp) } catch (e) { /* already gone */ } } + } } - // Accepts both shapes: the JSON written above, and the bare integer written - // by encoders that predate this change (a lock can outlive an upgrade). + // Accepts three shapes: the JSON written above, the tokenless JSON written by + // encoders that predate the token, and the bare integer written by those that + // predate the record (a lock can outlive an upgrade). `legible` separates "no + // readable record yet" from "a record that names no usable pid". const readHolder = () => { let text - try { text = fs.readFileSync(file, 'utf8') } catch (e) { return { pid: NaN, cmd: null } } + try { text = fs.readFileSync(file, 'utf8') } catch (e) { return { pid: NaN, cmd: null, token: null, legible: false } } + if (text.trim() === '') return { pid: NaN, cmd: null, token: null, legible: false } // NB: a bare pid is itself valid JSON ('1' parses to the number 1), so the // object check is what keeps a legacy lock on the legacy path instead of // reading it as a record with no pid and breaking a live holder's lock. try { const parsed = JSON.parse(text) if (parsed && typeof parsed === 'object') { - return { pid: parseInt(parsed.pid, 10), cmd: parsed.cmd || null } + const pid = parseInt(parsed.pid, 10) + return { pid, cmd: parsed.cmd || null, token: parsed.token || null, legible: Number.isInteger(pid) } } } catch (e) { /* not JSON at all: fall through to the bare-pid reading */ } - return { pid: parseInt(text, 10), cmd: null } + const pid = parseInt(text, 10) + return { pid, cmd: null, token: null, legible: Number.isInteger(pid) } } - try { - tryTake() - } catch (err) { - if (err.code !== 'EEXIST') throw err - const holder = readHolder() - const holderPid = holder.pid - if (Number.isInteger(holderPid) && holderPid > 0 && alive(holderPid) && holderPid !== process.pid) { - // Alive, and not us. Only a matching identity makes it a real conflict. - const liveCmd = describe(holderPid) - // Recorded identity: the strong test, an exact mismatch means reuse. - let reused = holder.cmd !== null && liveCmd !== null && liveCmd !== holder.cmd - // A lock written before this change carries no identity to compare, and - // refusing on it would reproduce the very wedge this fixes on the first - // restart after the upgrade (the lock outlives the process that wrote - // it, so an old bare-pid file is exactly what a rolling deploy meets). - // Weaker but sufficient test for that case: the holder cannot be an - // encoder if its command line is not running one. - if (!reused && holder.cmd === null && liveCmd !== null && !looksLikeEncoder(liveCmd)) { - reused = true + // Re-read an illegible lock a bounded number of times before judging it. + // An owner publishing through the non-atomic fallback above is mid-write, + // not dead, and breaking its lock is how two processes both came to hold one. + const readHolderSettled = () => { + let holder = readHolder() + for (let pass = 0; !holder.legible && pass < INITIALIZING_RECHECK_PASSES; pass++) { + sleepSync(INITIALIZING_RECHECK_MS) + holder = readHolder() + } + return holder + } + + for (let pass = 0; ; pass++) { + try { + tryTake() + break + } catch (err) { + if (err.code !== 'EEXIST') throw err + const holder = readHolderSettled() + const holderPid = holder.pid + if (Number.isInteger(holderPid) && holderPid > 0 && alive(holderPid) && holderPid !== process.pid) { + // Alive, and not us. Only a matching identity makes it a real conflict. + const liveCmd = describe(holderPid) + // Recorded identity: the strong test, an exact mismatch means reuse. + let reused = holder.cmd !== null && liveCmd !== null && liveCmd !== holder.cmd + // A lock written before this change carries no identity to compare, and + // refusing on it would reproduce the very wedge this fixes on the first + // restart after the upgrade (the lock outlives the process that wrote + // it, so an old bare-pid file is exactly what a rolling deploy meets). + // Weaker but sufficient test for that case: the holder cannot be an + // encoder if its command line is not running one. + if (!reused && holder.cmd === null && liveCmd !== null && !looksLikeEncoder(liveCmd)) { + reused = true + } + if (!reused) { + throw new Error( + 'Another xchain-encoder instance (pid ' + holderPid + ') holds the instance lock ' + + file + '. The outpoint-reservation and recent-build stores are in-process; ' + + 'running two encoder instances against one UTXO set risks conflicting ' + + 'double-spend PSBTs, and lets one transaction be built twice and journaled ' + + 'as two successes. Stop the other instance, or set ENCODER_INSTANCE_LOCK_FILE ' + + 'to isolate intentionally separate deployments.' + ) + } + console.warn('singleInstanceGuard: breaking a stale lock on ' + file + ': pid ' + + holderPid + ' is alive but is running "' + liveCmd + '", ' + + (holder.cmd === null + ? 'which is not an encoder (the lock predates identity recording)' + : 'not the "' + holder.cmd + '" that took the lock') + + '. This is pid reuse after an unclean shutdown, not a second instance.') } - if (!reused) { + // Stale (dead holder, garbage contents, or a reused pid): break and + // re-take. A second process can reach this same verdict on the same lock, + // so the re-take is not guaranteed to win. Losing it is a normal outcome: + // go round again and re-evaluate whoever now holds the lock, and only + // when the passes run out does the conflict error below stand in place of + // a raw EEXIST escaping this function and killing boot. + try { fs.unlinkSync(file) } catch (e) { /* somebody else broke it first */ } + if (pass >= BREAK_PASSES) { throw new Error( - 'Another xchain-encoder instance (pid ' + holderPid + ') holds the instance lock ' + - file + '. The outpoint-reservation and recent-build stores are in-process; ' + - 'running two encoder instances against one UTXO set risks conflicting ' + - 'double-spend PSBTs, and lets one transaction be built twice and journaled ' + - 'as two successes. Stop the other instance, or set ENCODER_INSTANCE_LOCK_FILE ' + - 'to isolate intentionally separate deployments.' + 'Another xchain-encoder instance keeps re-taking the instance lock ' + file + + ' faster than this one can. The outpoint-reservation and recent-build stores ' + + 'are in-process; running two encoder instances against one UTXO set risks ' + + 'conflicting double-spend PSBTs. Stop the other instance, or set ' + + 'ENCODER_INSTANCE_LOCK_FILE to isolate intentionally separate deployments.' ) } - console.warn('singleInstanceGuard: breaking a stale lock on ' + file + ': pid ' + - holderPid + ' is alive but is running "' + liveCmd + '", ' + - (holder.cmd === null - ? 'which is not an encoder (the lock predates identity recording)' - : 'not the "' + holder.cmd + '" that took the lock') + - '. This is pid reuse after an unclean shutdown, not a second instance.') } - // Stale (dead holder, garbage contents, or a reused pid): break and re-take. - fs.unlinkSync(file) - tryTake() } let released = false return function release() { if (released) return released = true - try { fs.unlinkSync(file) } catch (e) { /* already gone */ } + // Unlink only OUR record. The lock may legitimately have changed hands + // (an instance ruled stale on the pid-reuse path is still running and + // still owns a release), and deleting by path alone is how one owner's + // exit freed another owner's lock. + try { + if (readHolder().token === token) fs.unlinkSync(file) + } catch (e) { /* already gone, or unreadable: leave it for the stale path */ } } } diff --git a/test/regression/reg-19-envelope-cancel-reservation.test.js b/test/regression/reg-19-envelope-cancel-reservation.test.js index 4f16fd2..721de97 100644 --- a/test/regression/reg-19-envelope-cancel-reservation.test.js +++ b/test/regression/reg-19-envelope-cancel-reservation.test.js @@ -179,4 +179,96 @@ describe('envelope-cancel outpoint reservation @regression', function () { assert.strictEqual(encoder.envelopeCancelClaims.size, 0, 'a stale stamp would let a foreign claim read as this path\'s own') }) + + // A lost-response retry legitimately re-claims an outpoint the first build is + // still outstanding on. While ownership was one expiry stamp per outpoint, the + // retry overwrote it and its own failure then deleted the reservation outright, + // handing the commit outpoint to create_tx while the first unsigned cancel was + // still spending it. Ownership is a per-build token set now: the last owner out + // frees the outpoint, nobody else. + it('a failed retry leaves the outstanding cancel\'s reservation in place', async function () { + const encoder = makeEncoder() + const first = await encoder.createEnvelopeCancelTransaction(cancelRecord(encoder)) + const firstExpiry = encoder.outpointReservations.get(COMMIT_KEY) + + await assert.rejects( + // Below the dust floor: throws after the retry has taken its own claim. + encoder.createEnvelopeCancelTransaction(cancelRecord(encoder, { commitValue: 600 })), + (err) => err.xchainCode === 'ENVELOPE_CANCEL_BELOW_DUST') + + assert.strictEqual(encoder.outpointReservations.get(COMMIT_KEY), firstExpiry, + 'the retry\'s failure must hand back only its own claim, not the outstanding build\'s') + assert.ok(first.reservation, 'the successful build must carry a release ticket') + }) + + it('create_tx still cannot select the commit outpoint after a failed retry', async function () { + const encoder = makeEncoder() + const address = callerAddress(encoder.network) + const action = actions.makeSend('JDOG', '42', actions.ADDR_BTC) + + await encoder.createEnvelopeCancelTransaction(cancelRecord(encoder)) + await assert.rejects( + encoder.createEnvelopeCancelTransaction(cancelRecord(encoder, { commitValue: 600 })), + (err) => err.xchainCode === 'ENVELOPE_CANCEL_BELOW_DUST') + + const utxos = [ + segwitUtxo(encoder.network, COMMIT_TXID, COMMIT_VOUT, 100000000), + segwitUtxo(encoder.network, OTHER_TXID, 0, 100000000) + ] + const result = await encoder.createTransaction( + utxos, address, null, action.data, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) + const spent = result.psbt.txInputs.map( + (i) => Buffer.from(i.hash).reverse().toString('hex')) + assert.ok(!spent.includes(COMMIT_TXID), + 'a retry\'s failure must not free an outpoint an outstanding unsigned cancel spends') + }) + + it('the first build\'s ticket still releases after a failed retry', async function () { + const encoder = makeEncoder() + const first = await encoder.createEnvelopeCancelTransaction(cancelRecord(encoder)) + await assert.rejects( + encoder.createEnvelopeCancelTransaction(cancelRecord(encoder, { commitValue: 600 })), + (err) => err.xchainCode === 'ENVELOPE_CANCEL_BELOW_DUST') + + const released = encoder.releaseReservation(first.reservation.id) + assert.deepStrictEqual(released.released, [COMMIT_KEY], + 'the outstanding build\'s receipt must still work; a moved stamp reported it retained') + assert.strictEqual(encoder.outpointReservations.has(COMMIT_KEY), false) + assert.strictEqual(encoder.envelopeCancelClaims.has(COMMIT_KEY), false) + }) + + it('last cancel owner out frees the outpoint, not the first', async function () { + const encoder = makeEncoder() + const first = await encoder.createEnvelopeCancelTransaction(cancelRecord(encoder)) + const second = await encoder.createEnvelopeCancelTransaction(cancelRecord(encoder)) + + const firstRelease = encoder.releaseReservation(first.reservation.id) + assert.deepStrictEqual(firstRelease.retained, [COMMIT_KEY], + 'the second build is still outstanding, so the outpoint stays reserved') + assert.strictEqual(encoder.outpointReservations.has(COMMIT_KEY), true) + + const secondRelease = encoder.releaseReservation(second.reservation.id) + assert.deepStrictEqual(secondRelease.released, [COMMIT_KEY]) + assert.strictEqual(encoder.outpointReservations.has(COMMIT_KEY), false) + assert.strictEqual(encoder.envelopeCancelClaims.has(COMMIT_KEY), false) + }) + + it('three overlapping builds: an out-of-order failure frees nothing', async function () { + const encoder = makeEncoder() + const first = await encoder.createEnvelopeCancelTransaction(cancelRecord(encoder)) + const second = await encoder.createEnvelopeCancelTransaction(cancelRecord(encoder)) + await assert.rejects( + encoder.createEnvelopeCancelTransaction(cancelRecord(encoder, { commitValue: 600 })), + (err) => err.xchainCode === 'ENVELOPE_CANCEL_BELOW_DUST') + assert.strictEqual(encoder.outpointReservations.has(COMMIT_KEY), true) + + encoder.releaseReservation(second.reservation.id) + assert.strictEqual(encoder.outpointReservations.has(COMMIT_KEY), true, + 'the first build is still outstanding') + encoder.releaseReservation(first.reservation.id) + assert.strictEqual(encoder.outpointReservations.has(COMMIT_KEY), false) + assert.strictEqual(encoder.envelopeCancelClaims.size, 0) + }) }) diff --git a/test/security/concurrency-gate.test.js b/test/security/concurrency-gate.test.js index 6dbf2ee..795b494 100644 --- a/test/security/concurrency-gate.test.js +++ b/test/security/concurrency-gate.test.js @@ -97,6 +97,19 @@ function buildServer(options){ await held res.json({ ok: true, ip: req.ip }) }) + + // /held is /expensive wrapped in gate.hold(), which is the shape api.js + // mounts the JSON-RPC router in. stubHold swaps the wrapper for a + // pass-through, and that IS the pre-fix gate, so the held-slot assertions + // below get a negative control instead of a second flavour of one route. + let heldEntered = 0 + const wrap = options.stubHold ? (fn) => fn : gate.hold + app.get('/held', wrap(async (req, res) => { + heldEntered++ + await held + res.json({ ok: true, ip: req.ip }) + })) + // The real /status makes an HTTP round-trip to the utxo-tracker, so it can // be made to park exactly like an expensive route; opts in per test. app.get('/status', async (req, res) => { @@ -110,6 +123,7 @@ function buildServer(options){ return { app, gate, probeGate, server, arrivals: () => expensiveArrivals, + enteredHeld: () => heldEntered, release: () => releaseHeld(), releaseProbe: () => releaseProbe() } } @@ -208,6 +222,96 @@ describe('Security: global in-flight concurrency cap', function () { await waitFor(() => gate.getStats().in_flight === 0, 'aborted slot to be released') }) + it('keeps a held slot while the upstream work runs on after the client aborts', async function () { + // Express never awaits an async handler, so releasing on the socket's + // 'close' let a client start a create_tx, hang up, and take a fresh slot + // while the first build was still fanning out into node and tracker RPCs. + // Repeat the abort and the cap admits work it has already counted out, + // with in_flight reading zero. hold() binds the slot to the handler's + // promise instead. + const { server, gate, release, enteredHeld } = buildServer({ limit: 1 }) + await listen(server) + + const controller = new AbortController() + const aborted = get(server, '/held', 1, { signal: controller.signal }) + await waitFor(() => gate.getStats().in_flight === 1, 'gate to reach its cap') + await waitFor(() => enteredHeld() === 1, 'the handler to have entered') + + controller.abort() + await aborted.catch(() => {}) + // Deliberate delay, NOT a synchronization point: do not convert this to + // waitFor. The claim is that in_flight STAYS 1 across a window in which + // 'close' had every chance to fire and be ignored, so the elapsed time IS + // the measurement. A predicate on in_flight === 1 already holds on entry + // and would return on its first tick, asserting nothing; without the + // wrapper the slot is already back by the end of this window. + await new Promise(r => setTimeout(r, 50)) + + assert.strictEqual(gate.getStats().in_flight, 1) + // The behavioural half: the next caller is refused because the work the + // first one started is still running. + const overflow = await get(server, '/held', 2) + assert.strictEqual(overflow.status, 429) + assert.strictEqual((await overflow.json()).error.message, 'Server busy, retry shortly') + assert.strictEqual(enteredHeld(), 1) + + release() + await waitFor(() => gate.getStats().in_flight === 0, 'slot to come back once the work settled') + assert.strictEqual((await get(server, '/held', 3)).status, 200) + }) + + it('negative control: with hold() stubbed out, the aborted slot is handed to the next caller', async function () { + // The same scenario against a pass-through wrapper, which is exactly the + // pre-fix gate. If this ever goes green the assertion above has stopped + // measuring anything. + const { server, gate, enteredHeld } = buildServer({ limit: 1, stubHold: true }) + await listen(server) + + const controller = new AbortController() + const aborted = get(server, '/held', 1, { signal: controller.signal }) + await waitFor(() => gate.getStats().in_flight === 1, 'gate to reach its cap') + await waitFor(() => enteredHeld() === 1, 'the handler to have entered') + + controller.abort() + await aborted.catch(() => {}) + await waitFor(() => gate.getStats().in_flight === 0, 'the socket close to free the slot') + + // Over-admission: a second handler now runs the same expensive work the + // cap of 1 was meant to forbid. It is never awaited, because it parks in + // the handler exactly like the first one did. + get(server, '/held', 2).catch(() => {}) + await waitFor(() => enteredHeld() === 2, 'a second handler to be admitted past the cap') + assert.strictEqual(gate.getStats().shed, 0) + }) + + it('passes a request through hold() untouched when the gate holds no slot for it', async function () { + // A disabled gate hands out no slots, so hold() must not invent one and + // must not swallow the handler. + const { server, gate, release, enteredHeld } = buildServer({ limit: 0 }) + await listen(server) + + const parked = get(server, '/held', 1) + await waitFor(() => enteredHeld() === 1, 'the wrapped handler to run past the disabled gate') + assert.deepStrictEqual(gate.getStats(), { limit: 0, in_flight: 0, shed: 0 }) + + release() + assert.strictEqual((await parked).status, 200) + }) + + it('api.js mounts the JSON-RPC router and /status inside hold()', function () { + // The wrapper is the whole fix, and it lives at the mount site rather + // than inside the gate, so an unwrapped mount silently puts a route back + // outside the cap with no test of its own going red. + const apiSource = require('fs').readFileSync( + require('path').join(__dirname, '../../src/api.js'), 'utf8') + assert.ok(apiSource.includes('app.use(requestGate.hold(jsonRouter('), + 'the JSON-RPC router must be mounted inside requestGate.hold()') + // /status is skipped by the main gate, so its slot lives in the probe + // reserve and only that gate's hold() can claim it. + assert.ok(apiSource.includes("app.get('/status', probeGate.hold("), + '/status must hold its PROBE-reserve slot, not a main-gate slot') + }) + it('still answers the /status readiness probe while the main gate sheds', async function () { const { server, gate } = buildServer({ limit: 1 }) await listen(server) diff --git a/test/unit/BlockchainConnector.test.js b/test/unit/BlockchainConnector.test.js index 571951a..4d8c56b 100644 --- a/test/unit/BlockchainConnector.test.js +++ b/test/unit/BlockchainConnector.test.js @@ -102,6 +102,35 @@ describe('BlockchainConnector.getNetworkInfo()', () => { ) }) + // A warming node answers -28 "Loading block index..."; LTC/DOGE carry that in + // an HTTP 500 body, BTC v28 in an HTTP 200 one. Both must keep the RPC code and + // message: a bare status line leaves warmup indistinguishable from a network fault. + it('keeps the node RPC code and message from an HTTP-500 error body', async () => { + const err = new Error('Request failed with status code 500') + err.response = { status: 500, data: { error: { code: -28, message: 'Loading block index...' } } } + err.config = { auth: { username: 'rpcuser', password: 'FAKEPASS_must_never_be_logged_9c2d' } } + stubAxiosPostThrow(err) + const c = makeConnector() + await assert.rejects(() => c.getNetworkInfo(), (e) => { + assert.ok(/^Error in network request/.test(e.message), e.message) + assert.ok(e.message.includes('-28'), e.message) + assert.ok(e.message.includes('Loading block index'), e.message) + assert.ok(!e.message.includes('FAKEPASS'), 'the RPC password must never reach the message') + return true + }) + }) + + it('keeps the node RPC code and message from an HTTP-200 error body', async () => { + stubAxiosPost({ data: { result: null, error: { code: -28, message: 'Loading block index...' } } }) + const c = makeConnector() + await assert.rejects(() => c.getNetworkInfo(), (e) => { + assert.ok(/^Error in network request/.test(e.message), e.message) + assert.ok(e.message.includes('-28'), e.message) + assert.ok(e.message.includes('Loading block index'), e.message) + return true + }) + }) + it('sets a timeout on the request', async () => { let capturedOptions axios.post = async (url, data, options) => { @@ -171,6 +200,30 @@ describe('BlockchainConnector.isRegtest()', () => { /Error in network request.*timeout/ ) }) + + it('keeps the node RPC code and message from an HTTP-500 error body', async () => { + const err = new Error('Request failed with status code 500') + err.response = { status: 500, data: { error: { code: -28, message: 'Loading block index...' } } } + stubAxiosPostThrow(err) + const c = makeConnector() + await assert.rejects(() => c.chainName(), (e) => { + assert.ok(/^Error in network request/.test(e.message), e.message) + assert.ok(e.message.includes('-28'), e.message) + assert.ok(e.message.includes('Loading block index'), e.message) + return true + }) + }) + + it('keeps the node RPC code and message from an HTTP-200 error body', async () => { + stubAxiosPost({ data: { result: null, error: { code: -28, message: 'Loading block index...' } } }) + const c = makeConnector() + await assert.rejects(() => c.chainName(), (e) => { + assert.ok(/^Error in network request/.test(e.message), e.message) + assert.ok(e.message.includes('-28'), e.message) + assert.ok(e.message.includes('Loading block index'), e.message) + return true + }) + }) }) describe('BlockchainConnector.getTransactionHex()', () => { @@ -649,6 +702,59 @@ describe('BlockchainConnector.getFeePerKilobyte()', () => { ) }) + // A node reports "no fee estimate" in two shapes, and only one of them is a + // success body carrying feerate:-1. LTC/DOGE answer HTTP 500 with a JSON-RPC + // error body, so axios throws before the try-block fallback is reached; the + // testnet relay-multiplier fallback has to run on that shape too, or a public + // testnet node in an ordinary state fails every fee-bearing build. + describe('no-estimate reported as an RPC error rather than feerate:-1', () => { + it('falls back to 10x the relayfee floor on testnet when estimatesmartfee throws', async () => { + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'test' } } } + if (data.method === 'getnetworkinfo') return { data: { result: { relayfee: 0.001 } } } + if (data.method === 'estimatesmartfee') { + const err = new Error('Request failed with status code 500') + err.response = { status: 500, data: { error: { code: -1, message: 'Insufficient data' } } } + throw err + } + return { data: { result: {} } } + } + const c = makeConnector() + const feerate = await c.getFeePerKilobyte(6) + assert.ok(Math.abs(feerate - 0.01) < 1e-12, 'expected 10x the 0.001 relay floor, got ' + feerate) + }) + + it('still rejects on testnet when the node reports no relayfee either', async () => { + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'test' } } } + if (data.method === 'getnetworkinfo') return { data: { result: {} } } + if (data.method === 'estimatesmartfee') throw new Error('Request failed with status code 500') + return { data: { result: {} } } + } + const c = makeConnector() + await assert.rejects(() => c.getFeePerKilobyte(6), /status code 500/) + }) + + it('does NOT invent a rate on mainnet when estimatesmartfee throws', async () => { + let networkInfoCalled = false + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'main' } } } + if (data.method === 'getnetworkinfo') { networkInfoCalled = true; return { data: { result: { relayfee: 0.001 } } } } + if (data.method === 'estimatesmartfee') throw new Error('Request failed with status code 500') + return { data: { result: {} } } + } + const c = makeConnector() + await assert.rejects(() => c.getFeePerKilobyte(6), /status code 500/) + assert.strictEqual(networkInfoCalled, false, 'mainnet must never substitute the relay floor') + }) + + it('keeps a node-down failure a failure', async () => { + stubAxiosPostThrow(new Error('ECONNREFUSED')) + const c = makeConnector() + await assert.rejects(() => c.getFeePerKilobyte(6), /ECONNREFUSED/) + }) + }) + it('throws when result is entirely missing (non-regtest)', async () => { axios.post = async (url, data) => { if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'main' } } } diff --git a/test/unit/errorSanitize.test.js b/test/unit/errorSanitize.test.js index 3e09945..5104242 100644 --- a/test/unit/errorSanitize.test.js +++ b/test/unit/errorSanitize.test.js @@ -13,7 +13,7 @@ *********************************************************************/ const assert = require('assert') -const { upstreamErrorMessage, isTransportError } = require('../../src/errorSanitize') +const { upstreamErrorMessage, isTransportError, leaksInternalDetail } = require('../../src/errorSanitize') // Guards the encoder's outbound error sanitization: useful upstream RPC reasons // pass through, transport-level failures (which leak the internal node host:port) @@ -55,4 +55,42 @@ describe('errorSanitize.upstreamErrorMessage', () => { assert.strictEqual(isTransportError({ code: 'ECONNRESET' }), true) assert.strictEqual(isTransportError(new Error('dust')), false) }) + + // Every re-wrap between the socket and this helper builds a plain Error and + // drops err.code, so a connect failure arrives UNCLASSIFIABLE. These pin the + // second gate: an unclassified error is never safe to forward verbatim. + it('collapses a credentialed RPC URL that arrives with no transport code', () => { + const err = new Error('Error in network request: connect to http://user:secret@10.0.0.5:8332 failed') + assert.strictEqual(isTransportError(err), false, 'the re-wrap has stripped the errno') + const out = upstreamErrorMessage(err, FALLBACK) + assert.strictEqual(out, FALLBACK) + assert.ok(!out.includes('user:secret'), 'no credentials survive') + assert.ok(!out.includes('10.0.0.5'), 'no host survives') + }) + + it('collapses bare topology: a dotted quad, a host:port, and a scheme', () => { + for (const message of [ + 'Error getting utxos: tracker at 10.0.0.7 is unreachable', + 'Error in network request: btc-node.internal:8332 refused the call', + 'upstream said https://utxo-tracker.internal/rpc returned nothing' + ]) { + assert.strictEqual(upstreamErrorMessage(new Error(message), FALLBACK), FALLBACK, message) + } + }) + + // The wallet's broadcast-permanence classifier substring-matches these exact + // reasons (xchain-wallet/packages/core/src/flows/broadcastPermanence.js), so + // collapsing them would silently turn a permanent failure into a retry loop. + it('still forwards node reasons the wallet classifies on', () => { + for (const reason of [ + 'min relay fee not met, 0 < 110', + 'bad-txns-inputs-missingorspent', + 'dust', + 'too-long-mempool-chain', + 'Error getting utxos: [ADDRESS_TOO_LARGE] address has too many utxos' + ]) { + assert.strictEqual(upstreamErrorMessage(new Error(reason), FALLBACK), reason, reason) + assert.strictEqual(leaksInternalDetail(reason), false, reason) + } + }) }) diff --git a/test/unit/maintenanceWindow.test.js b/test/unit/maintenanceWindow.test.js index 5fcaef2..ab836f3 100644 --- a/test/unit/maintenanceWindow.test.js +++ b/test/unit/maintenanceWindow.test.js @@ -108,6 +108,47 @@ describe('maintenanceWindow: an operator-declared scheduled outage @regression', assert.strictEqual(parseMaintenanceWindow(JSON.stringify({ until: {} }), NOW), null); }); + // A finite number past the Date range formats as a RangeError, not a + // string, so before this the readiness probe answered 500 instead of the + // honest 200/503 the sentinel exists to annotate. + it('refuses a timestamp no Date can represent instead of throwing', function () { + assert.strictEqual(parseMaintenanceWindow(JSON.stringify({ until: in2h, since: -1e100 }), NOW), null); + assert.strictEqual(parseMaintenanceWindow(JSON.stringify({ until: 1e20 }), NOW), null); + assert.strictEqual(parseMaintenanceWindow(JSON.stringify({ until: in2h, since: 8.64e15 + 1 }), NOW), null); + }); + + // The remaining-time ceiling alone is not monotone: a 30-day declaration + // is refused for 29 days and then turns active for its final 24 hours, + // which is exactly when a real outage would be running under it. + it('refuses an overlong DECLARED span, not just an overlong remainder', function () { + const thirtyDays = 30 * 24 * 3600 * 1000; + const stale = JSON.stringify({ since: NOW - thirtyDays, until: NOW + 3600 * 1000 }); + assert.strictEqual(parseMaintenanceWindow(stale, NOW), null, 'a 30-day span is not a maintenance window'); + + // The same unchanged sentinel, evaluated at two clocks: an invalid + // declaration must not become valid as its expiry approaches. + const opened = JSON.stringify({ since: NOW, until: NOW + thirtyDays }); + assert.strictEqual(parseMaintenanceWindow(opened, NOW + 60000), null, 'invalid just after it opens'); + assert.strictEqual(parseMaintenanceWindow(opened, NOW + thirtyDays - 3600 * 1000), null, 'still invalid an hour before expiry'); + }); + + it('keeps a declared span of exactly the ceiling, and refuses one millisecond more', function () { + const atCeiling = JSON.stringify({ since: NOW - (MAX_WINDOW_MS - 3600 * 1000), until: NOW + 3600 * 1000 }); + assert.ok(parseMaintenanceWindow(atCeiling, NOW), 'the ceiling itself is still a window'); + const overCeiling = JSON.stringify({ since: NOW - (MAX_WINDOW_MS - 3600 * 1000) - 1, until: NOW + 3600 * 1000 }); + assert.strictEqual(parseMaintenanceWindow(overCeiling, NOW), null); + }); + + // MAX_SENTINEL_BYTES is named in bytes, so it must be measured in bytes: + // String.length counts UTF-16 code units, which a multibyte reason + // undercounts by up to 3x against the limit it is compared to. + it('measures the sentinel ceiling in bytes, not UTF-16 code units', function () { + const multibyte = JSON.stringify({ until: in2h, reason: '中'.repeat(1600) }); + assert.ok(multibyte.length <= MAX_SENTINEL_BYTES, 'the fixture is under the ceiling by character count'); + assert.ok(Buffer.byteLength(multibyte, 'utf8') > MAX_SENTINEL_BYTES, 'and over it by byte count'); + assert.strictEqual(parseMaintenanceWindow(multibyte, NOW), null, 'an oversized file is not a sentinel'); + }); + // The body is public JSON and the reason is free text, so it is bounded // and stripped before it can leave the process. it('bounds and sanitizes the reason', function () { @@ -145,6 +186,27 @@ describe('maintenanceWindow: an operator-declared scheduled outage @regression', assert.strictEqual(w.active, true); assert.strictEqual(w.reason, 'bootstrap publish'); }); + + // A runaway file must be refused by SIZE, before it is decoded or + // parsed, which is the whole reason the ceiling exists. + it('refuses an oversized file without materializing it', async function () { + const p = path.join(dir, 'runaway.json'); + fs.writeFileSync(p, JSON.stringify({ until: in2h, reason: '中'.repeat(200000) })); + assert.ok(fs.statSync(p).size > MAX_SENTINEL_BYTES * 100, 'the fixture is far past the ceiling'); + assert.strictEqual(await readMaintenanceWindow(NOW, p), null); + }); + + it('never rejects on a sentinel whose timestamps cannot be formatted', async function () { + const p = path.join(dir, 'unrepresentable.json'); + fs.writeFileSync(p, JSON.stringify({ until: in2h, since: -1e100 })); + assert.strictEqual(await readMaintenanceWindow(NOW, p), null); + }); + + it('refuses an overlong declared span read off disk', async function () { + const p = path.join(dir, 'stale.json'); + fs.writeFileSync(p, JSON.stringify({ since: NOW - 30 * 24 * 3600 * 1000, until: NOW + 3600 * 1000 })); + assert.strictEqual(await readMaintenanceWindow(NOW, p), null); + }); }); describe('sentinelPath()', function () { diff --git a/test/unit/packageFeeSizing.test.js b/test/unit/packageFeeSizing.test.js index 4a19f2b..9d85f2c 100644 --- a/test/unit/packageFeeSizing.test.js +++ b/test/unit/packageFeeSizing.test.js @@ -157,6 +157,87 @@ describe('BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 assert.strictEqual(await makeConnector().getUnconfirmedAncestorPackage([TXID_PARENT_A]), null) }) + // Number(null), Number('') and Number(false) are all a finite 0, so a bare + // Number() cast priced an unreadable fee field as a genuine zero-fee ancestor + // and the package was uplifted against an understated total. An unreadable + // field has to invalidate the package, exactly as the contract above says. + for (const [label, fee] of [['null', null], ['an empty string', ''], ['false', false]]) { + it(`returns null when the flat fee field is ${label}`, async () => { + stubMempool({ + [TXID_PARENT_A]: { entry: { size: 400, fee }, ancestors: [] } + }) + assert.strictEqual(await makeConnector().getUnconfirmedAncestorPackage([TXID_PARENT_A]), null) + }) + } + + it('returns null when the nested fees.base field is null', async () => { + stubMempool({ + [TXID_PARENT_A]: { entry: { vsize: 400, fees: { base: null } }, ancestors: [] } + }) + assert.strictEqual(await makeConnector().getUnconfirmedAncestorPackage([TXID_PARENT_A]), null) + }) + + it('still prices a legitimate zero-fee ancestor, in both field layouts', async () => { + stubMempool({ + [TXID_PARENT_A]: { entry: { size: 400, fee: 0 }, ancestors: [] } + }) + assert.deepStrictEqual(await makeConnector().getUnconfirmedAncestorPackage([TXID_PARENT_A]), { size: 400, fees: 0 }) + stubMempool({ + [TXID_PARENT_B]: { entry: { vsize: 250, fees: { base: 0 } }, ancestors: [] } + }) + assert.deepStrictEqual(await makeConnector().getUnconfirmedAncestorPackage([TXID_PARENT_B]), { size: 250, fees: 0 }) + }) + + it('returns null when the size field is a boolean rather than a number', async () => { + // Number(true) is 1, which used to be accepted as a one-byte ancestor. + stubMempool({ + [TXID_PARENT_A]: { entry: { vsize: true, fee: 0.001 }, ancestors: [] } + }) + assert.strictEqual(await makeConnector().getUnconfirmedAncestorPackage([TXID_PARENT_A]), null) + }) + + // getmempoolentry and getmempoolancestors are two independent round trips, so + // a block can confirm the root between them; the node then answers the second + // call with -5. The root's bytes and fee must not survive that: paying to + // accelerate an already-mined parent is real coin spent on nothing. + it('discards a root that confirms between its two RPC calls', async () => { + const mempool = { + [TXID_PARENT_A]: { entry: entry(400, 0.001), ancestors: [TXID_GRANDPA] }, + [TXID_GRANDPA]: { entry: entry(300, 0.0005), ancestors: [] } + } + stubMempool(mempool, (data) => { + if (data.method === 'getmempoolancestors' && data.params[0] === TXID_PARENT_A) { + delete mempool[TXID_PARENT_A] + } + }) + const pkg = await makeConnector().getUnconfirmedAncestorPackage([TXID_PARENT_A]) + assert.deepStrictEqual(pkg, { size: 0, fees: 0 }) + }) + + it('keeps an earlier root\'s branch when a later root confirms mid-sequence', async () => { + const mempool = { + [TXID_PARENT_A]: { entry: entry(400, 0.001), ancestors: [TXID_SHARED] }, + [TXID_PARENT_B]: { entry: entry(500, 0.002), ancestors: [TXID_SHARED] }, + [TXID_SHARED]: { entry: entry(200, 0.0004), ancestors: [] } + } + stubMempool(mempool, (data) => { + if (data.method === 'getmempoolancestors' && data.params[0] === TXID_PARENT_B) { + delete mempool[TXID_PARENT_B] + } + }) + const pkg = await makeConnector().getUnconfirmedAncestorPackage([TXID_PARENT_A, TXID_PARENT_B]) + assert.strictEqual(pkg.size, 600, 'parent A plus the shared ancestor it validly committed') + assert.ok(Math.abs(pkg.fees - 0.0014) < 1e-12, 'got ' + pkg.fees) + }) + + it('returns null when the second call fails for a reason other than absence', async () => { + axios.post = async (url, data) => { + if (data.method === 'getmempoolentry') return { data: { result: entry(400, 0.001) } } + return { data: { error: { code: -32603, message: 'Internal error' } } } + } + assert.strictEqual(await makeConnector().getUnconfirmedAncestorPackage([TXID_PARENT_A]), null) + }) + it('does not leak the RPC password when a mempool call fails', async () => { const util = require('util') const FAKE_RPC_PASSWORD = 'FAKEPASS_must_never_be_logged_4b1e' diff --git a/test/unit/singleInstanceGuard.test.js b/test/unit/singleInstanceGuard.test.js index 21634eb..b29958f 100644 --- a/test/unit/singleInstanceGuard.test.js +++ b/test/unit/singleInstanceGuard.test.js @@ -306,6 +306,77 @@ describe('singleInstanceGuard', function () { assert.strictEqual(fs.existsSync(file), true) release() }) + + // Publication is a single link() of a fully written record: finished, or not + // at all. A zero-byte file observable at the lock path reads as pid NaN, which + // fails the Number.isInteger test and so skips the ENTIRE liveness and identity + // block, falling straight through to unlink-and-take: two processes then hold + // the lock, and the first release deletes the other's record. + it('never leaves a half-written record observable at the lock path', function () { + const file = path.join(dir, 'atomic.lock') + const observed = [] + // selfDescription runs INSIDE publication, which is exactly the window + // the old protocol left open, so it doubles as the observation hook. + const release = acquireInstanceLock(file, {}, { + selfDescription: () => { + observed.push(fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null) + return 'node /XChainEncoder/src/api.js' + } + }) + assert.ok(observed.length > 0, 'the observation hook must have run inside publication') + for (const sample of observed) { + if (sample === null) continue // the name does not exist yet: fine + const parsed = JSON.parse(sample) // throws on the zero-byte file + assert.ok(Number.isInteger(parsed.pid), 'a published record must name a pid') + } + release() + }) + + it('leaves no .tmp file behind after an acquire/release cycle', function () { + const file = path.join(dir, 'tmp.lock') + const release = acquireInstanceLock(file) + assert.deepStrictEqual(fs.readdirSync(dir).filter((n) => n.endsWith('.tmp')), []) + release() + assert.deepStrictEqual(fs.readdirSync(dir).filter((n) => n.endsWith('.tmp')), []) + }) + + it('waits out an illegible lock instead of breaking it on sight', function () { + const file = path.join(dir, 'initializing.lock') + // A zero-byte lock is an owner mid-publication on the no-hardlink + // fallback path, not a stale lock, so it must not be unlinked on sight. + // The ELAPSED TIME is the measurement: the bounded re-check has to + // expire before the break, and breaking on sight returns in no time. + fs.writeFileSync(file, '') + const started = Date.now() + const release = acquireInstanceLock(file, {}, { isPidAlive: () => true }) + const elapsed = Date.now() - started + assert.ok(elapsed >= 50, + 'an illegible lock must be re-read before it is judged stale, took ' + elapsed + 'ms') + assert.strictEqual(heldPid(file), process.pid, 'and it is still recoverable afterwards') + release() + }) + + it('release does not delete a lock that has changed hands', function () { + const file = path.join(dir, 'ownership.lock') + const release = acquireInstanceLock(file) + // A successor's record at the same path: a pid-reuse break leaves the + // ruled-stale process alive and still holding a release closure. + const successor = JSON.stringify({ pid: 999999, cmd: 'node other', token: 'f'.repeat(32) }) + fs.writeFileSync(file, successor) + release() + assert.strictEqual(fs.existsSync(file), true, + 'unlinking by path alone is how one owner\'s exit freed another owner\'s lock') + assert.strictEqual(fs.readFileSync(file, 'utf8'), successor) + }) + + it('records an ownership token, which is what release verifies against', function () { + const file = path.join(dir, 'token.lock') + const release = acquireInstanceLock(file) + const record = JSON.parse(fs.readFileSync(file, 'utf8')) + assert.match(record.token, /^[0-9a-f]{32}$/) + release() + assert.strictEqual(fs.existsSync(file), false) + }) }) describe('isPidAlive', function () { From 4e3ef9bc66e08913d9f089022766252e90abed86 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sat, 12 Sep 2026 21:32:18 -0700 Subject: [PATCH 004/148] fix(encoder): warn on an unknown action name instead of dropping it silently A direct library caller whose payload led with an unrecognized action name previously had it compiled and paid for with no signal that it would be dropped. createTransaction now reports it as a warning, covering both string and Buffer inputs. --- src/XChainEncoder.js | 39 +++++++++++++- src/validator.js | 38 +++++++++++--- .../XChainEncoder.createTransaction.test.js | 51 +++++++++++++++++++ 3 files changed, 119 insertions(+), 9 deletions(-) diff --git a/src/XChainEncoder.js b/src/XChainEncoder.js index 8ae5174..0959f83 100644 --- a/src/XChainEncoder.js +++ b/src/XChainEncoder.js @@ -28,7 +28,7 @@ const BlockchainConnector = require('./BlockchainConnector') const CryptoNetworks = require('./CryptoNetworks') const UtxoTracker = require('./UtxoTracker') const TxSizeEstimator = require("./TxSizeEstimator") -const { MAX_COMPILED_ACTION_DATA_LENGTH, ENVELOPE_MAX_PAYLOAD, MAX_UTXO_COUNT, validateUtxoEntry, parseSatoshiAmount, validateFeePerKb, validateOptionalBoolean, validateAddress, validateDataParam, validateActionPushDecodability } = require('./validator') +const { MAX_COMPILED_ACTION_DATA_LENGTH, ENVELOPE_MAX_PAYLOAD, MAX_UTXO_COUNT, validateUtxoEntry, parseSatoshiAmount, validateFeePerKb, validateOptionalBoolean, validateAddress, validateDataParam, validateActionPushDecodability, unknownActionName } = require('./validator') const { compressPayloadForAction } = require('./compression') const { OperationalError } = require('./errors') const { upstreamErrorMessage } = require('./errorSanitize') @@ -1174,6 +1174,28 @@ class XChainEncoder { if (typeof data === 'string') validateDataParam(data, 'data') if (typeof rawData === 'string') validateDataParam(rawData, 'rawData') + // The ACTION-name recognition check has the same one-surface problem, and a + // different answer. validateAll REFUSES an unrecognized leading token on the + // JSON-RPC path (validator.js), so only a direct library caller reaches the + // compiler with one: the transaction compiles, pays a real miner fee, and the + // decoder drops the ACTION with nothing reported back. The compiled-size + // checks cannot catch it, because an unknown name is an ordinary length. + // + // REPORTED, not refused, matching the rawDataOnlyPayload advisory below + // rather than the RPC gate. createTransaction is a general PSBT builder as + // well as an ACTION builder, and its supported shapes include a payload that + // is not an XChain ACTION at all; refusing here would withdraw that contract + // from every library and browser-bundle caller to close a silence, and the + // silence is the whole of the harm. The fee-payer learns before it signs, + // which needs no acceptance change. + // + // Read BEFORE compression, on the caller's own bytes, which is the surface + // validateAll measures; the compression pass rewrites `data` afterwards. + // Buffer inputs read too, unlike the latin-1 re-check above: that one is + // string-only because a Buffer loses nothing in the conversion, while the + // decoder tokenizes the compiled push whatever shape the caller handed over. + const unknownAction = unknownActionName(data) + // If feeQuote is provided, inject it as a custom output if(feeQuote && feeQuote.address && feeQuote.amount > 0){ if(!customOutputs) customOutputs = []; @@ -2985,6 +3007,21 @@ class XChainEncoder { }] } + // The library path's ACTION-name advisory; see unknownAction above. Same + // additive `warnings` field, appended rather than assigned so it can ride + // alongside the rawData-only advisory when a caller earns both. + if (unknownAction != null){ + if (!result.warnings) result.warnings = [] + result.warnings.push({ + code: 'UNKNOWN_ACTION_NAME', + message: `data leads with '${String(unknownAction).slice(0, 32)}', which is ` + + 'neither a canonical XChain ACTION name nor a known alias: the transaction ' + + 'will confirm and the fee will be paid, but every decoder drops the payload ' + + 'instead of indexing it as an ACTION. The JSON-RPC create_tx surface refuses ' + + 'this payload outright' + }) + } + // What compression actually did, reported rather than inferred. The // wallet has to show the REAL on-chain size, and with the // default ON a caller can no longer assume from its own request whether diff --git a/src/validator.js b/src/validator.js index 90c9fb1..811f787 100644 --- a/src/validator.js +++ b/src/validator.js @@ -375,16 +375,37 @@ function validateActionPushDecodability(data, rawData) { // ACTION token to validate and the decoder never reaches its own name gate // for a zero-length payload either (see the `parseResult["data"].length > 0` // guard around the decoder's gate). +// +// The recognition test itself lives in unknownActionName below, so the two +// surfaces that need it share one tokenizer: this validator REFUSES on the +// JSON-RPC path, and XChainEncoder._buildTransaction REPORTS on the library +// path, where a generic `data` payload is a supported shape. function validateActionName(data) { - if (data == null || data.length === 0) return - const rawActionName = data.split('|')[0] + const rawActionName = unknownActionName(data) + if (rawActionName == null) return + throw new RangeError( + `data has unknown ACTION name '${rawActionName.slice(0, 32)}'; ` + + 'the decoder rejects any leading token that is not a canonical action ' + + 'name or alias, silently dropping the ACTION on a fee-paid transaction') +} + +// The leading ACTION token of `data` when the decoder would NOT recognize it, +// else null. Same tokenization as the decoder and as validateActionName's own +// contract above; splitting it out lets a caller that must stay buildable +// report the token instead of throwing on it. +// +// Buffer `data` reads too, because the library builder accepts that shape and +// the compiler copies those bytes verbatim, so the decoder tokenizes the same +// leading token whichever shape arrived. Every canonical name and alias is +// ASCII, so decoding the buffer never turns a recognized name into an +// unrecognized one. A value that is neither a string nor a Buffer reads as null +// and is left to the shape errors downstream. +function unknownActionName(data) { + if (data == null || data.length === 0) return null + if (typeof data !== 'string' && !Buffer.isBuffer(data)) return null + const rawActionName = (Buffer.isBuffer(data) ? data.toString('utf8') : data).split('|')[0] const actionName = ACTION_ALIASES[rawActionName] ?? rawActionName - if (!VALID_ACTION_NAMES.has(actionName)) { - throw new RangeError( - `data has unknown ACTION name '${rawActionName.slice(0, 32)}'; ` + - 'the decoder rejects any leading token that is not a canonical action ' + - 'name or alias, silently dropping the ACTION on a fee-paid transaction') - } + return VALID_ACTION_NAMES.has(actionName) ? null : rawActionName } // MEASURAND: the CALLER'S bytes, before compression, deliberately (AML #5308). @@ -893,6 +914,7 @@ module.exports = { isMinimalOpSingleByte, validateActionPushDecodability, validateActionName, + unknownActionName, VALID_ACTION_NAMES, ACTION_ALIASES, // Exported for the decoder's compiledPushSizeConformance test, which pins diff --git a/test/unit/XChainEncoder.createTransaction.test.js b/test/unit/XChainEncoder.createTransaction.test.js index 53703f3..37fdfc9 100644 --- a/test/unit/XChainEncoder.createTransaction.test.js +++ b/test/unit/XChainEncoder.createTransaction.test.js @@ -1258,4 +1258,55 @@ describe('XChainEncoder.createTransaction() payload guards (library boundary)', ) assert.ok(result.psbt instanceof bitcoin.Psbt) }) + + // The ACTION-name parity gap. validateAll refuses an unrecognized leading + // token on the JSON-RPC path, so the direct library entry point is the one + // surface that can compile and pay for a payload every decoder then drops. The + // builder reports it instead of refusing, because a non-ACTION payload is a + // supported library shape; what must not happen is the silence. + const warningCodes = (result) => (result.warnings || []).map((w) => w.code) + + it('warns a library caller whose data leads with an unknown ACTION name', async () => { + const encoder = makeEncoder() + const result = await encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + 'TRANSFRE|0|TOKEN|1|^2', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + assert.ok(result.psbt instanceof bitcoin.Psbt, 'the build must still succeed') + assert.ok(warningCodes(result).includes('UNKNOWN_ACTION_NAME')) + assert.ok(/TRANSFRE/.test(result.warnings.find((w) => w.code === 'UNKNOWN_ACTION_NAME').message)) + }) + + it('warns when an unknown ACTION name arrives as a Buffer', async () => { + const encoder = makeEncoder() + const result = await encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + Buffer.from('TRANSFRE|0|TOKEN|1|^2', 'utf8'), null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + assert.ok(warningCodes(result).includes('UNKNOWN_ACTION_NAME')) + }) + + // The negative controls: a canonical name and an alias must produce NO + // advisory, or the warning is decoration rather than a signal. + it('does not warn on a canonical ACTION name', async () => { + const encoder = makeEncoder() + const result = await encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + 'SEND|0|TOKEN|1|^2', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + assert.ok(!warningCodes(result).includes('UNKNOWN_ACTION_NAME')) + }) + + it('does not warn on a known ACTION alias', async () => { + const encoder = makeEncoder() + const result = await encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + 'TRANSFER|0|TOKEN|1|^2', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + assert.ok(!warningCodes(result).includes('UNKNOWN_ACTION_NAME')) + }) }) From 7004ff1ffa8330835b9c346a4676b6e842b633ec Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 13 Sep 2026 14:59:47 -0700 Subject: [PATCH 005/148] test: pin suite titles and identity hashes for the unit tier Ports the suite-title and reachability tooling into this repo's bin/ (with a fix so it recognizes mocha invoked via npx), plus a script that pins the vendored coin files and the canonical fixture by sha256. Records the current suite titles and unit-suite wall time as a baseline. --- bin/pin-identity.js | 106 +++ bin/pins/at1-suite-titles.json | 1059 +++++++++++++++++++++++++++ bin/pins/identity.json | 13 + bin/pins/wall-times.json | 12 + bin/reachability.js | 445 ++++++++++++ bin/sibling-reference-map.js | 1246 ++++++++++++++++++++++++++++++++ bin/suite-title-map.js | 324 +++++++++ 7 files changed, 3205 insertions(+) create mode 100644 bin/pin-identity.js create mode 100644 bin/pins/at1-suite-titles.json create mode 100644 bin/pins/identity.json create mode 100644 bin/pins/wall-times.json create mode 100644 bin/reachability.js create mode 100644 bin/sibling-reference-map.js create mode 100644 bin/suite-title-map.js diff --git a/bin/pin-identity.js b/bin/pin-identity.js new file mode 100644 index 0000000..13797de --- /dev/null +++ b/bin/pin-identity.js @@ -0,0 +1,106 @@ +#!/usr/bin/env node +/********************************************************************* + * + * Copyright © 2025-2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC - https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. + * + ********************************************************************** + * + * The AT1 identity pin for xchain-encoder: sha256 of every file whose byte + * identity a later milestone must not move by accident. Two populations: + * + * coins the five hub-vendored src/coins/ files, refreshed by + * sync-coins.sh and never edited here. A rename or a byte + * edit both move the hash, so this pin catches either. + * conformance test/fixtures/roundtrip-conformance.json, whose canonical + * copy is THIS repo (decoder and sdk copy it outward). It is + * pinned here for the same reason: nothing here may move + * a byte in it, only its consumers may. + * + * USAGE + * node bin/pin-identity.js --out bin/pins/identity.json write the pin + * node bin/pin-identity.js --compare bin/pins/identity.json re-read and + * diff against it + * + ********************************************************************/ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const REPO_ROOT = path.resolve(__dirname, '..'); + +const COINS_FILES = [ + 'src/coins/BTC.js', + 'src/coins/LTC.js', + 'src/coins/DOGE.js', + 'src/coins/index.js', + 'src/coins/consensus_pin.js', +]; + +const CONFORMANCE_FILES = [ + 'test/fixtures/roundtrip-conformance.json', +]; + +function sha256(rel) { + const abs = path.join(REPO_ROOT, rel); + const buf = fs.readFileSync(abs); + return crypto.createHash('sha256').update(buf).digest('hex'); +} + +function buildPin() { + const coins = {}; + for (const rel of COINS_FILES) coins[rel] = sha256(rel); + const conformance = {}; + for (const rel of CONFORMANCE_FILES) conformance[rel] = sha256(rel); + return { capturedAt: new Date().toISOString(), coins, conformance }; +} + +function parseArgs(argv) { + const opts = {}; + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] === '--out') { opts.out = path.resolve(argv[i + 1]); i += 1; } + else if (argv[i] === '--compare') { opts.compare = path.resolve(argv[i + 1]); i += 1; } + } + return opts; +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + const pin = buildPin(); + if (opts.compare) { + const prior = JSON.parse(fs.readFileSync(opts.compare, 'utf8')); + const diffs = []; + for (const rel of Object.keys(prior.coins || {})) { + if (pin.coins[rel] !== prior.coins[rel]) diffs.push(`coins:${rel}`); + } + for (const rel of Object.keys(prior.conformance || {})) { + if (pin.conformance[rel] !== prior.conformance[rel]) diffs.push(`conformance:${rel}`); + } + if (diffs.length) { + console.log(`${diffs.length} identity difference(s): ${diffs.join(', ')}`); + process.exitCode = 1; + return; + } + console.log('identity pin holds: coins and conformance byte-identical'); + return; + } + if (opts.out) { + fs.mkdirSync(path.dirname(opts.out), { recursive: true }); + fs.writeFileSync(opts.out, `${JSON.stringify(pin, null, 2)}\n`); + console.log(`written to ${path.relative(REPO_ROOT, opts.out)}`); + return; + } + console.log(JSON.stringify(pin, null, 2)); +} + +if (require.main === module) main(); + +module.exports = { buildPin, COINS_FILES, CONFORMANCE_FILES }; diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json new file mode 100644 index 0000000..4c1aefd --- /dev/null +++ b/bin/pins/at1-suite-titles.json @@ -0,0 +1,1059 @@ +{ + "titleSets": { + "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", + "XChainEncoder create_tx options.exactInputs greedy selection stops at sufficiency, so a named set is not the input set", + "XChainEncoder create_tx options.exactInputs keeps the caller order, so ins[0] is the outpoint the caller chose", + "XChainEncoder create_tx options.exactInputs leaves greedy selection alone when the flag is absent or explicitly false", + "XChainEncoder create_tx options.exactInputs refuses an empty or absent utxos array instead of falling back to the tracker", + "XChainEncoder create_tx options.exactInputs refuses to combine with p2shHash, whose inputs never pass through selection", + "XChainEncoder create_tx options.exactInputs reservations, and how exact-input mode interacts with them a second identical rescue is refused rather than rebuilt, as on every other path", + "XChainEncoder create_tx options.exactInputs reservations, and how exact-input mode interacts with them hands back its own claims when the build fails, so a retry is not blocked", + "XChainEncoder create_tx options.exactInputs reservations, and how exact-input mode interacts with them refuses the whole build when another build holds a named outpoint", + "XChainEncoder create_tx options.exactInputs reservations, and how exact-input mode interacts with them reserves every named outpoint, so a concurrent build cannot take one", + "XChainEncoder create_tx options.exactInputs returns the surplus as change, which is itself the next CPFP handle", + "XChainEncoder create_tx options.exactInputs signals RBF on every input when rbf is set, not just the first", + "XChainEncoder create_tx options.exactInputs spends every named outpoint, so a CPFP child descends from the whole chain", + "XChainEncoder create_tx options.exactInputs validator accepts exactInputs as a real boolean", + "XChainEncoder create_tx options.exactInputs validator refuses a stringly-typed exactInputs, which would read as true", + "XChainEncoder create_tx options.exactInputs validator still refuses an unknown options key and names exactInputs as valid" + ], + "0748973400fcace3": [ + "compression ON by default (\u00a75.2/\u00a77) an explicit false still opts out", + "compression ON by default (\u00a75.2/\u00a77) compresses a FILE with no flag passed, and reports what it did", + "compression ON by default (\u00a75.2/\u00a77) compression can make a payload fit a lane it would otherwise overflow", + "compression ON by default (\u00a75.2/\u00a77) the default pass rides raw where an explicit request throws a non-FILE action: an explicit request still fails closed", + "compression ON by default (\u00a75.2/\u00a77) the default pass rides raw where an explicit request throws a non-FILE action: the default pass builds and reports \"not-a-file-action\"", + "compression ON by default (\u00a75.2/\u00a77) the default pass rides raw where an explicit request throws a token-gated FILE: an explicit request still fails closed", + "compression ON by default (\u00a75.2/\u00a77) the default pass rides raw where an explicit request throws a token-gated FILE: the default pass builds and reports \"gated-file\"", + "compression ON by default (\u00a75.2/\u00a77) the default pass rides raw where an explicit request throws an action that already declares a codec: an explicit request still fails closed", + "compression ON by default (\u00a75.2/\u00a77) the default pass rides raw where an explicit request throws an action that already declares a codec: the default pass builds and reports \"codec-already-declared\"", + "compression ON by default (\u00a75.2/\u00a77) the deploy-time lever turns the default off without a code change", + "compression ON by default (\u00a75.2/\u00a77) turning the default on does not break an ordinary SEND carrying rawData", + "size-aware encoding selection (\u00a76) selectEncoding falls back to P2WSH when the signer cannot sign a tapscript spend", + "size-aware encoding selection (\u00a76) selectEncoding falls back to P2WSH with no pubkey to use as the envelope internal key", + "size-aware encoding selection (\u00a76) selectEncoding picks OP_RETURN up to the single-output ceiling, and leaves it one byte later", + "size-aware encoding selection (\u00a76) selectEncoding picks P2SH on a non-segwit chain, never the worst-density MULTISIGN lane", + "size-aware encoding selection (\u00a76) selectEncoding picks TAPROOT for a large payload on a Taproot chain with a capable signer", + "size-aware encoding selection (\u00a76) through createTransaction AUTO refuses an over-cap payload rather than splitting it across envelopes", + "size-aware encoding selection (\u00a76) through createTransaction AUTO returns a single PSBT when it selects P2WSH", + "size-aware encoding selection (\u00a76) through createTransaction AUTO returns the commit/reveal pair when it selects TAPROOT", + "size-aware encoding selection (\u00a76) through createTransaction leaves the legacy no-encoding path exactly as shipped", + "size-aware encoding selection (\u00a76) validator accepts AUTO as an encoding", + "size-aware encoding selection (\u00a76) validator accepts the options bag and refuses unknown keys", + "size-aware encoding selection (\u00a76) validator does not require compressedPubKey for AUTO (it degrades instead)" + ], + "0f2abe37f99c5a17": [ + "Security: remediated dependency advisories @regression @tier4 ADV-1: package.json pins axios at or above 1.18.0", + "Security: remediated dependency advisories @regression @tier4 ADV-1: package.json pins brace-expansion at or above 5.0.9", + "Security: remediated dependency advisories @regression @tier4 ADV-1: package.json pins fast-uri at or above 3.1.5", + "Security: remediated dependency advisories @regression @tier4 ADV-1: package.json pins form-data at or above 4.0.6", + "Security: remediated dependency advisories @regression @tier4 ADV-1: package.json pins ip-address at or above 10.3.1", + "Security: remediated dependency advisories @regression @tier4 ADV-1: package.json pins js-yaml at or above 4.3.1", + "Security: remediated dependency advisories @regression @tier4 ADV-1: package.json pins minimatch at or above 10.2.5", + "Security: remediated dependency advisories @regression @tier4 ADV-1: package.json pins serialize-javascript at or above 7.0.5", + "Security: remediated dependency advisories @regression @tier4 ADV-1: package.json pins shell-quote at or above 1.9.0", + "Security: remediated dependency advisories @regression @tier4 ADV-1: package.json pins tmp at or above 0.2.6", + "Security: remediated dependency advisories @regression @tier4 ADV-2: every axios entry in package-lock.json is at or above 1.18.0", + "Security: remediated dependency advisories @regression @tier4 ADV-2: every brace-expansion entry in package-lock.json is at or above 5.0.9", + "Security: remediated dependency advisories @regression @tier4 ADV-2: every fast-uri entry in package-lock.json is at or above 3.1.5", + "Security: remediated dependency advisories @regression @tier4 ADV-2: every form-data entry in package-lock.json is at or above 4.0.6", + "Security: remediated dependency advisories @regression @tier4 ADV-2: every ip-address entry in package-lock.json is at or above 10.3.1", + "Security: remediated dependency advisories @regression @tier4 ADV-2: every js-yaml entry in package-lock.json is at or above 4.3.1", + "Security: remediated dependency advisories @regression @tier4 ADV-2: every minimatch entry in package-lock.json is at or above 10.2.5", + "Security: remediated dependency advisories @regression @tier4 ADV-2: every serialize-javascript entry in package-lock.json is at or above 7.0.5", + "Security: remediated dependency advisories @regression @tier4 ADV-2: every shell-quote entry in package-lock.json is at or above 1.9.0", + "Security: remediated dependency advisories @regression @tier4 ADV-2: every tmp entry in package-lock.json is at or above 0.2.6", + "Security: remediated dependency advisories @regression @tier4 ADV-3: minimatch can still brace-expand through the overridden brace-expansion", + "Security: remediated dependency advisories @regression @tier4 ADV-4: brace-expansion survives the CVE-2026-14257 unbounded-length input", + "Security: remediated dependency advisories @regression @tier4 ADV-5: the installed axios reports a patched runtime version" + ], + "11a13e37d91017bf": [ + "XChainEncoder.obfuscate() different keys produce different output", + "XChainEncoder.obfuscate() handles 1-byte data", + "XChainEncoder.obfuscate() handles empty data (0 bytes)", + "XChainEncoder.obfuscate() handles large data (several KB)", + "XChainEncoder.obfuscate() is deterministic (same inputs produce same output)", + "XChainEncoder.obfuscate() output differs from input (data is actually encrypted)", + "XChainEncoder.obfuscate() produces output with same length as input (CTR mode)", + "XChainEncoder.obfuscate() round-trips correctly (decrypt recovers original)", + "XChainEncoder.obfuscate() uses first 16 hex chars as key and next 16 as IV" + ], + "121f9fc54d5673a7": [ + "CORS_ORIGIN allowlist parsing parseCorsOrigin disables CORS when the var is unset, empty, blank, or only separators", + "CORS_ORIGIN allowlist parsing parseCorsOrigin passes a lone value straight through, so `*` and a single origin behave as before", + "CORS_ORIGIN allowlist parsing parseCorsOrigin splits a comma-separated value into an array and trims each entry", + "CORS_ORIGIN allowlist parsing what a caller actually receives echoes each allowlisted origin BACK TO ITSELF, never the raw list", + "CORS_ORIGIN allowlist parsing what a caller actually receives fails CLOSED on `*` mixed with real origins rather than silently opening up", + "CORS_ORIGIN allowlist parsing what a caller actually receives names the single configured origin to every caller, leaving the browser to refuse", + "CORS_ORIGIN allowlist parsing what a caller actually receives refuses an unlisted origin server-side once the value is a list", + "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" + ], + "140f5ed1c625c9ab": [ + "encoder FILE payload compression (spec Part B) action-string helpers detects FILE v0 and gating correctly", + "encoder FILE payload compression (spec Part B) action-string helpers pads intermediate optional fields to reach index 10", + "encoder FILE payload compression (spec Part B) action-string helpers preserves the gating fields when a client sets the marker", + "encoder FILE payload compression (spec Part B) action-string helpers setting the field empty leaves the action byte-identical", + "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 (spec Part B) constants conformance agrees with the SDK helper on every decision (skips without the sdk sibling)", + "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)", + "encoder FILE payload compression (spec Part B) createTransaction integration an incompressible payload opted in still builds, just uncompressed", + "encoder FILE payload compression (spec Part B) createTransaction integration compresses by DEFAULT, with no compress flag passed at all", + "encoder FILE payload compression (spec Part B) createTransaction integration compresses when opted in, and the estimator prices the COMPRESSED bytes", + "encoder FILE payload compression (spec Part B) createTransaction integration honours an explicit opt-out", + "encoder FILE payload compression (spec Part B) createTransaction integration refuses at build time when a gated FILE asks for encoder-side compression", + "encoder FILE payload compression (spec Part B) createTransaction integration the compressed transaction carries the marker AND reconstructs byte-exactly", + "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", + "encoder FILE payload compression (spec Part B) the compression report carries the bytes that were written says nothing extra when compression did not fire", + "encoder FILE payload compression (spec Part B) the compression report carries the bytes that were written the marker WITHOUT the stored bytes builds a different carrier (why both halves ride)", + "encoder FILE payload compression (spec Part B) validator accepts compress as an optional boolean; absent means \"use the deployment default\"" + ], + "1afcffc2f1fa70d9": [ + "UtxoTracker constructor builds the correct URL from host and port", + "UtxoTracker constructor stores the port", + "UtxoTracker.getSyncStatus() propagates transport errors", + "UtxoTracker.getSyncStatus() returns the result object on success", + "UtxoTracker.getSyncStatus() sends get_sync_status JSON-RPC method", + "UtxoTracker.getSyncStatus() sends params as an empty object", + "UtxoTracker.getSyncStatus() sets a timeout on the request", + "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", + "UtxoTracker.getUtxosFromAddress() accepts an empty utxos array", + "UtxoTracker.getUtxosFromAddress() calls getSyncStatus before fetching UTXOs", + "UtxoTracker.getUtxosFromAddress() coerces numeric-string confirmations and rejects non-integer values", + "UtxoTracker.getUtxosFromAddress() defaults confirmations to 0 when null", + "UtxoTracker.getUtxosFromAddress() defaults confirmations to 0 when undefined", + "UtxoTracker.getUtxosFromAddress() fetches normally from a tracker that omits mempool_ready", + "UtxoTracker.getUtxosFromAddress() handles multiple valid UTXOs correctly", + "UtxoTracker.getUtxosFromAddress() names the block lag, not mempool reconvergence, when a lagging tracker de-asserts both", + "UtxoTracker.getUtxosFromAddress() preserves existing confirmations when > 0", + "UtxoTracker.getUtxosFromAddress() reports the correct index in malformed UTXO error for second item", + "UtxoTracker.getUtxosFromAddress() rethrows get_utxos transport errors", + "UtxoTracker.getUtxosFromAddress() returns the result object on success", + "UtxoTracker.getUtxosFromAddress() sends get_utxos with the address param", + "UtxoTracker.getUtxosFromAddress() throws TypeError for malformed UTXO (missing txid)", + "UtxoTracker.getUtxosFromAddress() throws TypeError for malformed UTXO (missing value)", + "UtxoTracker.getUtxosFromAddress() throws TypeError for malformed UTXO (missing vout)", + "UtxoTracker.getUtxosFromAddress() throws TypeError for malformed UTXO (txid not a string)", + "UtxoTracker.getUtxosFromAddress() throws TypeError when a UTXO element is null", + "UtxoTracker.getUtxosFromAddress() throws TypeError when scriptPubKey is an empty string", + "UtxoTracker.getUtxosFromAddress() throws TypeError when scriptPubKey is missing", + "UtxoTracker.getUtxosFromAddress() throws TypeError when txid is 64 chars but not hex", + "UtxoTracker.getUtxosFromAddress() throws TypeError when txid is not a 64-char hex string (too short)", + "UtxoTracker.getUtxosFromAddress() throws TypeError when utxos is not an array", + "UtxoTracker.getUtxosFromAddress() throws when UTXO result is missing", + "UtxoTracker.getUtxosFromAddress() throws when UTXO result is not an object", + "UtxoTracker.getUtxosFromAddress() throws when getSyncStatus transport fails", + "UtxoTracker.getUtxosFromAddress() throws when lag is null (tracker has not indexed any blocks)", + "UtxoTracker.getUtxosFromAddress() throws when lag is undefined", + "UtxoTracker.getUtxosFromAddress() throws when the tracker has not reconverged its mempool, even at lag 0", + "UtxoTracker.getUtxosFromAddress() throws when the tracker is ahead of the node (negative lag)", + "UtxoTracker.getUtxosFromAddress() throws when the tracker is halted, even at lag 0 with synced=true", + "UtxoTracker.getUtxosFromAddress() throws when tracker is not synced (synced=false)" + ], + "2442df610ebb6911": [ + "coin-registry conformance (vendored copy) @regression byte-identity to canonical xchain-hub/src/coins BTC.js is byte-identical to the canonical xchain-hub copy", + "coin-registry conformance (vendored copy) @regression byte-identity to canonical xchain-hub/src/coins DOGE.js is byte-identical to the canonical xchain-hub copy", + "coin-registry conformance (vendored copy) @regression byte-identity to canonical xchain-hub/src/coins LTC.js is byte-identical to the canonical xchain-hub copy", + "coin-registry conformance (vendored copy) @regression byte-identity to canonical xchain-hub/src/coins consensus_pin.js is byte-identical to the canonical xchain-hub copy", + "coin-registry conformance (vendored copy) @regression byte-identity to canonical xchain-hub/src/coins index.js is byte-identical to the canonical xchain-hub copy", + "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files BTC/regtest vendored pin matches the vendored consensusHash", + "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files BTC/testnet vendored pin matches the vendored consensusHash", + "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files DOGE/regtest vendored pin matches the vendored consensusHash", + "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files DOGE/testnet vendored pin matches the vendored consensusHash", + "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files LTC/regtest vendored pin matches the vendored consensusHash", + "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files LTC/testnet vendored pin matches the vendored consensusHash", + "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files verifyConsensusPin passes for every network on the vendored bundle" + ], + "2a1c91a7aceda8eb": [ + "_buildTransaction outpoint dedup / mempool filter applies the mempool filter BEFORE dedup, so a confirmed twin survives", + "_buildTransaction outpoint dedup / mempool filter collapses a set that is nothing but copies of one outpoint", + "_buildTransaction outpoint dedup / mempool filter distinguishes two vouts of the same txid", + "_buildTransaction outpoint dedup / mempool filter drops every mempool entry when unconfirmed is false", + "_buildTransaction outpoint dedup / mempool filter filters a large set of DISTINCT outpoints in linear time", + "_buildTransaction outpoint dedup / mempool filter keeps mempool entries when unconfirmed is true", + "_buildTransaction outpoint dedup / mempool filter keeps the FIRST occurrence of a repeated outpoint and drops the rest", + "_buildTransaction outpoint dedup / mempool filter preserves the relative order of the survivors" + ], + "2aa020357e5b268b": [ + "XChainEncoder input selection: segwit inputs on a chain without segwit leaves the same P2WPKH input alone on a segwit-capable network", + "XChainEncoder input selection: segwit inputs on a chain without segwit names the offending outpoint, so a coin-control caller can drop it", + "XChainEncoder input selection: segwit inputs on a chain without segwit refuses a P2WPKH input on a network the registry marks as having no segwit", + "XChainEncoder input selection: segwit inputs on a chain without segwit refuses a witness v1 (taproot) input there too, not only witness v0", + "XChainEncoder input selection: segwit inputs on a chain without segwit still spends a legacy input on that same network" + ], + "2c605d4ec40ce8e5": [ + "ChainAdapter base every surface method throws AdapterNotImplementedError with the method name", + "EVM finality gate (evmFinality) .finalityGate() resolves a known chain case-insensitively", + "EVM finality gate (evmFinality) .finalityGate() throws on an unknown chain rather than returning undefined", + "EVM finality gate (evmFinality) L1 Ethereum L1 block depth IS a sound finality signal", + "EVM finality gate (evmFinality) L1 Ethereum gates on checkpoint finality, not raw depth", + "EVM finality gate (evmFinality) L2 rollups (THE L2 TRAP, spec \u00a712) ARBITRUM block depth is NOT a sound finality signal", + "EVM finality gate (evmFinality) L2 rollups (THE L2 TRAP, spec \u00a712) ARBITRUM gates on L1 data-posting, never L2 block depth", + "EVM finality gate (evmFinality) L2 rollups (THE L2 TRAP, spec \u00a712) BASE block depth is NOT a sound finality signal", + "EVM finality gate (evmFinality) L2 rollups (THE L2 TRAP, spec \u00a712) BASE gates on L1 data-posting, never L2 block depth", + "EVM finality gate (evmFinality) L2 rollups (THE L2 TRAP, spec \u00a712) OPTIMISM block depth is NOT a sound finality signal", + "EVM finality gate (evmFinality) L2 rollups (THE L2 TRAP, spec \u00a712) OPTIMISM gates on L1 data-posting, never L2 block depth", + "EVM finality gate (evmFinality) no EVM chain is armed into a consensus pin (pre-work only)", + "EVM finality gate (evmFinality) recommends Base as the primary L2 target (spec \u00a711)", + "EvmAdapter (pre-work stub) defaults to the spec primary L2 target", + "EvmAdapter (pre-work stub) exposes the finality gate for its chain (the load-bearing pre-work)", + "EvmAdapter (pre-work stub) leaves every tx-building surface as a loud not-implemented stub", + "EvmAdapter (pre-work stub) normalizes the chain key and validates it at construction", + "EvmAdapter (pre-work stub) surfaces THE L2 TRAP guard per chain" + ], + "358184203c4ccfba": [ + "encoder stress-sweep unit @regression JSON-RPC batch-size guard passes a batch at the cap and a single request", + "encoder stress-sweep unit @regression JSON-RPC batch-size guard rejects an over-cap batch array with 400 and does not call next", + "encoder stress-sweep unit @regression MULTISIGN requires compressedPubKey validateAll accepts MULTISIGN when compressedPubKey is present", + "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)" + ], + "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\"", + "CryptoNetworks .getBitcoinJsNetwork() Bitcoin networks returns testnet config with dustThreshold for \"bitcoin-testnet\"", + "CryptoNetworks .getBitcoinJsNetwork() Dogecoin networks dogecoin-mainnet has correct pubKeyHash 0x1e", + "CryptoNetworks .getBitcoinJsNetwork() Dogecoin networks dogecoin-testnet has pubKeyHash 0x71; dogecoin-regtest uses Bitcoin-testnet prefix 0x6f", + "CryptoNetworks .getBitcoinJsNetwork() Dogecoin networks returns a valid config for \"dogecoin-mainnet\"", + "CryptoNetworks .getBitcoinJsNetwork() Dogecoin networks returns a valid config for \"dogecoin-regtest\"", + "CryptoNetworks .getBitcoinJsNetwork() Dogecoin networks returns a valid config for \"dogecoin-testnet\"", + "CryptoNetworks .getBitcoinJsNetwork() Litecoin networks litecoin-mainnet has bech32 prefix \"ltc\"", + "CryptoNetworks .getBitcoinJsNetwork() Litecoin networks litecoin-regtest has bech32 prefix \"rltc\"", + "CryptoNetworks .getBitcoinJsNetwork() Litecoin networks litecoin-testnet has bech32 prefix \"tltc\"", + "CryptoNetworks .getBitcoinJsNetwork() Litecoin networks returns a valid config for \"litecoin-mainnet\"", + "CryptoNetworks .getBitcoinJsNetwork() Litecoin networks returns a valid config for \"litecoin-regtest\"", + "CryptoNetworks .getBitcoinJsNetwork() Litecoin networks returns a valid config for \"litecoin-testnet\"", + "CryptoNetworks .getBitcoinJsNetwork() Unknown network throws TypeError for an unrecognized network name", + "CryptoNetworks .getBitcoinJsNetwork() Unknown network throws TypeError for empty string", + "CryptoNetworks .getFirstBlock() returns 0 for bitcoin-regtest", + "CryptoNetworks .getFirstBlock() returns 0 for dogecoin-regtest", + "CryptoNetworks .getFirstBlock() returns 0 for litecoin-regtest", + "CryptoNetworks .getFirstBlock() returns 0 for unknown network", + "CryptoNetworks .getFirstBlock() returns 149700 for bitcoin-testnet", + "CryptoNetworks .getFirstBlock() returns 3120000 for litecoin-mainnet", + "CryptoNetworks .getFirstBlock() returns 4862500 for litecoin-testnet", + "CryptoNetworks .getFirstBlock() returns 6240000 for dogecoin-mainnet", + "CryptoNetworks .getFirstBlock() returns 67847500 for dogecoin-testnet", + "CryptoNetworks .getFirstBlock() returns 950000 for bitcoin-mainnet" + ], + "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", + "coverage ratchet floors ships the coverage:check script the CI coverage job invokes" + ], + "4a88b66dc7f07157": [ + "XChainEncoder.prepareData() MULTISIGN encoding multiple chunks for data exceeding limit", + "XChainEncoder.prepareData() MULTISIGN encoding prepends XCHN magic word to each chunk", + "XChainEncoder.prepareData() MULTISIGN encoding preserves all data across chunks (trailing pad is stripped on read)", + "XChainEncoder.prepareData() MULTISIGN encoding pubKey is not used (null pubKey works)", + "XChainEncoder.prepareData() MULTISIGN encoding regression: every chunk fills the full 64-byte slot compiled=120: exact multiple of 60 (baseline: both chunks already full)", + "XChainEncoder.prepareData() MULTISIGN encoding regression: every chunk fills the full 64-byte slot compiled=61: 1 data byte in last chunk (first multi-chunk boundary)", + "XChainEncoder.prepareData() MULTISIGN encoding regression: every chunk fills the full 64-byte slot compiled=88: last chunk = magic(4) + 28 = 32 bytes (pubkey2 would be empty before fix)", + "XChainEncoder.prepareData() MULTISIGN encoding single chunk for data within limit", + "XChainEncoder.prepareData() OP_RETURN encoding accepts a payload exactly at the 76-byte single-output limit", + "XChainEncoder.prepareData() OP_RETURN encoding chunk size does not exceed OP_RETURN_SIZE (80 bytes)", + "XChainEncoder.prepareData() OP_RETURN encoding data follows the magic word", + "XChainEncoder.prepareData() OP_RETURN encoding prepends XCHN magic word to each chunk", + "XChainEncoder.prepareData() OP_RETURN encoding produces a single chunk for data that fits", + "XChainEncoder.prepareData() OP_RETURN encoding pubKey is not used (null pubKey works)", + "XChainEncoder.prepareData() OP_RETURN encoding rejects a payload one byte over the single-output limit", + "XChainEncoder.prepareData() OP_RETURN encoding rejects a payload spanning multiple chunks", + "XChainEncoder.prepareData() P2SH encoding preserves all data across chunks", + "XChainEncoder.prepareData() P2SH encoding produces compiled redeem scripts with correct opcodes", + "XChainEncoder.prepareData() P2SH encoding single chunk for data exactly at boundary", + "XChainEncoder.prepareData() P2SH encoding splits data into chunks of 476 bytes max", + "XChainEncoder.prepareData() P2WSH encoding produces compiled redeem scripts same structure as P2SH", + "XChainEncoder.prepareData() P2WSH encoding splits into multiple chunks when data exceeds 476 bytes", + "XChainEncoder.prepareData() P2WSH encoding uses the same 476-byte chunk size as P2SH (bounded by the 520-byte push limit)", + "XChainEncoder.prepareData() auto-selection (encoding = null/undefined) selects OP_RETURN for empty data", + "XChainEncoder.prepareData() auto-selection (encoding = null/undefined) selects OP_RETURN for very small data (1 byte)", + "XChainEncoder.prepareData() auto-selection (encoding = null/undefined) selects OP_RETURN when data fits within 76 bytes (80 - 4 magic)", + "XChainEncoder.prepareData() auto-selection (encoding = null/undefined) selects P2SH when data exceeds 76 bytes", + "XChainEncoder.prepareData() caller identity resolution (P2SH chunk lane) all three identity forms for one key yield the SAME gate hash", + "XChainEncoder.prepareData() caller identity resolution (P2SH chunk lane) resolves a base58 P2PKH address (legacy, unchanged)", + "XChainEncoder.prepareData() caller identity resolution (P2SH chunk lane) resolves a raw compressed pubkey hex (the wallet identity that used to crash)", + "XChainEncoder.prepareData() caller identity resolution (P2SH chunk lane) resolves a v0 bech32 P2WPKH address (witness program IS HASH160(pubkey))", + "XChainEncoder.prepareData() caller identity resolution (P2SH chunk lane) throws a clear error for an unresolvable identity", + "XChainEncoder.prepareData() invalid encoding throws TypeError for unrecognized encoding string" + ], + "57c53b6db4d98a77": [ + "XChainEncoder.createTransaction() MULTISIGN encoding path builds a valid tx where total outputs do not exceed inputs", + "XChainEncoder.createTransaction() MULTISIGN encoding path creates multisig output with correct structure", + "XChainEncoder.createTransaction() MULTISIGN encoding path encodes a short final chunk without throwing (x=0 pubkey regression)", + "XChainEncoder.createTransaction() MULTISIGN encoding path third pubkey is the real compressed public key", + "XChainEncoder.createTransaction() OP_RETURN encoding path OP_RETURN output has value 0", + "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", + "XChainEncoder.createTransaction() P2SH encoding path (tx1: funding) P2SH output value covers the spending tx fee", + "XChainEncoder.createTransaction() P2SH encoding path (tx1: funding) creates P2SH output for large data", + "XChainEncoder.createTransaction() P2SH encoding path (tx2: spending) creates tx2 with P2SH input and OP_RETURN marker", + "XChainEncoder.createTransaction() P2SH native-fee customOutputs phase 1 (funding) does NOT emit the fee-destination output", + "XChainEncoder.createTransaction() P2SH native-fee customOutputs phase 1 (funding) over-funds the P2SH output by the fee value plus its byte cost", + "XChainEncoder.createTransaction() P2SH native-fee customOutputs phase 2 (reveal) emits the fee-destination output funded by phase 1", + "XChainEncoder.createTransaction() UTXO deduplication removes duplicate UTXOs with same txid+vout", + "XChainEncoder.createTransaction() UTXO sorting (largest first) uses the largest UTXO first", + "XChainEncoder.createTransaction() attachPrevTx attaches the full previous transaction when asked", + "XChainEncoder.createTransaction() attachPrevTx leaves segwit inputs witnessUtxo-only by default", + "XChainEncoder.createTransaction() attachPrevTx still attaches nothing when the input is legacy anyway", + "XChainEncoder.createTransaction() change output adds change output when there is leftover and change address given", + "XChainEncoder.createTransaction() change output throws when change address is falsy and change would be burned", + "XChainEncoder.createTransaction() custom dust parameter clamps a caller dust below the output floor up to the floor", + "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", + "XChainEncoder.createTransaction() custom outputs adds custom outputs with correct address and value", + "XChainEncoder.createTransaction() custom outputs skips custom outputs when customOutputs is not an array", + "XChainEncoder.createTransaction() custom outputs skips custom outputs when customOutputs is null", + "XChainEncoder.createTransaction() estimateSpendingP2shTx() increases monotonically with redeem data size", + "XChainEncoder.createTransaction() estimateSpendingP2shTx() returns 10 + P2SH input + OP_RETURN + 8-byte safety margin", + "XChainEncoder.createTransaction() fee handling calls connector.getFeePerKilobyte when feePerKb is null", + "XChainEncoder.createTransaction() fee handling floors fee to dustAmount when computed fee is lower", + "XChainEncoder.createTransaction() fee handling throws RangeError when the custom fee exceeds 100x the fair-fee estimate (burn backstop)", + "XChainEncoder.createTransaction() fee handling uses custom fee when provided", + "XChainEncoder.createTransaction() fee handling uses feePerKb parameter when provided (no RPC call)", + "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", + "XChainEncoder.createTransaction() no UTXOs available resolves a raw pubkey to P2PKH before querying the tracker on a network without segwit", + "XChainEncoder.createTransaction() no UTXOs available throws when utxos is empty and tracker returns nothing", + "XChainEncoder.createTransaction() no UTXOs available throws when utxos is null and tracker returns null", + "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", + "XChainEncoder.createTransaction() payload guards (library boundary) rejects a `data` string that is not well-formed Unicode", + "XChainEncoder.createTransaction() payload guards (library boundary) rejects a rawData code unit above U+00FF instead of truncating it", + "XChainEncoder.createTransaction() payload guards (library boundary) rejects a single minimal-opcode `data` byte", + "XChainEncoder.createTransaction() payload guards (library boundary) rejects a single minimal-opcode `data` byte passed as a Buffer", + "XChainEncoder.createTransaction() payload guards (library boundary) rejects a single minimal-opcode rawData byte", + "XChainEncoder.createTransaction() payload guards (library boundary) rejects a single minimal-opcode rawData byte passed as a Buffer", + "XChainEncoder.createTransaction() payload guards (library boundary) still accepts a Buffer rawData from a library caller", + "XChainEncoder.createTransaction() payload guards (library boundary) still builds a payment-only transaction with no payload at all", + "XChainEncoder.createTransaction() payload guards (library boundary) still builds an ordinary multi-byte data + rawData payload", + "XChainEncoder.createTransaction() payload guards (library boundary) still builds the deliberately-supported rawData-only shape", + "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", + "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", + "XChainEncoder.createTransaction() payment-only still emits the OP_RETURN when an action IS present", + "XChainEncoder.createTransaction() payment-only treats an empty-string action the same as an absent one", + "XChainEncoder.createTransaction() replace-by-fee sequence arms RBF without enabling BIP68 relative locktime", + "XChainEncoder.createTransaction() replace-by-fee sequence sets sequence to 0xfffffffd when rbf=true (RBF armed, BIP68 still disabled)", + "XChainEncoder.createTransaction() replace-by-fee sequence sets sequence to 0xffffffff when rbf=false", + "XChainEncoder.createTransaction() return value returns { psbt, encoding } where psbt is a Psbt instance", + "XChainEncoder.createTransaction() unconfirmed filtering excludes mempool UTXOs when unconfirmed=false", + "XChainEncoder.createTransaction() unconfirmed filtering keeps mempool UTXOs when unconfirmed=true" + ], + "58706b2a54fa0008": [ + "JSON-RPC body guard (Express 5 / body-parser 2.x regression) does not crash WITH the guard (GET -> not 500)", + "JSON-RPC body guard (Express 5 / body-parser 2.x regression) does not crash WITH the guard on an empty POST (-> not 500)", + "JSON-RPC body guard (Express 5 / body-parser 2.x regression) reproduces the crash WITHOUT the guard (bodiless GET -> 500)", + "JSON-RPC body guard (Express 5 / body-parser 2.x regression) src/api.js wires the guard before the jsonRouter mount", + "JSON-RPC body guard (Express 5 / body-parser 2.x regression) still serves a valid JSON-RPC POST WITH the guard" + ], + "5ada45b5f0f336ef": [ + "XChainEncoder fee-rate cap accepts a generous but plausible absolute fee (priority/RBF headroom)", + "XChainEncoder fee-rate cap applies the absolute MAX_FEE_RATE_KB cap when it is tighter than the relative cap", + "XChainEncoder fee-rate cap burn backstop anchors on the relayfee when the smart-fee estimate is unavailable (cap disabled)", + "XChainEncoder fee-rate cap burn backstop ceiling is anchored to the node rate, not a caller-inflated feePerKb (cap disabled)", + "XChainEncoder fee-rate cap caps the absolute fee against MAX_FEE_RATE_KB even without a node estimate", + "XChainEncoder fee-rate cap clamps a hostile feePerKb via the relayfee anchor when estimatesmartfee is unavailable", + "XChainEncoder fee-rate cap clamps an over-cap feePerKb to the relative cap instead of draining inputs", + "XChainEncoder fee-rate cap converts a caller sat/kB rate to the same units as the cap and fee formula", + "XChainEncoder fee-rate cap default-path node estimate is never clamped by its own cap", + "XChainEncoder fee-rate cap honors feePerKb only when neither estimatesmartfee nor relayfee is available", + "XChainEncoder fee-rate cap multiplier 0 disables the relative cap, but the fixed 100x burn backstop still applies", + "XChainEncoder fee-rate cap rejects a drain-shaped absolute fee with a RangeError" + ], + "63c55285b6bac324": [ + "applyBufferutilsPatch BigInt fee accounting (getFee / getFeeRate / extractTransaction) returns 0 for a zero-fee PSBT with BigInt values instead of re-throwing", + "applyBufferutilsPatch BigInt fee accounting (getFee / getFeeRate / extractTransaction) returns a 0 fee rate for a sub-1-sat/vbyte fee instead of re-throwing", + "applyBufferutilsPatch BigInt fee accounting (getFee / getFeeRate / extractTransaction) still computes a non-zero fee and fee rate across the BigInt path", + "applyBufferutilsPatch BigInt fee accounting (getFee / getFeeRate / extractTransaction) the all-Number fast path is unchanged", + "applyBufferutilsPatch exposes the patched bufferutils surface", + "applyBufferutilsPatch module-level helpers accept the full u64 range and reject one past it", + "applyBufferutilsPatch narrows a representable value to Number and keeps a BigInt only above 2^53-1", + "applyBufferutilsPatch rejects every invalid value with the stock verifuint string", + "applyBufferutilsPatch round-trips a small 64-bit LE value", + "applyBufferutilsPatch round-trips a value above 2^53 without precision loss", + "applyBufferutilsPatch twin guard against the SDK copy declares every package it deep-requires", + "applyBufferutilsPatch twin guard against the SDK copy is byte-identical to the SDK copy below the banner", + "applyBufferutilsPatch varuint encode/decode round-trips across size classes" + ], + "65be62f0e174b504": [ + "docs/openrpc.json declares an OpenRPC version and info block", + "docs/openrpc.json documents the ping health method", + "docs/openrpc.json exposes a non-empty, well-formed methods array", + "docs/openrpc.json has unique method names" + ], + "6a874601dbd714de": [ + "XChainEncoder consensus-pin boot guard fails closed: a pin mismatch aborts construction rather than authoring under a drifted bundle", + "XChainEncoder consensus-pin boot guard the real check passes for the bundle actually vendored here", + "XChainEncoder consensus-pin boot guard verifies the vendored bundle at construction, with the net portion of the network key" + ], + "750c1552249846a5": [ + "singleInstanceGuard acquireInstanceLock [REGRESSION] breaks a legacy bare-pid lock whose holder is npm's shell wrapper", + "singleInstanceGuard acquireInstanceLock [REGRESSION] breaks a legacy bare-pid lock whose holder is the npm parent", + "singleInstanceGuard acquireInstanceLock [REGRESSION] breaks a lock whose live pid has been REUSED by another process", + "singleInstanceGuard acquireInstanceLock breaks a legacy bare-pid lock whose holder is unrelated", + "singleInstanceGuard acquireInstanceLock breaks a lock with garbage contents", + "singleInstanceGuard acquireInstanceLock breaks a stale lock held by a dead pid", + "singleInstanceGuard acquireInstanceLock honors ENCODER_INSTANCE_LOCK_FILE from env when no path arg given", + "singleInstanceGuard acquireInstanceLock leaves no .tmp file behind after an acquire/release cycle", + "singleInstanceGuard acquireInstanceLock names the in-process stores in the lock-conflict refusal", + "singleInstanceGuard acquireInstanceLock never leaves a half-written record observable at the lock path", + "singleInstanceGuard acquireInstanceLock off Linux, refuses on the same lock because identity is unknowable", + "singleInstanceGuard acquireInstanceLock on Linux, rules out init as a lock holder through the real /proc read", + "singleInstanceGuard acquireInstanceLock re-takes a lock recorded under our own pid (crash-restart same pid file)", + "singleInstanceGuard acquireInstanceLock records an ownership token, which is what release verifies against", + "singleInstanceGuard acquireInstanceLock records its own identity in the lock, which is what makes reuse detectable", + "singleInstanceGuard acquireInstanceLock refuses a real second encoder started under an absolute node path", + "singleInstanceGuard acquireInstanceLock refuses on a legacy bare-pid lock whose holder IS running an encoder", + "singleInstanceGuard acquireInstanceLock refuses on a legacy lock when the holder cannot be described at all", + "singleInstanceGuard acquireInstanceLock refuses when a live other process holds the lock", + "singleInstanceGuard acquireInstanceLock refuses when identity cannot be established, which is the safe side", + "singleInstanceGuard acquireInstanceLock release does not delete a lock that has changed hands", + "singleInstanceGuard acquireInstanceLock still refuses when the live pid IS the process that took the lock", + "singleInstanceGuard acquireInstanceLock takes the lock, writes our pid, and release removes the file", + "singleInstanceGuard acquireInstanceLock waits out an illegible lock instead of breaking it on sight", + "singleInstanceGuard assertSingleInstance names every in-process store in the replica refusal", + "singleInstanceGuard assertSingleInstance passes when ENCODER_REPLICAS is empty", + "singleInstanceGuard assertSingleInstance passes when ENCODER_REPLICAS is unset", + "singleInstanceGuard assertSingleInstance passes when ENCODER_REPLICAS=1", + "singleInstanceGuard assertSingleInstance throws on non-integer values", + "singleInstanceGuard assertSingleInstance throws when ENCODER_REPLICAS > 1", + "singleInstanceGuard isPidAlive reports our own pid alive", + "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", + "singleInstanceGuard releaseLockOnSignals does not force an exit when the re-raise did kill the process", + "singleInstanceGuard releaseLockOnSignals forces exit 130 when a re-raised SIGINT is ignored (PID 1)", + "singleInstanceGuard releaseLockOnSignals forces exit 143 when a re-raised SIGTERM is ignored (PID 1)", + "singleInstanceGuard releaseLockOnSignals leaves OTHER listeners for the same signal registered", + "singleInstanceGuard releaseLockOnSignals re-raises the same signal so the death stays a signalled death", + "singleInstanceGuard releaseLockOnSignals registers a handler for both signals a stop can send", + "singleInstanceGuard releaseLockOnSignals releases the lock on SIGINT too", + "singleInstanceGuard releaseLockOnSignals releases the lock on SIGTERM, which is what `docker stop` sends" + ], + "78e833e8f04d5380": [ + "CORS is mounted above the API-key gate @regression answers a preflight instead of 401ing it, on a keyed deploy", + "CORS is mounted above the API-key gate @regression emits CORS headers on the 401 the gate returns", + "CORS is mounted above the API-key gate @regression lets a correctly keyed request past the gate", + "CORS is mounted above the API-key gate @regression still rejects a wrong key", + "a CORS-disabled keyed deploy is unchanged by the mount position @regression grants no origin on a keyed request either", + "a CORS-disabled keyed deploy is unchanged by the mount position @regression still 401s an unauthenticated preflight and grants no origin" + ], + "85489f2e3279585d": [ + "Encoder input validator MAX_FEE_SATOSHIS is pinned at 21,000 BTC in satoshis, not 21M BTC", + "Encoder input validator alias expansion at the compiled ceiling accepts the CAST payload sitting exactly at the compiled ceiling", + "Encoder input validator alias expansion at the compiled ceiling rejects the CAST payload one byte over the ceiling with a RangeError", + "Encoder input validator alias expansion at the compiled ceiling the accepted at-ceiling CAST is stored as a record past the cap", + "Encoder input validator alias expansion at the compiled ceiling the at-ceiling CAST push measures exactly the cap", + "Encoder input validator validateActionName (encoder ACTION-name gate) accepts a bare canonical name with no pipe-delimited payload", + "Encoder input validator validateActionName (encoder ACTION-name gate) accepts every canonical ACTION name from VALID_ACTION_NAMES", + "Encoder input validator validateActionName (encoder ACTION-name gate) accepts every known alias and expands it the same way the decoder does", + "Encoder input validator validateActionName (encoder ACTION-name gate) does not reject the intentionally-supported empty/absent data shapes", + "Encoder input validator validateActionName (encoder ACTION-name gate) is case-sensitive: a lowercase canonical name is not recognized", + "Encoder input validator validateActionName (encoder ACTION-name gate) is wired into validateAll and fires before feeQuote/utxo checks", + "Encoder input validator validateActionName (encoder ACTION-name gate) rejects a typoed ACTION name with a RangeError", + "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", + "Encoder input validator validateAddress accepts a valid string (incl. exactly 100 chars); rejects empty, non-string, and over-length", + "Encoder input validator validateAll exercises validateFeeQuote validation paths through validateAll", + "Encoder input validator validateAll rbf/unconfirmed absent or null stay undefined so downstream defaults apply", + "Encoder input validator validateAll rejects an absent or null pubkey (openrpc marks it required)", + "Encoder input validator validateAll rejects non-boolean rbf/unconfirmed instead of truthiness-coercing (string \"false\" must not flip policy to true)", + "Encoder input validator validateAll returns a fully-coerced, normalized param set", + "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", + "Encoder input validator validateChange null passes; rejects empty and over-long", + "Encoder input validator validateCombinedDataLength accepts a single-push payload at the compiled ceiling (backwards-compatible)", + "Encoder input validator validateCombinedDataLength accepts an explicit OP_RETURN payload at/under the 76-byte ceiling", + "Encoder input validator validateCombinedDataLength accounts for per-push overhead on dual-push payloads at the boundary", + "Encoder input validator validateCombinedDataLength applies the envelope ceiling to AUTO, like TAPROOT", + "Encoder input validator validateCombinedDataLength counts data + rawData and throws over the limit", + "Encoder input validator validateCombinedDataLength counts the OP_PUSHDATA4 band on a push past 65,535 bytes", + "Encoder input validator validateCombinedDataLength does NOT apply the OP_RETURN ceiling when encoding is omitted (P2SH auto-fallback)", + "Encoder input validator validateCombinedDataLength leaves the OP_PUSHDATA2 band alone at its upper edge", + "Encoder input validator validateCombinedDataLength measures rawData-only payloads (data defaults to an OP_0 push)", + "Encoder input validator validateCombinedDataLength rejects an explicit OP_RETURN payload above the 76-byte ceiling", + "Encoder input validator validateCombinedDataLength rejects dual-push payloads whose compiled size exceeds the ceiling", + "Encoder input validator validateCombinedDataLength rejects the documented dual-push undercount case (raw sum within old limit)", + "Encoder input validator validateCombinedDataLength returns early for null data and accepts within-limit payloads", + "Encoder input validator validateCompressedPubKey null passes; enforces the 02/03 + 64-hex shape", + "Encoder input validator validateCustomOutputs (+ validateCustomOutput via wrapper) coerces value and rejects malformed entries", + "Encoder input validator validateCustomOutputs (+ validateCustomOutput via wrapper) null passes; non-array and over-cap throw", + "Encoder input validator validateDataParam null passes, string passes, non-string throws with field name", + "Encoder input validator validateDataParam rejects a data string that is not well-formed Unicode (utf8 would emit U+FFFD)", + "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", + "Encoder input validator validateDust null/false pass; coerces; rejects NaN and negative", + "Encoder input validator validateEncoding accepts the valid set, null passes, unknown throws", + "Encoder input validator validateFee null/false pass; coerces; rejects NaN, negative, over-max", + "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", + "Encoder input validator validateP2shParams both-omitted yields nulls; mismatch throws", + "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", + "Encoder input validator validatePubkey passes null/valid through, throws on bad input", + "Encoder input validator validateRawTxHex accepts hex between the p2shHex cap and the broadcast cap (an envelope reveal)", + "Encoder input validator validateRawTxHex accepts well-formed hex and returns it", + "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", + "Encoder input validator validateUtxoArray / validateUtxoEntry coerces numeric-string confirmations and rejects untyped values", + "Encoder input validator validateUtxoArray / validateUtxoEntry coerces vout/value and defaults confirmations", + "Encoder input validator validateUtxoArray / validateUtxoEntry null passes; non-array and over-cap throw", + "Encoder input validator validateUtxoArray / validateUtxoEntry preserves an explicit confirmations value", + "Encoder input validator validateUtxoArray / validateUtxoEntry rejects each malformed field", + "Encoder input validator validateUtxoArray / validateUtxoEntry rejects vout values bare Number() would coerce to a plausible index (uuid:4555d78c)" + ], + "8c8c02bad5e89e5b": [ + "errorSanitize.upstreamErrorMessage collapses a credentialed RPC URL that arrives with no transport code", + "errorSanitize.upstreamErrorMessage collapses bare topology: a dotted quad, a host:port, and a scheme", + "errorSanitize.upstreamErrorMessage falls back when the error has no message", + "errorSanitize.upstreamErrorMessage forwards a bad-txns rejection reason", + "errorSanitize.upstreamErrorMessage forwards a genuine coin-node rejection reason (safe + useful)", + "errorSanitize.upstreamErrorMessage genericizes an ECONNREFUSED transport error that leaks host:port", + "errorSanitize.upstreamErrorMessage genericizes an axios transport failure with no response", + "errorSanitize.upstreamErrorMessage isTransportError matches network errnos but not application errors", + "errorSanitize.upstreamErrorMessage still forwards node reasons the wallet classifies on" + ], + "923b72fe3989bb3c": [ + "XChainEncoder reservation release a claim another call has since re-taken is retained, never dropped", + "XChainEncoder reservation release a foreign ticket id releases nothing and leaves the claims standing", + "XChainEncoder reservation release a successful build returns a receipt naming the outpoints it reserved", + "XChainEncoder reservation release is idempotent: a second release of the same ticket reports nothing to do", + "XChainEncoder reservation release mints no receipt when the build kept no claims", + "XChainEncoder reservation release refuses a malformed ticket id with a TypeError, before touching any state", + "XChainEncoder reservation release releases only the presented build's claims, not a concurrent build's", + "XChainEncoder reservation release releasing frees the inputs, so the next compose funds instead of failing", + "XChainEncoder reservation release releasing retires the duplicate-build refusal for that same build", + "XChainEncoder reservation release sweeps expired tickets so the ticket map cannot outgrow the reservations", + "release_inputs JSON-RPC method answers an unknown ticket with found:false rather than an error", + "release_inputs JSON-RPC method maps a malformed or missing reservationId to invalid params", + "release_inputs JSON-RPC method releases the claims the presented ticket owns" + ], + "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", + "XChainEncoder reveal: p2shHash must be the id of p2shHex rejects a P2WSH reveal whose p2shHash is not the funding tx id", + "XChainEncoder reveal: p2shHash must be the id of p2shHex still builds a P2SH reveal when p2shHash is the funding tx id", + "XChainEncoder reveal: p2shHash must be the id of p2shHex still builds a P2WSH reveal when p2shHash is the funding tx id" + ], + "995c9801f2581aab": [ + "openrpc.json method coverage create_tx spec params === the params validateAll reads", + "openrpc.json method coverage docs/openrpc.json is exactly what docs/openrpc.build.js emits", + "openrpc.json method coverage every method has summary, paramStructure by-name, and a result", + "openrpc.json method coverage extracts a sane controller method list", + "openrpc.json method coverage extracts a sane validateAll param list", + "openrpc.json method coverage spec methods === controller methods" + ], + "99f2a27c2274c9bc": [ + "TxSizeEstimator .estimateInputSize(utxo) Legacy inputs (nonWitnessUtxo) returns 180 for P2PKH nonWitnessUtxo", + "TxSizeEstimator .estimateInputSize(utxo) Legacy inputs (nonWitnessUtxo) returns 289 for P2SH nonWitnessUtxo", + "TxSizeEstimator .estimateInputSize(utxo) No witnessUtxo or nonWitnessUtxo returns 350 (conservative fallback)", + "TxSizeEstimator .estimateInputSize(utxo) SegWit inputs (witnessUtxo) returns 105 for P2WSH (0020 prefix)", + "TxSizeEstimator .estimateInputSize(utxo) SegWit inputs (witnessUtxo) returns 350 (fallback) for unknown segwit script", + "TxSizeEstimator .estimateInputSize(utxo) SegWit inputs (witnessUtxo) returns 58 for P2TR (5120 prefix)", + "TxSizeEstimator .estimateInputSize(utxo) SegWit inputs (witnessUtxo) returns 68 for P2WPKH (0014 prefix)", + "TxSizeEstimator .estimateInputSize(utxo) nonWitnessUtxo edge cases returns 180 (assume P2PKH) when the referenced output index is missing", + "TxSizeEstimator .estimateInputSize(utxo) nonWitnessUtxo edge cases returns 350 when the nonWitnessUtxo buffer cannot be decoded", + "TxSizeEstimator .estimateMultisignOutput() returns 8 + 1 + the compiled 1-of-3 bare-multisig script length (114)", + "TxSizeEstimator .estimateOpReturnOutput(data) charges the 2-byte OP_PUSHDATA1 prefix for 76-byte data", + "TxSizeEstimator .estimateOpReturnOutput(data) charges the 2-byte OP_PUSHDATA1 prefix for 80-byte data", + "TxSizeEstimator .estimateOpReturnOutput(data) returns 11 for empty data (OP_RETURN + OP_0)", + "TxSizeEstimator .estimateOpReturnOutput(data) returns 12 for a 1-byte buffer", + "TxSizeEstimator .estimateOutputSizeForAddress(address, network) falls back rather than under-sizing an address from another network", + "TxSizeEstimator .estimateOutputSizeForAddress(address, network) falls back to the largest standard output (43) for a missing address", + "TxSizeEstimator .estimateOutputSizeForAddress(address, network) falls back to the largest standard output (43) for an unparseable address", + "TxSizeEstimator .estimateOutputSizeForAddress(address, network) sizes a P2PKH output at 34 bytes (8 value + 1 varint + 25 script)", + "TxSizeEstimator .estimateOutputSizeForAddress(address, network) sizes a P2SH output at 32 bytes, matching estimateP2shOutput()", + "TxSizeEstimator .estimateOutputSizeForAddress(address, network) sizes a P2WPKH output at 31 bytes (8 value + 1 varint + 22 script)", + "TxSizeEstimator .estimateOutputSizeForAddress(address, network) sizes a P2WSH output at 43 bytes, matching estimateP2wshOutput()", + "TxSizeEstimator .estimateP2shInputWithRedeem(redeemData) returns 149 for empty redeem (scriptSig=108, varint=1)", + "TxSizeEstimator .estimateP2shInputWithRedeem(redeemData) returns 150 for a 1-byte redeem script", + "TxSizeEstimator .estimateP2shInputWithRedeem(redeemData) returns 629 for 476-byte redeem (PUSHDATA2, varint=3)", + "TxSizeEstimator .estimateP2shInputWithRedeem(redeemData) uses 3-byte scriptSig varint when total scriptSig >= 253", + "TxSizeEstimator .estimateP2shInputWithRedeem(redeemData) uses OP_PUSHDATA1 (2-byte push) at redeem 76 bytes", + "TxSizeEstimator .estimateP2shInputWithRedeem(redeemData) uses OP_PUSHDATA2 (3-byte push) at redeem 256 bytes", + "TxSizeEstimator .estimateP2shOutput() always returns 32", + "TxSizeEstimator .estimateP2wshOutput() always returns 43" + ], + "99f46ad865902c51": [ + "GET /status publishes the lag ceiling its readiness was gated on @regression follows a non-default ceiling rather than reporting the default", + "GET /status publishes the lag ceiling its readiness was gated on @regression leaves the documented JSON-RPC health() shape alone", + "GET /status publishes the lag ceiling its readiness was gated on @regression reports tracker_max_lag_blocks equal to the gate create_tx enforces", + "health(): tracker_synced is serve-readiness (create_tx parity) @regression a halted tracker reads NOT synced even at lag 0", + "health(): tracker_synced is serve-readiness (create_tx parity) @regression a reconverged tracker reads synced and mempool-ready", + "health(): tracker_synced is serve-readiness (create_tx parity) @regression a tracker ahead of the node reads NOT synced", + "health(): tracker_synced is serve-readiness (create_tx parity) @regression a tracker that omits mempool_ready fails open", + "health(): tracker_synced is serve-readiness (create_tx parity) @regression a tracker whose mempool has not reconverged reads NOT synced at lag 0", + "health(): tracker_synced is serve-readiness (create_tx parity) @regression lag at the ceiling stays synced (create_tx serves at lag == max)", + "health(): tracker_synced is serve-readiness (create_tx parity) @regression lag over maxUtxoTrackerLagBlocks reads NOT synced even when the tracker says synced", + "health(): tracker_synced is serve-readiness (create_tx parity) @regression null/absent lag fails open, matching create_tx overLag gate", + "health(): tracker_synced is serve-readiness (create_tx parity) @regression reports tracker_halted false for a running tracker", + "health(): tracker_synced is serve-readiness (create_tx parity) @regression tracker verdict synced:false stays not synced regardless of lag" + ], + "a6d0a7d54f8fdf52": [ + "health()/GET /status carry the window without bending readiness @regression a window does not make a lagging tracker read synced", + "health()/GET /status carry the window without bending readiness @regression an expired sentinel reads as no maintenance", + "health()/GET /status carry the window without bending readiness @regression an unreachable tracker inside a declared window still reads unready", + "health()/GET /status carry the window without bending readiness @regression reports maintenance null when no window is declared", + "maintenanceWindow: an operator-declared scheduled outage @regression parseMaintenanceWindow() accepts epoch-millisecond timestamps as well as ISO strings", + "maintenanceWindow: an operator-declared scheduled outage @regression parseMaintenanceWindow() bounds and sanitizes the reason", + "maintenanceWindow: an operator-declared scheduled outage @regression parseMaintenanceWindow() holds a pre-announced window inactive until it opens", + "maintenanceWindow: an operator-declared scheduled outage @regression parseMaintenanceWindow() keeps a declared span of exactly the ceiling, and refuses one millisecond more", + "maintenanceWindow: an operator-declared scheduled outage @regression parseMaintenanceWindow() measures the sentinel ceiling in bytes, not UTF-16 code units", + "maintenanceWindow: an operator-declared scheduled outage @regression parseMaintenanceWindow() refuses a timestamp no Date can represent instead of throwing", + "maintenanceWindow: an operator-declared scheduled outage @regression parseMaintenanceWindow() refuses a window longer than the ceiling", + "maintenanceWindow: an operator-declared scheduled outage @regression parseMaintenanceWindow() refuses a window with no expiry", + "maintenanceWindow: an operator-declared scheduled outage @regression parseMaintenanceWindow() refuses an expired window, so a stale sentinel self-heals to Degraded", + "maintenanceWindow: an operator-declared scheduled outage @regression parseMaintenanceWindow() refuses an overlong DECLARED span, not just an overlong remainder", + "maintenanceWindow: an operator-declared scheduled outage @regression parseMaintenanceWindow() refuses an unparseable date rather than reading it as epoch 0 or NaN", + "maintenanceWindow: an operator-declared scheduled outage @regression parseMaintenanceWindow() reports an open, bounded window as active", + "maintenanceWindow: an operator-declared scheduled outage @regression parseMaintenanceWindow() resolves malformed, empty, non-object and oversized sentinels to no maintenance", + "maintenanceWindow: an operator-declared scheduled outage @regression parseMaintenanceWindow() tolerates a missing reason", + "maintenanceWindow: an operator-declared scheduled outage @regression readMaintenanceWindow() never rejects on a sentinel whose timestamps cannot be formatted", + "maintenanceWindow: an operator-declared scheduled outage @regression readMaintenanceWindow() never throws on an unreadable path", + "maintenanceWindow: an operator-declared scheduled outage @regression readMaintenanceWindow() reads an active window off disk", + "maintenanceWindow: an operator-declared scheduled outage @regression readMaintenanceWindow() refuses an overlong declared span read off disk", + "maintenanceWindow: an operator-declared scheduled outage @regression readMaintenanceWindow() refuses an oversized file without materializing it", + "maintenanceWindow: an operator-declared scheduled outage @regression readMaintenanceWindow() returns null when no sentinel exists (the normal case)", + "maintenanceWindow: an operator-declared scheduled outage @regression sentinelPath() defaults to the in-container path and honours the env override" + ], + "a76fc6f4a4157148": [ + "suggested fee-rate ceiling @regression @tier1 SUGGESTED_FEE_MAX_PER_VBYTE overrides the default, on any chain", + "suggested fee-rate ceiling @regression @tier1 SUGGESTED_FEE_MAX_PER_VBYTE=0 disables the clamp", + "suggested fee-rate ceiling @regression @tier1 clamps test chains to the default ceiling", + "suggested fee-rate ceiling @regression @tier1 identifies test networks by suffix", + "suggested fee-rate ceiling @regression @tier1 ignores an unparseable override rather than clamping to NaN", + "suggested fee-rate ceiling @regression @tier1 leaves mainnet UNCLAMPED, where the estimate is real", + "suggested fee-rate ceiling @regression @tier1 leaves the default ceiling in charge when the relay floor is below it (BTC/LTC)", + "suggested fee-rate ceiling @regression @tier1 prices the real failure inside the balance that funded it", + "suggested fee-rate ceiling @regression @tier1 raises the ceiling to 10x the node relay floor when the default is below it (DOGE)", + "suggested fee-rate ceiling @regression @tier1 returns null for a missing or non-positive relayfee", + "suggested fee-rate ceiling @regression @tier1 treats a ceiling equal to the node rate as NOT a breach", + "suggested fee-rate ceiling @regression @tier1 treats an unknown or empty network as mainnet, never as a test chain" + ], + "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" + ], + "b35bd4199facec7a": [ + "XChainEncoder.isSegwitUTXO() returns false for P2PKH", + "XChainEncoder.isSegwitUTXO() returns false for P2SH", + "XChainEncoder.isSegwitUTXO() returns false for a bare OP_1 (not a witness program)", + "XChainEncoder.isSegwitUTXO() returns false for empty string", + "XChainEncoder.isSegwitUTXO() returns false for invalid hex string", + "XChainEncoder.isSegwitUTXO() returns false when scriptPubKey is missing", + "XChainEncoder.isSegwitUTXO() returns true for P2TR (version 1, 32-byte x-only key)", + "XChainEncoder.isSegwitUTXO() returns true for P2WPKH (version 0, 20-byte hash)", + "XChainEncoder.isSegwitUTXO() returns true for P2WSH (version 0, 32-byte hash)" + ], + "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" + ], + "c512e0cbe2bc163a": [ + "large satoshi amounts (>2^53-1) createTransaction accepts a >2^53-1-sat input paying small outputs (giant consolidation UTXO)", + "large satoshi amounts (>2^53-1) createTransaction builds a PSBT paying a >2^53-1-sat custom output with exact change", + "large satoshi amounts (>2^53-1) createTransaction reports insufficient funds with JSON-safe (stringified) big amounts", + "large satoshi amounts (>2^53-1) parseSatoshiAmount accepts a >2^53-1 exact decimal string with allowBig, returning BigInt", + "large satoshi amounts (>2^53-1) parseSatoshiAmount keeps returning Number for safe-range values with allowBig", + "large satoshi amounts (>2^53-1) parseSatoshiAmount rejects a value above the u64 wire ceiling", + "large satoshi amounts (>2^53-1) parseSatoshiAmount rejects an unsafe NUMBER even with allowBig (already rounded upstream)", + "large satoshi amounts (>2^53-1) parseSatoshiAmount rejects negative and non-integer big strings", + "large satoshi amounts (>2^53-1) parseSatoshiAmount still rejects a >2^53-1 string without allowBig (fail closed)", + "large satoshi amounts (>2^53-1) patched 64-bit serializers rejects negative, fractional, and >u64 writes", + "large satoshi amounts (>2^53-1) patched 64-bit serializers round-trips safe-range values as Number and big values as BigInt", + "large satoshi amounts (>2^53-1) validateUtxoEntry / validateCustomOutputs accepts a >2^53-1 customOutputs value string, coercing to BigInt", + "large satoshi amounts (>2^53-1) validateUtxoEntry / validateCustomOutputs accepts a >2^53-1 utxo value string, coercing to BigInt" + ], + "ca8fa416b5e8a763": [ + "observability/installObservability buckets an unmatched path by first segment so URLs cannot explode cardinality", + "observability/installObservability gates the endpoint behind METRICS_TOKEN when one is configured", + "observability/installObservability honours a custom METRICS_PATH and can skip HTTP instrumentation", + "observability/installObservability instruments routes registered BEFORE the install call (layer is hoisted)", + "observability/installObservability normalizes a METRICS_PATH given without a leading slash", + "observability/installObservability reads a default-off config from an empty env", + "observability/installObservability registers NO route when the flag is unset, but still hands back a registry", + "observability/installObservability returns a usable logger even with no app to mount on", + "observability/installObservability serves the exposition text and instruments requests when enabled", + "observability/installObservability uses the express route pattern, not the concrete path, as the route label", + "observability/logShipper batches NDJSON to the transport once the batch size is reached", + "observability/logShipper drops the oldest lines when the buffer is full and counts the loss", + "observability/logShipper emits NDJSON with the envelope keys when LOG_FORMAT=json", + "observability/logShipper exposes shipper counters on a registry when one is supplied", + "observability/logShipper handles cyclic and deep field graphs without throwing", + "observability/logShipper honours LOG_LEVEL and drops quieter levels", + "observability/logShipper is inert by default: text output, no buffering, no shipping", + "observability/logShipper never lets a caller field forge the record envelope", + "observability/logShipper redacts credential-shaped field keys and inline key=value pairs", + "observability/logShipper releases the response body so a stalled collector cannot pin the socket", + "observability/logShipper requires BOTH the flag and a valid URL before shipping", + "observability/logShipper serializes an Error field with a scrubbed message", + "observability/logShipper stop() clears the flush timer so the process can exit", + "observability/logShipper survives a failing collector, re-queues the batch and rate-limits the stderr note", + "observability/logShipper: message redaction leaves real operational lines untouched, hex identifiers included", + "observability/logShipper: message redaction redacts the token, not the word Bearer", + "observability/logShipper: message redaction scrubs a api key", + "observability/logShipper: message redaction scrubs a bare bearer", + "observability/logShipper: message redaction scrubs a keyed bearer", + "observability/logShipper: message redaction scrubs a prefixed db pass", + "observability/logShipper: message redaction scrubs a prefixed env secret", + "observability/logShipper: message redaction scrubs a quoted mnemonic", + "observability/logShipper: message redaction scrubs a screaming env pass", + "observability/logShipper: text-with-fields format emits one NDJSON record per line under LOG_FORMAT=json", + "observability/logShipper: text-with-fields format keeps the level token lowercase so the server-monitor ERROR|FATAL grep does not match it", + "observability/logShipper: text-with-fields format puts the message immediately after the service tag so existing substring greps still match", + "observability/logShipper: text-with-fields format quotes a value carrying whitespace, = or a quote, and leaves plain tokens bare", + "observability/logShipper: text-with-fields format redacts a credential-shaped field and an inline credential in the message", + "observability/logShipper: text-with-fields format renders ts, lowercase level, service tag, message, then key=value", + "observability/logShipper: text-with-fields format silences info under LOG_LEVEL=warn while still emitting warn", + "observability/metrics: exposition format caps series per metric and counts the drops instead of growing", + "observability/metrics: exposition format collectDefaultMetrics exposes process and service identity", + "observability/metrics: exposition format emits cumulative histogram buckets with +Inf equal to _count", + "observability/metrics: exposition format escapes backslash, quote and newline in label values and help", + "observability/metrics: exposition format exports the Prometheus content type", + "observability/metrics: exposition format gauge set/inc/dec track a value and reject a non-finite set", + "observability/metrics: exposition format hands back the same metric when an identical declaration repeats", + "observability/metrics: exposition format ignores a non-finite histogram observation instead of poisoning the sum", + "observability/metrics: exposition format metric classes are usable standalone", + "observability/metrics: exposition format rejects a negative counter increment and an unknown label", + "observability/metrics: exposition format rejects invalid metric and label names at declaration", + "observability/metrics: exposition format renders a counter with HELP, TYPE and labelled samples", + "observability/metrics: exposition format reserves le for histogram buckets", + "observability/metrics: exposition format setMonotonic never lets a collector-driven counter go backwards", + "observability/metrics: exposition format still refuses a duplicate name declared with a different shape", + "observability/metrics: exposition format survives a throwing collector and still renders the rest", + "observability/metrics: exposition format treats label order as declared order, not caller order", + "observability/patchConsole JSON mode keeps the real newlines and still emits one physical line", + "observability/patchConsole does not double-format: a shipper built AFTER the patch writes to the pre-patch sink", + "observability/patchConsole does not recurse: the sink holds bound originals captured BEFORE the patch", + "observability/patchConsole escapes a bare carriage return too, so a progress writer cannot split a record", + "observability/patchConsole getLogger works before any install and reaches the real shipper after", + "observability/patchConsole hands out one registry, always constructed, before any install call", + "observability/patchConsole is idempotent and restores the exact original functions on unpatch", + "observability/patchConsole no-ops under XCHAIN_LOG_PATCH=0 so test bootstraps see stock console", + "observability/patchConsole renders a multi-line message as ONE line with the breaks escaped", + "observability/patchConsole resolves printf format strings and keeps an Error stack, via util.format", + "observability/patchConsole routes console.* through the shim, mapping log to info" + ], + "ccc87ed9c55081d5": [ + "XChainEncoder.dataToPubkey() always returns exactly 33 bytes", + "XChainEncoder.dataToPubkey() data bytes are preserved starting at offset 1", + "XChainEncoder.dataToPubkey() first byte is always 0x02 (compressed pubkey prefix)", + "XChainEncoder.dataToPubkey() handles 1-byte input", + "XChainEncoder.dataToPubkey() handles empty input (0-byte buffer)", + "XChainEncoder.dataToPubkey() no padding when data is exactly 32 bytes", + "XChainEncoder.dataToPubkey() pads with 0x00 when data is less than 32 bytes" + ], + "d51133e5aaa56d2f": [ + "XChainEncoder TAPROOT envelope TxSizeEstimator envelope sizing estimateEnvelopeRevealTx covers and does not wildly over-fund a real reveal", + "XChainEncoder TAPROOT envelope TxSizeEstimator envelope sizing estimateTaprootOutput matches the serialized P2TR output size", + "XChainEncoder TAPROOT envelope TxSizeEstimator envelope sizing uses compactSize framing for scripts above 65535 bytes (5-byte prefix band)", + "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) a payload at exactly the ceiling builds a reveal within MAX_STANDARD_TX_WEIGHT (\u00a74)", + "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) applies the per-encoding \u00a74 ceiling: over ENVELOPE_MAX_PAYLOAD rejects, over 8192 passes", + "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) emits customOutputs on the COMMIT (fee-destination outputs ride the commit, \u00a73.5)", + "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) pads the reveal over the LTC stripped-size floor and prefunds the pad (\u00a73.5)", + "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) propagates RBF to both transactions of the pair", + "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) refuses TAPROOT on DOGE with the P2WSH gate error shape (\u00a73.6)", + "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) refuses non-segwit commit inputs (\u00a73.5, fail closed)", + "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", + "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) accepts the x-only internal key form", + "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) applies the create_tx fee guards to feePerKb and replacebyfee", + "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) builds, signs and extracts a sweep using only the recovery record", + "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) fails closed when the sweep would land under dust", + "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) validates the recovery-record fields", + "XChainEncoder TAPROOT envelope prepareData grammar (\u00a73.2) emits OP_FALSE OP_IF <0x00> OP_ENDIF OP_CHECKSIG", + "XChainEncoder TAPROOT envelope prepareData grammar (\u00a73.2) matches the golden grammar vector", + "XChainEncoder TAPROOT envelope prepareData grammar (\u00a73.2) rebalances a degenerate 1-byte minimal-opcode final chunk (compile would canonicalize it away)", + "XChainEncoder TAPROOT envelope prepareData grammar (\u00a73.2) requires compressedPubKey and validates its shape", + "XChainEncoder TAPROOT envelope prepareData grammar (\u00a73.2) splits the payload into 520-byte pushes, in order", + "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" + ], + "d6e2e7913a5f7e7f": [ + "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", + "XChainEncoder.createTransaction() - feeQuote injection adds feeQuote as an extra output when address and amount > 0", + "XChainEncoder.createTransaction() - feeQuote injection does not add feeQuote when amount is 0", + "XChainEncoder.createTransaction() - feeQuote injection does not add feeQuote when feeQuote has no address", + "XChainEncoder.createTransaction() - invalid fee throws RangeError for a NaN fee string", + "XChainEncoder.createTransaction() - invalid fee throws RangeError for a negative fee", + "XChainEncoder.createTransaction() - maxFeeRateKb cap caps the fee rate when connector returns a rate above maxFeeRateKb", + "XChainEncoder.createTransaction() - rawData parameter accepts rawData and includes it in the compiled payload", + "XChainEncoder.createTransaction() - rawData parameter rawData does not affect result when null (false branch of rawData != null)", + "XChainEncoder.createTransaction() - remaining branch coverage throws RangeError when a UTXO value is not a valid satoshi amount", + "XChainEncoder.createTransaction() - remaining branch coverage throws RangeError when customOutputs[i].value is negative", + "XChainEncoder.createTransaction() - remaining branch coverage throws RangeError when customOutputs[i].value is not a valid satoshi amount", + "XChainEncoder.createTransaction() - remaining branch coverage throws when unconfirmed=false strips all UTXOs (line 325 path)", + "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", + "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", + "XChainEncoder.createTransaction(): P2WSH tx2 (spending) throws RangeError when p2shHex has no output at the expected voutPsbtIndex (P2SH)", + "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", + "XChainEncoder.createTransaction(): non-segwit UTXO path uses multiple UTXOs when first is insufficient to cover fee", + "XChainEncoder.createTransaction(): payload size guard throws RangeError when compiled payload exceeds MAX_COMPILED_ACTION_DATA_LENGTH (8192)", + "XChainEncoder.estimateSpendingP2wshTx() does not add an OP_PUSHDATA1 byte in the 76..252 band (len \u2261 1 mod 4)", + "XChainEncoder.estimateSpendingP2wshTx() includes the 8-byte safety margin in the result", + "XChainEncoder.estimateSpendingP2wshTx() increases monotonically with data size", + "XChainEncoder.estimateSpendingP2wshTx() is smaller than estimateSpendingP2shTx for the same data (witness discount)", + "XChainEncoder.estimateSpendingP2wshTx() switches prefix width at 253, not at 76 or 256", + "XChainEncoder.estimateSpendingP2wshTx() uses a 1-byte compactSize prefix for a witness script < 76 bytes", + "XChainEncoder.estimateSpendingP2wshTx() uses a 1-byte compactSize prefix for a witness script in 76..252", + "XChainEncoder.estimateSpendingP2wshTx() uses a 3-byte compactSize prefix for a witness script >= 253 bytes" + ], + "d8d1e52267ea606f": [ + "cross-repo sibling coverage (what this run could NOT verify) declares every sibling it lists in .ci-siblings, so the venue ships them", + "cross-repo sibling coverage (what this run could NOT verify) reports every sibling it looked for, so the list itself cannot rot silently", + "cross-repo sibling coverage (what this run could NOT verify) resolves every sibling checkout the cross-repo guards depend on" + ], + "dfc3f143ee7a2329": [ + "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", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 discards a root that confirms between its two RPC calls", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 does not leak the RPC password when a mempool call fails", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 keeps an earlier root's branch when a later root confirms mid-sequence", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 reads the modern Core field layout (vsize and fees.base)", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns an empty package for an empty or non-array input", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns an empty package when every input is already confirmed", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null on an HTTP-500 RPC error body", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null on an RPC error rather than throwing", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when an entry carries no readable size or fee", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when the flat fee field is an empty string", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when the flat fee field is false", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when the flat fee field is null", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when the nested fees.base field is null", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when the node is unreachable", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when the second call fails for a reason other than absence", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when the size field is a boolean rather than a number", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 skips a txid the mempool does not hold (already confirmed)", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 still prices a legitimate zero-fee ancestor, in both field layouts", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 sums an unconfirmed parent and everything above it", + "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", + "P2SH two-phase package prefund @regression @tier1 funds the package rate into the leg when the commit pays under target", + "P2SH two-phase package prefund @regression @tier1 the reveal keeps the package money as fee instead of sweeping it back", + "XChainEncoder package-aware fee sizing @regression @tier1 MAX_CPFP_UPLIFT_SAT=0 turns package sizing off entirely", + "XChainEncoder package-aware fee sizing @regression @tier1 clamps the uplift at MAX_CPFP_UPLIFT_SAT and warns that the package stays under target", + "XChainEncoder package-aware fee sizing @regression @tier1 clamps the uplift to the fee-rate cap rather than blowing through it", + "XChainEncoder package-aware fee sizing @regression @tier1 counts a shared ancestor once by handing the node every unconfirmed input txid", + "XChainEncoder package-aware fee sizing @regression @tier1 degrades when the connector has no package method at all", + "XChainEncoder package-aware fee sizing @regression @tier1 does not lower the fee when the ancestors already pay well above the target", + "XChainEncoder package-aware fee sizing @regression @tier1 falls back to the per-transaction fee when the ancestor lookup fails", + "XChainEncoder package-aware fee sizing @regression @tier1 leaves the fee untouched when every input is confirmed, and never asks the node", + "XChainEncoder package-aware fee sizing @regression @tier1 lifts the fee so a cheap ancestor package clears the target rate", + "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", + "packageFeeUpliftSatoshis() @regression @tier1 reproduces the live DOGE shortfall", + "packageFeeUpliftSatoshis() @regression @tier1 returns 0 for an empty package or a missing target", + "packageFeeUpliftSatoshis() @regression @tier1 returns 0 when the package already clears the target", + "packageFeeUpliftSatoshis() @regression @tier1 subtracts what the transaction already pays", + "packageFeeUpliftSatoshis() @regression @tier1 takes the ancestor fee in base units when one is given, ahead of the coin-unit figure", + "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", + "two-phase reveal package prefund @regression @tier1 carries ancestors the commit's own capped uplift could not pay for", + "two-phase reveal package prefund @regression @tier1 clamps at MAX_CPFP_UPLIFT_SAT and warns that the package stays under target", + "two-phase reveal package prefund @regression @tier1 leaves the reveal its change output rather than paying the uplift out of it", + "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" + ], + "e349cec537f9beb2": [ + "assertEnvelopeRecognized() FAIL-CLOSES when the tip is unknowable, rather than assuming", + "assertEnvelopeRecognized() REFUSES below the activation height, naming the shortfall", + "assertEnvelopeRecognized() allows a genesis-active network without consulting the node at all", + "assertEnvelopeRecognized() allows at the activation height and above", + "assertEnvelopeRecognized() refuses a network that never recognizes envelopes (null: DOGE, no segwit)", + "assertEnvelopeRecognized() refuses an unparseable network key rather than treating it as allowed", + "assertEnvelopeRecognized() refuses one block below and allows exactly at the height (boundary is inclusive)", + "assertEnvelopeRecognized() resolves the real shipped heights per network", + "encoder activation heights are byte-equal to the decoder consensus copy every network key the encoder can parse resolves to the decoder height", + "encoder activation heights are byte-equal to the decoder consensus copy the whole table is deep-equal to the decoder consensus copy" + ], + "e3e957d2c34ec853": [ + "XChainEncoder P2SH reveal headroom funds floor-dominant legs with exactly one extra output floor of headroom (tiny fee rate)", + "XChainEncoder P2SH reveal headroom funds the DOGE leg to cover the reveal fee plus the change it must leave", + "XChainEncoder P2SH reveal headroom keeps the BTC default two-phase lane (P2WSH) byte-identical, funding and reveal", + "XChainEncoder P2SH reveal headroom keeps the DOGE single-phase OP_RETURN path byte-identical", + "XChainEncoder P2SH reveal headroom keeps the LTC default two-phase lane (P2WSH) byte-identical, funding and reveal", + "XChainEncoder P2SH reveal headroom replays a legacy (pre-fix, headroomless) funding leg into the exact pre-fix outputless reveal", + "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" + ], + "e9e98dbc28c54a4f": [ + "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" + ], + "ef95d11a5f979b57": [ + "XChainEncoder soft-dust floor on authored outputs P2SH funding legs at the fee rate that stalled testnet authors no funding leg below 0.01 DOGE", + "XChainEncoder soft-dust floor on authored outputs P2SH funding legs at the fee rate that stalled testnet clamps a caller dust below the floor up to the floor", + "XChainEncoder soft-dust floor on authored outputs P2SH funding legs at the fee rate that stalled testnet is a discriminating rate: the per-chunk fee share alone sits under the soft limit", + "XChainEncoder soft-dust floor on authored outputs P2SH funding legs at the fee rate that stalled testnet still lets a caller raise the floor above the soft limit", + "XChainEncoder soft-dust floor on authored outputs the floor leaves BTC and LTC at their pinned dust threshold (no soft limit there)", + "XChainEncoder soft-dust floor on authored outputs the floor lets an operator DUST_AMOUNT raise the floor but never lower it", + "XChainEncoder soft-dust floor on authored outputs the floor raises the DOGE floor from the pinned hard limit to the 0.01 DOGE soft limit on every network" + ], + "f16582a001cac4d2": [ + "BlockchainConnector RPC-credential log sanitization does not leak the RPC password when an axios call fails", + "BlockchainConnector constructor builds the correct URL from host and port", + "BlockchainConnector constructor stores rpcUser and rpcPassword", + "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", + "BlockchainConnector.getNetworkInfo() keeps the node RPC code and message from an HTTP-200 error body", + "BlockchainConnector.getNetworkInfo() keeps the node RPC code and message from an HTTP-500 error body", + "BlockchainConnector.getNetworkInfo() passes auth credentials to axios", + "BlockchainConnector.getNetworkInfo() returns the result object on success", + "BlockchainConnector.getNetworkInfo() sends the correct JSON-RPC method", + "BlockchainConnector.getNetworkInfo() sets a timeout on the request", + "BlockchainConnector.getNetworkInfo() throws when result is missing from response", + "BlockchainConnector.getNetworkInfo() wraps axios transport errors", + "BlockchainConnector.getTransactionHex() includes the txid in the \"not found\" error message", + "BlockchainConnector.getTransactionHex() passes hexFormat=false when requested", + "BlockchainConnector.getTransactionHex() rethrows transport errors directly", + "BlockchainConnector.getTransactionHex() returns the hex string on success", + "BlockchainConnector.getTransactionHex() sends auth credentials", + "BlockchainConnector.getTransactionHex() sends getrawtransaction with correct txid and hexFormat=true", + "BlockchainConnector.getTransactionHex() throws \"not found\" message when error.code is -5", + "BlockchainConnector.getTransactionHex() throws generic error when result is missing and error is not -5", + "BlockchainConnector.isRegtest() keeps the node RPC code and message from an HTTP-200 error body", + "BlockchainConnector.isRegtest() keeps the node RPC code and message from an HTTP-500 error body", + "BlockchainConnector.isRegtest() returns false when chain is \"main\"", + "BlockchainConnector.isRegtest() returns false when chain is \"test\"", + "BlockchainConnector.isRegtest() returns true when chain is \"regtest\"", + "BlockchainConnector.isRegtest() sends getblockchaininfo method", + "BlockchainConnector.isRegtest() throws when result has no chain property", + "BlockchainConnector.isRegtest() throws when result is missing", + "BlockchainConnector.isRegtest() wraps transport errors", + "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() maxfeerate retry does NOT retry on regtest for non-fee-cap errors", + "BlockchainConnector.sendRawTransaction() maxfeerate retry does NOT retry on the fee-cap error when chain is not regtest", + "BlockchainConnector.sendRawTransaction() maxfeerate retry matches the bare \"maxfeerate\" wording variant", + "BlockchainConnector.sendRawTransaction() maxfeerate retry propagates the original fee-cap error when the chain check itself fails", + "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", + "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" + ] + }, + "scripts": { + "test:unit": { + "fileCount": 47, + "titleCount": 906, + "files": { + "test/unit/ActionManifestConformance.test.js": "babd2db450e2e6a1", + "test/unit/BlockchainConnector.test.js": "f16582a001cac4d2", + "test/unit/CryptoNetworks.test.js": "3f015c065bb424cc", + "test/unit/LargeSatoshiAmounts.test.js": "c512e0cbe2bc163a", + "test/unit/TxSizeEstimator.test.js": "99f2a27c2274c9bc", + "test/unit/UtxoTracker.test.js": "1afcffc2f1fa70d9", + "test/unit/XChainEncoder.autoEncoding.test.js": "0748973400fcace3", + "test/unit/XChainEncoder.consensusPinBoot.test.js": "6a874601dbd714de", + "test/unit/XChainEncoder.createTransaction.test.js": "57c53b6db4d98a77", + "test/unit/XChainEncoder.dataToPubkey.test.js": "ccc87ed9c55081d5", + "test/unit/XChainEncoder.exactInputs.test.js": "0402af4b590d4603", + "test/unit/XChainEncoder.extra.test.js": "d6e2e7913a5f7e7f", + "test/unit/XChainEncoder.feeRateCap.test.js": "5ada45b5f0f336ef", + "test/unit/XChainEncoder.isSegwitUTXO.test.js": "b35bd4199facec7a", + "test/unit/XChainEncoder.obfuscate.test.js": "11a13e37d91017bf", + "test/unit/XChainEncoder.p2shRevealHeadroom.test.js": "e3e957d2c34ec853", + "test/unit/XChainEncoder.prepareData.test.js": "4a88b66dc7f07157", + "test/unit/XChainEncoder.releaseReservations.test.js": "923b72fe3989bb3c", + "test/unit/XChainEncoder.revealFundingBinding.test.js": "990dee7527465c8e", + "test/unit/XChainEncoder.segwitInputGuard.test.js": "2aa020357e5b268b", + "test/unit/XChainEncoder.softDustFloor.test.js": "ef95d11a5f979b57", + "test/unit/XChainEncoder.taprootEnvelope.test.js": "d51133e5aaa56d2f", + "test/unit/XChainEncoder.trackerFreshnessClassifier.test.js": "e9e98dbc28c54a4f", + "test/unit/XChainEncoder.utxoDedup.test.js": "2a1c91a7aceda8eb", + "test/unit/applyBufferutilsPatch.test.js": "63c55285b6bac324", + "test/unit/coins-conformance.test.js": "2442df610ebb6911", + "test/unit/compression.test.js": "140f5ed1c625c9ab", + "test/unit/corsOrigin.test.js": "121f9fc54d5673a7", + "test/unit/corsPreflight.test.js": "78e833e8f04d5380", + "test/unit/coverage-thresholds-sync.test.js": "4405034a199528ec", + "test/unit/crashHandlers.test.js": "ab75df16ddef33d7", + "test/unit/docs-openrpc.test.js": "65be62f0e174b504", + "test/unit/encoderStressSweep.test.js": "358184203c4ccfba", + "test/unit/envelopeRecognitionGate.test.js": "e349cec537f9beb2", + "test/unit/errorSanitize.test.js": "8c8c02bad5e89e5b", + "test/unit/evmAdapter.test.js": "2c605d4ec40ce8e5", + "test/unit/health-serve-readiness.test.js": "99f46ad865902c51", + "test/unit/jsonrpc-body-guard.test.js": "58706b2a54fa0008", + "test/unit/maintenanceWindow.test.js": "a6d0a7d54f8fdf52", + "test/unit/observability.test.js": "ca8fa416b5e8a763", + "test/unit/openrpc-coverage.test.js": "995c9801f2581aab", + "test/unit/packageFeeSizing.test.js": "dfc3f143ee7a2329", + "test/unit/security/configuration/dependency-advisories.test.js": "0f2abe37f99c5a17", + "test/unit/sibling-coverage.test.js": "d8d1e52267ea606f", + "test/unit/singleInstanceGuard.test.js": "750c1552249846a5", + "test/unit/suggestedFeeCeiling.test.js": "a76fc6f4a4157148", + "test/unit/validator.test.js": "85489f2e3279585d" + } + } + } +} diff --git a/bin/pins/identity.json b/bin/pins/identity.json new file mode 100644 index 0000000..c53c8ac --- /dev/null +++ b/bin/pins/identity.json @@ -0,0 +1,13 @@ +{ + "capturedAt": "2026-09-13T21:59:23.727Z", + "coins": { + "src/coins/BTC.js": "900d82359d27269ebb775a207e84cac7ac0702f57f07c0239d5406f2a0ec6c90", + "src/coins/LTC.js": "c227025a7b1e8d70f5165f6065cd2894161abd4966814c8f7b0f462e036474c9", + "src/coins/DOGE.js": "a0952d619edec50c09d0cbac90023cba2e8e75f0fa98b650e2ee1f4eadd7540b", + "src/coins/index.js": "af301d7ba0a0456db6a19f0ea07584b3e136293ed4e040e11e82b9bf31ace8e1", + "src/coins/consensus_pin.js": "f41142b6b3c9e3f1c1d491b9737fee5e6fd988d700bd96f0d200f7bd0b301ae7" + }, + "conformance": { + "test/fixtures/roundtrip-conformance.json": "d9963ac7c35bdcfab99595fec040d7791d73119cb62fef20db136f8ea56236ed" + } +} diff --git a/bin/pins/wall-times.json b/bin/pins/wall-times.json new file mode 100644 index 0000000..131ae30 --- /dev/null +++ b/bin/pins/wall-times.json @@ -0,0 +1,12 @@ +{ + "measuredAt": "2026-09-13T21:58:40.000Z", + "scripts": { + "test:unit": { + "command": "npm run test:unit", + "wallSeconds": 3.97, + "passing": 903, + "pending": 3 + } + }, + "note": "test:unit is this repo's own unit-suite tier; well under the 5-minute drop-out threshold" +} diff --git a/bin/reachability.js b/bin/reachability.js new file mode 100644 index 0000000..6e44ae5 --- /dev/null +++ b/bin/reachability.js @@ -0,0 +1,445 @@ +#!/usr/bin/env node +/********************************************************************* + * + * Copyright © 2025-2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC - https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. + * + ********************************************************************** + * + * Can anything still reach this file? Asked of every src/*.js, across the + * platform rather than inside this repo alone. + * + * WHY THIS EXISTS, AND WHY IT IS NOT A REPO-LOCAL QUESTION. A restructure that + * deletes a file because no runtime path inside the repo reaches it will delete + * a module another service requires by relative path out of this checkout, and + * nothing here fails: the break lands in the sibling's CI, later, attributed to + * the sibling. Several indexer modules are exactly that shape. A file with no + * caller in this repo is therefore a CANDIDATE for deletion, and the sibling + * sweep is what turns a candidate into a verdict. + * + * THE FOUR REACHES, kept apart because they carry different weight: + * + * runtime the require closure of what the service actually starts: + * the Dockerfile CMD, and every `node ` an npm script runs. + * A file outside this closure cannot execute in production. + * tooling the closure of bin/, scripts/ and tools/: operator commands, + * verifiers, benchmarks. Real callers, not production ones. + * test the closure of test/. A file reached only from here exists to + * be tested and nothing else, which CODE-STYLE calls a signal that + * the file is dead rather than a reason to keep it. + * siblings any other repo naming the path, from bin/sibling-reference-map.js + * so the two tools can never disagree about what a reference is. + * + * A file outside all four is unreferenced across the platform and is the only + * shape a dead-code sweep deletes outright. + * + * THE SIBLING REACH INCLUDES THE PLATFORM TOOLING, and it has to. The map tool + * sweeps the `xchain-*` siblings by default and treats the surrounding tree's + * tooling directories as OPT-IN, because those paths belong to the tree around + * this checkout rather than to this repo. A deletion verdict cannot take that + * option: the twin-copier script alone byte-copies about thirty of this repo's + * src/ files outward and no CI job runs it, so a module held only from there + * reads deletable with the sweep off and takes the tooling down with it when + * deleted. This tool therefore turns the sweep ON and names the directories + * itself, from SIBLING_MAP_EXTRA_DIRS when the caller set it and otherwise from + * a probe of the tree beside this checkout (see toolingSweepDirs). + * + * DYNAMIC EDGES. A static walk cannot see a require built at runtime, so every + * such edge is declared in DYNAMIC_EDGES below with the site that builds it. + * The walk reports how many it applied, so a new computed require that nobody + * declared shows up as a file that suddenly reads unreachable. + * + * USAGE + * node bin/reachability.js human summary plus the candidates + * node bin/reachability.js --json the full per-file verdict + * node bin/reachability.js --siblings sweep root for the sibling half + * node bin/reachability.js --no-siblings repo-local reaches only, fast + * SIBLING_MAP_EXTRA_DIRS=, node bin/reachability.js + * name the tooling directories + * instead of probing for them + * + ********************************************************************/ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const { buildReferenceMap, platformToolingDirs } = require('./sibling-reference-map.js'); + +const REPO_ROOT = path.resolve(__dirname, '..'); + +// The twin-copier script, by name only. Which directory of the surrounding tree +// holds the platform's tooling is that tree's business, so the sweep finds the +// tooling parent by looking for this script instead of carrying its path. +const TWIN_COPIER = 'reconcile-twins.sh'; + +// Tooling that sits at the top of the surrounding tree rather than inside the +// tooling parent. Swept when present, skipped silently when this checkout +// stands alone, which is every consumer outside the platform tree. +const TOP_LEVEL_TOOLING = ['bin', 'tools']; + +// Subdirectories of the tooling parent that hold executables. Its other +// subdirectories are prose (specs, reports, runbooks), and a document naming a +// module is a mention, not a holder: sweeping them would clear a dead file. +const TOOLING_PARENT_SUBDIRS = ['bin', 'scripts']; + +/** + * The platform tooling directories to sweep, relative to the siblings root. + * SIBLING_MAP_EXTRA_DIRS wins when the caller named them; otherwise they are + * probed for, so a verdict taken on a fresh checkout is the same verdict. + * + * @param {string} siblingsRoot the tree this checkout sits in + * @returns {string[]} directories, relative to that root, possibly empty + */ +function toolingSweepDirs(siblingsRoot) { + const named = platformToolingDirs(); + if (named.length) return named; + + const dirs = []; + const present = (rel) => fs.existsSync(path.join(siblingsRoot, rel)); + for (const rel of TOP_LEVEL_TOOLING) if (present(rel)) dirs.push(rel); + + let entries; + try { entries = fs.readdirSync(siblingsRoot, { withFileTypes: true }); } catch (e) { return dirs; } + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name.startsWith('xchain-')) continue; + if (!present(path.join(entry.name, 'bin', TWIN_COPIER))) continue; + for (const sub of TOOLING_PARENT_SUBDIRS) { + const rel = path.join(entry.name, sub); + if (present(rel)) dirs.push(rel); + } + } + return dirs; +} + +/** + * Requires this repo builds at runtime, which no static walk can follow. + * Each entry names the site that builds the path and what it resolves to, so a + * reader can check the claim instead of trusting the table. + */ +const DYNAMIC_EDGES = [ + { + from: 'src/consensus_rules_digest.js', + // loadGateValues requires './.js' for every SHARED_GATES row, so + // the gate carriers are held by the digest and not by any literal. The + // list is read from the module rather than restated, because a restated + // copy is a second registry that drifts. + toList: () => { + const { SHARED_GATES } = require('../src/consensus_rules_digest.js'); + return SHARED_GATES.map(([mod]) => `src/${mod}.js`); + }, + why: 'the consensus-rules digest requires every SHARED_GATES module by computed path', + }, + { + from: 'src/db/index.js', + // The mixin install loop calls require(file) over its MIXIN_FILES rows, so + // not one literal in the file names a mixin and all of src/db/ reads + // unreachable without this edge. The list is read out of the declaration + // rather than restated here, because a restated copy is a second registry + // that drifts away from the one the loop actually walks. + toList: () => { + const declared = fs.readFileSync(path.join(REPO_ROOT, 'src/db/index.js'), 'utf8'); + const block = /const MIXIN_FILES = \[([\s\S]*?)\];/.exec(declared); + if (!block) { + throw new Error('src/db/index.js no longer declares MIXIN_FILES: the mixin edge cannot be read'); + } + const rows = Array.from(block[1].matchAll(/(['"])([^'"]+)\1/g)) + .map((m) => resolveRequire('src/db/index.js', m[2])) + .filter(Boolean); + if (!rows.length) throw new Error('MIXIN_FILES declares no resolvable mixin: the edge is stale'); + return rows; + }, + why: 'the Database mixin install loop requires every MIXIN_FILES row by computed path', + }, +]; + +const SOURCE_EXT = ['.js']; + +/** Tracked files only: an untracked scratch copy under src/ is not the tree. */ +function trackedFiles() { + const out = execFileSync('git', ['ls-files', '-z'], { cwd: REPO_ROOT, maxBuffer: 64 * 1024 * 1024 }); + return out.toString('utf8').split('\0').filter(Boolean); +} + +/** Node's own resolution for a relative require, restricted to this repo. */ +function resolveRequire(fromRel, spec) { + if (!spec.startsWith('.')) return null; + const base = path.posix.join(path.posix.dirname(fromRel), spec); + const candidates = [base]; + for (const ext of SOURCE_EXT) candidates.push(base + ext); + for (const ext of SOURCE_EXT) candidates.push(path.posix.join(base, `index${ext}`)); + for (const c of candidates) { + const abs = path.join(REPO_ROOT, c); + if (fs.existsSync(abs) && fs.statSync(abs).isFile() && c.endsWith('.js')) return c; + } + return null; +} + +const REQUIRE_LITERAL = /require\(\s*(['"])([^'"]+)\1\s*\)/g; + +/** Every repo-local file `rel` requires by a literal path, plus its declared dynamic edges. */ +function edgesFrom(rel, fileSet) { + const abs = path.join(REPO_ROOT, rel); + let text; + try { text = fs.readFileSync(abs, 'utf8'); } catch (e) { return []; } + const out = new Set(); + REQUIRE_LITERAL.lastIndex = 0; + let m; + while ((m = REQUIRE_LITERAL.exec(text)) !== null) { + const target = resolveRequire(rel, m[2]); + if (target && fileSet.has(target)) out.add(target); + } + for (const edge of DYNAMIC_EDGES) { + if (edge.from !== rel) continue; + // A declared edge that no longer resolves is louder as a thrown error + // than as a file that quietly starts reading unreachable. + for (const target of edge.toList()) if (fileSet.has(target)) out.add(target); + } + return Array.from(out); +} + +/** + * Who requires each file, counting only non-test callers. Reachability alone + * calls a module dead when its one caller is itself unreached, which is the + * wrong verdict whenever that caller is being kept (a tool about to be promoted + * into bin/, for instance). The reverse edge is what separates the two. + */ +function reverseEdges(fileSet) { + const back = {}; + for (const rel of fileSet) { + if (rel.startsWith('test/')) continue; + for (const target of edgesFrom(rel, fileSet)) { + if (!back[target]) back[target] = []; + back[target].push(rel); + } + } + for (const key of Object.keys(back)) back[key] = Array.from(new Set(back[key])).sort(); + return back; +} + +/** Transitive closure of `entries` over the require graph. */ +function closure(entries, fileSet) { + const seen = new Set(); + const stack = entries.filter((e) => fileSet.has(e)); + while (stack.length) { + const cur = stack.pop(); + if (seen.has(cur)) continue; + seen.add(cur); + for (const next of edgesFrom(cur, fileSet)) if (!seen.has(next)) stack.push(next); + } + return seen; +} + +/** + * What the service starts. Two sources, both read rather than assumed: the + * Dockerfile's exec-form CMD or ENTRYPOINT, and every `node ` in an npm + * script (`npm run migrate` is as much a production path as the API server). + */ +function runtimeEntries(fileSet) { + const entries = new Set(); + + const dockerfile = path.join(REPO_ROOT, 'Dockerfile'); + if (fs.existsSync(dockerfile)) { + for (const line of fs.readFileSync(dockerfile, 'utf8').split('\n')) { + if (!/^\s*(CMD|ENTRYPOINT)\b/.test(line)) continue; + for (const m of line.matchAll(/["']([^"']+\.js)["']/g)) { + const rel = m[1].replace(/^\.\//, ''); + if (fileSet.has(rel)) entries.add(rel); + } + } + } + + const pkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')); + for (const [name, script] of Object.entries(pkg.scripts || {})) { + if (name.startsWith('test') || name === 'ci' || name === 'coverage') continue; + // Token walk rather than one regex: the argument between `node` and the + // script can be a flag (`--no-node-snapshot`) or nothing at all, and a + // pattern loose enough for both is loose enough to capture half a path. + const tokens = script.split(/\s+/); + for (let i = 0; i < tokens.length; i += 1) { + if (tokens[i] !== 'node') continue; + for (let j = i + 1; j < tokens.length; j += 1) { + if (tokens[j].startsWith('-')) continue; + if (tokens[j].endsWith('.js')) { + const rel = tokens[j].replace(/^\.\//, ''); + if (fileSet.has(rel)) entries.add(rel); + } + break; + } + } + } + return Array.from(entries).sort(); +} + +/** + * Sibling repos carrying a file at the SAME repo-relative path. A vendored twin + * is a reference no text sweep can see: the sibling requires its own copy, and + * the two are kept equal by a sync script, so deleting or moving the original + * silently orphans a live file in another service. `byteIdentical` separates a + * maintained twin from two files that merely share a name. + */ +function twinCopies(sources, siblingsRoot) { + const out = {}; + let repos = []; + try { + repos = fs.readdirSync(siblingsRoot, { withFileTypes: true }) + .filter((e) => e.name.startsWith('xchain-') && e.name !== 'xchain-indexer') + .map((e) => e.name) + .sort(); + } catch (e) { + return out; + } + for (const rel of sources) { + const mine = path.join(REPO_ROOT, rel); + let mineText = null; + try { mineText = fs.readFileSync(mine, 'utf8'); } catch (e) { mineText = null; } + const found = []; + for (const repo of repos) { + const other = path.join(siblingsRoot, repo, rel); + let otherText; + try { otherText = fs.readFileSync(other, 'utf8'); } catch (e) { continue; } + found.push({ path: `${repo}/${rel}`, byteIdentical: mineText !== null && otherText === mineText }); + } + if (found.length) out[rel] = found; + } + return out; +} + +/** Every .js directly under a directory tree, as entry points in their own right. */ +function entriesUnder(prefixes, fileSet) { + return Array.from(fileSet).filter((f) => prefixes.some((p) => f.startsWith(p)) && f.endsWith('.js')).sort(); +} + +/** + * The verdict for every src/*.js. + * @returns {{summary: object, files: object}} + */ +function analyse(opts) { + const all = trackedFiles(); + const fileSet = new Set(all.filter((f) => f.endsWith('.js'))); + const sources = Array.from(fileSet).filter((f) => f.startsWith('src/')).sort(); + + const runtimeEntryList = runtimeEntries(fileSet); + const toolingEntryList = entriesUnder(['bin/', 'scripts/', 'tools/'], fileSet); + const testEntryList = entriesUnder(['test/'], fileSet); + + const runtime = closure(runtimeEntryList, fileSet); + const tooling = closure(toolingEntryList, fileSet); + const tested = closure(testEntryList, fileSet); + + let siblings = { paths: {}, siblingRepos: [], distinctPathCount: 0 }; + let twins = {}; + if (opts.siblings !== false) { + siblings = buildReferenceMap(opts.siblingsRoot, { + includePlatformTooling: true, + extraDirs: toolingSweepDirs(opts.siblingsRoot), + }); + twins = twinCopies(sources, opts.siblingsRoot); + } + + const back = reverseEdges(fileSet); + + const files = {}; + for (const rel of sources) { + const sibling = siblings.paths[rel]; + const reachableFromIndexerRuntime = runtime.has(rel); + const reachableFromTooling = tooling.has(rel); + const reachableFromTests = tested.has(rel); + files[rel] = { + reachableFromIndexerRuntime, + reachableFromTooling, + reachableFromTests, + referencedBySiblings: sibling ? sibling.referrers.map((r) => `${r.file}:${r.line} (${r.kind})`) : [], + siblingLoadCount: sibling ? sibling.referrers.filter((r) => r.kind !== 'text').length : 0, + twinCopies: twins[rel] || [], + requiredByInRepo: back[rel] || [], + testOnly: !reachableFromIndexerRuntime && !reachableFromTooling && reachableFromTests, + // Tests are deliberately not a reason to keep a file: CODE-STYLE + // reads a suite over an otherwise unreachable module as evidence the + // module is dead, and the suite is deleted with it. Everything else + // that can hold a file counts. + unreferencedAcrossPlatform: !reachableFromIndexerRuntime && !reachableFromTooling + && !sibling && !twins[rel] && !(back[rel] || []).length, + }; + } + + const notRuntime = sources.filter((f) => !files[f].reachableFromIndexerRuntime); + return { + summary: { + sourceFiles: sources.length, + runtimeEntryPoints: runtimeEntryList, + toolingEntryPoints: toolingEntryList.length, + testEntryPoints: testEntryList.length, + dynamicEdgesDeclared: DYNAMIC_EDGES.length, + reachableFromIndexerRuntime: sources.length - notRuntime.length, + notReachableFromIndexerRuntime: notRuntime.length, + testOnly: sources.filter((f) => files[f].testOnly).length, + unreferencedAcrossPlatform: sources.filter((f) => files[f].unreferencedAcrossPlatform).length, + siblingRepos: siblings.siblingRepos, + }, + candidates: notRuntime, + files, + }; +} + +function parseArgs(argv) { + const opts = { json: false, siblings: true, siblingsRoot: path.resolve(REPO_ROOT, '..') }; + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] === '--json') opts.json = true; + else if (argv[i] === '--no-siblings') opts.siblings = false; + else if (argv[i] === '--siblings') { opts.siblingsRoot = path.resolve(argv[i + 1]); i += 1; } + else if (argv[i] === '--help' || argv[i] === '-h') opts.help = true; + } + return opts; +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help) { + console.log(fs.readFileSync(__filename, 'utf8').split('*/')[0]); + return; + } + const report = analyse(opts); + if (opts.json) { + console.log(JSON.stringify(report, null, 2)); + return; + } + const s = report.summary; + console.log(`src/*.js tracked: ${s.sourceFiles}`); + console.log(`runtime entry points: ${s.runtimeEntryPoints.join(', ')}`); + console.log(`tooling entry points: ${s.toolingEntryPoints}`); + console.log(`test entry points: ${s.testEntryPoints}`); + console.log(`declared dynamic edges: ${s.dynamicEdgesDeclared}`); + console.log(`reachable from indexer runtime: ${s.reachableFromIndexerRuntime}`); + console.log(`NOT reachable from indexer runtime: ${s.notReachableFromIndexerRuntime}`); + console.log(`test-only: ${s.testOnly}`); + console.log(`unreferenced across the platform: ${s.unreferencedAcrossPlatform}`); + console.log(''); + if (!report.candidates.length) return; + console.log('files no runtime path in this repo reaches, and what else holds them:'); + for (const rel of report.candidates) { + const f = report.files[rel]; + const holds = []; + if (f.reachableFromTooling) holds.push('bin/scripts'); + if (f.reachableFromTests) holds.push('tests'); + if (f.referencedBySiblings.length) { + holds.push(`siblings x${f.referencedBySiblings.length} (${f.siblingLoadCount} load)`); + } + if (f.twinCopies.length) { + holds.push(`twin in ${f.twinCopies.map((t) => t.path.split('/')[0]).join('+')}`); + } + if (f.requiredByInRepo.length) holds.push(`required by ${f.requiredByInRepo.join(', ')}`); + console.log(` ${rel.padEnd(48)} ${holds.length ? holds.join(', ') : 'NOTHING: unreferenced across the platform'}`); + } +} + +if (require.main === module) main(); + +module.exports = { analyse, closure, runtimeEntries, resolveRequire, toolingSweepDirs, DYNAMIC_EDGES }; diff --git a/bin/sibling-reference-map.js b/bin/sibling-reference-map.js new file mode 100644 index 0000000..3441d14 --- /dev/null +++ b/bin/sibling-reference-map.js @@ -0,0 +1,1246 @@ +#!/usr/bin/env node +/********************************************************************* + * + * Copyright © 2025-2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC - https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. + * + ********************************************************************** + * + * Every `src/` path of THIS repo that a sibling repo names, and who names it. + * + * WHY THIS EXISTS. Moving or renaming a file under src/ is a cross-repo edit + * whenever another service reaches into this checkout for it, and several do: + * sibling suites require this repo's modules by relative path, build DDL paths by + * hand, and quote module paths inside assertions. A grep run by hand finds the + * requires and misses the string literals, so the restructure needs ONE + * mechanical sweep whose output can be diffed before and after a move. A path + * that leaves this map without a matching edit in the referring repo is a + * broken sibling, and that break surfaces at the referrer's next CI run rather + * than at the commit that caused it. + * + * WHAT COUNTS AS A REFERENCE. Six shapes, reported as each site's `form`, + * because a rename tool has to find every one of them: + * + * text any literal run of `xchain-encoder/src/` in any text file: + * a relative require, a comment, a shell script, a markdown runbook. + * join a path built segment by segment, the shape + * path.join(root, 'xchain-encoder', 'src', 'foo.js'). When every + * segment after `src` is a literal the path is resolved; when one is + * a variable the site is reported under `dynamicReferences` instead, + * because a rename must be checked there by a human. + * root-var the checkout held in a variable and the file joined onto it: + * `const R = path.resolve(__dirname, '../../xchain-encoder')` then + * path.join(R, 'src', 'foo.js'), path.join(R, 'src/foo.js') or + * `${R}/src/foo.js`. The root may equally be + * process.env.XCHAIN_ENCODER_PATH, a candidate array filtered to its + * first live entry, or a variable that points at `/src`. + * helper a closure that takes a repo-relative path and returns a file in + * this checkout, `encoderFile('src/rollback.js')`. A call over a + * literal array, `encoderFile('src/' + twin)`, is emitted once per + * element of that array. + * shell-var the same root-in-a-variable idea in bash, `"$VAR/src/foo.js"`. + * shell-arg the repo name passed as its own word with the path beside it, + * `copy_twin xchain-encoder "src/$f"`, resolved against the literal + * `for f in ...` list above it. The twin-copier script in the + * platform's tooling directories, reconcile-twins.sh, is built + * entirely out of this one and is wired into no CI. + * + * AND ONE THAT IS NEVER A PATH. A module loaded with `require('./' + mod)` over + * a literal list is named by no string at all, so a missed move can report a + * gate ABSENT rather than throwing. This repo builds no such require today, but + * the form is still detected, because the first one to land would otherwise be + * invisible: those sites are listed under `dynamicReferences` with the file list + * they resolve to, form `computed-require`. + * + * SCOPE. Sibling repos are the `xchain-*` directories beside this checkout + * (`--siblings ` overrides the search root), listed in `siblingRepos`. That + * default scope is everything this repo publishes and everything a pin carries. + * + * The surrounding tree may also hold platform tooling that is not a shipped + * service and still reaches in just as hard: the twin-copier script + * reconcile-twins.sh alone byte-copies about thirty of this repo's src/ files + * outward and no CI job runs it, so a sweep that ignores those directories reads + * safer than the tree is. Sweeping them is OPT-IN, because their paths belong to + * the tree around this checkout rather than to this repo: pass + * --include-platform-tooling and name the directories, relative to the siblings + * root and comma-separated, in SIBLING_MAP_EXTRA_DIRS. Their hits land under the + * referrer label `platform-tooling` and the directories swept are echoed in + * `platformToolingSwept`, so a map that used them says so. With the flag off, + * `platformToolingSwept` is empty and only the `xchain-*` siblings are swept. + * + * USAGE + * node bin/sibling-reference-map.js human summary + * node bin/sibling-reference-map.js --json the full map on stdout + * node bin/sibling-reference-map.js --siblings /path/to/platform + * node bin/sibling-reference-map.js --pin bin/pins/at1-sibling-reference-map.json \ + * --base-sha --note "" + * SIBLING_MAP_EXTRA_DIRS=, node bin/sibling-reference-map.js \ + * --include-platform-tooling --json + * + ********************************************************************/ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const REPO_NAME = 'xchain-encoder'; + +// Every reference regex is built from REPO_NAME rather than spelling it again. +// A ported copy that changed the constant and missed one literal would sweep +// for another service's paths and report this one's files as unreferenced. +const NAME_RE = REPO_NAME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +// Directories that hold no first-party source and would otherwise dominate the +// sweep: an installed dependency tree can carry a vendored copy of this repo. +const SKIP_DIRS = new Set([ + 'node_modules', '.git', '.nyc_output', 'coverage', 'dist', 'build', '.cache', '.venv', +]); + +// Binary payloads a text scan would only produce noise from. Everything else is +// read as utf8, because a reference can live in a shell script, a Dockerfile, a +// YAML workflow or a markdown runbook just as easily as in a .js file. +const SKIP_EXT = new Set([ + '.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.pdf', '.zip', '.gz', '.tgz', + '.bz2', '.xz', '.wasm', '.node', '.so', '.dylib', '.dll', '.woff', '.woff2', '.ttf', + '.eot', '.mp4', '.mov', '.class', '.jar', +]); + +// A file larger than this is a data dump, not code that requires a module. +const MAX_FILE_BYTES = 4 * 1024 * 1024; + +// Platform tooling that hardcodes this repo's paths without being a shipped service. +// It is swept under one label rather than added to the sibling list, so the +// `siblingRepos` array means exactly the `xchain-*` set and nothing else. The +// directories are named by the caller, not by this file: they are paths in the +// tree AROUND this checkout, and a repo carries no inventory of its surroundings. +const PLATFORM_TOOLING_LABEL = 'platform-tooling'; +const PLATFORM_TOOLING_ENV = 'SIBLING_MAP_EXTRA_DIRS'; + +/** + * The tooling directories to sweep, relative to the siblings root, from + * SIBLING_MAP_EXTRA_DIRS (comma-separated). Empty when the caller named none, + * which is what turns the opt-in sweep into a no-op rather than a guess. + * + * @returns {string[]} + */ +function platformToolingDirs(env = process.env) { + return String(env[PLATFORM_TOOLING_ENV] || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} + +// `xchain-encoder/src/`, however it was spelled: a relative require +// (`../../xchain-encoder/src/utility.js`), a prose mention, a shell path. +const TEXT_REFERENCE = new RegExp(`${NAME_RE}\\/(src\\/[A-Za-z0-9_@.\\-/]+)`, 'g'); + +// path.join(..., 'xchain-encoder', 'src', ...): the tail is captured raw and +// parsed for literal segments afterwards. +const JOIN_REFERENCE = new RegExp(`['\"\`]${NAME_RE}['\"\`]\\s*,\\s*['\"\`]src['\"\`]\\s*,([^)\\]]*)`, 'g'); + +// A captured path stops at the first character that cannot be part of one. The +// text regex is deliberately greedy over dots and slashes so `foo.js` survives, +// which means a sentence-ending period or a closing quote can ride along. +function trimPath(raw) { + let out = raw; + while (out.length && '.,;:)\'"`]}>*'.includes(out[out.length - 1])) out = out.slice(0, -1); + return out; +} + +/** + * The path as it exists in the tree, or null when nothing resolves. A require + * may omit the extension (`require('.../hub_db_sync')`) and may name a + * directory, so both are tried before the reference is called unresolvable. + */ +function resolveInRepo(rel) { + const candidates = [rel, `${rel}.js`, path.posix.join(rel, 'index.js')]; + for (const c of candidates) { + const abs = path.join(REPO_ROOT, c); + if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return c; + } + return null; +} + +/** Every literal segment of a path.join tail, or null when one is an expression. */ +function literalJoinTail(rawTail) { + const segments = []; + // The call's own closing bracket ends the argument list; anything past it + // belongs to the enclosing expression and is not a path segment. + const stop = rawTail.search(/[)\]]/); + const tail = stop === -1 ? rawTail : rawTail.slice(0, stop); + // Consume `'a', 'b', ...` until the tail stops being literal segments. + const re = /\s*(?:(['"`])([^'"`]*)\1|([^,]+))\s*(,|$)/g; + let m; + while ((m = re.exec(tail)) !== null) { + if (m[3] !== undefined) { + const token = m[3].trim(); + if (token === '') break; + return null; + } + // A backticked segment holding `${...}` is a template, not a literal: + // path.join(INDEXER, 'coins', `${c}.js`) names three coins, and reading + // it as one file called "${c}.js" records a path nobody can repoint. + if (m[1] === '`' && m[2].includes('${')) return null; + segments.push(m[2]); + if (m[4] !== ',') break; + } + return segments.length ? segments.join('/') : null; +} + +/** Byte offset to 1-based line number, for a file already in memory. */ +function lineAt(text, index) { + let line = 1; + for (let i = 0; i < index; i += 1) if (text.charCodeAt(i) === 10) line += 1; + return line; +} + +/** The whole source line a match sits on, which tells a load from a mention. */ +function lineTextAt(text, index) { + const start = text.lastIndexOf('\n', index) + 1; + const end = text.indexOf('\n', index); + return text.slice(start, end === -1 ? text.length : end); +} + +/** + * A load or a mention. A load breaks the referring repo the moment the path + * moves; a mention only misleads the next reader, so the two carry different + * urgency and the map has to separate them. + */ +function referenceKind(line) { + return /\brequire\s*\(|\bimport\s*\(|\bfrom\s+['"`]/.test(line) ? 'require' : 'text'; +} + +// --------------------------------------------------------------------------- +// THE INDIRECT IDIOMS +// +// The two matchers above only see a path someone spelled out in one piece. The +// shape the sibling suites actually use keeps the checkout in a variable and +// joins the file onto it, so the repo name and the file name never share a +// string and a text sweep leaves no trace of the reference at all. That blind +// spot was measured on 2026-09-13: about 29 test and tool files across five +// sibling repos plus a bash script covering thirty more, none of them visible +// to the original map, all of them broken the moment a src/ file moves. +// +// Everything below reconstructs the literal file list such a site resolves to. +// A tail that is not literal goes to `dynamicReferences` rather than being +// guessed, because a rename cannot be checked against a guess. +// --------------------------------------------------------------------------- + +// Environment variables that hold a checkout of this repo. XCHAIN_ENCODER_SQL_PATH +// is deliberately absent: it points below src/ and every caller walks back up +// from it, so the declaration that reads it also carries the literal fallback +// this list would otherwise have to guess at. +const ENV_ROOT_VARS = ['XCHAIN_ENCODER_PATH', 'XCHAIN_ENCODER_DIR']; + +// The common stem of those names, derived rather than restated. The cheap +// pre-filter below skips any file that does not mention this repo at all, and a +// file that points here only through an environment variable never spells the +// repo name, so the filter has to admit the stem too or that whole idiom stays +// invisible to the sweep. +const ENV_PREFIX = REPO_NAME.toUpperCase().replace(/-/g, '_'); + +/** True when an identifier is the node path module under some alias. */ +function isPathAlias(name) { return /path/i.test(name); } + +/** + * The repo-relative directory an expression points at: `''` for the checkout + * itself, `'src'`, `'src/sql'`, and so on, or null when the expression is not a + * directory in this repo at all (it names a FILE, which the text matcher already + * owns, or it names some other repo). + * + * The prefix has to be the whole path rather than a two-valued repo/src flag: + * A consumer pins `path.join(..., REPO_NAME, 'src', 'sql')` and then joins bare + * table names onto it, so a flag that could only say "src" recorded + * src/blocks.sql for a file that lives at src/sql/blocks.sql. + */ +function rootPrefix(init) { + let prefix = null; + const named = /[A-Za-z0-9_@.\-]+/; + const re = new RegExp(`${NAME_RE}((?:\\/[A-Za-z0-9_@.\\-]+)*)`, 'g'); + let m; + while ((m = re.exec(init)) !== null) { + const segments = (m[1] || '').split('/').filter(Boolean); + // Separately quoted segments spell the same directory: + // path.join(base, 'xchain-encoder', 'src', 'sql'). + let rest = init.slice(re.lastIndex); + let more = /^['"`]\s*,\s*['"`]([A-Za-z0-9_@.\-]+)['"`]/.exec(rest); + while (more) { + segments.push(more[1]); + rest = rest.slice(more[0].length - 1); + more = /^['"`]\s*,\s*['"`]([A-Za-z0-9_@.\-]+)['"`]/.exec(rest); + } + if (segments.some((s) => !named.test(s) || /\.[A-Za-z0-9]+$/.test(s))) return null; + prefix = segments.join('/'); + } + if (prefix !== null) return prefix; + for (const env of ENV_ROOT_VARS) if (init.includes(`process.env.${env}`)) return ''; + return null; +} + +/** The `{ ... }` starting at openIndex, brace-counted, capped so a stray brace cannot run away. */ +function bodySlice(text, openIndex, limit) { + let depth = 0; + const end = Math.min(text.length, openIndex + limit); + for (let i = openIndex; i < end; i += 1) { + if (text[i] === '{') depth += 1; + else if (text[i] === '}') { depth -= 1; if (depth === 0) return text.slice(openIndex, i + 1); } + } + return text.slice(openIndex, end); +} + +/** The text between the parentheses opening at openIndex, paren-counted. */ +function callArg(text, openIndex, limit) { + let depth = 0; + const end = Math.min(text.length, openIndex + limit); + for (let i = openIndex; i < end; i += 1) { + if (text[i] === '(') depth += 1; + else if (text[i] === ')') { depth -= 1; if (depth === 0) return text.slice(openIndex + 1, i); } + } + return null; +} + +/** + * A root variable derived from one already known: the candidate list filtered + * to its first live entry, the `.find()` over that list, a plain alias, or a + * join that walks the root down to its `src/` directory. Anything else that + * merely mentions a root (an `existsSync` probe, a file path built from it) is + * not itself a root and must not become one, or every boolean in the file turns + * into a phantom reference site. + */ +function inheritedPrefix(init, roots) { + for (const [name, prefix] of roots) { + if (!new RegExp(`\\b${name}\\b`).test(init)) continue; + if (new RegExp(`^\\s*${name}\\s*$`).test(init)) return prefix; + if (new RegExp(`\\b${name}\\s*(?:\\.\\s*(?:find|filter)\\s*\\(|\\[\\s*0\\s*\\])`).test(init)) return prefix; + const join = new RegExp(`([A-Za-z_$][\\w$]*)\\s*\\.\\s*(?:join|resolve)\\s*\\(\\s*${name}\\s*,([^)]*)\\)`).exec(init); + if (join && isPathAlias(join[1])) { + const tail = literalJoinTail(join[2]); + // Walking the root down to another directory gives another root; a + // tail whose last segment has an extension names a file, and a file + // is a reference, not a root to hang more references off. + if (tail === null || /\.[A-Za-z0-9]+$/.test(tail)) continue; + return [prefix, tail.replace(/\/$/, '')].filter(Boolean).join('/'); + } + if (/^\s*\[/.test(init)) return prefix; + } + return null; +} + +/** + * Functions that RETURN a root, the `resolveIndexerRoot()` shape. Without these + * the variable holding their result is invisible and every join onto it is lost. + */ +function collectRootProducers(text) { + const producers = new Map(); + const re = /function\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/g; + let m; + while ((m = re.exec(text)) !== null) { + const body = bodySlice(text, m.index + m[0].length - 1, 3000); + // A BARE identifier, not an expression. `return path.resolve(root, rel)` + // hands back one file; treating its caller as a root made every + // `const p = encoderFile('src/x.js')` look like another checkout. + if (!/\breturn\s+[A-Za-z_$][\w$]*\s*;/.test(body)) continue; + const prefix = rootPrefix(body); + if (prefix !== null) producers.set(m[1], prefix); + } + return producers; +} + +/** + * Every variable in a file that holds this checkout's root (or its src/ + * directory). The scan repeats until it stops learning names, because a root is + * routinely derived from another one two or three steps away. + */ +function collectRootVars(text) { + const roots = new Map(); + const producers = collectRootProducers(text); + const declare = (name, prefix) => { + if (!name || prefix === null || prefix === undefined || roots.has(name)) return false; + roots.set(name, prefix); + return true; + }; + const classify = (init) => { + const direct = rootPrefix(init); + if (direct !== null) return direct; + const derived = inheritedPrefix(init, roots); + if (derived !== null) return derived; + const call = /^\s*(?:await\s+)?([A-Za-z_$][\w$]*)\s*\(/.exec(init); + return call && producers.has(call[1]) ? producers.get(call[1]) : null; + }; + for (let pass = 0; pass < 4; pass += 1) { + let learned = false; + let m; + const decl = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([\s\S]{0,400}?);/g; + while ((m = decl.exec(text)) !== null) { + if (declare(m[1], classify(m[2]))) learned = true; + } + const forOf = /for\s*\(\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s+of\s+([^)\n]{0,200})\)/g; + while ((m = forOf.exec(text)) !== null) { + if (declare(m[1], classify(m[2]))) learned = true; + } + const cb = /\b([A-Za-z_$][\w$]*)\s*\.\s*(?:find|filter|map|forEach|some|every)\s*\(\s*(?:async\s+)?\(?\s*([A-Za-z_$][\w$]*)\s*[,)]/g; + while ((m = cb.exec(text)) !== null) { + if (roots.has(m[1]) && declare(m[2], roots.get(m[1]))) learned = true; + } + if (!learned) break; + } + return roots; +} + +/** A repo-relative path is only ours when it lands under src/ and walks nowhere. */ +function underSrc(rel) { + return /^src\/[^\s]+$/.test(rel) && !rel.split('/').includes('..'); +} + +/** + * The literal path or paths a `path.join(, ...)` tail resolves to. A tail + * whose last segment is a loop variable over a literal array resolves to one + * path per element: the explorer's hub-mirror guard and the xchain-sync twin + * loop are both written that way, and a move has to repoint every element, so + * collapsing them to a single "dynamic, ask a human" line would hide the bulk + * of the blast radius behind one note. + */ +function resolveJoinTail(rawTail, lists, index) { + const literal = literalJoinTail(rawTail); + if (literal !== null) return [literal]; + const stop = rawTail.search(/[)\]]/); + const segments = splitTopLevel(stop === -1 ? rawTail : rawTail.slice(0, stop)).map((s) => s.trim()); + if (!segments.length) return null; + const last = segments.pop(); + // Either the bare loop variable, or a template built around it: + // path.join(INDEXER, 'coins', `${c}.js`) over ['BTC', 'LTC', 'DOGE']. + let ident = /^[A-Za-z_$][\w$]*$/.test(last) ? last : null; + let pre = ''; + let post = ''; + if (!ident) { + const tpl = /^`([^`$]*)\$\{\s*([A-Za-z_$][\w$]*)\s*\}([^`$]*)`$/.exec(last); + if (!tpl) return null; + pre = tpl[1]; + ident = tpl[2]; + post = tpl[3]; + } + const prefix = []; + for (const segment of segments) { + const lit = /^(['"`])([^'"`]*)\1$/.exec(segment); + if (!lit) return null; + prefix.push(lit[2]); + } + const items = nearestList(lists, ident, index); + if (!items) return null; + return items.map((item) => prefix.concat(pre + item + post).join('/')); +} + +/** The repo-relative path a tail names when joined onto a root at `prefix`. */ +function relFromTail(prefix, tail) { + return prefix ? `${prefix}/${tail}` : tail; +} + +/** + * Every src/ path of this repo that a root variable is joined with, in all three + * spellings the tree uses: path.join(R, 'src', 'x.js'), path.join(R, 'src/x.js') + * and `${R}/src/x.js`. + */ +function rootVarReferences(text, roots, lists, skipRanges) { + const found = []; + const dynamic = []; + // A helper closure joins its own parameter onto the root; that site is the + // helper's definition, and every call of it is already reported through the + // helper channel. Reporting it again as an unresolvable join would put a + // phantom "ask a human" line beside every one of them. + const suppressed = (index) => (skipRanges || []).some((r) => index >= r.start && index < r.end); + for (const [name, suffix] of roots) { + let m; + const joined = new RegExp(`([A-Za-z_$][\\w$]*)\\s*\\.\\s*(?:join|resolve)\\s*\\(\\s*${name}\\s*,([^)]*)\\)`, 'g'); + while ((m = joined.exec(text)) !== null) { + if (!isPathAlias(m[1])) continue; + const tails = resolveJoinTail(m[2], lists || [], m.index); + if (tails === null) { + if (!suppressed(m.index)) { + dynamic.push({ index: m.index, form: 'root-var', root: name, expression: m[0].trim().slice(0, 120) }); + } + continue; + } + for (const tail of tails) { + const rel = trimPath(relFromTail(suffix, tail)); + if (underSrc(rel)) found.push({ index: m.index, path: rel, form: 'root-var', root: name }); + } + } + const templated = new RegExp(`\\$\\{\\s*${name}\\s*\\}/([A-Za-z0-9_@.\\-/]+)`, 'g'); + while ((m = templated.exec(text)) !== null) { + const rel = trimPath(relFromTail(suffix, m[1])); + if (underSrc(rel)) found.push({ index: m.index, path: rel, form: 'root-var', root: name }); + } + const concat = new RegExp(`\\b${name}\\s*\\+\\s*['"\`]/?([A-Za-z0-9_@.\\-/]+)`, 'g'); + while ((m = concat.exec(text)) !== null) { + const rel = trimPath(relFromTail(suffix, m[1])); + if (underSrc(rel)) found.push({ index: m.index, path: rel, form: 'root-var', root: name }); + } + } + return { found, dynamic }; +} + +/** + * Closures that take a repo-relative path and hand back a file inside this + * checkout, the `encoderFile('src/rollback.js')` idiom. Recognised by a body + * that joins its own first parameter onto a root for this repo, which is narrow + * enough to leave the sibling-presence guards beside them alone. + */ +function collectHelpers(text, roots, outRanges) { + const helpers = new Map(); + const re = /(?:function\s+([A-Za-z_$][\w$]*)\s*\(([^)]*)\)|(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\s*)?\(([^)]*)\)\s*(?:=>)?)\s*\{/g; + let m; + while ((m = re.exec(text)) !== null) { + const name = m[1] || m[3]; + const param = (m[2] || m[4] || '').split(',')[0].trim().replace(/[^\w$].*$/, ''); + if (!name || !param) continue; + const open = m.index + m[0].length - 1; + const body = bodySlice(text, open, 2000); + const call = new RegExp(`([A-Za-z_$][\\w$]*)\\s*\\.\\s*(?:join|resolve)\\s*\\(\\s*([A-Za-z_$][\\w$]*)[^)]*\\b${param}\\b`).exec(body); + if (!call || !isPathAlias(call[1])) continue; + const prefix = roots.has(call[2]) ? roots.get(call[2]) : rootPrefix(body); + if (prefix === null) continue; + helpers.set(name, prefix); + if (outRanges) outRanges.push({ start: open, end: open + body.length }); + } + return helpers; +} + +/** + * The literal string elements of `const NAME = [ ... ]`. When `tupleIndex` is + * given the array holds rows rather than names (SHARED_GATES is + * `[[module, [constants]], ...]`) and that column is taken from each row. + */ +function arrayLiteralItems(source, name, tupleIndex) { + // A real list is commented row by row (SHARED_GATES explains why each gate is + // there). Leaving the comments in makes the row after one fail to parse as a + // literal, which silently truncates the list and under-reports the move. + const text = stripComments(source); + const decl = new RegExp(`(?:const|let|var)\\s+${name}\\s*=\\s*\\[`).exec(text); + if (!decl) return null; + const body = balancedArrayBody(text, text.indexOf('[', decl.index)); + return body === null ? null : itemsFromArrayBody(body, tupleIndex); +} + +/** The contents of the `[ ... ]` opening at openIndex, brackets balanced and strings respected. */ +function balancedArrayBody(text, openIndex) { + let depth = 0; + let quote = null; + for (let i = openIndex; i < text.length; i += 1) { + const c = text[i]; + if (quote) { if (c === quote && text[i - 1] !== '\\') quote = null; continue; } + if (c === '\'' || c === '"' || c === '`') { quote = c; continue; } + if (c === '[') depth += 1; + else if (c === ']') { depth -= 1; if (depth === 0) return text.slice(openIndex + 1, i); } + } + return null; +} + +/** + * The literal strings in an array body. `tupleIndex` reads one column out of an + * array of rows, which is how both SHARED_GATES and the post-move xchain-sync + * twin loop are written: `[['merkle.js', 'src/consensus/merkle.js'], ...]`. + */ +function itemsFromArrayBody(body, tupleIndex) { + const rows = splitTopLevel(stripComments(body)); + const items = []; + for (const row of rows) { + const cell = tupleIndex === undefined + ? row + : (splitTopLevel(row.replace(/^\s*\[/, '').replace(/\]\s*$/, ''))[tupleIndex] || ''); + const lit = /^\s*(['"`])([^'"`]*)\1\s*$/.exec(cell); + if (lit) items.push(lit[2]); + } + return items.length ? items : null; +} + +/** + * The same text with javascript comments blanked to spaces, byte offsets and + * line numbers preserved so a match found here still points at the real line. + * Quote state is tracked, so a `//` inside a string literal survives. + */ +function stripComments(text) { + let out = ''; + let quote = null; + for (let i = 0; i < text.length; i += 1) { + const c = text[i]; + if (quote) { + out += c; + if (c === '\\') { out += text[i + 1] || ''; i += 1; continue; } + if (c === quote) quote = null; + continue; + } + if (c === '\'' || c === '"' || c === '`') { quote = c; out += c; continue; } + if (c === '/' && text[i + 1] === '/') { + while (i < text.length && text[i] !== '\n') { out += ' '; i += 1; } + out += '\n'; + continue; + } + if (c === '/' && text[i + 1] === '*') { + const end = text.indexOf('*/', i + 2); + const stop = end === -1 ? text.length : end + 2; + for (let j = i; j < stop; j += 1) out += text[j] === '\n' ? '\n' : ' '; + i = stop - 1; + continue; + } + out += c; + } + return out; +} + +/** Split on commas that are not inside brackets, braces, parens or a string. */ +function splitTopLevel(body) { + const out = []; + let depth = 0; + let quote = null; + let start = 0; + for (let i = 0; i < body.length; i += 1) { + const c = body[i]; + if (quote) { if (c === quote && body[i - 1] !== '\\') quote = null; continue; } + if (c === '\'' || c === '"' || c === '`') { quote = c; continue; } + if ('[{('.includes(c)) depth += 1; + else if (']})'.includes(c)) depth -= 1; + else if (c === ',' && depth === 0) { out.push(body.slice(start, i)); start = i + 1; } + } + out.push(body.slice(start)); + return out.filter((s) => s.trim() !== ''); +} + +/** + * Every loop in a file whose variable ranges over a literal list, javascript and + * bash alike, with the byte offset of the loop header so a use site can bind to + * the nearest one above it rather than to every list in the file. + */ +function collectLoopLists(text) { + const lists = []; + let m; + // Where a loop variable stops meaning anything. Without this a use site + // binds to the nearest list ABOVE it wherever that list happens to be, so a + // `for (const f of SQL_FILES)` reading a directory would be attributed to + // some earlier literal `f` loop and the map would invent files nobody names. + const scopeEnd = (from) => { + const brace = text.indexOf('{', from); + if (brace !== -1 && brace - from <= 200) return brace + bodySlice(text, brace, 200000).length; + return Math.min(text.length, from + 300); + }; + const push = (index, from, name, items) => { + if (items && items.length) lists.push({ index, end: scopeEnd(from), name, items }); + }; + const inline = /for\s*\(\s*(?:const|let|var)\s+(?:\[([^\]]*)\]|([A-Za-z_$][\w$]*))\s+of\s+\[/g; + while ((m = inline.exec(text)) !== null) { + const body = balancedArrayBody(text, inline.lastIndex - 1); + if (body === null) continue; + const after = inline.lastIndex + body.length + 1; + if (m[1] !== undefined) { + const cols = m[1].split(',').map((s) => s.trim()); + for (let col = 0; col < cols.length; col += 1) { + if (!/^[A-Za-z_$][\w$]*$/.test(cols[col])) continue; + push(m.index, after, cols[col], itemsFromArrayBody(body, col)); + } + } else { + push(m.index, after, m[2], itemsFromArrayBody(body)); + } + inline.lastIndex = after; + } + const named = /for\s*\(\s*(?:const|let|var)\s+(?:\[([^\]]*)\]|([A-Za-z_$][\w$]*))\s+of\s+([A-Za-z_$][\w$]*)\s*\)/g; + while ((m = named.exec(text)) !== null) { + if (m[1] !== undefined) { + const cols = m[1].split(',').map((s) => s.trim()); + for (let col = 0; col < cols.length; col += 1) { + if (!/^[A-Za-z_$][\w$]*$/.test(cols[col])) continue; + push(m.index, named.lastIndex, cols[col], arrayLiteralItems(text, m[3], col)); + } + continue; + } + push(m.index, named.lastIndex, m[2], arrayLiteralItems(text, m[3])); + } + // NAMES.forEach(function(f){ ... }) is the same loop written as a callback, + // and the explorer's twin guard reaches into this repo from inside one. + const each = /\b([A-Za-z_$][\w$]*)\s*\.\s*(?:forEach|map)\s*\(\s*(?:async\s+)?(?:function\s*)?\(?\s*([A-Za-z_$][\w$]*)\s*[,)]/g; + while ((m = each.exec(text)) !== null) { + push(m.index, each.lastIndex, m[2], arrayLiteralItems(text, m[1])); + } + // The list written where it is used: ['BTC', 'LTC', 'DOGE'].map((c) => ...). + const eachInline = /\[/g; + while ((m = eachInline.exec(text)) !== null) { + const body = balancedArrayBody(text, m.index); + if (body === null) continue; + const after = m.index + body.length + 2; + const call = /^\s*\.\s*(?:forEach|map)\s*\(\s*(?:async\s+)?(?:function\s*)?\(?\s*([A-Za-z_$][\w$]*)\s*[,)]/.exec(text.slice(after, after + 80)); + if (!call) continue; + push(m.index, after + call[0].length, call[1], itemsFromArrayBody(body)); + } + const shell = /\bfor\s+([A-Za-z_][\w]*)\s+in\s+([\s\S]{0,500}?)(?:;\s*|\n\s*)do\b/g; + while ((m = shell.exec(text)) !== null) { + const items = m[2].replace(/\\\s*\n/g, ' ').split(/\s+/) + .filter((t) => t && /^[A-Za-z0-9_@.\-/]+$/.test(t)); + if (!items.length) continue; + const done = text.indexOf('\ndone', shell.lastIndex); + lists.push({ + index: m.index, + end: done === -1 ? Math.min(text.length, shell.lastIndex + 600) : done, + name: m[1], + items, + }); + } + return lists; +} + +/** + * The list a loop variable ranges over at this offset: the innermost loop whose + * body contains it, never a list from a scope that has already closed. + */ +function nearestList(lists, name, index) { + let best = null; + for (const entry of lists) { + if (entry.name !== name || entry.index > index || index >= entry.end) continue; + if (!best || entry.index > best.index) best = entry; + } + return best ? best.items : null; +} + +/** Single-assignment string constants, so `const rel = 'src/x.js'` survives one hop. */ +function collectStringConsts(text) { + const seen = new Map(); + const re = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(['"`])([^'"`\n]*)\2\s*;/g; + let m; + while ((m = re.exec(text)) !== null) { + if (seen.has(m[1]) && seen.get(m[1]) !== m[3]) seen.set(m[1], null); + else if (!seen.has(m[1])) seen.set(m[1], m[3]); + } + return seen; +} + +/** + * Every call of a helper closure, resolved to the file or files it reads. A + * `helper('src/' + twin)` over a literal array is emitted once per element, + * because that is exactly the set a move has to repoint; a tail nothing names + * goes to the dynamic channel instead. + */ +function helperReferences(text, helpers, lists, strings) { + const found = []; + const dynamic = []; + const push = (index, rel, helper) => { + const clean = trimPath(rel); + if (underSrc(clean)) found.push({ index, path: clean, form: 'helper', helper }); + }; + for (const [name, suffix] of helpers) { + const re = new RegExp(`\\b${name}\\s*\\(`, 'g'); + let m; + while ((m = re.exec(text)) !== null) { + // `function encoderFile(rel){` reads as a call of itself. Its + // parameter is not a path, and counting it would put one phantom + // "ask a human" line under every helper in the platform. + if (/\bfunction\s+$/.test(text.slice(Math.max(0, m.index - 24), m.index))) continue; + const arg = callArg(text, m.index + m[0].length - 1, 400); + if (arg === null) continue; + const expr = arg.trim(); + const literal = /^(['"`])([^'"`]*)\1$/.exec(expr); + if (literal) { push(m.index, relFromTail(suffix, literal[2]), name); continue; } + const prefixed = /^(['"`])([^'"`]*)\1\s*\+\s*([A-Za-z_$][\w$]*)$/.exec(expr); + if (prefixed) { + const items = nearestList(lists, prefixed[3], m.index); + if (items) { + for (const item of items) push(m.index, relFromTail(suffix, prefixed[2] + item), name); + } else { + dynamic.push({ index: m.index, form: 'helper', helper: name, expression: expr.slice(0, 120) }); + } + continue; + } + const ident = /^[A-Za-z_$][\w$]*$/.exec(expr); + if (ident) { + // A loop variable first: the post-move twin loops pass the whole + // this-repo-side path per row rather than deriving it from a + // basename, so the literal list IS the reference set. + const items = nearestList(lists, expr, m.index); + if (items) { + for (const item of items) push(m.index, relFromTail(suffix, item), name); + continue; + } + const value = strings.get(expr); + if (value) push(m.index, relFromTail(suffix, value), name); + else dynamic.push({ index: m.index, form: 'helper', helper: name, expression: expr.slice(0, 120) }); + continue; + } + const joined = /^([A-Za-z_$][\w$]*)\s*\.\s*(?:join|resolve)\s*\(([\s\S]*)\)$/.exec(expr); + if (joined && isPathAlias(joined[1])) { + const tails = resolveJoinTail(joined[2], lists, m.index); + if (tails !== null) { + for (const tail of tails) push(m.index, relFromTail(suffix, tail), name); + continue; + } + } + dynamic.push({ index: m.index, form: 'helper', helper: name, expression: expr.slice(0, 120) }); + } + } + return { found, dynamic }; +} + +/** + * The bash side of the same blind spot. Two shapes, both in the platform's + * twin-copier script reconcile-twins.sh: a variable holding the checkout and then + * "$VAR/src/", and the repo name passed as its own word followed by the + * relative path, `copy_twin xchain-encoder "src/$f"`, where the file comes from + * a literal `for f in ...` list. That script is wired into no CI at all, so a + * move it does not follow fails silently at the next hand run. + */ +function shellReferences(text, lists) { + const found = []; + const dynamic = []; + let m; + const roots = new Map(); + const assign = /^[ \t]*(?:local\s+|export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(["']?)([^"'\n]*)\2/gm; + while ((m = assign.exec(text)) !== null) { + const prefix = rootPrefix(m[3]); + if (prefix !== null && !roots.has(m[1])) roots.set(m[1], prefix); + } + for (const [name, suffix] of roots) { + const use = new RegExp(`\\$\\{?${name}\\}?/([A-Za-z0-9_@.\\-/]+)`, 'g'); + while ((m = use.exec(text)) !== null) { + const rel = trimPath(relFromTail(suffix, m[1])); + if (underSrc(rel)) found.push({ index: m.index, path: rel, form: 'shell-var', root: name }); + } + } + const positional = new RegExp(`${NAME_RE}[ \\t]+[\"']?(src\\/[^\"'\\s;)]+)`, 'g'); + while ((m = positional.exec(text)) !== null) { + const raw = m[1]; + const interpolated = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/.exec(raw); + if (!interpolated) { + const rel = trimPath(raw); + if (underSrc(rel)) found.push({ index: m.index, path: rel, form: 'shell-arg' }); + continue; + } + const items = nearestList(lists, interpolated[1], m.index); + if (!items) { + dynamic.push({ index: m.index, form: 'shell-arg', expression: raw.slice(0, 120) }); + continue; + } + for (const item of items) { + const rel = trimPath(raw.replace(interpolated[0], item)); + if (underSrc(rel)) found.push({ index: m.index, path: rel, form: 'shell-arg', loopVar: interpolated[1] }); + } + } + return { found, dynamic }; +} + +/** + * `require('./' + mod + '.js')` over a literal list, which is how + * src/consensus_rules_digest.js loads its seventeen shared gate carriers. No + * string names the loaded file, so neither matcher above nor a grep can see the + * edge, and the digest reports a gate it fails to load as ABSENT instead of + * throwing: a move that misses one of these is silent all the way to a rules + * mismatch on the fleet. + */ +function computedRequireSites(text, dirRel) { + const lists = collectLoopLists(text); + const out = []; + const re = /require\s*\(\s*(['"`])(\.\.?\/[^'"`]*)\1\s*\+\s*([A-Za-z_$][\w$]*)\s*(?:\+\s*(['"`])([^'"`]*)\4)?/g; + let m; + while ((m = re.exec(text)) !== null) { + const items = nearestList(lists, m[3], m.index) || []; + const candidates = items + .map((item) => path.posix.normalize(path.posix.join(dirRel, `${m[2]}${item}${m[5] || ''}`))) + .filter((p) => p.startsWith('src/')); + out.push({ + index: m.index, + form: 'computed-require', + expression: m[0].trim().slice(0, 120), + listVariable: m[3], + listCandidates: candidates, + }); + } + return out; +} + +/** + * The indirect idioms over one file's text, offsets only; the caller owns line + * numbers and the repo-relative name. Kept as one entry point so a fixture + * string can drive exactly what the sweep drives. + */ +function scanIndirectIdioms(text, opts) { + const options = opts || {}; + const lists = collectLoopLists(text); + const found = []; + const dynamic = []; + if (options.shell) { + const shell = shellReferences(text, lists); + found.push(...shell.found); + dynamic.push(...shell.dynamic); + } else { + const roots = collectRootVars(text); + const helperRanges = []; + const helpers = collectHelpers(text, roots, helperRanges); + const rootHits = rootVarReferences(text, roots, lists, helperRanges); + found.push(...rootHits.found); + dynamic.push(...rootHits.dynamic); + const helperHits = helperReferences(text, helpers, lists, collectStringConsts(text)); + found.push(...helperHits.found); + dynamic.push(...helperHits.dynamic); + } + return { found, dynamic }; +} + +/** A shell script by extension or by shebang, which is what picks the bash matchers. */ +function isShellFile(file, text) { + if (path.extname(file).toLowerCase() === '.sh') return true; + return /^#!.*\b(?:ba|z|k)?sh\b/.test(text.slice(0, 120)); +} + +function walkFiles(dir, out) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (e) { + return out; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + // A symlinked directory inside a repo points at another checkout that + // this sweep visits under its own name (xchain-e2e-test/xchain-hub is + // ../xchain-hub), so following it would count every hit twice. The + // sibling roots themselves may still be symlinks: readdir resolves + // those, and this walk starts below them. + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) { + if (SKIP_DIRS.has(entry.name)) continue; + walkFiles(full, out); + continue; + } + if (!entry.isFile()) continue; + if (SKIP_EXT.has(path.extname(entry.name).toLowerCase())) continue; + out.push(full); + } + return out; +} + +/** + * The sibling repos to sweep: `xchain-*` directories beside this checkout, + * minus this checkout itself, sorted so the output is stable. + */ +function siblingRepos(root) { + const self = fs.realpathSync(REPO_ROOT); + const names = []; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.name.startsWith('xchain-')) continue; + // By name as well as by real path: a git worktree resolves somewhere + // else entirely, so a sweep aimed at the platform root would otherwise + // count this repo's own checkout as one of its siblings. + if (entry.name === REPO_NAME) continue; + const full = path.join(root, entry.name); + let real; + try { real = fs.realpathSync(full); } catch (e) { continue; } + if (real === self) continue; + if (!fs.statSync(full).isDirectory()) continue; + names.push(entry.name); + } + return names.sort(); +} + +/** + * The map itself. `opts.includePlatformTooling` adds the opt-in sweep of the + * directories SIBLING_MAP_EXTRA_DIRS names (see SCOPE in the header); with it off + * the map covers the `xchain-*` siblings and this repo's own computed requires. + * + * @param {string} root the directory the sibling checkouts sit in + * @param {{includePlatformTooling?: boolean, extraDirs?: string[]}} [opts] + * @returns {{siblingRepos: string[], paths: object, dynamicReferences: object[], + * distinctPathCount: number, referenceCount: number}} + */ +function buildReferenceMap(root, opts = {}) { + const repos = siblingRepos(root); + const paths = new Map(); + const dynamic = []; + + // One site can be spelled so that two matchers see it (a comment beside a + // join that quotes the same path). The literal matchers run first and own + // the site; an indirect matcher that lands on the same file, line and path + // is the same reference seen twice, not a second one. + const seen = new Set(); + + const record = (rel, ref) => { + const key = resolveInRepo(rel) || rel; + const fingerprint = `${ref.file}|${ref.line}|${key}`; + if (ref.form !== 'text' && ref.form !== 'join' && seen.has(fingerprint)) return; + seen.add(fingerprint); + if (!paths.has(key)) paths.set(key, { exists: resolveInRepo(rel) !== null, referrers: [] }); + paths.get(key).referrers.push(ref); + }; + + const scanOne = (file, repo, scanRoot) => { + { + let stat; + try { stat = fs.statSync(file); } catch (e) { return; } + if (stat.size > MAX_FILE_BYTES) return; + let text; + try { text = fs.readFileSync(file, 'utf8'); } catch (e) { return; } + // The env-variable form never spells the repo name, so the cheap + // pre-filter has to admit it too or the whole idiom stays invisible. + if (!text.includes(REPO_NAME) && !text.includes(ENV_PREFIX)) return; + const rel = `${repo}/${path.relative(scanRoot, file)}`; + + TEXT_REFERENCE.lastIndex = 0; + let m; + while ((m = TEXT_REFERENCE.exec(text)) !== null) { + const captured = trimPath(m[1]); + if (captured === 'src' || captured === 'src/') continue; + record(captured, { + repo, + file: rel, + line: lineAt(text, m.index), + kind: referenceKind(lineTextAt(text, m.index)), + form: 'text', + raw: captured, + }); + } + + JOIN_REFERENCE.lastIndex = 0; + while ((m = JOIN_REFERENCE.exec(text)) !== null) { + const tail = literalJoinTail(m[1]); + const line = lineAt(text, m.index); + if (tail === null) { + dynamic.push({ repo, file: rel, line, form: 'join', expression: m[1].trim().slice(0, 120) }); + continue; + } + record(`src/${tail}`, { repo, file: rel, line, kind: 'join', form: 'join', raw: `src/${tail}` }); + } + + const indirect = scanIndirectIdioms(text, { shell: isShellFile(file, text) }); + for (const hit of indirect.found) { + record(hit.path, { + repo, + file: rel, + line: lineAt(text, hit.index), + kind: referenceKind(lineTextAt(text, hit.index)), + form: hit.form, + raw: hit.path, + via: hit.root || hit.helper || hit.loopVar, + }); + } + for (const hit of indirect.dynamic) { + dynamic.push({ + repo, + file: rel, + line: lineAt(text, hit.index), + form: hit.form, + expression: hit.expression, + via: hit.root || hit.helper, + }); + } + } + }; + + for (const repo of repos) { + const repoRoot = path.join(root, repo); + for (const file of walkFiles(repoRoot, [])) scanOne(file, repo, repoRoot); + } + + // The platform's own tooling: not a shipped service, but it reaches into this + // repo just as hard, since the twin-copier script alone byte-copies about thirty + // src/ files outward through the bash idioms above and no CI job runs it. OPT-IN + // (see SCOPE in the header), because the directories live in the tree around this + // checkout: the caller that wants them names them, and the default map is the + // `xchain-*` siblings only. Swept under one label so `siblingRepos` still means + // exactly the sibling checkouts a downstream reader already knows. + const toolingSwept = []; + const extraDirs = opts.includePlatformTooling + ? (opts.extraDirs || platformToolingDirs()) + : []; + for (const dir of extraDirs) { + const abs = path.join(root, dir); + if (!fs.existsSync(abs)) continue; + toolingSwept.push(dir); + for (const file of walkFiles(abs, [])) scanOne(file, PLATFORM_TOOLING_LABEL, root); + } + + // This repo's own computed requires. They name no sibling, but they are the + // other half of what a move has to be checked against, and nothing else in + // the toolchain reports them. + for (const file of walkFiles(path.join(REPO_ROOT, 'src'), [])) { + let text; + try { text = fs.readFileSync(file, 'utf8'); } catch (e) { continue; } + const relFile = path.relative(REPO_ROOT, file); + for (const hit of computedRequireSites(text, path.posix.dirname(relFile))) { + dynamic.push({ + repo: REPO_NAME, + file: `${REPO_NAME}/${relFile}`, + line: lineAt(text, hit.index), + form: hit.form, + expression: hit.expression, + via: hit.listVariable, + listCandidates: hit.listCandidates, + }); + } + } + + const sortedPaths = {}; + let referenceCount = 0; + const byKind = { require: 0, join: 0, text: 0 }; + // Which matcher found a site, kept beside the load/mention split rather than + // folded into it: the two answer different questions, and a downstream reader + // that only knows about `kind` must keep reading the same numbers it did. + const byForm = { text: 0, join: 0, 'root-var': 0, helper: 0, 'shell-var': 0, 'shell-arg': 0 }; + for (const key of Array.from(paths.keys()).sort()) { + const entry = paths.get(key); + entry.referrers.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file.localeCompare(b.file))); + referenceCount += entry.referrers.length; + for (const ref of entry.referrers) { + byKind[ref.kind] += 1; + byForm[ref.form] = (byForm[ref.form] || 0) + 1; + } + sortedPaths[key] = { + exists: entry.exists, + referenceCount: entry.referrers.length, + referringRepos: Array.from(new Set(entry.referrers.map((r) => r.repo))).sort(), + referrers: entry.referrers, + }; + } + dynamic.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file.localeCompare(b.file))); + + return { + siblingRepos: repos, + platformToolingSwept: toolingSwept, + distinctPathCount: Object.keys(sortedPaths).length, + // The subset that resolves to a file in the tree. The rest are + // directory prefixes (`src/sql/`) and stale paths, which still matter + // on a move but are not files anyone can repoint one-for-one. + existingPathCount: Object.values(sortedPaths).filter((p) => p.exists).length, + referenceCount, + referenceCountByKind: byKind, + referenceCountByForm: byForm, + dynamicReferenceCount: dynamic.length, + dynamicReferenceCountByForm: dynamic.reduce((acc, d) => { + acc[d.form || 'join'] = (acc[d.form || 'join'] || 0) + 1; + return acc; + }, {}), + paths: sortedPaths, + dynamicReferences: dynamic, + }; +} + +function parseArgs(argv) { + const opts = { json: false, siblings: path.resolve(REPO_ROOT, '..'), includePlatformTooling: false }; + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] === '--json') opts.json = true; + else if (argv[i] === '--include-platform-tooling') opts.includePlatformTooling = true; + else if (argv[i] === '--siblings') { opts.siblings = path.resolve(argv[i + 1]); i += 1; } + else if (argv[i] === '--pin') { opts.pin = path.resolve(argv[i + 1]); i += 1; } + else if (argv[i] === '--base-sha') { opts.baseSha = argv[i + 1]; i += 1; } + else if (argv[i] === '--note') { opts.note = argv[i + 1]; i += 1; } + else if (argv[i] === '--help' || argv[i] === '-h') opts.help = true; + } + return opts; +} + +/** + * The src/ inventory of a commit, read without touching the working tree. + * + * WHY THE PIN CARRIES IT. `exists` in a pin is only ever true of the tree the + * pin was taken from, so a pin taken after a move has begun cannot tell a + * reference that was always stale from one the move just broke. The commit's own + * file list can, and it is the same answer whenever it is read. + */ +function srcInventoryAt(sha) { + if (!sha) return null; + try { + const out = require('child_process').execFileSync( + 'git', ['ls-tree', '-r', '--name-only', sha, 'src'], + { cwd: REPO_ROOT, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }, + ); + return out.split('\n').filter(Boolean).sort(); + } catch (e) { + return null; + } +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help) { + console.log(fs.readFileSync(__filename, 'utf8').split('*/')[0]); + return; + } + if (opts.includePlatformTooling && !platformToolingDirs().length) { + console.error(`--include-platform-tooling with no ${PLATFORM_TOOLING_ENV}: ` + + 'name the directories to sweep, relative to the siblings root and comma-separated.'); + process.exitCode = 2; + return; + } + const map = buildReferenceMap(opts.siblings, { includePlatformTooling: opts.includePlatformTooling }); + if (opts.pin) { + const pinned = Object.assign({ + pinMetadata: { + tool: 'bin/sibling-reference-map.js', + capturedAt: new Date().toISOString(), + repoSha: opts.baseSha || null, + // Relative to this checkout, never as the operator spelled it: an + // absolute path names somebody's machine and pins to no tree at all. + siblingsRoot: path.relative(REPO_ROOT, opts.siblings) || '.', + note: opts.note || null, + repoSrcFilesAtBaseSha: srcInventoryAt(opts.baseSha), + }, + }, map); + fs.writeFileSync(opts.pin, `${JSON.stringify(pinned, null, 2)}\n`); + console.log(`pin written: ${opts.pin}`); + console.log(` ${pinned.distinctPathCount} distinct paths, ${pinned.existingPathCount} resolving, ` + + `${pinned.referenceCount} reference sites`); + return; + } + if (opts.json) { + console.log(JSON.stringify(map, null, 2)); + return; + } + console.log(`sibling repos swept: ${map.siblingRepos.length} (${map.siblingRepos.join(', ')})`); + console.log(`platform tooling swept: ${map.platformToolingSwept.length + ? map.platformToolingSwept.join(', ') + : `none (opt in with --include-platform-tooling and ${PLATFORM_TOOLING_ENV})`}`); + console.log(`distinct ${REPO_NAME} src/ paths referenced: ${map.distinctPathCount} ` + + `(${map.existingPathCount} resolve to a file in the tree)`); + console.log(`total reference sites: ${map.referenceCount} ` + + `(require ${map.referenceCountByKind.require}, ` + + `join ${map.referenceCountByKind.join}, mention ${map.referenceCountByKind.text})`); + console.log('reference sites by form: ' + + Object.keys(map.referenceCountByForm).map((f) => `${f} ${map.referenceCountByForm[f]}`).join(', ')); + console.log(`unresolvable path references: ${Object.values(map.paths).filter((p) => !p.exists).length}`); + console.log(`dynamic references (a human checks these on a rename): ${map.dynamicReferenceCount} ` + + `(${Object.keys(map.dynamicReferenceCountByForm) + .map((f) => `${f} ${map.dynamicReferenceCountByForm[f]}`).join(', ')})`); + console.log(''); + const perRepo = {}; + for (const entry of Object.values(map.paths)) { + for (const ref of entry.referrers) perRepo[ref.repo] = (perRepo[ref.repo] || 0) + 1; + } + console.log('reference sites per repo:'); + for (const repo of Object.keys(perRepo).sort()) console.log(` ${repo.padEnd(24)} ${perRepo[repo]}`); +} + +if (require.main === module) main(); + +module.exports = { + buildReferenceMap, + siblingRepos, + platformToolingDirs, + PLATFORM_TOOLING_LABEL, + PLATFORM_TOOLING_ENV, + resolveInRepo, + literalJoinTail, + trimPath, + // The indirect matchers, exported so a fixture string drives exactly what + // the sweep drives rather than a re-implementation of it. + rootPrefix, + collectRootVars, + rootVarReferences, + collectHelpers, + helperReferences, + collectLoopLists, + collectStringConsts, + arrayLiteralItems, + shellReferences, + computedRequireSites, + scanIndirectIdioms, + isShellFile, +}; diff --git a/bin/suite-title-map.js b/bin/suite-title-map.js new file mode 100644 index 0000000..fd5e8e8 --- /dev/null +++ b/bin/suite-title-map.js @@ -0,0 +1,324 @@ +#!/usr/bin/env node +/********************************************************************* + * + * Copyright © 2025-2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC - https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. + * + ********************************************************************** + * + * What every npm test script collects, file by file and title by title. + * + * WHY A MAP AND NOT A COUNT. A restructure that renames test files has to prove + * it changed nothing about what runs, and a passing count proves nothing: a + * renamed file can drop out of one glob while another file joins it and the + * total holds. A suite that silently stops being collected reads as green + * forever. So the invariant is the SET of full test titles per file, per npm + * script, and a later run is compared against the committed pin through the + * rename map that the moving commit declares. + * + * WHY IT NEEDS NO DATABASE. `mocha --dry-run` loads every spec file and walks + * the suite tree without invoking a single hook or test body. Titles are + * declared at load time, so they are all there; nothing connects, nothing + * writes. That is what makes this pin cheap enough to re-take at every + * milestone instead of once. + * + * EACH SCRIPT RUNS WITH ITS OWN ARGUMENTS, unchanged apart from the reporter + * and the dry run. That matters more than it looks: the plain `test` script + * carries no --no-config, so .mocharc.yml's spec list merges with its + * positional globs, and a run that "tidied" the arguments would pin a + * different collection than the one CI executes. A --grep stays, because the + * filtered set is the script's identity. + * + * THE SHAPE ON DISK. Titles are stored once in `titleSets`, keyed by a hash of + * the list, and each script's `files` map points a test file at the set it + * contributed. Written out flat the pin is six megabytes of text repeated + * across the scripts whose globs overlap; the indirection is lossless and the + * comparison below reads it, so nothing has to unpack it by hand. + * + * USAGE + * node bin/suite-title-map.js human summary + * node bin/suite-title-map.js --json the full map on stdout + * node bin/suite-title-map.js --out write the map as JSON + * node bin/suite-title-map.js --script test one script only + * node bin/suite-title-map.js --compare diff the tree against a pin, + * exit 1 on any difference + * node bin/suite-title-map.js --compare --rename-map + * the same, with the moving + * commit's {old: new} paths + * applied to the pin first + * + ********************************************************************/ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { spawnSync } = require('child_process'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const MOCHA_BIN = path.join(REPO_ROOT, 'node_modules', '.bin', 'mocha'); + +/** + * A shell-ish split that keeps quoted globs whole. The scripts are plain + * `mocha ...` command lines with quoted glob arguments and the occasional + * leading VAR=value; nothing here has a pipe, a subshell or a redirect, and a + * script that grows one is reported as unsupported rather than mis-parsed. + */ +function splitCommand(script) { + const tokens = []; + let current = ''; + let quote = null; + let started = false; + let quoted = false; + const push = () => { tokens.push({ value: current, quoted }); current = ''; started = false; quoted = false; }; + for (const ch of script) { + if (quote) { + if (ch === quote) quote = null; + else current += ch; + continue; + } + if (ch === '"' || ch === "'") { quote = ch; started = true; quoted = true; continue; } + if (/\s/.test(ch)) { + if (started || current) push(); + continue; + } + current += ch; + } + if (started || current) push(); + return tokens; +} + +// Shell operators, recognised only on an UNQUOTED token: --grep '@x.*(a|b)' +// carries a pipe inside its pattern and is a perfectly ordinary single command. +const SHELL_OPERATOR = /^(?:&&|\|\||[|;]|[<>]+)$/; + +/** + * What a test script actually is: one mocha command, a chain of other npm + * scripts, or something this tool will not guess at. + * @returns {{args: string[], env: object}|{composite: string[]}|{skip: string}} + */ +function mochaArgsFor(script) { + const tokens = splitCommand(script); + const operators = tokens.filter((t) => !t.quoted && SHELL_OPERATOR.test(t.value)); + if (operators.length) { + const members = []; + for (let i = 0; i < tokens.length - 1; i += 1) { + if (tokens[i].value !== 'npm') continue; + // `npm test` is the same member as `npm run test` and has to land in + // the list under the same name, or the union looks short by a suite. + if (tokens[i + 1].value === 'run' && tokens[i + 2]) members.push(tokens[i + 2].value); + else if (tokens[i + 1].value === 'test') members.push('test'); + } + if (members.length) return { composite: members }; + return { skip: 'shell composition this tool does not expand' }; + } + // Leading environment assignments (FUZZ_RUNS=1000 mocha ...) are set on the + // child rather than dropped: a suite may name its title from one. + const env = {}; + let i = 0; + while (i < tokens.length && !tokens[i].quoted && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i].value)) { + const eq = tokens[i].value.indexOf('='); + env[tokens[i].value.slice(0, eq)] = tokens[i].value.slice(eq + 1); + i += 1; + } + // encoder's scripts spawn mocha through `npx mocha ...` rather than a bare + // `mocha ...`; npx is not the command being pinned, so it is skipped here + // rather than counted as an unsupported runner (this repo's own fork of this tool). + if (tokens[i] && tokens[i].value === 'npx') i += 1; + if (!tokens[i] || tokens[i].value !== 'mocha') { + return { skip: `not a mocha command (runs ${tokens[i] ? tokens[i].value : 'nothing'})` }; + } + return { args: tokens.slice(i + 1).map((t) => t.value), env }; +} + +/** Titles for one script, keyed by repo-relative test file. */ +function collect(scriptName, script) { + const parsed = mochaArgsFor(script); + if (parsed.skip) return { skipped: parsed.skip }; + // A chain of npm scripts collects exactly the union of its members, each of + // which is pinned in its own right; restating their titles here would pin + // the same suites twice and make one rename look like two. + if (parsed.composite) return { composite: parsed.composite }; + + const res = spawnSync(MOCHA_BIN, ['--dry-run', '--reporter', 'json', ...parsed.args], { + cwd: REPO_ROOT, + env: { ...process.env, ...parsed.env }, + maxBuffer: 256 * 1024 * 1024, + encoding: 'utf8', + }); + if (res.error) return { error: String(res.error.message) }; + + let report; + try { + // The json reporter writes the report to stdout, but a spec file that + // logs at load time writes there too; the report is the last JSON + // object, so parsing starts at the last line that opens one. + const start = res.stdout.indexOf('{\n "stats"'); + report = JSON.parse(start === -1 ? res.stdout : res.stdout.slice(start)); + } catch (e) { + return { error: `unparseable mocha json (exit ${res.status}): ${res.stderr.slice(0, 400)}` }; + } + + const files = {}; + for (const test of (report.tests || []).concat(report.pending || [])) { + const rel = test.file ? path.relative(REPO_ROOT, test.file) : '(no file)'; + if (!files[rel]) files[rel] = []; + files[rel].push(test.fullTitle); + } + const sorted = {}; + let titles = 0; + for (const rel of Object.keys(files).sort()) { + sorted[rel] = files[rel].slice().sort(); + titles += sorted[rel].length; + } + return { fileCount: Object.keys(sorted).length, titleCount: titles, files: sorted }; +} + +function setKey(titles) { + return crypto.createHash('sha256').update(titles.join('\n')).digest('hex').slice(0, 16); +} + +function buildMap(only) { + const pkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')); + const names = Object.keys(pkg.scripts || {}).filter((n) => n.startsWith('test')).sort(); + const titleSets = {}; + const scripts = {}; + for (const name of names) { + if (only && name !== only) continue; + const result = collect(name, pkg.scripts[name]); + if (result.files) { + const files = {}; + for (const rel of Object.keys(result.files)) { + const key = setKey(result.files[rel]); + titleSets[key] = result.files[rel]; + files[rel] = key; + } + result.files = files; + } + scripts[name] = result; + } + const sortedSets = {}; + for (const key of Object.keys(titleSets).sort()) sortedSets[key] = titleSets[key]; + return { titleSets: sortedSets, scripts }; +} + +/** The flat {file: [titles]} view of one script in a map, pin or fresh. */ +function expand(map, scriptName) { + const s = map.scripts[scriptName]; + if (!s || !s.files) return null; + const out = {}; + for (const rel of Object.keys(s.files).sort()) out[rel] = map.titleSets[s.files[rel]] || []; + return out; +} + +/** + * Pin against tree, script by script. `renames` is the moving commit's declared + * {oldPath: newPath}; a pin entry is compared under its new name so a pure move + * reports no difference while a move that changed a title still does. + */ +function compare(pin, fresh, renames, only) { + const differences = []; + // A run narrowed to one script compares that script only: every other + // script in the pin is absent because it was not collected, which is not a + // finding and would bury the one that is. + const names = Array.from(new Set(Object.keys(pin.scripts).concat(Object.keys(fresh.scripts)))) + .filter((n) => !only || n === only) + .sort(); + for (const name of names) { + const before = expand(pin, name); + const after = expand(fresh, name); + if (!before && !after) continue; + if (!before || !after) { + differences.push({ script: name, kind: 'script', detail: before ? 'script removed' : 'script added' }); + continue; + } + const mapped = {}; + for (const rel of Object.keys(before)) mapped[renames[rel] || rel] = before[rel]; + const files = Array.from(new Set(Object.keys(mapped).concat(Object.keys(after)))).sort(); + for (const rel of files) { + if (!mapped[rel]) { differences.push({ script: name, kind: 'file_added', file: rel }); continue; } + if (!after[rel]) { differences.push({ script: name, kind: 'file_dropped', file: rel }); continue; } + const gone = mapped[rel].filter((t) => !after[rel].includes(t)); + const added = after[rel].filter((t) => !mapped[rel].includes(t)); + for (const t of gone) differences.push({ script: name, kind: 'title_dropped', file: rel, title: t }); + for (const t of added) differences.push({ script: name, kind: 'title_added', file: rel, title: t }); + } + } + return differences; +} + +function parseArgs(argv) { + const opts = { json: false }; + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] === '--json') opts.json = true; + else if (argv[i] === '--out') { opts.out = path.resolve(argv[i + 1]); i += 1; } + else if (argv[i] === '--script') { opts.script = argv[i + 1]; i += 1; } + else if (argv[i] === '--compare') { opts.compare = path.resolve(argv[i + 1]); i += 1; } + else if (argv[i] === '--rename-map') { opts.renameMap = path.resolve(argv[i + 1]); i += 1; } + else if (argv[i] === '--help' || argv[i] === '-h') opts.help = true; + } + return opts; +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help) { + console.log(fs.readFileSync(__filename, 'utf8').split('*/')[0]); + return; + } + const map = buildMap(opts.script); + + if (opts.compare) { + const pin = JSON.parse(fs.readFileSync(opts.compare, 'utf8')); + const renames = opts.renameMap ? JSON.parse(fs.readFileSync(opts.renameMap, 'utf8')) : {}; + const differences = compare(pin, map, renames, opts.script); + if (!differences.length) { + console.log(`suite identity holds against ${path.relative(REPO_ROOT, opts.compare)}` + + `${opts.renameMap ? ' through the declared rename map' : ''}`); + return; + } + console.log(`${differences.length} difference(s) against ${path.relative(REPO_ROOT, opts.compare)}:`); + for (const d of differences.slice(0, 200)) { + console.log(` [${d.script}] ${d.kind} ${d.file || ''} ${d.title ? `:: ${d.title}` : d.detail || ''}`); + } + if (differences.length > 200) console.log(` ... and ${differences.length - 200} more`); + process.exitCode = 1; + return; + } + + // Escaped to pure ASCII on the way out. The titles are captured verbatim and + // some of them carry characters the platform's prose rules keep out of + // committed files; escaping changes the encoding and not one parsed + // character, so the pin stays exactly what mocha reported. + const text = `${JSON.stringify(map, null, 2).replace(/[-￿]/g, + (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)}\n`; + if (opts.out) { + fs.mkdirSync(path.dirname(opts.out), { recursive: true }); + fs.writeFileSync(opts.out, text); + } + if (opts.json) { + process.stdout.write(text); + return; + } + let failed = 0; + for (const name of Object.keys(map.scripts)) { + const s = map.scripts[name]; + if (s.skipped) { console.log(`${name.padEnd(26)} skipped: ${s.skipped}`); continue; } + if (s.composite) { console.log(`${name.padEnd(26)} composite: ${s.composite.join(' + ')}`); continue; } + if (s.error) { console.log(`${name.padEnd(26)} ERROR: ${s.error}`); failed += 1; continue; } + console.log(`${name.padEnd(26)} ${String(s.fileCount).padStart(4)} files ` + + `${String(s.titleCount).padStart(5)} titles`); + } + if (opts.out) console.log(`\nwritten to ${path.relative(REPO_ROOT, opts.out)}`); + if (failed) process.exitCode = 1; +} + +if (require.main === module) main(); + +module.exports = { buildMap, collect, mochaArgsFor, splitCommand, compare, expand }; From 7abf05e709de73da21eb162e0ce21efd9712343a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 13 Sep 2026 15:06:13 -0700 Subject: [PATCH 006/148] chore: run a dead-code sweep over src, keep every candidate Checks reachability of every tracked src/*.js file and falsifies each of the four candidates by hand: the browserify entry point and three pre-work adapter-interface stubs are all still live. Records the sweep and its falsification for reference. --- bin/pins/dead-code-sweep.txt | 56 +++++ bin/pins/reachability-report.json | 404 ++++++++++++++++++++++++++++++ 2 files changed, 460 insertions(+) create mode 100644 bin/pins/dead-code-sweep.txt create mode 100644 bin/pins/reachability-report.json diff --git a/bin/pins/dead-code-sweep.txt b/bin/pins/dead-code-sweep.txt new file mode 100644 index 0000000..dece83d --- /dev/null +++ b/bin/pins/dead-code-sweep.txt @@ -0,0 +1,56 @@ +xchain-encoder dead-code sweep, 2026-09-13 +=================================================== + +Tool: bin/reachability.js --no-siblings --json (repo-local runtime, tooling +and test closures; sibling detection skipped because the ported +sibling-reference-map.js is indexer-name-hardcoded, so the +falsification step is command grep -r by hand instead). 29 tracked +src/*.js files, 4 candidates: not reached from api.js (the runtime +entry), bin/scripts/tools, or nothing at all. + +Candidates and their falsification, each by `command grep -r` (never the +tool-layer Grep) across every xchain-* checkout on this Mac +(xchain-contracts, xchain-dashboard, xchain-decoder, xchain-documentation, +xchain-dot-github, xchain-e2e-test, xchain-encoder, xchain-explorer, +xchain-hub, xchain-indexer, xchain-node, xchain-regtest-miner, xchain-sdk, +xchain-sync, xchain-utxo-tracker, xchain-vm, xchain-wallet, xchain-websites), +plus the platform's internal docs (1 hit, counted below): + +1. src/index.js + reachability.js verdict: NOT reachable from runtime/tooling/tests, + unreferencedAcrossPlatform. + FALSIFIED, NOT DEAD: it is the browserify entry point named literally by + package.json's `build` and `build:dev` scripts + (`npx browserify src/index.js ... -o dist/xchain_encoder.min.js`), and + xchain-documentation/components/encoder/README.md:119-120 documents that + exact build product for consumers. reachability.js's runtimeEntries() + only recognises `node .js` invocations in package.json scripts, so + a browserify entry is a blind spot in the ported tool, not a dead file. + VERDICT: KEEP. Not deleted. + +2. src/adapters/ChainAdapter.js +3. src/adapters/EvmAdapter.js +4. src/adapters/evmFinality.js + reachability.js verdict: testOnly (reached only from + test/unit/evmAdapter.test.js), EvmAdapter.js additionally + unreferencedAcrossPlatform. + FALSIFIED, NOT DEAD: all three carry an explicit "PRE-WORK STUB" file + header and are the concrete first instance of the `ChainAdapter` + interface that the account-chains support design (1 platform-docs hit) + lays out for future EVM-chain support ("Introduce a ChainAdapter + interface with one implementation per family: UtxoAdapter (existing + logic, refactored behind the interface), EvmAdapter (new)..."; "reuses + the ChainAdapter seam"). They are unbuilt on purpose (every EVM method + throws AdapterNotImplementedError) and are exercised only by their own + unit suite until that spec is scheduled, which is the CODE-STYLE + dead-file signal working as intended for pre-work rather than for + dead weight. + VERDICT: KEEP. Not deleted. Noted for later: this pre-work scaffold sits + outside CODE-STYLE's "test-only means dead" heuristic, so anyone + touching src/adapters/ afterward should read the header before assuming + it is stale. + +RESULT: 0 files deleted. The sweep is the falsification, not a rename list; +the acceptance bar ("every deleted file proven unreferenced ... before +deleting") is vacuously met because nothing survived falsification as an +actual deletion candidate. diff --git a/bin/pins/reachability-report.json b/bin/pins/reachability-report.json new file mode 100644 index 0000000..42746cb --- /dev/null +++ b/bin/pins/reachability-report.json @@ -0,0 +1,404 @@ +{ + "summary": { + "sourceFiles": 29, + "runtimeEntryPoints": [ + "src/api.js", + "test/performance/bench.js", + "test/performance/soak.js" + ], + "toolingEntryPoints": 4, + "testEntryPoints": 128, + "dynamicEdgesDeclared": 2, + "reachableFromIndexerRuntime": 25, + "notReachableFromIndexerRuntime": 4, + "testOnly": 3, + "unreferencedAcrossPlatform": 2, + "siblingRepos": [] + }, + "candidates": [ + "src/adapters/ChainAdapter.js", + "src/adapters/EvmAdapter.js", + "src/adapters/evmFinality.js", + "src/index.js" + ], + "files": { + "src/BlockchainConnector.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/XChainEncoder.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/CryptoNetworks.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/XChainEncoder.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/TxSizeEstimator.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/XChainEncoder.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/UtxoTracker.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/XChainEncoder.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/XChainEncoder.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/api.js", + "src/index.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/adapters/ChainAdapter.js": { + "reachableFromIndexerRuntime": false, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/adapters/EvmAdapter.js" + ], + "testOnly": true, + "unreferencedAcrossPlatform": false + }, + "src/adapters/EvmAdapter.js": { + "reachableFromIndexerRuntime": false, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [], + "testOnly": true, + "unreferencedAcrossPlatform": true + }, + "src/adapters/evmFinality.js": { + "reachableFromIndexerRuntime": false, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/adapters/EvmAdapter.js" + ], + "testOnly": true, + "unreferencedAcrossPlatform": false + }, + "src/api.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/applyBufferutilsPatch.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/XChainEncoder.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/coins/BTC.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/coins/index.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/coins/DOGE.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/coins/index.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/coins/LTC.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/coins/index.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/coins/consensus_pin.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/coins/index.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/coins/index.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/CryptoNetworks.js", + "src/XChainEncoder.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/compression.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/XChainEncoder.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/concurrencyGate.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/api.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/corsOrigin.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/api.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/crashHandlers.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/api.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/errorSanitize.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/XChainEncoder.js", + "src/api.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/errors.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/XChainEncoder.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/index.js": { + "reachableFromIndexerRuntime": false, + "reachableFromTooling": false, + "reachableFromTests": false, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [], + "testOnly": false, + "unreferencedAcrossPlatform": true + }, + "src/maintenanceWindow.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/api.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/observability/index.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/api.js", + "src/crashHandlers.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/observability/logShipper.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/observability/index.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/observability/metrics.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/observability/index.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/rateLimitLog.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/api.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/singleInstanceGuard.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/api.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + }, + "src/validator.js": { + "reachableFromIndexerRuntime": true, + "reachableFromTooling": false, + "reachableFromTests": true, + "referencedBySiblings": [], + "siblingLoadCount": 0, + "twinCopies": [], + "requiredByInRepo": [ + "src/TxSizeEstimator.js", + "src/XChainEncoder.js", + "src/api.js", + "src/compression.js" + ], + "testOnly": false, + "unreferencedAcrossPlatform": false + } + } +} From 148de61b149977023fe0432f44fc32927384dcfb Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 13 Sep 2026 15:08:33 -0700 Subject: [PATCH 007/148] chore: add the shared eslint config and its own lint script Vendors the shared eslint preset inline and adds eslint plus a lint script; scopes the existing ajv override so eslint's own ajv6 dependency still resolves alongside the same ajv8 security pin every repo carries. Running lint surfaces existing style debt this commit does not address. --- eslint.config.js | 86 ++++++ package-lock.json | 768 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 8 + 3 files changed, 862 insertions(+) create mode 100644 eslint.config.js diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..223040c --- /dev/null +++ b/eslint.config.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. + * + ********************************************************************** + * eslint flat config: the file-level half of the code-style rules, for + * editors and `npm run lint`. + * + * VENDORED BY COPY from the platform's master preset rather than imported: a + * public clone of this repo has no platform tree beside it to import from. + * Core rules only, no plugins, so this file adds exactly one devDependency. + * Changes belong in the master copy first and travel here as a re-vendor; + * this repo adds no rules of its own. + */ +'use strict'; + +const src = { + files: ['src/**/*.js'], + languageOptions: { + ecmaVersion: 2023, + sourceType: 'commonjs', + globals: { + require: 'readonly', module: 'writable', exports: 'writable', process: 'readonly', Buffer: 'readonly', + __dirname: 'readonly', __filename: 'readonly', console: 'readonly', setTimeout: 'readonly', + clearTimeout: 'readonly', setInterval: 'readonly', clearInterval: 'readonly', setImmediate: 'readonly', + URL: 'readonly', TextEncoder: 'readonly', TextDecoder: 'readonly', AbortController: 'readonly', + }, + }, + rules: { + // Naming: camelCase everywhere except property keys, which carry + // protocol fields and DB columns through one-to-one. + camelcase: ['error', { properties: 'never', ignoreDestructuring: true, ignoreImports: true }], + 'no-underscore-dangle': ['error', { enforceInMethodNames: true, allowAfterThis: false, allowFunctionParams: false }], + // Logging: one logger. Entry points override this below. + 'no-console': 'error', + // Module shape: requires at the top, environment in config.js only, + // one export shape per file. + 'no-restricted-syntax': ['error', + { + selector: ':function CallExpression[callee.name="require"][arguments.0.type="Literal"]', + message: 'require() at the top of the file; inside a body only for a computed path (CODE-STYLE.md, Module shape)', + }, + { + selector: 'MemberExpression[object.name="process"][property.name="env"]', + message: 'environment is read in config.js only (CODE-STYLE.md, Module shape)', + }, + ], + 'prefer-const': 'error', + 'no-var': 'error', + eqeqeq: ['error', 'smart'], + }, +}; + +const configAndEntry = { + files: ['src/config.js', 'src/api.js', 'src/migrate.js', 'src/index.js', 'bin/**/*.js'], + rules: { + 'no-console': 'off', + 'no-restricted-syntax': ['error', + { + selector: ':function CallExpression[callee.name="require"][arguments.0.type="Literal"]', + message: 'require() at the top of the file; inside a body only for a computed path (CODE-STYLE.md, Module shape)', + }, + ], + }, +}; + +const tests = { + files: ['test/**/*.js'], + languageOptions: src.languageOptions, + rules: { + camelcase: src.rules.camelcase, + 'no-underscore-dangle': src.rules['no-underscore-dangle'], + 'prefer-const': 'error', + 'no-var': 'error', + }, +}; + +module.exports = [src, configAndEntry, tests]; diff --git a/package-lock.json b/package-lock.json index aafb3a9..999d6cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "browserify": "^17.0.0", "c8": "^11.0.0", "chai": "^4.5.0", + "eslint": "^9.39.5", "mocha": "^11.7.5" }, "engines": { @@ -1603,6 +1604,240 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@inquirer/ansi": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", @@ -2295,12 +2530,26 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", "dev": true }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -2351,6 +2600,16 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/acorn-node": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", @@ -3082,6 +3341,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -3532,6 +3801,13 @@ "node": ">=6" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -3849,6 +4125,218 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4026,6 +4514,20 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -4105,6 +4607,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -4151,6 +4666,27 @@ "flat": "cli.js" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -4364,6 +4900,32 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4586,6 +5148,43 @@ } ] }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -4697,6 +5296,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -4725,6 +5334,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -4930,6 +5552,13 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-rpc-2.0": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/json-rpc-2.0/-/json-rpc-2.0-1.7.1.tgz", @@ -4944,6 +5573,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4981,6 +5617,16 @@ "node": "*" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/labeled-stream-splicer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/labeled-stream-splicer/-/labeled-stream-splicer-2.0.2.tgz", @@ -4991,6 +5637,20 @@ "stream-splicer": "^2.0.0" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5024,6 +5684,13 @@ "integrity": "sha512-eDn9kqrAmVUC1wmZvlQ6Uhde44n+tXpqPrN8olQJbttgh0oKclk+SF54P47VEGE9CEiMeRwAP8BaM7UHvBkz2A==", "dev": true }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -5407,6 +6074,13 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -5525,6 +6199,24 @@ "wrappy": "1" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/os-browserify": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", @@ -5574,6 +6266,19 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "dev": true }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parents": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parents/-/parents-1.0.1.tgz", @@ -5745,6 +6450,16 @@ "node": ">= 0.4" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -6039,6 +6754,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ripemd160": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.3.tgz", @@ -6776,6 +7501,19 @@ "node": ">=0.6.11 <=0.7.0 || >=0.7.3" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-detect": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", @@ -7026,6 +7764,26 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uri-js/node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/url": { "version": "0.11.4", "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", @@ -7169,6 +7927,16 @@ "safe-buffer": "^5.1.2" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/workerpool": { "version": "9.3.4", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", diff --git a/package.json b/package.json index 8825340..c5ff7e9 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,12 @@ "browserify": "^17.0.0", "c8": "^11.0.0", "chai": "^4.5.0", + "eslint": "^9.39.5", "mocha": "^11.7.5" }, "scripts": { "api": "node ./src/api.js", + "lint": "eslint .", "smoke-test": "npx mocha --timeout 10000 'test/smoke/**/*.test.js'", "test": "npx mocha --timeout 10000 --require ./test/setup.js 'test/unit/**/*.test.js' --recursive --exit", "test:regtest": "mocha --timeout 0 --require ./test/prepareRegtest.test.js", @@ -69,6 +71,12 @@ "brace-expansion": "^5.0.9", "minimatch": "^10.2.5", "ajv": "^8.18.0", + "eslint": { + "ajv": "^6.14.0" + }, + "@eslint/eslintrc": { + "ajv": "^6.14.0" + }, "elliptic": "^6.6.1", "qs": "^6.15.2", "serialize-javascript": "^7.0.5", From ec3ff171798766c0cb9b867e75aae393ae09bf1a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 13 Sep 2026 15:17:14 -0700 Subject: [PATCH 008/148] refactor: rename src/ files to snake_case and follow every reference Renames 15 top-level src/ files (plus the adapters/ trio) to snake_case, updating every require, __dirname join and literal path across src, test and package.json in the same commit; the vendored coins/ and observability/ trees are untouched. Restores two files a codemod rewrite incorrectly touched: a cross-repo twin-guard test's reference to xchain-sdk's own (not-yet-renamed) copy, and two dead-code-sweep evidence files that quote pre-rename names on purpose. --- src/XChainEncoder.js | 12 +++--- .../{ChainAdapter.js => chain_adapter.js} | 0 .../{EvmAdapter.js => evm_adapter.js} | 4 +- .../{evmFinality.js => evm_finality.js} | 0 src/api.js | 16 ++++---- ...ilsPatch.js => apply_bufferutils_patch.js} | 2 +- ...inConnector.js => blockchain_connector.js} | 0 ...concurrencyGate.js => concurrency_gate.js} | 0 src/{corsOrigin.js => cors_origin.js} | 0 src/{crashHandlers.js => crash_handlers.js} | 0 src/{CryptoNetworks.js => crypto_networks.js} | 0 src/{errorSanitize.js => error_sanitize.js} | 0 ...tenanceWindow.js => maintenance_window.js} | 0 src/{rateLimitLog.js => rate_limit_log.js} | 0 ...tanceGuard.js => single_instance_guard.js} | 0 ...xSizeEstimator.js => tx_size_estimator.js} | 0 src/{UtxoTracker.js => utxo_tracker.js} | 0 .../txsize-estimator-boundaries.test.js | 2 +- .../conformance/utxoRecordConformance.test.js | 4 +- test/e2e/multi-chain.e2e.js | 2 +- test/integration/helpers/utxoFactory.js | 2 +- test/integration/multi-chain.test.js | 2 +- test/mutation/stryker.conf.json | 4 +- test/regression/reg-03-fee-utxo.test.js | 2 +- test/regression/reg-05-multi-chain.test.js | 2 +- .../reg-14-p2sh-reveal-fee-floor.test.js | 2 +- .../reg-17-utxo-pagination-snapshot.test.js | 2 +- .../reg-18-chained-send-reservation.test.js | 2 +- test/security/concurrency-gate.test.js | 6 +-- test/security/rate-limit-log.test.js | 4 +- test/smoke/smoke.test.js | 39 +++++++------------ test/unit/BlockchainConnector.test.js | 4 +- test/unit/CryptoNetworks.test.js | 2 +- test/unit/LargeSatoshiAmounts.test.js | 5 +-- test/unit/TxSizeEstimator.test.js | 2 +- test/unit/UtxoTracker.test.js | 2 +- .../XChainEncoder.createTransaction.test.js | 7 ++-- test/unit/XChainEncoder.exactInputs.test.js | 2 +- test/unit/XChainEncoder.extra.test.js | 4 +- test/unit/XChainEncoder.feeRateCap.test.js | 2 +- .../XChainEncoder.p2shRevealHeadroom.test.js | 2 +- .../XChainEncoder.releaseReservations.test.js | 2 +- ...XChainEncoder.revealFundingBinding.test.js | 2 +- .../XChainEncoder.segwitInputGuard.test.js | 2 +- test/unit/XChainEncoder.softDustFloor.test.js | 2 +- .../XChainEncoder.taprootEnvelope.test.js | 4 +- test/unit/applyBufferutilsPatch.test.js | 10 +++-- test/unit/corsOrigin.test.js | 2 +- test/unit/crashHandlers.test.js | 2 +- test/unit/encoderStressSweep.test.js | 2 +- test/unit/envelopeRecognitionGate.test.js | 2 +- test/unit/errorSanitize.test.js | 2 +- test/unit/evmAdapter.test.js | 6 +-- test/unit/health-serve-readiness.test.js | 2 +- test/unit/maintenanceWindow.test.js | 2 +- test/unit/packageFeeSizing.test.js | 12 +++--- .../dependency-advisories.test.js | 2 +- test/unit/singleInstanceGuard.test.js | 2 +- test/unit/validator.test.js | 3 +- 59 files changed, 95 insertions(+), 107 deletions(-) rename src/adapters/{ChainAdapter.js => chain_adapter.js} (100%) rename src/adapters/{EvmAdapter.js => evm_adapter.js} (98%) rename src/adapters/{evmFinality.js => evm_finality.js} (100%) rename src/{applyBufferutilsPatch.js => apply_bufferutils_patch.js} (99%) rename src/{BlockchainConnector.js => blockchain_connector.js} (100%) rename src/{concurrencyGate.js => concurrency_gate.js} (100%) rename src/{corsOrigin.js => cors_origin.js} (100%) rename src/{crashHandlers.js => crash_handlers.js} (100%) rename src/{CryptoNetworks.js => crypto_networks.js} (100%) rename src/{errorSanitize.js => error_sanitize.js} (100%) rename src/{maintenanceWindow.js => maintenance_window.js} (100%) rename src/{rateLimitLog.js => rate_limit_log.js} (100%) rename src/{singleInstanceGuard.js => single_instance_guard.js} (100%) rename src/{TxSizeEstimator.js => tx_size_estimator.js} (100%) rename src/{UtxoTracker.js => utxo_tracker.js} (100%) diff --git a/src/XChainEncoder.js b/src/XChainEncoder.js index 0959f83..43a100e 100644 --- a/src/XChainEncoder.js +++ b/src/XChainEncoder.js @@ -20,18 +20,18 @@ // Must load before any PSBT is built: teaches bitcoinjs-lib/bip174 to carry // satoshi values above 2^53-1 as BigInt (DOGE has no supply cap). -require('./applyBufferutilsPatch') +require('./apply_bufferutils_patch') const bitcoin = require('bitcoinjs-lib'); const crypto = require('crypto'); const bs58check = require('bs58check') -const BlockchainConnector = require('./BlockchainConnector') -const CryptoNetworks = require('./CryptoNetworks') -const UtxoTracker = require('./UtxoTracker') -const TxSizeEstimator = require("./TxSizeEstimator") +const BlockchainConnector = require('./blockchain_connector') +const CryptoNetworks = require('./crypto_networks') +const UtxoTracker = require('./utxo_tracker') +const TxSizeEstimator = require("./tx_size_estimator") const { MAX_COMPILED_ACTION_DATA_LENGTH, ENVELOPE_MAX_PAYLOAD, MAX_UTXO_COUNT, validateUtxoEntry, parseSatoshiAmount, validateFeePerKb, validateOptionalBoolean, validateAddress, validateDataParam, validateActionPushDecodability, unknownActionName } = require('./validator') const { compressPayloadForAction } = require('./compression') const { OperationalError } = require('./errors') -const { upstreamErrorMessage } = require('./errorSanitize') +const { upstreamErrorMessage } = require('./error_sanitize') const OP_RETURN_SIZE = 80 const P2SH_SIZE = 520 diff --git a/src/adapters/ChainAdapter.js b/src/adapters/chain_adapter.js similarity index 100% rename from src/adapters/ChainAdapter.js rename to src/adapters/chain_adapter.js diff --git a/src/adapters/EvmAdapter.js b/src/adapters/evm_adapter.js similarity index 98% rename from src/adapters/EvmAdapter.js rename to src/adapters/evm_adapter.js index 43ace85..47def40 100644 --- a/src/adapters/EvmAdapter.js +++ b/src/adapters/evm_adapter.js @@ -36,8 +36,8 @@ * ********************************************************************/ -const { ChainAdapter, AdapterNotImplementedError } = require('./ChainAdapter'); -const evmFinality = require('./evmFinality'); +const { ChainAdapter, AdapterNotImplementedError } = require('./chain_adapter'); +const evmFinality = require('./evm_finality'); class EvmAdapter extends ChainAdapter { // `chain` is an EVM chain key present in evmFinality.EVM_FINALITY (e.g. 'ETH', diff --git a/src/adapters/evmFinality.js b/src/adapters/evm_finality.js similarity index 100% rename from src/adapters/evmFinality.js rename to src/adapters/evm_finality.js diff --git a/src/api.js b/src/api.js index c36e60f..99ca794 100644 --- a/src/api.js +++ b/src/api.js @@ -39,10 +39,10 @@ const helmet = require('helmet'); const cors = require('cors'); const crypto = require('crypto'); const rateLimit = require('express-rate-limit'); -const { limitedHandler } = require('./rateLimitLog.js') +const { limitedHandler } = require('./rate_limit_log.js') const XChainEncoder = require('./XChainEncoder'); const jsonRouter = require('express-json-rpc-router') -const concurrencyGate = require('./concurrencyGate.js') +const concurrencyGate = require('./concurrency_gate.js') // Express middleware that rejects an over-cap JSON-RPC batch array before dispatch, so one // HTTP request cannot amplify into thousands of backend RPCs (the rate limiter counts a batch @@ -59,13 +59,13 @@ function makeRpcBatchGuard(maxBatch){ } } const validator = require('./validator') -const { assertSingleInstance, acquireInstanceLock, releaseLockOnSignals } = require('./singleInstanceGuard') -const { upstreamErrorMessage } = require('./errorSanitize') -const { parseCorsOrigin } = require('./corsOrigin') +const { assertSingleInstance, acquireInstanceLock, releaseLockOnSignals } = require('./single_instance_guard') +const { upstreamErrorMessage } = require('./error_sanitize') +const { parseCorsOrigin } = require('./cors_origin') const { version: ENCODER_VERSION } = require('../package.json') const { installObservability } = require('./observability'); // default-off /metrics + structured log shim -const { installCrashHandlers } = require('./crashHandlers') -const { readMaintenanceWindow } = require('./maintenanceWindow') // operator-declared scheduled outage, reported beside readiness +const { installCrashHandlers } = require('./crash_handlers') +const { readMaintenanceWindow } = require('./maintenance_window') // operator-declared scheduled outage, reported beside readiness const NETWORK = process.env.NETWORK @@ -258,7 +258,7 @@ installObservability(app, { // tracker_halted (bool), tracker_mempool_ready (bool), maintenance (object|null). // // maintenance is the operator's DECLARED scheduled-outage window (see -// src/maintenanceWindow.js), carried alongside the readiness fields and never +// src/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, diff --git a/src/applyBufferutilsPatch.js b/src/apply_bufferutils_patch.js similarity index 99% rename from src/applyBufferutilsPatch.js rename to src/apply_bufferutils_patch.js index efce4b2..de0419c 100644 --- a/src/applyBufferutilsPatch.js +++ b/src/apply_bufferutils_patch.js @@ -29,7 +29,7 @@ * the value is exactly representable and a BigInt only above 2^53-1, so * existing Number-based callers see identical behavior for every value they * could already handle. The READ-SIDE copies (xchain-decoder and - * xchain-utxo-tracker, src/applyBufferutilsPatch.js) lift the same 2^53 wall + * xchain-utxo-tracker, src/apply_bufferutils_patch.js) lift the same 2^53 wall * for block decode but deliberately implement a DIFFERENT contract: * BufferReader.readUInt64 always returns a BigInt and the module-level * readUInt64LE/writeUInt64LE helpers keep the stock 2^53-1 ceiling. Only the diff --git a/src/BlockchainConnector.js b/src/blockchain_connector.js similarity index 100% rename from src/BlockchainConnector.js rename to src/blockchain_connector.js diff --git a/src/concurrencyGate.js b/src/concurrency_gate.js similarity index 100% rename from src/concurrencyGate.js rename to src/concurrency_gate.js diff --git a/src/corsOrigin.js b/src/cors_origin.js similarity index 100% rename from src/corsOrigin.js rename to src/cors_origin.js diff --git a/src/crashHandlers.js b/src/crash_handlers.js similarity index 100% rename from src/crashHandlers.js rename to src/crash_handlers.js diff --git a/src/CryptoNetworks.js b/src/crypto_networks.js similarity index 100% rename from src/CryptoNetworks.js rename to src/crypto_networks.js diff --git a/src/errorSanitize.js b/src/error_sanitize.js similarity index 100% rename from src/errorSanitize.js rename to src/error_sanitize.js diff --git a/src/maintenanceWindow.js b/src/maintenance_window.js similarity index 100% rename from src/maintenanceWindow.js rename to src/maintenance_window.js diff --git a/src/rateLimitLog.js b/src/rate_limit_log.js similarity index 100% rename from src/rateLimitLog.js rename to src/rate_limit_log.js diff --git a/src/singleInstanceGuard.js b/src/single_instance_guard.js similarity index 100% rename from src/singleInstanceGuard.js rename to src/single_instance_guard.js diff --git a/src/TxSizeEstimator.js b/src/tx_size_estimator.js similarity index 100% rename from src/TxSizeEstimator.js rename to src/tx_size_estimator.js diff --git a/src/UtxoTracker.js b/src/utxo_tracker.js similarity index 100% rename from src/UtxoTracker.js rename to src/utxo_tracker.js diff --git a/test/boundary/txsize-estimator-boundaries.test.js b/test/boundary/txsize-estimator-boundaries.test.js index 4e7799b..9c673c4 100644 --- a/test/boundary/txsize-estimator-boundaries.test.js +++ b/test/boundary/txsize-estimator-boundaries.test.js @@ -20,7 +20,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const TxSizeEstimator = require('../../src/TxSizeEstimator') +const TxSizeEstimator = require('../../src/tx_size_estimator') const { TXID_A, PUBKEY_BUF, diff --git a/test/conformance/utxoRecordConformance.test.js b/test/conformance/utxoRecordConformance.test.js index 020aa01..582af17 100644 --- a/test/conformance/utxoRecordConformance.test.js +++ b/test/conformance/utxoRecordConformance.test.js @@ -11,7 +11,7 @@ // CONSUMER half of the shared UTXO-record conformance fixture. // // WHAT THIS PINS: the record shape xchain-utxo-tracker actually serves, driven -// through BOTH of this repo's inbound gates - src/UtxoTracker.js's shape gate +// through BOTH of this repo's inbound gates - src/utxo_tracker.js's shape gate // inside getUtxosFromAddress, and src/validator.js validateUtxoEntry. Until // this file the inbound contract was pinned only by those two hand-written // restatements plus a prose comment on the tracker side, while every OUTBOUND @@ -35,7 +35,7 @@ const assert = require('assert') const fs = require('fs') const path = require('path') const axios = require('axios') -const UtxoTracker = require('../../src/UtxoTracker') +const UtxoTracker = require('../../src/utxo_tracker') const validator = require('../../src/validator') const VENDORED = path.join(__dirname, '..', 'fixtures', 'utxo-record-conformance.json') diff --git a/test/e2e/multi-chain.e2e.js b/test/e2e/multi-chain.e2e.js index 2560fb8..47785cd 100644 --- a/test/e2e/multi-chain.e2e.js +++ b/test/e2e/multi-chain.e2e.js @@ -19,7 +19,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const CryptoNetworks = require('../../src/CryptoNetworks') +const CryptoNetworks = require('../../src/crypto_networks') const { TXID_A, TXID_MULTISIGN, diff --git a/test/integration/helpers/utxoFactory.js b/test/integration/helpers/utxoFactory.js index 63ba7cc..848daeb 100644 --- a/test/integration/helpers/utxoFactory.js +++ b/test/integration/helpers/utxoFactory.js @@ -18,7 +18,7 @@ */ const bitcoin = require('bitcoinjs-lib') -const CryptoNetworks = require('../../../src/CryptoNetworks') +const CryptoNetworks = require('../../../src/crypto_networks') // Canonical test pubkey (secp256k1 generator point) const PUBKEY_BUF = Buffer.from( diff --git a/test/integration/multi-chain.test.js b/test/integration/multi-chain.test.js index 065d0f0..25b09f2 100644 --- a/test/integration/multi-chain.test.js +++ b/test/integration/multi-chain.test.js @@ -19,7 +19,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const CryptoNetworks = require('../../src/CryptoNetworks') +const CryptoNetworks = require('../../src/crypto_networks') const { TXID_A, TXID_MULTISIGN, diff --git a/test/mutation/stryker.conf.json b/test/mutation/stryker.conf.json index 34207cf..5dbedc6 100644 --- a/test/mutation/stryker.conf.json +++ b/test/mutation/stryker.conf.json @@ -3,8 +3,8 @@ "mutate": [ "src/XChainEncoder.js", "src/validator.js", - "src/TxSizeEstimator.js", - "src/CryptoNetworks.js" + "src/tx_size_estimator.js", + "src/crypto_networks.js" ], "testRunner": "mocha", "mochaOptions": { diff --git a/test/regression/reg-03-fee-utxo.test.js b/test/regression/reg-03-fee-utxo.test.js index 39e5a4e..43b6ad0 100644 --- a/test/regression/reg-03-fee-utxo.test.js +++ b/test/regression/reg-03-fee-utxo.test.js @@ -32,6 +32,7 @@ const { buildRawTxHex } = require('../integration/helpers/utxoFactory') const actions = require('../integration/helpers/actionFactory') +const XChainEncoder = require('../../src/XChainEncoder'); // Regression expectations encode BITCOIN fee semantics (explicit fees honored // verbatim, e.g. fee=50000; 546 dust). The network was mislabeled @@ -239,7 +240,6 @@ describe('REG-03: Fee & UTXO Selection', function () { }) it('maxFeeRateKb cap produces lower fee than uncapped encoder', async function () { - const XChainEncoder = require('../../src/XChainEncoder') const address = getTestAddress(NETWORK) const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() diff --git a/test/regression/reg-05-multi-chain.test.js b/test/regression/reg-05-multi-chain.test.js index 4c2ca14..75f9550 100644 --- a/test/regression/reg-05-multi-chain.test.js +++ b/test/regression/reg-05-multi-chain.test.js @@ -20,7 +20,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const CryptoNetworks = require('../../src/CryptoNetworks') +const CryptoNetworks = require('../../src/crypto_networks') const { extractOpReturnPayload, MAGIC_WORD diff --git a/test/regression/reg-14-p2sh-reveal-fee-floor.test.js b/test/regression/reg-14-p2sh-reveal-fee-floor.test.js index 10349f7..69bef43 100644 --- a/test/regression/reg-14-p2sh-reveal-fee-floor.test.js +++ b/test/regression/reg-14-p2sh-reveal-fee-floor.test.js @@ -33,7 +33,7 @@ const ecc = require('tiny-secp256k1') const { ECPairFactory } = require('ecpair') const psbtutils = require('bitcoinjs-lib/src/psbt/psbtutils') const XChainEncoder = require('../../src/XChainEncoder') -const TxSizeEstimator = require('../../src/TxSizeEstimator') +const TxSizeEstimator = require('../../src/tx_size_estimator') const ECPair = ECPairFactory(ecc) const NET = bitcoin.networks.regtest diff --git a/test/regression/reg-17-utxo-pagination-snapshot.test.js b/test/regression/reg-17-utxo-pagination-snapshot.test.js index ad69c6f..d785495 100644 --- a/test/regression/reg-17-utxo-pagination-snapshot.test.js +++ b/test/regression/reg-17-utxo-pagination-snapshot.test.js @@ -19,7 +19,7 @@ const assert = require('assert') const axios = require('axios') -const UtxoTracker = require('../../src/UtxoTracker') +const UtxoTracker = require('../../src/utxo_tracker') const SCRIPT = '0014' + '11'.repeat(20) const HEALTHY_STATUS = { synced: true, lag: 0, halted: false, mempool_ready: true } diff --git a/test/regression/reg-18-chained-send-reservation.test.js b/test/regression/reg-18-chained-send-reservation.test.js index 808c906..0054cf4 100644 --- a/test/regression/reg-18-chained-send-reservation.test.js +++ b/test/regression/reg-18-chained-send-reservation.test.js @@ -28,6 +28,7 @@ // the reservation window is refused even if the outpoint map was bypassed. const assert = require('assert') +const bitcoin = require('bitcoinjs-lib'); const { makeEncoder, makeUtxo, getTestAddress, TXID_A, TXID_B, TXID_C } = require('../integration/helpers/utxoFactory') @@ -44,7 +45,6 @@ function build (encoder, utxos, action = MINT) { } function txidOf (result) { - const bitcoin = require('bitcoinjs-lib') return bitcoin.Transaction.fromBuffer(result.psbt.data.globalMap.unsignedTx.toBuffer()).getId() } diff --git a/test/security/concurrency-gate.test.js b/test/security/concurrency-gate.test.js index 795b494..c2b6ee8 100644 --- a/test/security/concurrency-gate.test.js +++ b/test/security/concurrency-gate.test.js @@ -29,7 +29,9 @@ const assert = require('assert') const express = require('express') const http = require('http') const rateLimit = require('express-rate-limit') -const { createConcurrencyGate, resolveLimit } = require('../../src/concurrencyGate.js') +const { createConcurrencyGate, resolveLimit } = require('../../src/concurrency_gate.js') +const fs = require('fs'); +const path = require('path'); // The 429 body the gate serves in production (src/api.js). -32029 is the // encoder's "too many requests" JSON-RPC code, shared with the per-IP limiter, @@ -381,8 +383,6 @@ describe('Security: global in-flight concurrency cap', function () { describe('api.js wiring', function () { - const fs = require('fs') - const path = require('path') const apiSource = fs.readFileSync(path.join(__dirname, '../../src/api.js'), 'utf8') it('mounts the gate on the app with an env-overridable cap', function () { diff --git a/test/security/rate-limit-log.test.js b/test/security/rate-limit-log.test.js index 17d507c..fd5381d 100644 --- a/test/security/rate-limit-log.test.js +++ b/test/security/rate-limit-log.test.js @@ -13,7 +13,7 @@ ********************************************************************** * Security: rate-limit refusal counter/logger * - * Covers src/rateLimitLog.js directly (the 429 body, the once-per-window + * Covers src/rate_limit_log.js directly (the 429 body, the once-per-window * log line, the silence in between, and the accumulated-count reset) with * an injectable clock/logger so nothing here needs a real timer, plus one * end-to-end pass through express-rate-limit to prove the draft-6 headers @@ -32,7 +32,7 @@ const path = require('path') const express = require('express') const rateLimit = require('express-rate-limit') const http = require('http') -const { limitedHandler } = require('../../src/rateLimitLog.js') +const { limitedHandler } = require('../../src/rate_limit_log.js') const MESSAGE = { jsonrpc: '2.0', id: null, error: { code: -32029, message: 'Too many requests' } } diff --git a/test/smoke/smoke.test.js b/test/smoke/smoke.test.js index c191c2c..8b23829 100644 --- a/test/smoke/smoke.test.js +++ b/test/smoke/smoke.test.js @@ -21,36 +21,42 @@ const assert = require('assert') const crypto = require('crypto') +const mod = require('../../src/XChainEncoder'); +const bitcoin = require('bitcoinjs-lib'); +const ecc = require('tiny-secp256k1'); +const XChainEncoder = require('../../src/XChainEncoder'); +const CryptoNetworks = require('../../src/crypto_networks'); +const TxSizeEstimator = require('../../src/tx_size_estimator'); +const express = require('express'); +const bodyParser = require('body-parser'); +const helmet = require('helmet'); +const cors = require('cors'); +const jsonRouter = require('express-json-rpc-router'); +const http = require('http'); describe('S1: Module Loading', () => { it('loads XChainEncoder', () => { - const mod = require('../../src/XChainEncoder') assert.strictEqual(typeof mod, 'function') }) it('loads BlockchainConnector', () => { - const mod = require('../../src/BlockchainConnector') assert.strictEqual(typeof mod, 'function') }) it('loads CryptoNetworks', () => { - const mod = require('../../src/CryptoNetworks') assert.strictEqual(typeof mod.getBitcoinJsNetwork, 'function') }) it('loads TxSizeEstimator', () => { - const mod = require('../../src/TxSizeEstimator') assert.strictEqual(typeof mod.estimateOpReturnOutput, 'function') assert.strictEqual(typeof mod.estimateInputSize, 'function') }) it('loads UtxoTracker', () => { - const mod = require('../../src/UtxoTracker') assert.strictEqual(typeof mod, 'function') }) it('loads bitcoinjs-lib with required exports', () => { - const bitcoin = require('bitcoinjs-lib') assert.ok(bitcoin.Psbt) assert.ok(bitcoin.payments) assert.ok(bitcoin.script) @@ -59,13 +65,11 @@ describe('S1: Module Loading', () => { }) it('loads tiny-secp256k1 native module', () => { - const ecc = require('tiny-secp256k1') assert.strictEqual(typeof ecc.isPoint, 'function') }) }) describe('S2: Encoder Instantiation', () => { - const XChainEncoder = require('../../src/XChainEncoder') const networks = ['bitcoin-regtest', 'dogecoin-regtest', 'litecoin-regtest'] @@ -96,7 +100,6 @@ describe('S2: Encoder Instantiation', () => { }) describe('S3: CryptoNetworks Integrity', () => { - const CryptoNetworks = require('../../src/CryptoNetworks') const allNetworks = [ 'bitcoin-mainnet', 'bitcoin-testnet', 'bitcoin-regtest', @@ -137,8 +140,6 @@ describe('S3: CryptoNetworks Integrity', () => { }) describe('S4: PSBT Creation', () => { - const bitcoin = require('bitcoinjs-lib') - const CryptoNetworks = require('../../src/CryptoNetworks') it('creates a PSBT for bitcoin-regtest', () => { const network = CryptoNetworks.getBitcoinJsNetwork('bitcoin-regtest') @@ -172,15 +173,13 @@ describe('S4: PSBT Creation', () => { }) describe('S5: prepareData', () => { - const bitcoin = require('bitcoinjs-lib') - const XChainEncoder = require('../../src/XChainEncoder') // Use a regtest P2PKH address for P2SH/P2WSH encoding paths const PUBKEY_BUF = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', 'hex' ) - const network = require('../../src/CryptoNetworks').getBitcoinJsNetwork('bitcoin-regtest') + const network = require('../../src/crypto_networks').getBitcoinJsNetwork('bitcoin-regtest') const testAddress = bitcoin.payments.p2pkh({ pubkey: PUBKEY_BUF, network }).address let encoder @@ -250,7 +249,6 @@ describe('S5: prepareData', () => { }) describe('S6: Obfuscation Round-Trip', () => { - const XChainEncoder = require('../../src/XChainEncoder') const TXID = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789' let encoder @@ -293,8 +291,6 @@ describe('S6: Obfuscation Round-Trip', () => { }) describe('S7: TxSizeEstimator', () => { - const TxSizeEstimator = require('../../src/TxSizeEstimator') - const bitcoin = require('bitcoinjs-lib') it('estimateOpReturnOutput returns positive integer', () => { const data = Buffer.from('XCHN test data') @@ -365,8 +361,6 @@ describe('S7: TxSizeEstimator', () => { }) describe('S8: Segwit UTXO Detection', () => { - const XChainEncoder = require('../../src/XChainEncoder') - const bitcoin = require('bitcoinjs-lib') const PUBKEY_BUF = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', @@ -423,7 +417,6 @@ describe('S8: Segwit UTXO Detection', () => { }) describe('S9: dataToPubkey', () => { - const XChainEncoder = require('../../src/XChainEncoder') let encoder before(() => { @@ -457,11 +450,6 @@ describe('S9: dataToPubkey', () => { }) describe('S10: API Server Startup', () => { - const express = require('express') - const bodyParser = require('body-parser') - const helmet = require('helmet') - const cors = require('cors') - const jsonRouter = require('express-json-rpc-router') let server @@ -488,7 +476,6 @@ describe('S10: API Server Startup', () => { // Listen on port 0 to let the OS pick a free port server = app.listen(0, () => { const port = server.address().port - const http = require('http') const payload = JSON.stringify({ jsonrpc: '2.0', method: 'ping', id: 1 }) const req = http.request({ diff --git a/test/unit/BlockchainConnector.test.js b/test/unit/BlockchainConnector.test.js index 4d8c56b..6e53d4b 100644 --- a/test/unit/BlockchainConnector.test.js +++ b/test/unit/BlockchainConnector.test.js @@ -10,7 +10,8 @@ const assert = require('assert') const axios = require('axios') -const BlockchainConnector = require('../../src/BlockchainConnector') +const BlockchainConnector = require('../../src/blockchain_connector') +const util = require('util'); function makeConnector () { return new BlockchainConnector('127.0.0.1', 18332, 'rpcuser', 'rpcpass') @@ -864,7 +865,6 @@ describe('BlockchainConnector.getFeePerKilobyte()', () => { // scrubbed from the re-thrown error. FAKE_RPC_PASSWORD is a test sentinel. describe('BlockchainConnector RPC-credential log sanitization', () => { it('does not leak the RPC password when an axios call fails', async () => { - const util = require('util') const FAKE_RPC_PASSWORD = 'FAKEPASS_must_never_be_logged_9c3f' const err = new Error('Request failed with status code 401') diff --git a/test/unit/CryptoNetworks.test.js b/test/unit/CryptoNetworks.test.js index de23485..d504ded 100644 --- a/test/unit/CryptoNetworks.test.js +++ b/test/unit/CryptoNetworks.test.js @@ -10,7 +10,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const CryptoNetworks = require('../../src/CryptoNetworks') +const CryptoNetworks = require('../../src/crypto_networks') describe('CryptoNetworks', () => { diff --git a/test/unit/LargeSatoshiAmounts.test.js b/test/unit/LargeSatoshiAmounts.test.js index 27bf51b..37778ec 100644 --- a/test/unit/LargeSatoshiAmounts.test.js +++ b/test/unit/LargeSatoshiAmounts.test.js @@ -33,7 +33,8 @@ const pubkeyBuf = Buffer.from( ) const TXID_A = 'a'.repeat(64) -const LTC_REGTEST = require('../../src/CryptoNetworks').getBitcoinJsNetwork('litecoin-regtest') +const LTC_REGTEST = require('../../src/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') +const bufferutils = require('bitcoinjs-lib/src/bufferutils'); const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: LTC_REGTEST }).address function makeSegwitUtxo (txid, vout, value) { @@ -163,7 +164,6 @@ describe('large satoshi amounts (>2^53-1)', () => { describe('patched 64-bit serializers', () => { it('round-trips safe-range values as Number and big values as BigInt', () => { - const bufferutils = require('bitcoinjs-lib/src/bufferutils') const buf = Buffer.alloc(8) bufferutils.writeUInt64LE(buf, 546, 0) assert.strictEqual(bufferutils.readUInt64LE(buf, 0), 546) @@ -172,7 +172,6 @@ describe('large satoshi amounts (>2^53-1)', () => { }) it('rejects negative, fractional, and >u64 writes', () => { - const bufferutils = require('bitcoinjs-lib/src/bufferutils') const buf = Buffer.alloc(8) assert.throws(() => bufferutils.writeUInt64LE(buf, -1, 0), /negative/) assert.throws(() => bufferutils.writeUInt64LE(buf, 1.5, 0), /fractional/) diff --git a/test/unit/TxSizeEstimator.test.js b/test/unit/TxSizeEstimator.test.js index 3958d3f..042cf3f 100644 --- a/test/unit/TxSizeEstimator.test.js +++ b/test/unit/TxSizeEstimator.test.js @@ -10,7 +10,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const TxSizeEstimator = require('../../src/TxSizeEstimator') +const TxSizeEstimator = require('../../src/tx_size_estimator') describe('TxSizeEstimator', () => { diff --git a/test/unit/UtxoTracker.test.js b/test/unit/UtxoTracker.test.js index 569e73f..b770a83 100644 --- a/test/unit/UtxoTracker.test.js +++ b/test/unit/UtxoTracker.test.js @@ -10,7 +10,7 @@ const assert = require('assert') const axios = require('axios') -const UtxoTracker = require('../../src/UtxoTracker') +const UtxoTracker = require('../../src/utxo_tracker') function makeTracker () { return new UtxoTracker('127.0.0.1', 18420) diff --git a/test/unit/XChainEncoder.createTransaction.test.js b/test/unit/XChainEncoder.createTransaction.test.js index 37fdfc9..95a317f 100644 --- a/test/unit/XChainEncoder.createTransaction.test.js +++ b/test/unit/XChainEncoder.createTransaction.test.js @@ -93,7 +93,8 @@ function makeEncoder (networkName = 'litecoin-regtest') { return encoder } -const LTC_REGTEST = require('../../src/CryptoNetworks').getBitcoinJsNetwork('litecoin-regtest') +const LTC_REGTEST = require('../../src/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') +const TxSizeEstimator = require('../../src/tx_size_estimator'); const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: LTC_REGTEST @@ -562,7 +563,7 @@ describe('XChainEncoder.createTransaction()', () => { return { utxos: [makeLegacyUtxo(TXID_A, 0, 100000000)] } } - const dogeNetwork = require('../../src/CryptoNetworks').getBitcoinJsNetwork('dogecoin-regtest') + const dogeNetwork = require('../../src/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') const dogeAddress = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: dogeNetwork }).address const rawPubkeyHex = pubkeyBuf.toString('hex') await encoder.createTransaction( @@ -794,7 +795,6 @@ describe('XChainEncoder.createTransaction()', () => { encoder.dustAmount = 546 encoder.outputFloor = 546 const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - const TxSizeEstimator = require('../../src/TxSizeEstimator') // Baseline funding output value with no customOutputs. const base = await encoder.createTransaction( @@ -1027,7 +1027,6 @@ describe('XChainEncoder.createTransaction()', () => { describe('estimateSpendingP2shTx()', () => { it('returns 10 + P2SH input + OP_RETURN + 8-byte safety margin', () => { const encoder = makeEncoder() - const TxSizeEstimator = require('../../src/TxSizeEstimator') const redeemData = Buffer.alloc(200, 0xAA) const expected = 10 diff --git a/test/unit/XChainEncoder.exactInputs.test.js b/test/unit/XChainEncoder.exactInputs.test.js index b819dec..0c51604 100644 --- a/test/unit/XChainEncoder.exactInputs.test.js +++ b/test/unit/XChainEncoder.exactInputs.test.js @@ -61,7 +61,7 @@ function makeEncoder () { return encoder } -const LTC_REGTEST = require('../../src/CryptoNetworks').getBitcoinJsNetwork('litecoin-regtest') +const LTC_REGTEST = require('../../src/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: LTC_REGTEST diff --git a/test/unit/XChainEncoder.extra.test.js b/test/unit/XChainEncoder.extra.test.js index 193e246..265c4ce 100644 --- a/test/unit/XChainEncoder.extra.test.js +++ b/test/unit/XChainEncoder.extra.test.js @@ -55,7 +55,8 @@ function makeP2pkhUtxo (txid, vout, value) { return { txid, vout, value, confirmations: 6, scriptPubKey: script } } -const DOGE_REGTEST = require('../../src/CryptoNetworks').getBitcoinJsNetwork('dogecoin-regtest') +const DOGE_REGTEST = require('../../src/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const TxSizeEstimator = require('../../src/tx_size_estimator'); const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST @@ -490,7 +491,6 @@ describe('XChainEncoder.createTransaction() - invalid fee', () => { }) describe('XChainEncoder.estimateSpendingP2wshTx()', () => { - const TxSizeEstimator = require('../../src/TxSizeEstimator') const MAGIC_WORD = 'XCHN' function expectedSize (witnessData) { diff --git a/test/unit/XChainEncoder.feeRateCap.test.js b/test/unit/XChainEncoder.feeRateCap.test.js index 8d16bd0..299d042 100644 --- a/test/unit/XChainEncoder.feeRateCap.test.js +++ b/test/unit/XChainEncoder.feeRateCap.test.js @@ -31,7 +31,7 @@ const pubkeyBuf = Buffer.from( ) const TXID_A = 'a'.repeat(64) -const LTC_REGTEST = require('../../src/CryptoNetworks').getBitcoinJsNetwork('litecoin-regtest') +const LTC_REGTEST = require('../../src/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: LTC_REGTEST diff --git a/test/unit/XChainEncoder.p2shRevealHeadroom.test.js b/test/unit/XChainEncoder.p2shRevealHeadroom.test.js index 6b80720..7a0e434 100644 --- a/test/unit/XChainEncoder.p2shRevealHeadroom.test.js +++ b/test/unit/XChainEncoder.p2shRevealHeadroom.test.js @@ -29,7 +29,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const XChainEncoder = require('../../src/XChainEncoder') -const CryptoNetworks = require('../../src/CryptoNetworks') +const CryptoNetworks = require('../../src/crypto_networks') const SATOSHI_UNIT = 100000000 const pubkeyBuf = Buffer.from( diff --git a/test/unit/XChainEncoder.releaseReservations.test.js b/test/unit/XChainEncoder.releaseReservations.test.js index 103590c..a802b48 100644 --- a/test/unit/XChainEncoder.releaseReservations.test.js +++ b/test/unit/XChainEncoder.releaseReservations.test.js @@ -29,7 +29,7 @@ const pubkeyBuf = Buffer.from( const TXID_ONE = 'a'.repeat(64) const TXID_TWO = 'b'.repeat(64) -const LTC_REGTEST = require('../../src/CryptoNetworks').getBitcoinJsNetwork('litecoin-regtest') +const LTC_REGTEST = require('../../src/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: LTC_REGTEST }).address const PUBKEY_HEX = pubkeyBuf.toString('hex') diff --git a/test/unit/XChainEncoder.revealFundingBinding.test.js b/test/unit/XChainEncoder.revealFundingBinding.test.js index 5178c11..44ff856 100644 --- a/test/unit/XChainEncoder.revealFundingBinding.test.js +++ b/test/unit/XChainEncoder.revealFundingBinding.test.js @@ -81,7 +81,7 @@ const BTC_REGTEST_ADDR = bitcoin.payments.p2pkh({ network: bitcoin.networks.regtest }).address -const DOGE_REGTEST = require('../../src/CryptoNetworks').getBitcoinJsNetwork('dogecoin-regtest') +const DOGE_REGTEST = require('../../src/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') const DOGE_REGTEST_ADDR = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST diff --git a/test/unit/XChainEncoder.segwitInputGuard.test.js b/test/unit/XChainEncoder.segwitInputGuard.test.js index 341e975..9a967fd 100644 --- a/test/unit/XChainEncoder.segwitInputGuard.test.js +++ b/test/unit/XChainEncoder.segwitInputGuard.test.js @@ -17,7 +17,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const XChainEncoder = require('../../src/XChainEncoder') -const CryptoNetworks = require('../../src/CryptoNetworks') +const CryptoNetworks = require('../../src/crypto_networks') const pubkeyBuf = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', diff --git a/test/unit/XChainEncoder.softDustFloor.test.js b/test/unit/XChainEncoder.softDustFloor.test.js index 390bdaf..bbbf66f 100644 --- a/test/unit/XChainEncoder.softDustFloor.test.js +++ b/test/unit/XChainEncoder.softDustFloor.test.js @@ -15,7 +15,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const XChainEncoder = require('../../src/XChainEncoder') -const CryptoNetworks = require('../../src/CryptoNetworks') +const CryptoNetworks = require('../../src/crypto_networks') const SOFT_DUST_DOGE = 1000000 // 0.01 DOGE in koinu const HARD_DUST_DOGE = 100000 // the pinned network.dustThreshold diff --git a/test/unit/XChainEncoder.taprootEnvelope.test.js b/test/unit/XChainEncoder.taprootEnvelope.test.js index 7f5fdc3..60f637d 100644 --- a/test/unit/XChainEncoder.taprootEnvelope.test.js +++ b/test/unit/XChainEncoder.taprootEnvelope.test.js @@ -23,8 +23,9 @@ const bitcoin = require('bitcoinjs-lib') const ecc = require('tiny-secp256k1') const { ECPairFactory } = require('ecpair') const XChainEncoder = require('../../src/XChainEncoder') -const TxSizeEstimator = require('../../src/TxSizeEstimator') +const TxSizeEstimator = require('../../src/tx_size_estimator') const { ENVELOPE_MAX_PAYLOAD, MAX_COMPILED_ACTION_DATA_LENGTH, MAX_STANDARD_TX_WEIGHT } = require('../../src/validator') +const vectors = require('../../../xchain-documentation/protocol/test-vectors/taproot_envelope.json'); bitcoin.initEccLib(ecc) const ECPair = ECPairFactory(ecc) @@ -146,7 +147,6 @@ describe('XChainEncoder TAPROOT envelope', function () { }) it('matches the golden grammar vector', function () { - const vectors = require('../../../xchain-documentation/protocol/test-vectors/taproot_envelope.json') const v = vectors.envelope_grammar const payload = Buffer.from(v.compiled_payload_hex, 'hex') const prepared = encoder.prepareData(payload, 'TAPROOT', callerAddress(encoder.network), v.internal_pubkey_compressed) diff --git a/test/unit/applyBufferutilsPatch.test.js b/test/unit/applyBufferutilsPatch.test.js index 091338e..7fb41bf 100644 --- a/test/unit/applyBufferutilsPatch.test.js +++ b/test/unit/applyBufferutilsPatch.test.js @@ -7,7 +7,7 @@ const assert = require('assert'); const fs = require('fs'); const path = require('path'); -const bufferutils = require('../../src/applyBufferutilsPatch.js'); +const bufferutils = require('../../src/apply_bufferutils_patch.js'); // The two copies share one body and differ only in the licence/doc banner, // because each header names the other copy and its own read-side relationship. @@ -156,7 +156,11 @@ describe('applyBufferutilsPatch', function () { describe('twin guard against the SDK copy', function () { const repoRoot = path.resolve(__dirname, '../../..'); const sdkRoot = path.join(repoRoot, 'xchain-sdk'); - const mine = path.resolve(__dirname, '../../src/applyBufferutilsPatch.js'); + const mine = path.join(__dirname, '../../src/apply_bufferutils_patch.js'); + // NOT renamed here: this resolves into xchain-sdk, whose own copy of + // the patch has not moved. A bare-literal rewrite pass cannot tell + // this string from a same-repo reference and over-corrected it once; + // restored by hand. const twin = path.join(sdkRoot, 'src/applyBufferutilsPatch.js'); it('is byte-identical to the SDK copy below the banner', function () { @@ -184,7 +188,7 @@ describe('applyBufferutilsPatch', function () { for (const name of required) { assert.ok( Object.prototype.hasOwnProperty.call(pkg.dependencies || {}, name), - name + ' is required by src/applyBufferutilsPatch.js but not declared in package.json dependencies' + name + ' is required by src/apply_bufferutils_patch.js but not declared in package.json dependencies' ); } }); diff --git a/test/unit/corsOrigin.test.js b/test/unit/corsOrigin.test.js index e402271..d273978 100644 --- a/test/unit/corsOrigin.test.js +++ b/test/unit/corsOrigin.test.js @@ -31,7 +31,7 @@ const assert = require('assert') const express = require('express') const cors = require('cors') -const { parseCorsOrigin } = require('../../src/corsOrigin') +const { parseCorsOrigin } = require('../../src/cors_origin') // Mount cors exactly as src/api.js does and ask what a browser would receive. // Returns the ACAO header (or null) for each probed origin. diff --git a/test/unit/crashHandlers.test.js b/test/unit/crashHandlers.test.js index a6835a9..7a9dd82 100644 --- a/test/unit/crashHandlers.test.js +++ b/test/unit/crashHandlers.test.js @@ -20,7 +20,7 @@ const { EventEmitter } = require('events') const fs = require('fs') const path = require('path') -const crash = require('../../src/crashHandlers') +const crash = require('../../src/crash_handlers') const observability = require('../../src/observability') describe('encoder crash handlers', function () { diff --git a/test/unit/encoderStressSweep.test.js b/test/unit/encoderStressSweep.test.js index 8d6375a..4cbb6a8 100644 --- a/test/unit/encoderStressSweep.test.js +++ b/test/unit/encoderStressSweep.test.js @@ -31,7 +31,7 @@ for (const k of _envKeys) { } const v = require('../../src/validator') const axios = require('axios') -const UtxoTracker = require('../../src/UtxoTracker') +const UtxoTracker = require('../../src/utxo_tracker') describe('encoder stress-sweep unit @regression', function () { this.timeout(10000) diff --git a/test/unit/envelopeRecognitionGate.test.js b/test/unit/envelopeRecognitionGate.test.js index 6da5c72..ff7b14d 100644 --- a/test/unit/envelopeRecognitionGate.test.js +++ b/test/unit/envelopeRecognitionGate.test.js @@ -24,7 +24,7 @@ const fs = require('fs'); const path = require('path'); const XChainEncoder = require('../../src/XChainEncoder'); -const CryptoNetworks = require('../../src/CryptoNetworks'); +const CryptoNetworks = require('../../src/crypto_networks'); // The gate reads only the resolved recognition height and connector.getBlockCount, // so a network KEY plus a stub connector is the whole surface; building a real diff --git a/test/unit/errorSanitize.test.js b/test/unit/errorSanitize.test.js index 5104242..8866d86 100644 --- a/test/unit/errorSanitize.test.js +++ b/test/unit/errorSanitize.test.js @@ -13,7 +13,7 @@ *********************************************************************/ const assert = require('assert') -const { upstreamErrorMessage, isTransportError, leaksInternalDetail } = require('../../src/errorSanitize') +const { upstreamErrorMessage, isTransportError, leaksInternalDetail } = require('../../src/error_sanitize') // Guards the encoder's outbound error sanitization: useful upstream RPC reasons // pass through, transport-level failures (which leak the internal node host:port) diff --git a/test/unit/evmAdapter.test.js b/test/unit/evmAdapter.test.js index b5138a9..d4e7964 100644 --- a/test/unit/evmAdapter.test.js +++ b/test/unit/evmAdapter.test.js @@ -14,9 +14,9 @@ // undefined). They do NOT exercise any real EVM tx path (there is none yet). const assert = require('assert') -const evmFinality = require('../../src/adapters/evmFinality') -const { ChainAdapter, AdapterNotImplementedError } = require('../../src/adapters/ChainAdapter') -const { EvmAdapter } = require('../../src/adapters/EvmAdapter') +const evmFinality = require('../../src/adapters/evm_finality') +const { ChainAdapter, AdapterNotImplementedError } = require('../../src/adapters/chain_adapter') +const { EvmAdapter } = require('../../src/adapters/evm_adapter') describe('EVM finality gate (evmFinality)', () => { diff --git a/test/unit/health-serve-readiness.test.js b/test/unit/health-serve-readiness.test.js index c4b2eb3..e9a6c54 100644 --- a/test/unit/health-serve-readiness.test.js +++ b/test/unit/health-serve-readiness.test.js @@ -25,6 +25,7 @@ const assert = require('assert'); const { jsonRpcController, encoder } = require('../../src/api'); +const http = require('http'); function stubSync(status) { const orig = encoder.utxoTrackerConnector.getSyncStatus; @@ -150,7 +151,6 @@ describe('health(): tracker_synced is serve-readiness (create_tx parity) @regres * /status only. The JSON-RPC health() shape is documented in docs/openrpc.json * and stays as it is. */ describe('GET /status publishes the lag ceiling its readiness was gated on @regression', function () { - const http = require('http'); const { app, encoder } = require('../../src/api'); async function statusBody() { diff --git a/test/unit/maintenanceWindow.test.js b/test/unit/maintenanceWindow.test.js index ab836f3..11ab806 100644 --- a/test/unit/maintenanceWindow.test.js +++ b/test/unit/maintenanceWindow.test.js @@ -45,7 +45,7 @@ const { MAX_SENTINEL_BYTES, MAX_REASON_CHARS, MAX_WINDOW_MS -} = require('../../src/maintenanceWindow'); +} = require('../../src/maintenance_window'); const NOW = Date.parse('2026-09-02T12:00:00.000Z'); const in2h = new Date(NOW + 2 * 3600 * 1000).toISOString(); diff --git a/test/unit/packageFeeSizing.test.js b/test/unit/packageFeeSizing.test.js index 9d85f2c..f420f7a 100644 --- a/test/unit/packageFeeSizing.test.js +++ b/test/unit/packageFeeSizing.test.js @@ -21,8 +21,10 @@ const assert = require('assert') const axios = require('axios') const bitcoin = require('bitcoinjs-lib') -const BlockchainConnector = require('../../src/BlockchainConnector') +const BlockchainConnector = require('../../src/blockchain_connector') const XChainEncoder = require('../../src/XChainEncoder') +const util = require('util'); +const ecc = require('tiny-secp256k1'); const SATOSHI_UNIT = 100000000 @@ -239,7 +241,6 @@ describe('BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 }) it('does not leak the RPC password when a mempool call fails', async () => { - const util = require('util') const FAKE_RPC_PASSWORD = 'FAKEPASS_must_never_be_logged_4b1e' const err = new Error('Request failed with status code 401') err.config = { auth: { username: 'rpcuser', password: FAKE_RPC_PASSWORD } } @@ -325,7 +326,7 @@ describe('packageFeeUpliftSatoshis() @regression @tier1', () => { describe('XChainEncoder package-aware fee sizing @regression @tier1', () => { const pubkeyBuf = Buffer.from('0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', 'hex') - const DOGE_REGTEST = require('../../src/CryptoNetworks').getBitcoinJsNetwork('dogecoin-regtest') + const DOGE_REGTEST = require('../../src/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST }).address const INPUT_VALUE = 100000000 // 1 DOGE in koinu const NODE_RATE_PER_KB = 0.01 // Dogecoin's block-inclusion floor @@ -551,13 +552,12 @@ describe('XChainEncoder package-aware fee sizing @regression @tier1', () => { // commit. These pin that the commit prefunds the reveal at the PACKAGE rate, and // that the P2SH reveal keeps that money as fee instead of sweeping it home. describe('two-phase reveal package prefund @regression @tier1', () => { - const ecc = require('tiny-secp256k1') const { ECPairFactory } = require('ecpair') bitcoin.initEccLib(ecc) const KEY = ECPairFactory(ecc).fromPrivateKey(Buffer.alloc(32, 7)) const PUBKEY_HEX = Buffer.from(KEY.publicKey).toString('hex') - const BTC_REGTEST = require('../../src/CryptoNetworks').getBitcoinJsNetwork('bitcoin-regtest') + const BTC_REGTEST = require('../../src/crypto_networks').getBitcoinJsNetwork('bitcoin-regtest') const BTC_RATE_KB = 0.0001 // 10 sat/byte const BTC_TARGET_PER_BYTE = BTC_RATE_KB * SATOSHI_UNIT / 1000 const COMMIT_INPUT_VALUE = 100000000 @@ -745,7 +745,7 @@ describe('two-phase reveal package prefund @regression @tier1', () => { // instead of sweeping it back to the caller. describe('P2SH two-phase package prefund @regression @tier1', () => { const DOGE = 'dogecoin-regtest' - const DOGE_REGTEST_NET = require('../../src/CryptoNetworks').getBitcoinJsNetwork(DOGE) + const DOGE_REGTEST_NET = require('../../src/crypto_networks').getBitcoinJsNetwork(DOGE) const RATE_KB = 1000000 // 1000 koinu/byte, the venue rate const TARGET_PER_BYTE = RATE_KB / 1000 const pubkeyBuf = Buffer.from('0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', 'hex') diff --git a/test/unit/security/configuration/dependency-advisories.test.js b/test/unit/security/configuration/dependency-advisories.test.js index 1c5cee8..447df6c 100644 --- a/test/unit/security/configuration/dependency-advisories.test.js +++ b/test/unit/security/configuration/dependency-advisories.test.js @@ -13,6 +13,7 @@ const assert = require('assert'); const fs = require('fs'); const path = require('path'); +const axios = require('axios'); // Guards remediated dependency advisories so a lockfile refresh cannot // silently resolve back into a known-vulnerable range. npm only re-resolves @@ -195,7 +196,6 @@ describe('Security: remediated dependency advisories @regression @tier4', functi // by a partial install satisfies every lockfile assertion above. it('ADV-5: the installed axios reports a patched runtime version', function () { if (!lockEntries('axios').length) return this.skip(); - const axios = require('axios'); assert.ok(axios.VERSION, 'axios did not expose a VERSION'); assert.ok(cmp(parse(axios.VERSION), [1, 18, 0]) >= 0, diff --git a/test/unit/singleInstanceGuard.test.js b/test/unit/singleInstanceGuard.test.js index b29958f..94b4a53 100644 --- a/test/unit/singleInstanceGuard.test.js +++ b/test/unit/singleInstanceGuard.test.js @@ -13,7 +13,7 @@ const fs = require('fs') const os = require('os') const path = require('path') -const { assertSingleInstance, acquireInstanceLock, releaseLockOnSignals, isPidAlive } = require('../../src/singleInstanceGuard') +const { assertSingleInstance, acquireInstanceLock, releaseLockOnSignals, isPidAlive } = require('../../src/single_instance_guard') describe('singleInstanceGuard', function () { diff --git a/test/unit/validator.test.js b/test/unit/validator.test.js index 90c68d4..ee14756 100644 --- a/test/unit/validator.test.js +++ b/test/unit/validator.test.js @@ -19,6 +19,7 @@ const assert = require('assert'); const v = require('../../src/validator.js'); +const bitcoin = require('bitcoinjs-lib'); const HEX64 = 'a'.repeat(64); @@ -190,7 +191,6 @@ describe('Encoder input validator', function () { // compiled buffer, so under-counting here by 2 per large push moved the // boundary payload from this -32602 pre-check to a -32603 builder error. it('counts the OP_PUSHDATA4 band on a push past 65,535 bytes', function () { - const bitcoin = require('bitcoinjs-lib'); // The widest rawData whose real compiled size is exactly the ceiling: // OP_0 (1 byte, the empty data push) + rawLen + 5. const atCeiling = v.ENVELOPE_MAX_PAYLOAD - 1 - 5; @@ -209,7 +209,6 @@ describe('Encoder input validator', function () { }); it('leaves the OP_PUSHDATA2 band alone at its upper edge', function () { - const bitcoin = require('bitcoinjs-lib'); // 65,535 is still +3, so the correction must not start a byte early, // and compiledPushSize itself must stay unforked for the decoder pin. assert.strictEqual(bitcoin.script.compile([Buffer.alloc(65535)]).length, 65538); From 028a7532ecaace711d72f2b8a78469a04ffcd14b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 13 Sep 2026 15:19:17 -0700 Subject: [PATCH 009/148] refactor: route console calls through the shared logger Replaces direct console.log/warn/error calls outside the vendored trees with the shared logger, following the same argument-folding rule the vendored console patch already uses. Fixes a test that spied on console.error directly, which stopped seeing anything once the logger's real shipper installed; it now spies on the logger method instead. --- src/XChainEncoder.js | 25 ++++++++++++++----------- src/blockchain_connector.js | 15 +++++++++------ src/compression.js | 5 ++++- src/single_instance_guard.js | 4 +++- src/tx_size_estimator.js | 7 +++++-- src/utxo_tracker.js | 7 +++++-- test/unit/BlockchainConnector.test.js | 14 ++++++++++---- 7 files changed, 50 insertions(+), 27 deletions(-) diff --git a/src/XChainEncoder.js b/src/XChainEncoder.js index 43a100e..bc19819 100644 --- a/src/XChainEncoder.js +++ b/src/XChainEncoder.js @@ -32,6 +32,9 @@ const { MAX_COMPILED_ACTION_DATA_LENGTH, ENVELOPE_MAX_PAYLOAD, MAX_UTXO_COUNT, v const { compressPayloadForAction } = require('./compression') const { OperationalError } = require('./errors') const { upstreamErrorMessage } = require('./error_sanitize') +const util = require('node:util'); +const { getLogger } = require('./observability'); +const logger = getLogger(); const OP_RETURN_SIZE = 80 const P2SH_SIZE = 520 @@ -1230,7 +1233,7 @@ class XChainEncoder { // with). The relative cap has no anchor; fall back to the // relayfee anchor below and the absolute MAX_FEE_RATE_KB cap, // if configured. - console.warn('Relative fee cap skipped: node fee estimate unavailable:', err.message) + logger.warn(util.format('Relative fee cap skipped: node fee estimate unavailable:', err.message)) } } else { feePerBytes = await this.connector.getFeePerKilobyte(1)/1000 //Highest fee. In bitcoin context every kilobyte is 1000 bytes @@ -1248,7 +1251,7 @@ class XChainEncoder { const floor = suggestedFeeCeilingFloorPerByte(info && info.relayfee) if (floor != null && floor > suggestedCap) suggestedCap = floor } catch (err) { - console.warn('Suggested-fee ceiling relayfee floor unavailable; using the configured ceiling:', err.message) + logger.warn(util.format('Suggested-fee ceiling relayfee floor unavailable; using the configured ceiling:', err.message)) } } // Compare with a relative epsilon. Both sides are coin-per-byte floats @@ -1260,7 +1263,7 @@ class XChainEncoder { if (suggestedCap != null && feePerBytes > suggestedCap * (1 + 1e-12)){ if (!this._suggestedFeeClampWarned){ this._suggestedFeeClampWarned = true - console.warn(`Suggested fee rate ${Math.round(feePerBytes * SATOSHI_UNIT)} per vByte exceeds the ` + + logger.warn(`Suggested fee rate ${Math.round(feePerBytes * SATOSHI_UNIT)} per vByte exceeds the ` + `test-chain ceiling; using ${Math.round(suggestedCap * SATOSHI_UNIT)} per vByte. ` + `Set SUGGESTED_FEE_MAX_PER_VBYTE to change or 0 to disable.`) } @@ -1288,7 +1291,7 @@ class XChainEncoder { const relayfee = Number(info && info.relayfee); if (relayfee > 0) nodeFeePerBytes = relayfee / 1000; } catch (err) { - console.warn('Fee cap relayfee-anchor fallback failed; feePerKb cap disabled this build:', err.message); + logger.warn(util.format('Fee cap relayfee-anchor fallback failed; feePerKb cap disabled this build:', err.message)); } } @@ -1303,7 +1306,7 @@ class XChainEncoder { capFeePerBytes = (capFeePerBytes != null) ? Math.min(capFeePerBytes, relativeCap) : relativeCap } if (capFeePerBytes != null && feePerBytes > capFeePerBytes) { - console.warn(`Fee rate ${feePerBytes * 1000 * SATOSHI_UNIT} sat/kB exceeds the fee-rate cap, clamping to ${capFeePerBytes * 1000 * SATOSHI_UNIT} sat/kB`) + logger.warn(`Fee rate ${feePerBytes * 1000 * SATOSHI_UNIT} sat/kB exceeds the fee-rate cap, clamping to ${capFeePerBytes * 1000 * SATOSHI_UNIT} sat/kB`) feePerBytes = capFeePerBytes } @@ -2551,7 +2554,7 @@ class XChainEncoder { try { ancestorPackage = await this.connector.getUnconfirmedAncestorPackage(unconfirmedInputTxids) } catch (err) { - console.warn('Package fee sizing skipped: ancestor lookup failed:', err.message) + logger.warn(util.format('Package fee sizing skipped: ancestor lookup failed:', err.message)) } if (ancestorPackage && Number.isFinite(Number(ancestorPackage.size)) && Number(ancestorPackage.size) > 0){ commitAncestorSize = Number(ancestorPackage.size) @@ -2601,7 +2604,7 @@ class XChainEncoder { if (allowed < wanted){ const packageSize = ancestorPackage.size + estimatedTxSize const packageFee = Math.round(ancestorPackage.fees * SATOSHI_UNIT) + estimatedFee + allowed - console.warn(`Package fee uplift clamped to ${allowed} of ${wanted} base units: this transaction ` + + logger.warn(`Package fee uplift clamped to ${allowed} of ${wanted} base units: this transaction ` + `spends ${unconfirmedInputTxids.length} unconfirmed input(s) whose ancestors total ` + `${ancestorPackage.size} bytes. The package will pay ${Math.round(packageFee / packageSize * 1000)} ` + `base units/kB against a target of ${Math.round(feePerBytes * SATOSHI_UNIT * 1000)}, so it may ` + @@ -2669,7 +2672,7 @@ class XChainEncoder { if (allowed < wanted){ const packageSize = commitAncestorSize + estimatedTxSize + revealPrefund.revealSize const packageFee = commitAncestorFeeSat + estimatedFee + revealPrefund.revealFee + allowed - console.warn(`Reveal package prefund clamped to ${allowed} of ${wanted} base units: this commit ` + + logger.warn(`Reveal package prefund clamped to ${allowed} of ${wanted} base units: this commit ` + `pays ${estimatedFee} base units over ~${estimatedTxSize} bytes, so the commit/reveal package ` + `will pay ${Math.round(packageFee / packageSize * 1000)} base units/kB against a target of ` + `${Math.round(feePerBytes * SATOSHI_UNIT * 1000)}. The reveal cannot raise its own fee later, ` + @@ -2825,7 +2828,7 @@ class XChainEncoder { try { commitPackage = await this.connector.getUnconfirmedAncestorPackage([p2shHash]) } catch (err) { - console.warn('Reveal package fee sizing skipped: commit lookup failed:', err.message) + logger.warn(util.format('Reveal package fee sizing skipped: commit lookup failed:', err.message)) } const wanted = commitPackage ? packageFeeUpliftSatoshis({ currentFee: revealFeeKept, @@ -2854,7 +2857,7 @@ class XChainEncoder { if (allowed < wanted){ const packageSize = commitPackage.size + revealSizeForFee const packageFee = Math.round(commitPackage.fees * SATOSHI_UNIT) + revealFeeKept + allowed - console.warn(`Reveal package fee uplift clamped to ${allowed} of ${wanted} base units: the commit ` + + logger.warn(`Reveal package fee uplift clamped to ${allowed} of ${wanted} base units: the commit ` + `package totals ${commitPackage.size} bytes, so the commit/reveal package will pay ` + `${Math.round(packageFee / packageSize * 1000)} base units/kB against a target of ` + `${Math.round(feePerBytes * SATOSHI_UNIT * 1000)} and may stay unmined. The reveal has no ` + @@ -3388,7 +3391,7 @@ class XChainEncoder { try { nodeFeePerBytes = await this.connector.getFeePerKilobyte(1) / 1000 } catch (err) { - console.warn('Envelope-cancel relative fee cap skipped: node fee estimate unavailable:', err.message) + logger.warn(util.format('Envelope-cancel relative fee cap skipped: node fee estimate unavailable:', err.message)) } } else { feePerBytes = await this.connector.getFeePerKilobyte(1) / 1000 diff --git a/src/blockchain_connector.js b/src/blockchain_connector.js index 8614b84..027c260 100644 --- a/src/blockchain_connector.js +++ b/src/blockchain_connector.js @@ -19,6 +19,9 @@ ********************************************************************/ const axios = require('axios') +const util = require('node:util'); +const { getLogger } = require('./observability'); +const logger = getLogger(); const RPC_TIMEOUT = parseInt(process.env.NODE_RPC_TIMEOUT ?? '30000', 10) // Fee-rate multiple of the node's relay floor used on non-mainnet chains when @@ -273,7 +276,7 @@ class BlockchainConnector { if (body && body.error?.code === -5) { throw new Error(`Transaction ${txid} not found (the coin node may require txindex=1 to retrieve confirmed transactions)`); } - console.error('Error:', sanitizeRpcError(error)); + logger.error(util.format('Error:', sanitizeRpcError(error))); throw error; } } @@ -346,7 +349,7 @@ class BlockchainConnector { if (body && body.error) { throw new Error(body.error.message || JSON.stringify(body.error)); } - console.error('Error:', sanitizeRpcError(error)); + logger.error(util.format('Error:', sanitizeRpcError(error))); throw error; } } @@ -382,7 +385,7 @@ class BlockchainConnector { } catch (error) { const body = error.response && error.response.data if (body && body.error) return classify(body.error) - console.warn(`Mempool RPC ${method} failed:`, sanitizeRpcError(error)) + logger.warn(util.format(`Mempool RPC ${method} failed:`, sanitizeRpcError(error))) return { ok: false } } } @@ -485,7 +488,7 @@ class BlockchainConnector { if (!(relayfee > 0)) return null; const multiplier = noEstimateRelayMultiplier(); const feerate = relayfee * multiplier; - console.warn('estimatesmartfee returned no estimate on a non-mainnet chain; using ' + + logger.warn('estimatesmartfee returned no estimate on a non-mainnet chain; using ' + multiplier + 'x the node relayfee floor: ' + feerate + '/kB ' + '(FEE_NO_ESTIMATE_RELAY_MULTIPLIER to change)'); return feerate; @@ -544,7 +547,7 @@ class BlockchainConnector { if (feerate > ceiling) { // Loud diagnostic: this is a money-affecting path and a clamp here // is silent everywhere else unless it is logged at error level. - console.error('estimatesmartfee returned ' + feerate + '/kB on a non-regtest ' + + 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; @@ -601,7 +604,7 @@ class BlockchainConnector { } catch (_) { // isRegtest() or the fallback's own RPC failed; fall through to rethrow. } - console.error('Error:', sanitizeRpcError(error)); + logger.error(util.format('Error:', sanitizeRpcError(error))); throw error; } } diff --git a/src/compression.js b/src/compression.js index 9d1184a..c6352c0 100644 --- a/src/compression.js +++ b/src/compression.js @@ -44,6 +44,9 @@ 'use strict' const zlib = require('zlib') +const util = require('node:util'); +const { getLogger } = require('./observability'); +const logger = getLogger(); const { COMPRESSION_CODE_DEFLATE_RAW, COMPRESSION_MAX_RATIO, @@ -208,7 +211,7 @@ async function compressPayloadForAction(actionString, rawDataBuffer, options = { } catch (err) { // A zlib failure is not fatal to the transaction: the uncompressed // payload is still perfectly valid, so degrade rather than refuse. - console.warn('Compression skipped, deflate failed:', err && err.message ? err.message : err) + logger.warn(util.format('Compression skipped, deflate failed:', err && err.message ? err.message : err)) return unchanged('deflate-failed') } diff --git a/src/single_instance_guard.js b/src/single_instance_guard.js index 45a4160..2487a19 100644 --- a/src/single_instance_guard.js +++ b/src/single_instance_guard.js @@ -34,6 +34,8 @@ const fs = require('fs') const os = require('os') const path = require('path') const crypto = require('crypto') +const { getLogger } = require('./observability'); +const logger = getLogger(); // Filesystems that cannot hard-link. The atomic publication below falls back to // the older open-'wx'-then-write on exactly these and on nothing else, because @@ -266,7 +268,7 @@ function acquireInstanceLock(lockPath, env = process.env, deps = {}) { 'to isolate intentionally separate deployments.' ) } - console.warn('singleInstanceGuard: breaking a stale lock on ' + file + ': pid ' + + logger.warn('singleInstanceGuard: breaking a stale lock on ' + file + ': pid ' + holderPid + ' is alive but is running "' + liveCmd + '", ' + (holder.cmd === null ? 'which is not an encoder (the lock predates identity recording)' diff --git a/src/tx_size_estimator.js b/src/tx_size_estimator.js index 17c1975..5f630f6 100644 --- a/src/tx_size_estimator.js +++ b/src/tx_size_estimator.js @@ -20,6 +20,9 @@ const bitcoin = require('bitcoinjs-lib'); const { compiledPushSize } = require('./validator'); +const util = require('node:util'); +const { getLogger } = require('./observability'); +const logger = getLogger(); // Byte width of the compactSize varint that length-prefixes a witness-stack // item on the wire. NOT compiledPushSize: that models script-push framing, @@ -154,12 +157,12 @@ class TxSizeEstimator { scriptPubKey = output.script } } catch (e) { - console.error("Error decoding nonWitnessUtxo:", e) + logger.error(util.format("Error decoding nonWitnessUtxo:", e)) return 350 } if (!scriptPubKey) { - console.log("It was not possible to obtain the scriptPubKey. Assuming P2PKH."); + logger.info("It was not possible to obtain the scriptPubKey. Assuming P2PKH."); return 180; } } else { diff --git a/src/utxo_tracker.js b/src/utxo_tracker.js index 32cdcbd..528e9f2 100644 --- a/src/utxo_tracker.js +++ b/src/utxo_tracker.js @@ -19,6 +19,9 @@ ********************************************************************/ const axios = require('axios') +const util = require('node:util'); +const { getLogger } = require('./observability'); +const logger = getLogger(); // How long to wait on the tracker before giving up on a request. const TRACKER_TIMEOUT = 15000 @@ -142,7 +145,7 @@ class UtxoTracker { throw new Error('utxo-tracker has not reconverged its mempool yet, so an already-spent confirmed output cannot be filtered; refusing to fetch UTXOs') } } catch (error) { - console.error('Error checking UTXO tracker sync status:', error); + logger.error(util.format('Error checking UTXO tracker sync status:', error)); throw error; } @@ -271,7 +274,7 @@ class UtxoTracker { } } } catch (error) { - console.error('Error fetching UTXOs:', error); + logger.error(util.format('Error fetching UTXOs:', error)); throw error; } } diff --git a/test/unit/BlockchainConnector.test.js b/test/unit/BlockchainConnector.test.js index 6e53d4b..fb52a54 100644 --- a/test/unit/BlockchainConnector.test.js +++ b/test/unit/BlockchainConnector.test.js @@ -12,6 +12,8 @@ const assert = require('assert') const axios = require('axios') const BlockchainConnector = require('../../src/blockchain_connector') const util = require('util'); +const { getLogger } = require('../../src/observability'); +const logger = getLogger(); function makeConnector () { return new BlockchainConnector('127.0.0.1', 18332, 'rpcuser', 'rpcpass') @@ -781,15 +783,19 @@ describe('BlockchainConnector.getFeePerKilobyte()', () => { delete process.env.NETWORK delete process.env.FEE_ESTIMATE_SANITY_CEILING errorSpy = [] - const originalError = console.error - console.error = (...args) => { errorSpy.push(args.join(' ')); } - warnSpy = originalError + // Spy at the logger boundary, not console.error: this diagnostic now + // routes through observability/index.js's logger, whose _lazyLogger + // falls through to console.error only until some earlier-run test file + // has installed a real shipper in the shared _logger singleton, after + // which it no longer touches console at all. + warnSpy = logger.error + logger.error = (...args) => { errorSpy.push(args.join(' ')); } }) afterEach(() => { if (originalNetwork === undefined) delete process.env.NETWORK; else process.env.NETWORK = originalNetwork if (originalCeiling === undefined) delete process.env.FEE_ESTIMATE_SANITY_CEILING; else process.env.FEE_ESTIMATE_SANITY_CEILING = originalCeiling - console.error = warnSpy + logger.error = warnSpy }) it('clamps a spiked mainnet estimate to the coin-default ceiling and logs loudly', async () => { From f0c62f20ce446483bf84537a42a1dcbdde5778c0 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 13 Sep 2026 15:23:31 -0700 Subject: [PATCH 010/148] refactor: centralize process.env reads into src/config.js Adds src/config.js as the single place that reads process.env, converting call sites (including several that need per-call, not load-time, evaluation) to config getters instead of raw environment reads; src/api.js keeps its own direct reads as the entry point. Preserves the existing dependency-injection seam in single_instance_guard.js by defaulting to the config object instead of process.env. --- src/XChainEncoder.js | 7 ++--- src/blockchain_connector.js | 9 ++++--- src/config.js | 52 ++++++++++++++++++++++++++++++++++++ src/maintenance_window.js | 3 ++- src/single_instance_guard.js | 5 ++-- 5 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 src/config.js diff --git a/src/XChainEncoder.js b/src/XChainEncoder.js index bc19819..f8d03ef 100644 --- a/src/XChainEncoder.js +++ b/src/XChainEncoder.js @@ -34,6 +34,7 @@ const { OperationalError } = require('./errors') const { upstreamErrorMessage } = require('./error_sanitize') const util = require('node:util'); const { getLogger } = require('./observability'); +const config = require('./config'); const logger = getLogger(); const OP_RETURN_SIZE = 80 @@ -172,7 +173,7 @@ function isTestNetworkKey(networkKey){ // chain is unclamped. SUGGESTED_FEE_MAX_PER_VBYTE overrides the default; 0 // disables the clamp entirely. function suggestedFeeCeilingPerByte(networkKey, satoshiUnit){ - const raw = parseFloat(process.env.SUGGESTED_FEE_MAX_PER_VBYTE) + const raw = parseFloat(config.SUGGESTED_FEE_MAX_PER_VBYTE) const perVbyte = Number.isFinite(raw) ? raw : (isTestNetworkKey(networkKey) ? DEFAULT_SUGGESTED_FEE_MAX_PER_VBYTE : 0) if (!(perVbyte > 0)) return null @@ -207,7 +208,7 @@ function suggestedFeeCeilingFloorPerByte(relayfeePerKb){ const DEFAULT_MAX_CPFP_UPLIFT_SAT = 10000000 function maxCpfpUpliftSat(){ - const raw = parseFloat(process.env.MAX_CPFP_UPLIFT_SAT) + const raw = parseFloat(config.MAX_CPFP_UPLIFT_SAT) const bound = Number.isFinite(raw) ? raw : DEFAULT_MAX_CPFP_UPLIFT_SAT return (bound > 0) ? Math.floor(bound) : 0 } @@ -435,7 +436,7 @@ const Encoding = { // the operator turns it off for a staged rollout; read per call rather than // cached so a restart is not required to change it. function defaultCompressionEnabled(){ - const raw = process.env.XCHAIN_COMPRESSION_DEFAULT + const raw = config.XCHAIN_COMPRESSION_DEFAULT if (raw === undefined || raw === null || raw === '') return true return !(raw === '0' || raw.toLowerCase() === 'false' || raw.toLowerCase() === 'off') } diff --git a/src/blockchain_connector.js b/src/blockchain_connector.js index 027c260..469d7ee 100644 --- a/src/blockchain_connector.js +++ b/src/blockchain_connector.js @@ -21,9 +21,10 @@ const axios = require('axios') const util = require('node:util'); const { getLogger } = require('./observability'); +const config = require('./config'); const logger = getLogger(); -const RPC_TIMEOUT = parseInt(process.env.NODE_RPC_TIMEOUT ?? '30000', 10) +const RPC_TIMEOUT = parseInt(config.NODE_RPC_TIMEOUT ?? '30000', 10) // Fee-rate multiple of the node's relay floor used on non-mainnet chains when // estimatesmartfee has no data. Rationale at getFeePerKilobyte. // @@ -36,7 +37,7 @@ const RPC_TIMEOUT = parseInt(process.env.NODE_RPC_TIMEOUT ?? '30000', 10) // documented rate needs a higher one, and only the operator running that chain // can measure what it actually takes. function noEstimateRelayMultiplier(){ - const raw = parseFloat(process.env.FEE_NO_ESTIMATE_RELAY_MULTIPLIER) + const raw = parseFloat(config.FEE_NO_ESTIMATE_RELAY_MULTIPLIER) return (Number.isFinite(raw) && raw > 0) ? raw : 10 } @@ -64,9 +65,9 @@ const DEFAULT_FEE_ESTIMATE_SANITY_CEILING = { } function feeEstimateSanityCeiling(){ - const override = parseFloat(process.env.FEE_ESTIMATE_SANITY_CEILING) + const override = parseFloat(config.FEE_ESTIMATE_SANITY_CEILING) if (Number.isFinite(override) && override > 0) return override - const coin = String(process.env.NETWORK || '').split('-')[0].toLowerCase() + const coin = String(config.NETWORK).split('-')[0].toLowerCase() return DEFAULT_FEE_ESTIMATE_SANITY_CEILING[coin] || DEFAULT_FEE_ESTIMATE_SANITY_CEILING.bitcoin } diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..5945666 --- /dev/null +++ b/src/config.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. + * + ********************************************************************** + * The one place this service reads process.env (CODE-STYLE.md, Module shape). + * + * Every name is a GETTER, not a value captured once at require time: the + * prior call sites all read process.env live, per call, and several tests + * set process.env mid-test expecting that. A plain object literal would + * freeze every value at the first require and silently stop reacting, which + * would be an unwanted behaviour change. A getter keeps the + * read live while still confining the actual process.env access to this + * file, which is the rule this file exists to satisfy. + * + * A name whose call site coerces the value (parseInt, parseFloat, a bound + * check) keeps the RAW STRING here and coerces at the call site exactly as it + * did before: the value moved, the type decision did not, so no behaviour + * changed. A name with a single agreed default across every prior read + * carries that default here instead. + */ +'use strict'; + +const config = { + // codemod:env-entries + get ENCODER_MAINTENANCE_FILE() { return process.env.ENCODER_MAINTENANCE_FILE; }, + get NETWORK() { return process.env.NETWORK || ''; }, + get XCHAIN_COMPRESSION_DEFAULT() { return process.env.XCHAIN_COMPRESSION_DEFAULT; }, + // Raw pass-throughs: the reading module still parses/bounds-checks these, + // src/config.js only stops the process.env read happening outside it. + get SUGGESTED_FEE_MAX_PER_VBYTE() { return process.env.SUGGESTED_FEE_MAX_PER_VBYTE; }, + get MAX_CPFP_UPLIFT_SAT() { return process.env.MAX_CPFP_UPLIFT_SAT; }, + get NODE_RPC_TIMEOUT() { return process.env.NODE_RPC_TIMEOUT; }, + get FEE_NO_ESTIMATE_RELAY_MULTIPLIER() { return process.env.FEE_NO_ESTIMATE_RELAY_MULTIPLIER; }, + get FEE_ESTIMATE_SANITY_CEILING() { return process.env.FEE_ESTIMATE_SANITY_CEILING; }, + // Deploy-manifest replica declaration; see src/single_instance_guard.js. + get ENCODER_REPLICAS() { return process.env.ENCODER_REPLICAS; }, + // single_instance_guard.js's real-environment defaults (its `env` + // parameter defaults to this object; tests still inject their own). + get ENCODER_INSTANCE_LOCK_FILE() { return process.env.ENCODER_INSTANCE_LOCK_FILE; }, + get ENCODER_API_PORT() { return process.env.ENCODER_API_PORT; }, +}; + +module.exports = config; diff --git a/src/maintenance_window.js b/src/maintenance_window.js index 514eb93..628abbb 100644 --- a/src/maintenance_window.js +++ b/src/maintenance_window.js @@ -40,6 +40,7 @@ ********************************************************************/ const fs = require('fs') +const config = require('./config'); // Inside the encoder container, so xchain-node can write it with a plain // `docker exec tee` against an already-running encoder: no bind mount, and @@ -63,7 +64,7 @@ const MAX_REASON_CHARS = 120 const MAX_WINDOW_MS = 24 * 60 * 60 * 1000 function sentinelPath() { - return process.env.ENCODER_MAINTENANCE_FILE || DEFAULT_SENTINEL + return config.ENCODER_MAINTENANCE_FILE || DEFAULT_SENTINEL } // The largest absolute time value a JS Date can hold. A finite number past it diff --git a/src/single_instance_guard.js b/src/single_instance_guard.js index 2487a19..49b0557 100644 --- a/src/single_instance_guard.js +++ b/src/single_instance_guard.js @@ -35,6 +35,7 @@ const os = require('os') const path = require('path') const crypto = require('crypto') const { getLogger } = require('./observability'); +const config = require('./config'); const logger = getLogger(); // Filesystems that cannot hard-link. The atomic publication below falls back to @@ -70,7 +71,7 @@ function sleepSync(ms) { // orchestrator's replica count); any value above 1 is rejected because the // reservation, recent-build and rate-limit stores are all still per-process. // Unset/empty means the default single-replica deploy and passes. -function assertSingleInstance(env = process.env) { +function assertSingleInstance(env = config) { const raw = env.ENCODER_REPLICAS if (raw === undefined || raw === '') return true const replicas = Number(raw) @@ -157,7 +158,7 @@ function selfDescription() { // stands, because refusing to boot is the safe side of a genuine conflict. // `deps` is injectable so the tests can drive both branches without spawning // real processes. -function acquireInstanceLock(lockPath, env = process.env, deps = {}) { +function acquireInstanceLock(lockPath, env = config, deps = {}) { const alive = deps.isPidAlive || isPidAlive const describe = deps.describePid || describePid const self = deps.selfDescription || selfDescription From c275e5f13bfffdc8e5c2a13febf5bcc92c2996ef Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 13 Sep 2026 15:25:18 -0700 Subject: [PATCH 011/148] refactor: drop the leading underscore from internal method names Renames 19 underscore-prefixed methods to their plain form across src/ and every call site and comment that names them, leaving the vendored observability methods untouched. --- src/XChainEncoder.js | 114 +++++++++--------- src/blockchain_connector.js | 20 +-- src/crash_handlers.js | 4 +- src/single_instance_guard.js | 2 +- src/validator.js | 2 +- test/integration/encoderStressSweep.test.js | 2 +- .../reg-12-ins0-key-binding.test.js | 4 +- .../XChainEncoder.releaseReservations.test.js | 20 +-- test/unit/crashHandlers.test.js | 4 +- 9 files changed, 86 insertions(+), 86 deletions(-) diff --git a/src/XChainEncoder.js b/src/XChainEncoder.js index f8d03ef..5662c45 100644 --- a/src/XChainEncoder.js +++ b/src/XChainEncoder.js @@ -529,7 +529,7 @@ class XChainEncoder { // outpoint distinguishable: the reservation is worth the highest live owner // expiry and is dropped only when the last owner lets go, so a failed retry // can no longer delete the reservation an earlier outstanding cancel holds. - // See _releaseEnvelopeCancelClaims and _maxLiveCancelOwnerExpiry. + // See releaseEnvelopeCancelClaims and maxLiveCancelOwnerExpiry. this.envelopeCancelClaims = new Map() // reservation ticket id -> the claims ONE successful build kept, with the // expiry stamp each of them wrote plus the recent-build record that build @@ -541,9 +541,9 @@ class XChainEncoder { this.reservationTickets = new Map() } - // Sweep the recent-build map the same way _evictExpiredReservations sweeps + // Sweep the recent-build map the same way evictExpiredReservations sweeps // the outpoint map, so neither grows unbounded in a long-lived process. - _evictExpiredRecentBuilds(now) { + evictExpiredRecentBuilds(now) { for (const [txid, expiry] of this.recentBuilds) { if (expiry <= now) this.recentBuilds.delete(txid) } @@ -557,8 +557,8 @@ class XChainEncoder { // identical rebuild one layer earlier (every input is still reserved), so // this only fires when those were bypassed or cleared; it exists so the // "same txid twice" failure can never present as two successes again. - _refuseDuplicateBuild(psbt, now) { - this._evictExpiredRecentBuilds(now) + refuseDuplicateBuild(psbt, now) { + this.evictExpiredRecentBuilds(now) const unsignedTx = bitcoin.Transaction.fromBuffer(psbt.data.globalMap.unsignedTx.toBuffer()) const txid = unsignedTx.getId() if (this.recentBuilds.has(txid)) { @@ -581,7 +581,7 @@ class XChainEncoder { // A reserved outpoint is one an in-flight selection has claimed and not yet // released. Expired entries are treated as free and lazily evicted here. - _isOutpointReserved(key, now) { + isOutpointReserved(key, now) { const expiry = this.outpointReservations.get(key) if (expiry == null) return false if (expiry <= now) { @@ -592,8 +592,8 @@ class XChainEncoder { } // Returns the expiry it wrote, which doubles as this claim's ownership stamp - // (see _releaseCallReservations). - _reserveOutpoint(key, now) { + // (see releaseCallReservations). + reserveOutpoint(key, now) { const expiry = now + RESERVATION_TTL_MS this.outpointReservations.set(key, expiry) return expiry @@ -601,8 +601,8 @@ class XChainEncoder { // Reserve an outpoint AND record the claim on the calling createTransaction's // own ledger, so a later throw can hand it back. - _claimOutpoint(callReservations, key, now) { - callReservations.push({ key, expiry: this._reserveOutpoint(key, now) }) + claimOutpoint(callReservations, key, now) { + callReservations.push({ key, expiry: this.reserveOutpoint(key, now) }) } // Release the reservations a single createTransaction call took, and only @@ -613,7 +613,7 @@ class XChainEncoder { // a later clock. A mismatch therefore means the entry is foreign, and dropping // a foreign entry would reopen the same-address double-spend window the // reservation map exists to close. Leave it. - _releaseCallReservations(callReservations) { + releaseCallReservations(callReservations) { for (const claim of callReservations) { if (this.outpointReservations.get(claim.key) === claim.expiry){ this.outpointReservations.delete(claim.key) @@ -626,7 +626,7 @@ class XChainEncoder { // carry while the cancel path holds an outpoint, and comparing it against the // reservation is how the build guard tells "held only by this path" from // "held by a foreign createTransaction claim". - _maxLiveCancelOwnerExpiry(key, now) { + maxLiveCancelOwnerExpiry(key, now) { const owners = this.envelopeCancelClaims.get(key) if (!owners) return null let max = null @@ -642,11 +642,11 @@ class XChainEncoder { // of the owner set, because the releasing build's stamp is then no longer the // map's value and every stamp-checked delete below leaves the entry alone. // Returns true when the outpoint is still held by some other cancel owner. - _dropCancelOwner(key, ownerId, now) { + dropCancelOwner(key, ownerId, now) { const owners = this.envelopeCancelClaims.get(key) if (!owners) return false owners.delete(ownerId) - const remaining = this._maxLiveCancelOwnerExpiry(key, now) + const remaining = this.maxLiveCancelOwnerExpiry(key, now) if (remaining === null) { this.envelopeCancelClaims.delete(key) return false @@ -662,8 +662,8 @@ class XChainEncoder { // build is still outstanding on, and deleting the reservation outright on the // retry's failure freed that outpoint to createTransaction while the earlier // unsigned cancel spent it. Ownership-checked exactly like - // _releaseCallReservations: a stamp that moved belongs to somebody else. - _releaseEnvelopeCancelClaims(callReservations) { + // releaseCallReservations: a stamp that moved belongs to somebody else. + releaseEnvelopeCancelClaims(callReservations) { const now = Date.now() const handBack = [] for (const claim of callReservations) { @@ -671,16 +671,16 @@ class XChainEncoder { // release below, and deliberately not left to that check: two builds a // millisecond apart share an expiry, so the stamp alone cannot tell the // surviving owner's reservation from this one's. - if (claim.cancelOwnerId && this._dropCancelOwner(claim.key, claim.cancelOwnerId, now)) continue + if (claim.cancelOwnerId && this.dropCancelOwner(claim.key, claim.cancelOwnerId, now)) continue handBack.push(claim) } - this._releaseCallReservations(handBack) + this.releaseCallReservations(handBack) } // Sweep the cancel ownership tokens alongside the reservation map, so neither // grows unbounded in a long-lived process. An outpoint whose last owner token // has lapsed goes with it. - _evictExpiredEnvelopeCancelClaims(now) { + evictExpiredEnvelopeCancelClaims(now) { for (const [key, owners] of this.envelopeCancelClaims) { for (const [ownerId, expiry] of owners) { if (expiry <= now) owners.delete(ownerId) @@ -691,7 +691,7 @@ class XChainEncoder { // Sweep expired reservations so the map cannot grow unbounded across a // long-lived process. Called opportunistically at the start of selection. - _evictExpiredReservations(now) { + evictExpiredReservations(now) { for (const [key, expiry] of this.outpointReservations) { if (expiry <= now) this.outpointReservations.delete(key) } @@ -699,7 +699,7 @@ class XChainEncoder { // Sweep expired tickets so the ticket map cannot outgrow the reservation map // it describes. A ticket expires with the LAST of its claims. - _evictExpiredReservationTickets(now) { + evictExpiredReservationTickets(now) { for (const [id, ticket] of this.reservationTickets) { if (ticket.expiry <= now) this.reservationTickets.delete(id) } @@ -715,8 +715,8 @@ class XChainEncoder { // Only claims whose stamp is still the map's value go on the ticket. Anything // else already lapsed and was re-taken by another call, and putting it on this // ticket would hand this caller a lever over a foreign claim. - _mintReservationTicket(callReservations, now) { - this._evictExpiredReservationTickets(now) + mintReservationTicket(callReservations, now) { + this.evictExpiredReservationTickets(now) const live = [] for (const claim of callReservations) { // cancelOwnerId rides onto the ticket because releaseReservation has to @@ -744,7 +744,7 @@ class XChainEncoder { } // Explicit, ownership-stamped release of ONE build's reservations, the - // counterpart of the receipt _mintReservationTicket put on that build's + // counterpart of the receipt mintReservationTicket put on that build's // result. The wallet composes when its send modal opens and most of those // builds are never broadcast, so without this every abandoned compose held a // few-UTXO address's whole balance for five minutes and the next compose read @@ -764,10 +764,10 @@ class XChainEncoder { throw new TypeError('reservationId must be a 32-character lowercase hex string, as returned in create_tx result.reservation.id') } const now = Date.now() - this._evictExpiredReservations(now) - this._evictExpiredEnvelopeCancelClaims(now) - this._evictExpiredRecentBuilds(now) - this._evictExpiredReservationTickets(now) + this.evictExpiredReservations(now) + this.evictExpiredEnvelopeCancelClaims(now) + this.evictExpiredRecentBuilds(now) + this.evictExpiredReservationTickets(now) const ticket = this.reservationTickets.get(reservationId) if (!ticket) return { reservationId, found: false, released: [], retained: [] } // A ticket is single-use: the claims it names are either freed now or were @@ -779,11 +779,11 @@ class XChainEncoder { // Retire this build's cancel owner token first, whatever the stamp says. // A ticket whose stamp has moved still has to give the token up, or the // outpoint would stay reserved at this build's expiry long after the - // build that took it over released. _dropCancelOwner restates the + // build that took it over released. dropCancelOwner restates the // reservation at the highest expiry a surviving owner holds, which is // also what makes the stamp check below leave that owner's entry alone. const stillHeldByAnotherCancel = claim.cancelOwnerId - ? this._dropCancelOwner(claim.key, claim.cancelOwnerId, now) + ? this.dropCancelOwner(claim.key, claim.cancelOwnerId, now) : false if (!stillHeldByAnotherCancel && this.outpointReservations.get(claim.key) === claim.expiry) { this.outpointReservations.delete(claim.key) @@ -1132,21 +1132,21 @@ class XChainEncoder { // error explicitly asks for hit its OWN dead reservations and came back // INSUFFICIENT_FUNDS for up to five minutes. The release is ownership-stamped // so a concurrent call's entries are never dropped; see - // _releaseCallReservations. The success path keeps its reservations on purpose: + // releaseCallReservations. The success path keeps its reservations on purpose: // the caller is about to sign and broadcast those inputs. // A successful build's kept claims come back as a `reservation` receipt on the // result, which the caller hands to releaseReservation the moment it knows it - // will not broadcast; see _mintReservationTicket. + // will not broadcast; see mintReservationTicket. async createTransaction(...args){ const callReservations = [] let result try { result = await this._buildTransaction(callReservations, ...args) } catch (err) { - this._releaseCallReservations(callReservations) + this.releaseCallReservations(callReservations) throw err } - const reservation = this._mintReservationTicket(callReservations, Date.now()) + const reservation = this.mintReservationTicket(callReservations, Date.now()) if (reservation && result && typeof result === 'object') result.reservation = reservation return result } @@ -1692,7 +1692,7 @@ class XChainEncoder { // // The carve-out alone is NOT enough. It guarantees the pre-reserved // outpoint is SELECTED, not that it is FIRST: the selection loop re-evaluates - // reservations against a LATER clock, and _isOutpointReserved treats expiry <= now as + // reservations against a LATER clock, and isOutpointReserved treats expiry <= now as // free, so a foreign reservation blocking an EARLIER-sorted outpoint that lapses during // the async data loop un-skips that outpoint and it takes ins[0] while the key stays // bound here. Move the key-bound outpoint to the head of the selection order so ins[0] @@ -1716,10 +1716,10 @@ class XChainEncoder { if (utxos.length){ if (exactInputs){ const nowFirst = Date.now() - this._evictExpiredReservations(nowFirst) + this.evictExpiredReservations(nowFirst) const heldByOthers = utxos .map((u) => u.txid + ':' + u.vout) - .filter((k) => this._isOutpointReserved(k, nowFirst)) + .filter((k) => this.isOutpointReserved(k, nowFirst)) if (heldByOthers.length){ throw new OperationalError( 'INPUT_RESERVED', @@ -1731,7 +1731,7 @@ class XChainEncoder { ) } for (const u of utxos){ - this._claimOutpoint(callReservations, u.txid + ':' + u.vout, nowFirst) + this.claimOutpoint(callReservations, u.txid + ':' + u.vout, nowFirst) } // No head-of-order splice: the caller's order IS the input order here, // and firstReservedOutpoint stays null so the selection loop below @@ -1739,12 +1739,12 @@ class XChainEncoder { txidFirstInput = utxos[0]["txid"] } else { const nowFirst = Date.now() - this._evictExpiredReservations(nowFirst) + this.evictExpiredReservations(nowFirst) for (let i = 0; i < utxos.length; i++){ const u = utxos[i] const k = u.txid + ':' + u.vout - if (!this._isOutpointReserved(k, nowFirst)){ - this._claimOutpoint(callReservations, k, nowFirst) + if (!this.isOutpointReserved(k, nowFirst)){ + this.claimOutpoint(callReservations, k, nowFirst) firstReservedOutpoint = k txidFirstInput = u.txid // Head-of-order splice. The skipped entries ahead of it were all @@ -2294,7 +2294,7 @@ class XChainEncoder { let reservedCandidates = 0 if (!p2shHash){//The p2sh input is already created before const now = Date.now() - this._evictExpiredReservations(now) + this.evictExpiredReservations(now) let nextUtxoIndex = 0 while (nextUtxoIndex < utxos.length){ let nextUtxo = utxos[nextUtxoIndex] @@ -2345,13 +2345,13 @@ class XChainEncoder { const outpointKey = nextUtxo.txid + ':' + nextUtxo.vout // Skip outpoints reserved by OTHER calls, but NOT the one this // call pre-reserved for ins[0] above (the obfuscation key binds to it). - if (outpointKey !== firstReservedOutpoint && this._isOutpointReserved(outpointKey, now)){ + if (outpointKey !== firstReservedOutpoint && this.isOutpointReserved(outpointKey, now)){ reservedCandidates = reservedCandidates + 1 nextUtxoIndex = nextUtxoIndex + 1 continue } if (outpointKey !== firstReservedOutpoint){ - this._claimOutpoint(callReservations, outpointKey, now) + this.claimOutpoint(callReservations, outpointKey, now) } } @@ -2681,7 +2681,7 @@ class XChainEncoder { } if (allowed > 0){ - this._raiseOutputValue(psbt, revealPrefund.outputIndex, allowed) + this.raiseOutputValue(psbt, revealPrefund.outputIndex, allowed) outputSatoshis = outputSatoshis + BigInt(allowed) revealPrefund.revealFee = revealPrefund.revealFee + allowed // The envelope reveal is built in THIS call against these two @@ -2995,7 +2995,7 @@ class XChainEncoder { // states there why duplicate refusal must not apply to it. // The txid rides back on the per-call reservation ledger so the ticket // minted for this build can retire this record too (see releaseReservation). - callReservations.buildTxid = this._refuseDuplicateBuild(psbt, Date.now()) + callReservations.buildTxid = this.refuseDuplicateBuild(psbt, Date.now()) let result = {"psbt":psbt,"encoding":preparedData["encoding"]} @@ -3146,7 +3146,7 @@ class XChainEncoder { // psbt.toHex() and the reveal builder alike. Asserted rather than assumed: // a bitcoinjs release that reshapes this would otherwise underfund a reveal // silently, which is the exact failure this whole pass exists to prevent. - _raiseOutputValue(psbt, outputIndex, delta){ + raiseOutputValue(psbt, outputIndex, delta){ if (!Number.isInteger(delta) || delta <= 0){ throw new RangeError('output uplift must be a positive integer') } @@ -3284,17 +3284,17 @@ class XChainEncoder { const callReservations = [] let result try { - result = await this._buildEnvelopeCancelTransaction(callReservations, params) + result = await this.buildEnvelopeCancelTransaction(callReservations, params) } catch (err) { - this._releaseEnvelopeCancelClaims(callReservations) + this.releaseEnvelopeCancelClaims(callReservations) throw err } - const reservation = this._mintReservationTicket(callReservations, Date.now()) + const reservation = this.mintReservationTicket(callReservations, Date.now()) if (reservation && result && typeof result === 'object') result.reservation = reservation return result } - async _buildEnvelopeCancelTransaction(callReservations, { commitTxid, commitVout, commitValue, internalPubkey, tapleafHash, destination, feePerKb = null, replacebyfee = false } = {}){ + async buildEnvelopeCancelTransaction(callReservations, { commitTxid, commitVout, commitValue, internalPubkey, tapleafHash, destination, feePerKb = null, replacebyfee = false } = {}){ if (typeof commitTxid !== 'string' || !/^[0-9a-fA-F]{64}$/.test(commitTxid)) { throw new TypeError('commitTxid must be a 64-character hex string') } @@ -3344,13 +3344,13 @@ class XChainEncoder { // observes it. const outpointKey = commitTxid.toLowerCase() + ':' + commitVout const nowClaim = Date.now() - this._evictExpiredReservations(nowClaim) - this._evictExpiredEnvelopeCancelClaims(nowClaim) + this.evictExpiredReservations(nowClaim) + this.evictExpiredEnvelopeCancelClaims(nowClaim) // Reserved AND not held by this path: the reservation is worth the highest // live cancel-owner expiry whenever a cancel build holds it, so any other // value means a foreign createTransaction claim owns the outpoint. - if (this._isOutpointReserved(outpointKey, nowClaim) && - this._maxLiveCancelOwnerExpiry(outpointKey, nowClaim) !== this.outpointReservations.get(outpointKey)){ + if (this.isOutpointReserved(outpointKey, nowClaim) && + this.maxLiveCancelOwnerExpiry(outpointKey, nowClaim) !== this.outpointReservations.get(outpointKey)){ throw new OperationalError( 'ENVELOPE_CANCEL_OUTPOINT_RESERVED', `commit outpoint ${outpointKey} is reserved by a transaction built in the last ` + @@ -3359,7 +3359,7 @@ class XChainEncoder { { outpoint: outpointKey } ) } - // Re-taking this path's OWN live claim is allowed, and _refuseDuplicateBuild + // Re-taking this path's OWN live claim is allowed, and refuseDuplicateBuild // is deliberately not wired in here, for the same reason: the header // contract above is that a cancel rebuilds from the persisted recovery // record alone, so a lost-response retry must not be refused for five @@ -3369,7 +3369,7 @@ class XChainEncoder { // stamp, so an overlapping build's failure releases only what it took. A // single stamp, overwritten and then deleted, hands the commit outpoint back // to createTransaction while the first build's unsigned cancel still spends it. - this._claimOutpoint(callReservations, outpointKey, nowClaim) + this.claimOutpoint(callReservations, outpointKey, nowClaim) const cancelClaim = callReservations[callReservations.length - 1] const cancelOwnerId = crypto.randomBytes(16).toString('hex') cancelClaim.cancelOwnerId = cancelOwnerId diff --git a/src/blockchain_connector.js b/src/blockchain_connector.js index 469d7ee..7d442c8 100644 --- a/src/blockchain_connector.js +++ b/src/blockchain_connector.js @@ -284,7 +284,7 @@ class BlockchainConnector { async sendRawTransaction(txHex) { try { - return await this._sendRaw([txHex]); + return await this.sendRaw([txHex]); } catch (error) { // Regtest-only fee-cap recovery, mirroring xchain-e2e-test's // BlockchainConnector.broadcastTx. A regtest chain with accumulated @@ -303,7 +303,7 @@ class BlockchainConnector { let regtest = false; try { regtest = await this.isRegtest(); } catch (_) { /* keep original error */ } if (regtest) { - return await this._sendRaw([txHex, 0]); + return await this.sendRaw([txHex, 0]); } } throw error; @@ -312,7 +312,7 @@ class BlockchainConnector { // Single sendrawtransaction RPC to the coin node. `params` is [hex] or // [hex, maxfeerate]. Returns the txid; throws carrying the node's error body. - async _sendRaw(params) { + async sendRaw(params) { try { const data = { jsonrpc: '2.0', @@ -359,9 +359,9 @@ class BlockchainConnector { // break a build. Returns a verdict rather than throwing: {ok:true, result}, // {ok:false, absent:true} for a txid the mempool does not hold (RPC -5, the // normal answer for an already-confirmed parent), or {ok:false} for anything - // else. Mirrors _sendRaw in reading the node's JSON-RPC error body off + // else. Mirrors sendRaw in reading the node's JSON-RPC error body off // error.response, because LTC/DOGE answer HTTP 500 for RPC-level errors. - async _mempoolRpc(method, params) { + async mempoolRpc(method, params) { const classify = (rpcError) => { const code = rpcError && rpcError.code const message = (rpcError && rpcError.message) || '' @@ -439,7 +439,7 @@ class BlockchainConnector { // same mempool, so a confirmed root's bytes and fee never reach the // package the child pays to accelerate. const staged = new Map() - const self = await this._mempoolRpc('getmempoolentry', [txid]) + const self = await this.mempoolRpc('getmempoolentry', [txid]) if (!self.ok) { if (self.absent) continue // already confirmed, nothing to carry return null @@ -448,7 +448,7 @@ class BlockchainConnector { // verbose=true: the ancestors come back as a txid-keyed map of the same // entries, so one call per input covers the whole branch above it. - const ancestors = await this._mempoolRpc('getmempoolancestors', [txid, true]) + const ancestors = await this.mempoolRpc('getmempoolancestors', [txid, true]) if (!ancestors.ok) { // The root confirmed mid-sequence; its ancestors confirmed at or // before it did, so the whole staged branch is stale. Discard it. @@ -482,7 +482,7 @@ class BlockchainConnector { // or a node that reports no usable relayfee). Null means the caller throws. // Consulted from both shapes the condition arrives in, the feerate:-1 success // body and an RPC error body, so the two paths cannot drift apart. - async _noEstimateRelayFallback() { + async noEstimateRelayFallback() { if ((await this.chainName()) === 'main') return null; const info = await this.getNetworkInfo(); const relayfee = Number(info && info.relayfee); @@ -571,7 +571,7 @@ class BlockchainConnector { // testnets it is 10 sat/vB, cheap in coin that costs nothing. // Mainnet keeps throwing: there a missing estimate means the node is // unhealthy, and paying real coin on a guess is the worse failure. - const fallback = await this._noEstimateRelayFallback(); + const fallback = await this.noEstimateRelayFallback(); if (fallback !== null) return fallback; throw new Error('Error getting smart fee from node'); } catch (error) { @@ -600,7 +600,7 @@ class BlockchainConnector { // with nothing wrong at the node. Mainnet still returns null and // rethrows, and a genuinely unreachable node makes the fallback's // own RPCs throw, so node-down stays a hard failure. - const fallback = await this._noEstimateRelayFallback(); + const fallback = await this.noEstimateRelayFallback(); if (fallback !== null) return fallback; } catch (_) { // isRegtest() or the fallback's own RPC failed; fall through to rethrow. diff --git a/src/crash_handlers.js b/src/crash_handlers.js index eb056b9..9b54401 100644 --- a/src/crash_handlers.js +++ b/src/crash_handlers.js @@ -83,8 +83,8 @@ function installCrashHandlers({ proc = process, exitOnUncaught = true } = {}) { } // Tests only: the counter handles are process-wide. -function _resetCrashCounters() { +function resetCrashCounters() { _counters = null } -module.exports = { installCrashHandlers, _resetCrashCounters } +module.exports = { installCrashHandlers, resetCrashCounters } diff --git a/src/single_instance_guard.js b/src/single_instance_guard.js index 49b0557..85cdd0b 100644 --- a/src/single_instance_guard.js +++ b/src/single_instance_guard.js @@ -17,7 +17,7 @@ * Three of the encoder's guards hold their whole state in-process: the UTXO * outpoint-reservation store (XChainEncoder.js `outpointReservations`), the * recent-build duplicate refusal behind it (`recentBuilds`, enforced in - * `_refuseDuplicateBuild`), and the express-rate-limit MemoryStore. Running + * `refuseDuplicateBuild`), and the express-rate-limit MemoryStore. Running * more than one encoder replica behind one endpoint silently defeats all * three: two replicas can each build a PSBT spending the same tracker-fetched * UTXO (one tx is rejected at broadcast and the signer's fee work is wasted), diff --git a/src/validator.js b/src/validator.js index 811f787..b23e09d 100644 --- a/src/validator.js +++ b/src/validator.js @@ -789,7 +789,7 @@ function validateAddress(address, label = 'address') { } // Reservation-ticket id for release_inputs: exactly the 16 random bytes -// _mintReservationTicket emits, lowercase hex. Shape-checked here so a +// mintReservationTicket emits, lowercase hex. Shape-checked here so a // non-string, an oversized blob or a wrong-length id is a precise -32602 rather // than reaching the encoder, and so the id can never be used as an unbounded // key into the ticket map. diff --git a/test/integration/encoderStressSweep.test.js b/test/integration/encoderStressSweep.test.js index a890335..cfde1ac 100644 --- a/test/integration/encoderStressSweep.test.js +++ b/test/integration/encoderStressSweep.test.js @@ -35,7 +35,7 @@ describe('encoder stress-sweep @regression', function () { [makeUtxo(NETWORK, TXID_A, 0, 100000000), makeUtxo(NETWORK, TXID_B, 0, 100000000)] ) } - encoder._reserveOutpoint(TXID_A + ':0', Date.now()) + encoder.reserveOutpoint(TXID_A + ':0', Date.now()) const action = actions.makeSend('JDOG', '42', actions.ADDR_BTC) // No utxos passed -> fetched from tracker (fetchedFromTracker = true). diff --git a/test/regression/reg-12-ins0-key-binding.test.js b/test/regression/reg-12-ins0-key-binding.test.js index 34f4622..ca30420 100644 --- a/test/regression/reg-12-ins0-key-binding.test.js +++ b/test/regression/reg-12-ins0-key-binding.test.js @@ -13,7 +13,7 @@ // lapses between key binding and input selection. // // The two phases sample the clock separately (nowFirst at pre-reservation, now at -// selection) with the async data loop in between, and _isOutpointReserved treats +// selection) with the async data loop in between, and isOutpointReserved treats // expiry <= now as free. So an earlier-sorted outpoint whose foreign reservation // expired in that window is no longer skipped and takes ins[0], while the key // stays bound to the outpoint this call pre-reserved. The decoder derives its @@ -23,7 +23,7 @@ // These tests model the lapse by mutating the reservation map from inside a // pass-through wrapper around obfuscate(), which is exactly the async window the // finding names. Nothing in the reservation or selection logic is stubbed: the -// real _evictExpiredReservations / _isOutpointReserved / selection loop run, and +// real evictExpiredReservations / isOutpointReserved / selection loop run, and // the payload is really obfuscated and really deobfuscated. const assert = require('assert') diff --git a/test/unit/XChainEncoder.releaseReservations.test.js b/test/unit/XChainEncoder.releaseReservations.test.js index a802b48..b5ccbe8 100644 --- a/test/unit/XChainEncoder.releaseReservations.test.js +++ b/test/unit/XChainEncoder.releaseReservations.test.js @@ -81,7 +81,7 @@ describe('XChainEncoder reservation release', () => { assert.match(result.reservation.id, /^[0-9a-f]{32}$/) assert.ok(result.reservation.expiresAt > Date.now()) // The receipt describes real state, not a label: that outpoint is claimed. - assert.strictEqual(encoder._isOutpointReserved(`${TXID_ONE}:0`, Date.now()), true) + assert.strictEqual(encoder.isOutpointReserved(`${TXID_ONE}:0`, Date.now()), true) }) it('releasing frees the inputs, so the next compose funds instead of failing', async () => { @@ -121,7 +121,7 @@ describe('XChainEncoder reservation release', () => { const out = encoder.releaseReservation('f'.repeat(32)) assert.strictEqual(out.found, false) assert.deepStrictEqual(out.released, []) - assert.strictEqual(encoder._isOutpointReserved(`${TXID_ONE}:0`, Date.now()), true) + assert.strictEqual(encoder.isOutpointReserved(`${TXID_ONE}:0`, Date.now()), true) // And the address is still held, which is the point of refusing the release. await assert.rejects( () => createTx(encoder, oneFundedUtxo(), { fee: 11000 }), @@ -169,8 +169,8 @@ describe('XChainEncoder reservation release', () => { const out = encoder.releaseReservation(first.reservation.id) assert.deepStrictEqual(out.released, [firstKey]) - assert.strictEqual(encoder._isOutpointReserved(firstKey, Date.now()), false) - assert.strictEqual(encoder._isOutpointReserved(secondKey, Date.now()), true) + assert.strictEqual(encoder.isOutpointReserved(firstKey, Date.now()), false) + assert.strictEqual(encoder.isOutpointReserved(secondKey, Date.now()), true) }) it('refuses a malformed ticket id with a TypeError, before touching any state', async () => { @@ -179,14 +179,14 @@ describe('XChainEncoder reservation release', () => { for (const bad of [null, undefined, 42, {}, 'nothex', 'A'.repeat(32), 'f'.repeat(31)]) { assert.throws(() => encoder.releaseReservation(bad), TypeError, `accepted ${String(bad)}`) } - assert.strictEqual(encoder._isOutpointReserved(`${TXID_ONE}:0`, Date.now()), true) + assert.strictEqual(encoder.isOutpointReserved(`${TXID_ONE}:0`, Date.now()), true) }) it('mints no receipt when the build kept no claims', async () => { // The p2sh/p2wsh reveal funds itself from phase-1 outputs and runs no input // selection, so there is nothing to release and no ticket to hand out. const encoder = makeEncoder() - const ticket = encoder._mintReservationTicket([], Date.now()) + const ticket = encoder.mintReservationTicket([], Date.now()) assert.strictEqual(ticket, null) assert.strictEqual(encoder.reservationTickets.size, 0) }) @@ -196,7 +196,7 @@ describe('XChainEncoder reservation release', () => { const first = await createTx(encoder, oneFundedUtxo()) assert.strictEqual(encoder.reservationTickets.size, 1) const ticket = encoder.reservationTickets.get(first.reservation.id) - encoder._evictExpiredReservationTickets(ticket.expiry + 1) + encoder.evictExpiredReservationTickets(ticket.expiry + 1) assert.strictEqual(encoder.reservationTickets.size, 0) assert.strictEqual(encoder.releaseReservation(first.reservation.id).found, false) }) @@ -210,12 +210,12 @@ describe('release_inputs JSON-RPC method', () => { it('releases the claims the presented ticket owns', async () => { const claims = [] const key = `${TXID_TWO}:7` - encoder._claimOutpoint(claims, key, Date.now()) - const receipt = encoder._mintReservationTicket(claims, Date.now()) + encoder.claimOutpoint(claims, key, Date.now()) + const receipt = encoder.mintReservationTicket(claims, Date.now()) const out = await jsonRpcController.release_inputs({ reservationId: receipt.id }) assert.strictEqual(out.found, true) assert.deepStrictEqual(out.released, [key]) - assert.strictEqual(encoder._isOutpointReserved(key, Date.now()), false) + assert.strictEqual(encoder.isOutpointReserved(key, Date.now()), false) }) it('answers an unknown ticket with found:false rather than an error', async () => { diff --git a/test/unit/crashHandlers.test.js b/test/unit/crashHandlers.test.js index 7a9dd82..304b534 100644 --- a/test/unit/crashHandlers.test.js +++ b/test/unit/crashHandlers.test.js @@ -43,7 +43,7 @@ describe('encoder crash handlers', function () { beforeEach(function () { observability._resetObservability() - crash._resetCrashCounters() + crash.resetCrashCounters() sink = { lines: [] } const push = (m) => sink.lines.push(m) observability.installObservability(null, { @@ -53,7 +53,7 @@ describe('encoder crash handlers', function () { afterEach(function () { observability._resetObservability() - crash._resetCrashCounters() + crash.resetCrashCounters() }) it('an uncaught exception emits one CRASH record and exits non-zero', function () { From e4b0aee0cf8670ce3930869d563388fc219433b6 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 13 Sep 2026 15:33:25 -0700 Subject: [PATCH 012/148] test: rename test files to the shared snake_case pattern Renames 97 test files to snake_case (or into their kind-specific helper directory) and repoints package.json's two literal test paths and every comment naming an old test file by its prior basename. The remaining 17 files are genuine e2e suites and standalone scripts whose naming stays as-is for now. --- bin/coverage-thresholds.json | 2 +- bin/pins/test-rename-map.json | 99 +++++++++++++++++++ package.json | 4 +- src/api.js | 4 +- src/apply_bufferutils_patch.js | 2 +- src/validator.js | 2 +- ...t.js => change_address_boundaries.test.js} | 0 ...st.js => custom_output_boundaries.test.js} | 0 ...est.js => data_payload_boundaries.test.js} | 0 ...t.js => encoding_chunk_boundaries.test.js} | 0 ....js => fee_calculation_boundaries.test.js} | 0 ...test.js => obfuscation_boundaries.test.js} | 0 ...js => txsize_estimator_boundaries.test.js} | 0 ....test.js => utxo_value_boundaries.test.js} | 0 .../{api-layer.test.js => api_layer.test.js} | 0 ...state.test.js => arithmetic_state.test.js} | 0 ...ption.test.js => input_corruption.test.js} | 0 ...lures.test.js => library_failures.test.js} | 0 ...lures.test.js => network_failures.test.js} | 0 ...on.test.js => resource_exhaustion.test.js} | 16 +-- ....test.js => roundtrip_conformance.test.js} | 0 ...est.js => utxo_record_conformance.test.js} | 0 ...coding.test.js => action_encoding.test.js} | 0 ...outputs.test.js => custom_outputs.test.js} | 0 ...p.test.js => encoder_stress_sweep.test.js} | 0 ...g-types.test.js => encoding_types.test.js} | 0 ...andling.test.js => error_handling.test.js} | 0 test/integration/helpers/utxoFactory.js | 2 +- ....test.js => large_satoshi_tracker.test.js} | 0 ...ulti-chain.test.js => multi_chain.test.js} | 0 ....test.js => obfuscation_roundtrip.test.js} | 0 .../{utxo-fee.test.js => utxo_fee.test.js} | 2 +- ...egtest.test.js => prepare_regtest.test.js} | 0 ....test.js => reg_01_encoding_types.test.js} | 0 ...ion.test.js => reg_02_obfuscation.test.js} | 0 ...e-utxo.test.js => reg_03_fee_utxo.test.js} | 4 +- ...dator.test.js => reg_04_validator.test.js} | 0 ...ain.test.js => reg_05_multi_chain.test.js} | 0 ....js => reg_06_p2sh_p2wsh_sequence.test.js} | 0 ...test.js => reg_07_action_pipeline.test.js} | 0 ...ct.test.js => reg_08_api_contract.test.js} | 0 ....test.js => reg_09_deepdive_fixes.test.js} | 0 ... => reg_10_utxo_tracker_freshness.test.js} | 0 ...eg_11_minimal_op_canonicalization.test.js} | 0 ...est.js => reg_12_ins0_key_binding.test.js} | 0 ...g_13_reservation_release_on_throw.test.js} | 0 ...s => reg_14_p2sh_reveal_fee_floor.test.js} | 0 ...reg_15_uppercase_txid_key_binding.test.js} | 0 ...js => reg_16_rawdata_only_warning.test.js} | 0 ...> reg_17_utxo_pagination_snapshot.test.js} | 0 ...> reg_18_chained_send_reservation.test.js} | 0 ...eg_19_envelope_cancel_reservation.test.js} | 0 ...1_shortfall_names_reserved_inputs.test.js} | 0 ...-gate.test.js => concurrency_gate.test.js} | 2 +- ...ation.test.js => input_validation.test.js} | 0 ...on-key.test.js => obfuscation_key.test.js} | 0 ...load-size.test.js => payload_size.test.js} | 0 ...mit-log.test.js => rate_limit_log.test.js} | 2 +- test/{setup.js => setup/index.js} | 0 test/smoke/smoke.test.js | 2 +- ...js => action_manifest_conformance.test.js} | 0 ...est.js => apply_bufferutils_patch.test.js} | 0 ...r.test.js => blockchain_connector.test.js} | 0 ...ance.test.js => coins_conformance.test.js} | 0 ...corsOrigin.test.js => cors_origin.test.js} | 0 ...eflight.test.js => cors_preflight.test.js} | 0 ...st.js => coverage_thresholds_sync.test.js} | 0 ...andlers.test.js => crash_handlers.test.js} | 0 ...tworks.test.js => crypto_networks.test.js} | 0 ...s-openrpc.test.js => docs_openrpc.test.js} | 0 ...p.test.js => encoder_stress_sweep.test.js} | 0 ...t.js => envelope_recognition_gate.test.js} | 0 ...anitize.test.js => error_sanitize.test.js} | 0 ...evmAdapter.test.js => evm_adapter.test.js} | 0 ...test.js => health_serve_readiness.test.js} | 0 ...ard.test.js => jsonrpc_body_guard.test.js} | 0 ....test.js => large_satoshi_amounts.test.js} | 0 ...dow.test.js => maintenance_window.test.js} | 0 ...erage.test.js => openrpc_coverage.test.js} | 0 ...ing.test.js => package_fee_sizing.test.js} | 0 ....test.js => dependency_advisories.test.js} | 0 ...erage.test.js => sibling_coverage.test.js} | 0 ....test.js => single_instance_guard.test.js} | 0 ....test.js => suggested_fee_ceiling.test.js} | 2 +- ...ator.test.js => tx_size_estimator.test.js} | 0 ...xoTracker.test.js => utxo_tracker.test.js} | 0 ...s => xchain_encoder_auto_encoding.test.js} | 0 ...xchain_encoder_consensus_pin_boot.test.js} | 2 +- ...xchain_encoder_create_transaction.test.js} | 2 +- ... => xchain_encoder_data_to_pubkey.test.js} | 0 ...js => xchain_encoder_exact_inputs.test.js} | 0 ...a.test.js => xchain_encoder_extra.test.js} | 0 ...js => xchain_encoder_fee_rate_cap.test.js} | 0 ... xchain_encoder_is_segwit_u_t_x_o.test.js} | 0 ...st.js => xchain_encoder_obfuscate.test.js} | 0 ...hain_encoder_p2sh_reveal_headroom.test.js} | 0 ...js => xchain_encoder_prepare_data.test.js} | 0 ...hain_encoder_release_reservations.test.js} | 0 ...in_encoder_reveal_funding_binding.test.js} | 0 ...xchain_encoder_segwit_input_guard.test.js} | 0 ...=> xchain_encoder_soft_dust_floor.test.js} | 0 ...> xchain_encoder_taproot_envelope.test.js} | 0 ...oder_tracker_freshness_classifier.test.js} | 0 ...t.js => xchain_encoder_utxo_dedup.test.js} | 0 ...Encoder.test.js => xchain_encoder.test.js} | 0 105 files changed, 124 insertions(+), 25 deletions(-) create mode 100644 bin/pins/test-rename-map.json rename test/boundary/{change-address-boundaries.test.js => change_address_boundaries.test.js} (100%) rename test/boundary/{custom-output-boundaries.test.js => custom_output_boundaries.test.js} (100%) rename test/boundary/{data-payload-boundaries.test.js => data_payload_boundaries.test.js} (100%) rename test/boundary/{encoding-chunk-boundaries.test.js => encoding_chunk_boundaries.test.js} (100%) rename test/boundary/{fee-calculation-boundaries.test.js => fee_calculation_boundaries.test.js} (100%) rename test/boundary/{obfuscation-boundaries.test.js => obfuscation_boundaries.test.js} (100%) rename test/boundary/{txsize-estimator-boundaries.test.js => txsize_estimator_boundaries.test.js} (100%) rename test/boundary/{utxo-value-boundaries.test.js => utxo_value_boundaries.test.js} (100%) rename test/chaos/{api-layer.test.js => api_layer.test.js} (100%) rename test/chaos/{arithmetic-state.test.js => arithmetic_state.test.js} (100%) rename test/chaos/{input-corruption.test.js => input_corruption.test.js} (100%) rename test/chaos/{library-failures.test.js => library_failures.test.js} (100%) rename test/chaos/{network-failures.test.js => network_failures.test.js} (100%) rename test/chaos/{resource-exhaustion.test.js => resource_exhaustion.test.js} (89%) rename test/conformance/{roundtripConformance.test.js => roundtrip_conformance.test.js} (100%) rename test/conformance/{utxoRecordConformance.test.js => utxo_record_conformance.test.js} (100%) rename test/integration/{action-encoding.test.js => action_encoding.test.js} (100%) rename test/integration/{custom-outputs.test.js => custom_outputs.test.js} (100%) rename test/integration/{encoderStressSweep.test.js => encoder_stress_sweep.test.js} (100%) rename test/integration/{encoding-types.test.js => encoding_types.test.js} (100%) rename test/integration/{error-handling.test.js => error_handling.test.js} (100%) rename test/integration/{large-satoshi-tracker.test.js => large_satoshi_tracker.test.js} (100%) rename test/integration/{multi-chain.test.js => multi_chain.test.js} (100%) rename test/integration/{obfuscation-roundtrip.test.js => obfuscation_roundtrip.test.js} (100%) rename test/integration/{utxo-fee.test.js => utxo_fee.test.js} (99%) rename test/{prepareRegtest.test.js => prepare_regtest.test.js} (100%) rename test/regression/{reg-01-encoding-types.test.js => reg_01_encoding_types.test.js} (100%) rename test/regression/{reg-02-obfuscation.test.js => reg_02_obfuscation.test.js} (100%) rename test/regression/{reg-03-fee-utxo.test.js => reg_03_fee_utxo.test.js} (98%) rename test/regression/{reg-04-validator.test.js => reg_04_validator.test.js} (100%) rename test/regression/{reg-05-multi-chain.test.js => reg_05_multi_chain.test.js} (100%) rename test/regression/{reg-06-p2sh-p2wsh-sequence.test.js => reg_06_p2sh_p2wsh_sequence.test.js} (100%) rename test/regression/{reg-07-action-pipeline.test.js => reg_07_action_pipeline.test.js} (100%) rename test/regression/{reg-08-api-contract.test.js => reg_08_api_contract.test.js} (100%) rename test/regression/{reg-09-deepdive-fixes.test.js => reg_09_deepdive_fixes.test.js} (100%) rename test/regression/{reg-10-utxo-tracker-freshness.test.js => reg_10_utxo_tracker_freshness.test.js} (100%) rename test/regression/{reg-11-minimal-op-canonicalization.test.js => reg_11_minimal_op_canonicalization.test.js} (100%) rename test/regression/{reg-12-ins0-key-binding.test.js => reg_12_ins0_key_binding.test.js} (100%) rename test/regression/{reg-13-reservation-release-on-throw.test.js => reg_13_reservation_release_on_throw.test.js} (100%) rename test/regression/{reg-14-p2sh-reveal-fee-floor.test.js => reg_14_p2sh_reveal_fee_floor.test.js} (100%) rename test/regression/{reg-15-uppercase-txid-key-binding.test.js => reg_15_uppercase_txid_key_binding.test.js} (100%) rename test/regression/{reg-16-rawdata-only-warning.test.js => reg_16_rawdata_only_warning.test.js} (100%) rename test/regression/{reg-17-utxo-pagination-snapshot.test.js => reg_17_utxo_pagination_snapshot.test.js} (100%) rename test/regression/{reg-18-chained-send-reservation.test.js => reg_18_chained_send_reservation.test.js} (100%) rename test/regression/{reg-19-envelope-cancel-reservation.test.js => reg_19_envelope_cancel_reservation.test.js} (100%) rename test/regression/{reg-31-shortfall-names-reserved-inputs.test.js => reg_31_shortfall_names_reserved_inputs.test.js} (100%) rename test/security/{concurrency-gate.test.js => concurrency_gate.test.js} (99%) rename test/security/{input-validation.test.js => input_validation.test.js} (100%) rename test/security/{obfuscation-key.test.js => obfuscation_key.test.js} (100%) rename test/security/{payload-size.test.js => payload_size.test.js} (100%) rename test/security/{rate-limit-log.test.js => rate_limit_log.test.js} (99%) rename test/{setup.js => setup/index.js} (100%) rename test/unit/{ActionManifestConformance.test.js => action_manifest_conformance.test.js} (100%) rename test/unit/{applyBufferutilsPatch.test.js => apply_bufferutils_patch.test.js} (100%) rename test/unit/{BlockchainConnector.test.js => blockchain_connector.test.js} (100%) rename test/unit/{coins-conformance.test.js => coins_conformance.test.js} (100%) rename test/unit/{corsOrigin.test.js => cors_origin.test.js} (100%) rename test/unit/{corsPreflight.test.js => cors_preflight.test.js} (100%) rename test/unit/{coverage-thresholds-sync.test.js => coverage_thresholds_sync.test.js} (100%) rename test/unit/{crashHandlers.test.js => crash_handlers.test.js} (100%) rename test/unit/{CryptoNetworks.test.js => crypto_networks.test.js} (100%) rename test/unit/{docs-openrpc.test.js => docs_openrpc.test.js} (100%) rename test/unit/{encoderStressSweep.test.js => encoder_stress_sweep.test.js} (100%) rename test/unit/{envelopeRecognitionGate.test.js => envelope_recognition_gate.test.js} (100%) rename test/unit/{errorSanitize.test.js => error_sanitize.test.js} (100%) rename test/unit/{evmAdapter.test.js => evm_adapter.test.js} (100%) rename test/unit/{health-serve-readiness.test.js => health_serve_readiness.test.js} (100%) rename test/unit/{jsonrpc-body-guard.test.js => jsonrpc_body_guard.test.js} (100%) rename test/unit/{LargeSatoshiAmounts.test.js => large_satoshi_amounts.test.js} (100%) rename test/unit/{maintenanceWindow.test.js => maintenance_window.test.js} (100%) rename test/unit/{openrpc-coverage.test.js => openrpc_coverage.test.js} (100%) rename test/unit/{packageFeeSizing.test.js => package_fee_sizing.test.js} (100%) rename test/unit/security/configuration/{dependency-advisories.test.js => dependency_advisories.test.js} (100%) rename test/unit/{sibling-coverage.test.js => sibling_coverage.test.js} (100%) rename test/unit/{singleInstanceGuard.test.js => single_instance_guard.test.js} (100%) rename test/unit/{suggestedFeeCeiling.test.js => suggested_fee_ceiling.test.js} (99%) rename test/unit/{TxSizeEstimator.test.js => tx_size_estimator.test.js} (100%) rename test/unit/{UtxoTracker.test.js => utxo_tracker.test.js} (100%) rename test/unit/{XChainEncoder.autoEncoding.test.js => xchain_encoder_auto_encoding.test.js} (100%) rename test/unit/{XChainEncoder.consensusPinBoot.test.js => xchain_encoder_consensus_pin_boot.test.js} (96%) rename test/unit/{XChainEncoder.createTransaction.test.js => xchain_encoder_create_transaction.test.js} (99%) rename test/unit/{XChainEncoder.dataToPubkey.test.js => xchain_encoder_data_to_pubkey.test.js} (100%) rename test/unit/{XChainEncoder.exactInputs.test.js => xchain_encoder_exact_inputs.test.js} (100%) rename test/unit/{XChainEncoder.extra.test.js => xchain_encoder_extra.test.js} (100%) rename test/unit/{XChainEncoder.feeRateCap.test.js => xchain_encoder_fee_rate_cap.test.js} (100%) rename test/unit/{XChainEncoder.isSegwitUTXO.test.js => xchain_encoder_is_segwit_u_t_x_o.test.js} (100%) rename test/unit/{XChainEncoder.obfuscate.test.js => xchain_encoder_obfuscate.test.js} (100%) rename test/unit/{XChainEncoder.p2shRevealHeadroom.test.js => xchain_encoder_p2sh_reveal_headroom.test.js} (100%) rename test/unit/{XChainEncoder.prepareData.test.js => xchain_encoder_prepare_data.test.js} (100%) rename test/unit/{XChainEncoder.releaseReservations.test.js => xchain_encoder_release_reservations.test.js} (100%) rename test/unit/{XChainEncoder.revealFundingBinding.test.js => xchain_encoder_reveal_funding_binding.test.js} (100%) rename test/unit/{XChainEncoder.segwitInputGuard.test.js => xchain_encoder_segwit_input_guard.test.js} (100%) rename test/unit/{XChainEncoder.softDustFloor.test.js => xchain_encoder_soft_dust_floor.test.js} (100%) rename test/unit/{XChainEncoder.taprootEnvelope.test.js => xchain_encoder_taproot_envelope.test.js} (100%) rename test/unit/{XChainEncoder.trackerFreshnessClassifier.test.js => xchain_encoder_tracker_freshness_classifier.test.js} (100%) rename test/unit/{XChainEncoder.utxoDedup.test.js => xchain_encoder_utxo_dedup.test.js} (100%) rename test/{XChainEncoder.test.js => xchain_encoder.test.js} (100%) diff --git a/bin/coverage-thresholds.json b/bin/coverage-thresholds.json index ec6b8c2..864ff92 100644 --- a/bin/coverage-thresholds.json +++ b/bin/coverage-thresholds.json @@ -1,5 +1,5 @@ { - "comment": "Coverage floors for the CI coverage job (regression floors, ~1-1.5 points below measured, not tier targets; raise as coverage climbs). Mirrored into the coverage:check npm script in package.json and guarded by test/unit/coverage-thresholds-sync.test.js. Re-measured 2026-08-14 WITH the declared siblings checked out, which is what the coverage job now does: 87.77 lines/statements, 86.67 branches, 67.48 functions over 587 unit tests; the functions floor stays at its existing 66, already inside the band, and remains a holding line rather than a target.", + "comment": "Coverage floors for the CI coverage job (regression floors, ~1-1.5 points below measured, not tier targets; raise as coverage climbs). Mirrored into the coverage:check npm script in package.json and guarded by test/unit/coverage_thresholds_sync.test.js. Re-measured 2026-08-14 WITH the declared siblings checked out, which is what the coverage job now does: 87.77 lines/statements, 86.67 branches, 67.48 functions over 587 unit tests; the functions floor stays at its existing 66, already inside the band, and remains a holding line rather than a target.", "lines": 86.2, "statements": 86.2, "branches": 85.1, diff --git a/bin/pins/test-rename-map.json b/bin/pins/test-rename-map.json new file mode 100644 index 0000000..2c6f93f --- /dev/null +++ b/bin/pins/test-rename-map.json @@ -0,0 +1,99 @@ +{ + "test/XChainEncoder.test.js": "test/xchain_encoder.test.js", + "test/boundary/change-address-boundaries.test.js": "test/boundary/change_address_boundaries.test.js", + "test/boundary/custom-output-boundaries.test.js": "test/boundary/custom_output_boundaries.test.js", + "test/boundary/data-payload-boundaries.test.js": "test/boundary/data_payload_boundaries.test.js", + "test/boundary/encoding-chunk-boundaries.test.js": "test/boundary/encoding_chunk_boundaries.test.js", + "test/boundary/fee-calculation-boundaries.test.js": "test/boundary/fee_calculation_boundaries.test.js", + "test/boundary/obfuscation-boundaries.test.js": "test/boundary/obfuscation_boundaries.test.js", + "test/boundary/txsize-estimator-boundaries.test.js": "test/boundary/txsize_estimator_boundaries.test.js", + "test/boundary/utxo-value-boundaries.test.js": "test/boundary/utxo_value_boundaries.test.js", + "test/chaos/api-layer.test.js": "test/chaos/api_layer.test.js", + "test/chaos/arithmetic-state.test.js": "test/chaos/arithmetic_state.test.js", + "test/chaos/input-corruption.test.js": "test/chaos/input_corruption.test.js", + "test/chaos/library-failures.test.js": "test/chaos/library_failures.test.js", + "test/chaos/network-failures.test.js": "test/chaos/network_failures.test.js", + "test/chaos/resource-exhaustion.test.js": "test/chaos/resource_exhaustion.test.js", + "test/conformance/roundtripConformance.test.js": "test/conformance/roundtrip_conformance.test.js", + "test/conformance/utxoRecordConformance.test.js": "test/conformance/utxo_record_conformance.test.js", + "test/integration/action-encoding.test.js": "test/integration/action_encoding.test.js", + "test/integration/custom-outputs.test.js": "test/integration/custom_outputs.test.js", + "test/integration/encoderStressSweep.test.js": "test/integration/encoder_stress_sweep.test.js", + "test/integration/encoding-types.test.js": "test/integration/encoding_types.test.js", + "test/integration/error-handling.test.js": "test/integration/error_handling.test.js", + "test/integration/large-satoshi-tracker.test.js": "test/integration/large_satoshi_tracker.test.js", + "test/integration/multi-chain.test.js": "test/integration/multi_chain.test.js", + "test/integration/obfuscation-roundtrip.test.js": "test/integration/obfuscation_roundtrip.test.js", + "test/integration/utxo-fee.test.js": "test/integration/utxo_fee.test.js", + "test/prepareRegtest.test.js": "test/prepare_regtest.test.js", + "test/regression/reg-01-encoding-types.test.js": "test/regression/reg_01_encoding_types.test.js", + "test/regression/reg-02-obfuscation.test.js": "test/regression/reg_02_obfuscation.test.js", + "test/regression/reg-03-fee-utxo.test.js": "test/regression/reg_03_fee_utxo.test.js", + "test/regression/reg-04-validator.test.js": "test/regression/reg_04_validator.test.js", + "test/regression/reg-05-multi-chain.test.js": "test/regression/reg_05_multi_chain.test.js", + "test/regression/reg-06-p2sh-p2wsh-sequence.test.js": "test/regression/reg_06_p2sh_p2wsh_sequence.test.js", + "test/regression/reg-07-action-pipeline.test.js": "test/regression/reg_07_action_pipeline.test.js", + "test/regression/reg-08-api-contract.test.js": "test/regression/reg_08_api_contract.test.js", + "test/regression/reg-09-deepdive-fixes.test.js": "test/regression/reg_09_deepdive_fixes.test.js", + "test/regression/reg-10-utxo-tracker-freshness.test.js": "test/regression/reg_10_utxo_tracker_freshness.test.js", + "test/regression/reg-11-minimal-op-canonicalization.test.js": "test/regression/reg_11_minimal_op_canonicalization.test.js", + "test/regression/reg-12-ins0-key-binding.test.js": "test/regression/reg_12_ins0_key_binding.test.js", + "test/regression/reg-13-reservation-release-on-throw.test.js": "test/regression/reg_13_reservation_release_on_throw.test.js", + "test/regression/reg-14-p2sh-reveal-fee-floor.test.js": "test/regression/reg_14_p2sh_reveal_fee_floor.test.js", + "test/regression/reg-15-uppercase-txid-key-binding.test.js": "test/regression/reg_15_uppercase_txid_key_binding.test.js", + "test/regression/reg-16-rawdata-only-warning.test.js": "test/regression/reg_16_rawdata_only_warning.test.js", + "test/regression/reg-17-utxo-pagination-snapshot.test.js": "test/regression/reg_17_utxo_pagination_snapshot.test.js", + "test/regression/reg-18-chained-send-reservation.test.js": "test/regression/reg_18_chained_send_reservation.test.js", + "test/regression/reg-19-envelope-cancel-reservation.test.js": "test/regression/reg_19_envelope_cancel_reservation.test.js", + "test/regression/reg-31-shortfall-names-reserved-inputs.test.js": "test/regression/reg_31_shortfall_names_reserved_inputs.test.js", + "test/security/concurrency-gate.test.js": "test/security/concurrency_gate.test.js", + "test/security/input-validation.test.js": "test/security/input_validation.test.js", + "test/security/obfuscation-key.test.js": "test/security/obfuscation_key.test.js", + "test/security/payload-size.test.js": "test/security/payload_size.test.js", + "test/security/rate-limit-log.test.js": "test/security/rate_limit_log.test.js", + "test/setup.js": "test/setup/index.js", + "test/unit/ActionManifestConformance.test.js": "test/unit/action_manifest_conformance.test.js", + "test/unit/BlockchainConnector.test.js": "test/unit/blockchain_connector.test.js", + "test/unit/CryptoNetworks.test.js": "test/unit/crypto_networks.test.js", + "test/unit/LargeSatoshiAmounts.test.js": "test/unit/large_satoshi_amounts.test.js", + "test/unit/TxSizeEstimator.test.js": "test/unit/tx_size_estimator.test.js", + "test/unit/UtxoTracker.test.js": "test/unit/utxo_tracker.test.js", + "test/unit/XChainEncoder.autoEncoding.test.js": "test/unit/xchain_encoder_auto_encoding.test.js", + "test/unit/XChainEncoder.consensusPinBoot.test.js": "test/unit/xchain_encoder_consensus_pin_boot.test.js", + "test/unit/XChainEncoder.createTransaction.test.js": "test/unit/xchain_encoder_create_transaction.test.js", + "test/unit/XChainEncoder.dataToPubkey.test.js": "test/unit/xchain_encoder_data_to_pubkey.test.js", + "test/unit/XChainEncoder.exactInputs.test.js": "test/unit/xchain_encoder_exact_inputs.test.js", + "test/unit/XChainEncoder.extra.test.js": "test/unit/xchain_encoder_extra.test.js", + "test/unit/XChainEncoder.feeRateCap.test.js": "test/unit/xchain_encoder_fee_rate_cap.test.js", + "test/unit/XChainEncoder.isSegwitUTXO.test.js": "test/unit/xchain_encoder_is_segwit_u_t_x_o.test.js", + "test/unit/XChainEncoder.obfuscate.test.js": "test/unit/xchain_encoder_obfuscate.test.js", + "test/unit/XChainEncoder.p2shRevealHeadroom.test.js": "test/unit/xchain_encoder_p2sh_reveal_headroom.test.js", + "test/unit/XChainEncoder.prepareData.test.js": "test/unit/xchain_encoder_prepare_data.test.js", + "test/unit/XChainEncoder.releaseReservations.test.js": "test/unit/xchain_encoder_release_reservations.test.js", + "test/unit/XChainEncoder.revealFundingBinding.test.js": "test/unit/xchain_encoder_reveal_funding_binding.test.js", + "test/unit/XChainEncoder.segwitInputGuard.test.js": "test/unit/xchain_encoder_segwit_input_guard.test.js", + "test/unit/XChainEncoder.softDustFloor.test.js": "test/unit/xchain_encoder_soft_dust_floor.test.js", + "test/unit/XChainEncoder.taprootEnvelope.test.js": "test/unit/xchain_encoder_taproot_envelope.test.js", + "test/unit/XChainEncoder.trackerFreshnessClassifier.test.js": "test/unit/xchain_encoder_tracker_freshness_classifier.test.js", + "test/unit/XChainEncoder.utxoDedup.test.js": "test/unit/xchain_encoder_utxo_dedup.test.js", + "test/unit/applyBufferutilsPatch.test.js": "test/unit/apply_bufferutils_patch.test.js", + "test/unit/coins-conformance.test.js": "test/unit/coins_conformance.test.js", + "test/unit/corsOrigin.test.js": "test/unit/cors_origin.test.js", + "test/unit/corsPreflight.test.js": "test/unit/cors_preflight.test.js", + "test/unit/coverage-thresholds-sync.test.js": "test/unit/coverage_thresholds_sync.test.js", + "test/unit/crashHandlers.test.js": "test/unit/crash_handlers.test.js", + "test/unit/docs-openrpc.test.js": "test/unit/docs_openrpc.test.js", + "test/unit/encoderStressSweep.test.js": "test/unit/encoder_stress_sweep.test.js", + "test/unit/envelopeRecognitionGate.test.js": "test/unit/envelope_recognition_gate.test.js", + "test/unit/errorSanitize.test.js": "test/unit/error_sanitize.test.js", + "test/unit/evmAdapter.test.js": "test/unit/evm_adapter.test.js", + "test/unit/health-serve-readiness.test.js": "test/unit/health_serve_readiness.test.js", + "test/unit/jsonrpc-body-guard.test.js": "test/unit/jsonrpc_body_guard.test.js", + "test/unit/maintenanceWindow.test.js": "test/unit/maintenance_window.test.js", + "test/unit/openrpc-coverage.test.js": "test/unit/openrpc_coverage.test.js", + "test/unit/packageFeeSizing.test.js": "test/unit/package_fee_sizing.test.js", + "test/unit/security/configuration/dependency-advisories.test.js": "test/unit/security/configuration/dependency_advisories.test.js", + "test/unit/sibling-coverage.test.js": "test/unit/sibling_coverage.test.js", + "test/unit/singleInstanceGuard.test.js": "test/unit/single_instance_guard.test.js", + "test/unit/suggestedFeeCeiling.test.js": "test/unit/suggested_fee_ceiling.test.js" +} \ No newline at end of file diff --git a/package.json b/package.json index c5ff7e9..b067bfa 100644 --- a/package.json +++ b/package.json @@ -39,8 +39,8 @@ "api": "node ./src/api.js", "lint": "eslint .", "smoke-test": "npx mocha --timeout 10000 'test/smoke/**/*.test.js'", - "test": "npx mocha --timeout 10000 --require ./test/setup.js 'test/unit/**/*.test.js' --recursive --exit", - "test:regtest": "mocha --timeout 0 --require ./test/prepareRegtest.test.js", + "test": "npx mocha --timeout 10000 --require ./test/setup/index.js 'test/unit/**/*.test.js' --recursive --exit", + "test:regtest": "mocha --timeout 0 --require ./test/prepare_regtest.test.js", "coverage": "c8 --reporter=text --reporter=html --include 'src/**/*.js' mocha --timeout 10000 'test/unit/**/*.test.js' --exit", "coverage:check": "c8 --check-coverage --lines 86.2 --statements 86.2 --branches 85.1 --functions 66 --reporter=text-summary --include 'src/**/*.js' mocha --timeout 10000 'test/unit/**/*.test.js' --exit", "ci": "npx mocha --timeout 10000 'test/unit/**/*.test.js' --recursive --exit && npm run ci:security && npm run ci:regression && npm run ci:e2e && npm run ci:conformance", diff --git a/src/api.js b/src/api.js index 99ca794..308a09c 100644 --- a/src/api.js +++ b/src/api.js @@ -153,7 +153,7 @@ app.use(helmet()); // same order is what lets a browser read the gate 401 and the limiter/gate 429s // rather than an opaque network error. Trade: a preflight skips the limiter and // both gates for a 204 that does no upstream work, as in every sibling service. -// Ordering pinned by test/unit/corsPreflight.test.js. +// Ordering pinned by test/unit/cors_preflight.test.js. app.use(cors({ origin: parseCorsOrigin(CORS_ORIGIN) })); // 3mb (was 1mb): the TAPROOT envelope raises the largest legitimate request @@ -563,7 +563,7 @@ app.get('/status', probeGate.hold(async (req, res) => { })) // Machine-readable API spec (OpenRPC 1.3.2). Regenerated by docs/openrpc.build.js; -// test/unit/openrpc-coverage.test.js keeps it in lockstep with jsonRpcController. +// test/unit/openrpc_coverage.test.js keeps it in lockstep with jsonRpcController. let openrpcSpec = null app.get('/openrpc.json', (req, res) => { if (!openrpcSpec) diff --git a/src/apply_bufferutils_patch.js b/src/apply_bufferutils_patch.js index de0419c..ca1164f 100644 --- a/src/apply_bufferutils_patch.js +++ b/src/apply_bufferutils_patch.js @@ -38,7 +38,7 @@ * * Re-sync seam (uuid:311476c7). Everything below this banner is pinned * byte-identical to the SDK copy's body by - * test/unit/applyBufferutilsPatch.test.js. Convergence runs SDK-ward: change + * test/unit/apply_bufferutils_patch.test.js. Convergence runs SDK-ward: change * the SDK copy first, then copy its body here. Only the two headers differ, * because each names the other and its own read-side relationship. * diff --git a/src/validator.js b/src/validator.js index b23e09d..e334520 100644 --- a/src/validator.js +++ b/src/validator.js @@ -135,7 +135,7 @@ const COMPRESSED_PUBKEY_RE = /^(02|03)[0-9a-fA-F]{64}$/ // this set, after alias expansion, silently no-actioning the tx); the encoder // had no equivalent gate, so a typoed or too-new ACTION name would encode, pay // fees, and broadcast, then vanish silently at decode. Pinned against drift by -// test/unit/ActionManifestConformance.test.js, mirroring the decoder's own +// test/unit/action_manifest_conformance.test.js, mirroring the decoder's own // conformance guard. const VALID_ACTION_NAMES = new Set([ 'ADDRESS', 'AIRDROP', 'ANCHOR', 'ATTEST', diff --git a/test/boundary/change-address-boundaries.test.js b/test/boundary/change_address_boundaries.test.js similarity index 100% rename from test/boundary/change-address-boundaries.test.js rename to test/boundary/change_address_boundaries.test.js diff --git a/test/boundary/custom-output-boundaries.test.js b/test/boundary/custom_output_boundaries.test.js similarity index 100% rename from test/boundary/custom-output-boundaries.test.js rename to test/boundary/custom_output_boundaries.test.js diff --git a/test/boundary/data-payload-boundaries.test.js b/test/boundary/data_payload_boundaries.test.js similarity index 100% rename from test/boundary/data-payload-boundaries.test.js rename to test/boundary/data_payload_boundaries.test.js diff --git a/test/boundary/encoding-chunk-boundaries.test.js b/test/boundary/encoding_chunk_boundaries.test.js similarity index 100% rename from test/boundary/encoding-chunk-boundaries.test.js rename to test/boundary/encoding_chunk_boundaries.test.js diff --git a/test/boundary/fee-calculation-boundaries.test.js b/test/boundary/fee_calculation_boundaries.test.js similarity index 100% rename from test/boundary/fee-calculation-boundaries.test.js rename to test/boundary/fee_calculation_boundaries.test.js diff --git a/test/boundary/obfuscation-boundaries.test.js b/test/boundary/obfuscation_boundaries.test.js similarity index 100% rename from test/boundary/obfuscation-boundaries.test.js rename to test/boundary/obfuscation_boundaries.test.js diff --git a/test/boundary/txsize-estimator-boundaries.test.js b/test/boundary/txsize_estimator_boundaries.test.js similarity index 100% rename from test/boundary/txsize-estimator-boundaries.test.js rename to test/boundary/txsize_estimator_boundaries.test.js diff --git a/test/boundary/utxo-value-boundaries.test.js b/test/boundary/utxo_value_boundaries.test.js similarity index 100% rename from test/boundary/utxo-value-boundaries.test.js rename to test/boundary/utxo_value_boundaries.test.js diff --git a/test/chaos/api-layer.test.js b/test/chaos/api_layer.test.js similarity index 100% rename from test/chaos/api-layer.test.js rename to test/chaos/api_layer.test.js diff --git a/test/chaos/arithmetic-state.test.js b/test/chaos/arithmetic_state.test.js similarity index 100% rename from test/chaos/arithmetic-state.test.js rename to test/chaos/arithmetic_state.test.js diff --git a/test/chaos/input-corruption.test.js b/test/chaos/input_corruption.test.js similarity index 100% rename from test/chaos/input-corruption.test.js rename to test/chaos/input_corruption.test.js diff --git a/test/chaos/library-failures.test.js b/test/chaos/library_failures.test.js similarity index 100% rename from test/chaos/library-failures.test.js rename to test/chaos/library_failures.test.js diff --git a/test/chaos/network-failures.test.js b/test/chaos/network_failures.test.js similarity index 100% rename from test/chaos/network-failures.test.js rename to test/chaos/network_failures.test.js diff --git a/test/chaos/resource-exhaustion.test.js b/test/chaos/resource_exhaustion.test.js similarity index 89% rename from test/chaos/resource-exhaustion.test.js rename to test/chaos/resource_exhaustion.test.js index 8a5fd7f..cb8a327 100644 --- a/test/chaos/resource-exhaustion.test.js +++ b/test/chaos/resource_exhaustion.test.js @@ -37,14 +37,14 @@ describe('Chaos Category E: Resource Exhaustion', () => { const encoder = makeEncoder(BTC) const utxo = makeUtxo(BTC, TXID_A, 0, 1000000000) - // This test measures encoding time, not fee policy. It used to pass a - // flat 100000-sat fee and switch off the relative fee-rate cap - // (maxFeeRateMultiplier = null) to get it past. That knob no longer - // covers it: the absolute burn backstop is deliberately unconditional, - // precisely so disabling the rate cap cannot open a drain, and 100000 sat - // is over 100x the fair fee for this ~900-byte funding tx. Pay a fee that - // a real caller could pay instead of disarming the guard. Fee policy has - // its own suite (XChainEncoder.feeRateCap.test.js). + // This test measures encoding time, not fee policy, so it pays a fee a + // real caller could pay. A flat 100000-sat fee with the relative fee-rate + // cap switched off (maxFeeRateMultiplier = null) does not get it past: the + // absolute burn backstop is deliberately unconditional, precisely so + // disabling the rate cap cannot open a drain, and 100000 sat is over 100x + // the fair fee for this ~900-byte funding tx. The fee stays one a real + // caller could pay instead of disarming the guard. Fee policy has its own + // suite (xchain_encoder_fee_rate_cap.test.js). const start = Date.now() const result = await encoder.createTransaction( [utxo], BTC_ADDR, null, diff --git a/test/conformance/roundtripConformance.test.js b/test/conformance/roundtrip_conformance.test.js similarity index 100% rename from test/conformance/roundtripConformance.test.js rename to test/conformance/roundtrip_conformance.test.js diff --git a/test/conformance/utxoRecordConformance.test.js b/test/conformance/utxo_record_conformance.test.js similarity index 100% rename from test/conformance/utxoRecordConformance.test.js rename to test/conformance/utxo_record_conformance.test.js diff --git a/test/integration/action-encoding.test.js b/test/integration/action_encoding.test.js similarity index 100% rename from test/integration/action-encoding.test.js rename to test/integration/action_encoding.test.js diff --git a/test/integration/custom-outputs.test.js b/test/integration/custom_outputs.test.js similarity index 100% rename from test/integration/custom-outputs.test.js rename to test/integration/custom_outputs.test.js diff --git a/test/integration/encoderStressSweep.test.js b/test/integration/encoder_stress_sweep.test.js similarity index 100% rename from test/integration/encoderStressSweep.test.js rename to test/integration/encoder_stress_sweep.test.js diff --git a/test/integration/encoding-types.test.js b/test/integration/encoding_types.test.js similarity index 100% rename from test/integration/encoding-types.test.js rename to test/integration/encoding_types.test.js diff --git a/test/integration/error-handling.test.js b/test/integration/error_handling.test.js similarity index 100% rename from test/integration/error-handling.test.js rename to test/integration/error_handling.test.js diff --git a/test/integration/helpers/utxoFactory.js b/test/integration/helpers/utxoFactory.js index 848daeb..b0eafdc 100644 --- a/test/integration/helpers/utxoFactory.js +++ b/test/integration/helpers/utxoFactory.js @@ -27,7 +27,7 @@ const PUBKEY_BUF = Buffer.from( ) // The producer contract these fixtures model, pinned byte-for-byte by -// test/conformance/utxoRecordConformance.test.js. Read it rather than +// test/conformance/utxo_record_conformance.test.js. Read it rather than // hand-copying the numbers, so the doubles and the conformance tier cannot // describe two different producers (the drift that suite's header names). const CONFORMANCE = require('../../fixtures/utxo-record-conformance.json') diff --git a/test/integration/large-satoshi-tracker.test.js b/test/integration/large_satoshi_tracker.test.js similarity index 100% rename from test/integration/large-satoshi-tracker.test.js rename to test/integration/large_satoshi_tracker.test.js diff --git a/test/integration/multi-chain.test.js b/test/integration/multi_chain.test.js similarity index 100% rename from test/integration/multi-chain.test.js rename to test/integration/multi_chain.test.js diff --git a/test/integration/obfuscation-roundtrip.test.js b/test/integration/obfuscation_roundtrip.test.js similarity index 100% rename from test/integration/obfuscation-roundtrip.test.js rename to test/integration/obfuscation_roundtrip.test.js diff --git a/test/integration/utxo-fee.test.js b/test/integration/utxo_fee.test.js similarity index 99% rename from test/integration/utxo-fee.test.js rename to test/integration/utxo_fee.test.js index 762b3e9..3df6d8c 100644 --- a/test/integration/utxo-fee.test.js +++ b/test/integration/utxo_fee.test.js @@ -40,7 +40,7 @@ const actions = require('./helpers/actionFactory') // 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the // "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the // semantics these assertions actually test. (DOGE's high-dust floor is covered -// in test/boundary/fee-calculation-boundaries.test.js.) +// in test/boundary/fee_calculation_boundaries.test.js.) const NETWORK = 'bitcoin-regtest' describe('Category D: UTXO & Fee Integration', () => { diff --git a/test/prepareRegtest.test.js b/test/prepare_regtest.test.js similarity index 100% rename from test/prepareRegtest.test.js rename to test/prepare_regtest.test.js diff --git a/test/regression/reg-01-encoding-types.test.js b/test/regression/reg_01_encoding_types.test.js similarity index 100% rename from test/regression/reg-01-encoding-types.test.js rename to test/regression/reg_01_encoding_types.test.js diff --git a/test/regression/reg-02-obfuscation.test.js b/test/regression/reg_02_obfuscation.test.js similarity index 100% rename from test/regression/reg-02-obfuscation.test.js rename to test/regression/reg_02_obfuscation.test.js diff --git a/test/regression/reg-03-fee-utxo.test.js b/test/regression/reg_03_fee_utxo.test.js similarity index 98% rename from test/regression/reg-03-fee-utxo.test.js rename to test/regression/reg_03_fee_utxo.test.js index 43b6ad0..1c9c34d 100644 --- a/test/regression/reg-03-fee-utxo.test.js +++ b/test/regression/reg_03_fee_utxo.test.js @@ -83,7 +83,7 @@ describe('REG-03: Fee & UTXO Selection', function () { it('adds UTXOs until inputs cover outputs + fee', async function () { const encoder = makeEncoder(NETWORK) // The oversized explicit fee forces multi-UTXO selection; it would trip - // the relative fee-rate cap (tested in XChainEncoder.feeRateCap.test.js). + // the relative fee-rate cap (tested in xchain_encoder_fee_rate_cap.test.js). encoder.maxFeeRateMultiplier = null const address = getTestAddress(NETWORK) const action = actions.makeSend() @@ -174,7 +174,7 @@ describe('REG-03: Fee & UTXO Selection', function () { it('explicit fee parameter is used verbatim', async function () { const encoder = makeEncoder(NETWORK) // This fee sits above the relative fee-rate cap by design; the cap has - // its own suite (XChainEncoder.feeRateCap.test.js), disable it here. + // its own suite (xchain_encoder_fee_rate_cap.test.js), disable it here. encoder.maxFeeRateMultiplier = null const address = getTestAddress(NETWORK) const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) diff --git a/test/regression/reg-04-validator.test.js b/test/regression/reg_04_validator.test.js similarity index 100% rename from test/regression/reg-04-validator.test.js rename to test/regression/reg_04_validator.test.js diff --git a/test/regression/reg-05-multi-chain.test.js b/test/regression/reg_05_multi_chain.test.js similarity index 100% rename from test/regression/reg-05-multi-chain.test.js rename to test/regression/reg_05_multi_chain.test.js diff --git a/test/regression/reg-06-p2sh-p2wsh-sequence.test.js b/test/regression/reg_06_p2sh_p2wsh_sequence.test.js similarity index 100% rename from test/regression/reg-06-p2sh-p2wsh-sequence.test.js rename to test/regression/reg_06_p2sh_p2wsh_sequence.test.js diff --git a/test/regression/reg-07-action-pipeline.test.js b/test/regression/reg_07_action_pipeline.test.js similarity index 100% rename from test/regression/reg-07-action-pipeline.test.js rename to test/regression/reg_07_action_pipeline.test.js diff --git a/test/regression/reg-08-api-contract.test.js b/test/regression/reg_08_api_contract.test.js similarity index 100% rename from test/regression/reg-08-api-contract.test.js rename to test/regression/reg_08_api_contract.test.js diff --git a/test/regression/reg-09-deepdive-fixes.test.js b/test/regression/reg_09_deepdive_fixes.test.js similarity index 100% rename from test/regression/reg-09-deepdive-fixes.test.js rename to test/regression/reg_09_deepdive_fixes.test.js diff --git a/test/regression/reg-10-utxo-tracker-freshness.test.js b/test/regression/reg_10_utxo_tracker_freshness.test.js similarity index 100% rename from test/regression/reg-10-utxo-tracker-freshness.test.js rename to test/regression/reg_10_utxo_tracker_freshness.test.js diff --git a/test/regression/reg-11-minimal-op-canonicalization.test.js b/test/regression/reg_11_minimal_op_canonicalization.test.js similarity index 100% rename from test/regression/reg-11-minimal-op-canonicalization.test.js rename to test/regression/reg_11_minimal_op_canonicalization.test.js diff --git a/test/regression/reg-12-ins0-key-binding.test.js b/test/regression/reg_12_ins0_key_binding.test.js similarity index 100% rename from test/regression/reg-12-ins0-key-binding.test.js rename to test/regression/reg_12_ins0_key_binding.test.js diff --git a/test/regression/reg-13-reservation-release-on-throw.test.js b/test/regression/reg_13_reservation_release_on_throw.test.js similarity index 100% rename from test/regression/reg-13-reservation-release-on-throw.test.js rename to test/regression/reg_13_reservation_release_on_throw.test.js diff --git a/test/regression/reg-14-p2sh-reveal-fee-floor.test.js b/test/regression/reg_14_p2sh_reveal_fee_floor.test.js similarity index 100% rename from test/regression/reg-14-p2sh-reveal-fee-floor.test.js rename to test/regression/reg_14_p2sh_reveal_fee_floor.test.js diff --git a/test/regression/reg-15-uppercase-txid-key-binding.test.js b/test/regression/reg_15_uppercase_txid_key_binding.test.js similarity index 100% rename from test/regression/reg-15-uppercase-txid-key-binding.test.js rename to test/regression/reg_15_uppercase_txid_key_binding.test.js diff --git a/test/regression/reg-16-rawdata-only-warning.test.js b/test/regression/reg_16_rawdata_only_warning.test.js similarity index 100% rename from test/regression/reg-16-rawdata-only-warning.test.js rename to test/regression/reg_16_rawdata_only_warning.test.js diff --git a/test/regression/reg-17-utxo-pagination-snapshot.test.js b/test/regression/reg_17_utxo_pagination_snapshot.test.js similarity index 100% rename from test/regression/reg-17-utxo-pagination-snapshot.test.js rename to test/regression/reg_17_utxo_pagination_snapshot.test.js diff --git a/test/regression/reg-18-chained-send-reservation.test.js b/test/regression/reg_18_chained_send_reservation.test.js similarity index 100% rename from test/regression/reg-18-chained-send-reservation.test.js rename to test/regression/reg_18_chained_send_reservation.test.js diff --git a/test/regression/reg-19-envelope-cancel-reservation.test.js b/test/regression/reg_19_envelope_cancel_reservation.test.js similarity index 100% rename from test/regression/reg-19-envelope-cancel-reservation.test.js rename to test/regression/reg_19_envelope_cancel_reservation.test.js diff --git a/test/regression/reg-31-shortfall-names-reserved-inputs.test.js b/test/regression/reg_31_shortfall_names_reserved_inputs.test.js similarity index 100% rename from test/regression/reg-31-shortfall-names-reserved-inputs.test.js rename to test/regression/reg_31_shortfall_names_reserved_inputs.test.js diff --git a/test/security/concurrency-gate.test.js b/test/security/concurrency_gate.test.js similarity index 99% rename from test/security/concurrency-gate.test.js rename to test/security/concurrency_gate.test.js index c2b6ee8..d5614e5 100644 --- a/test/security/concurrency-gate.test.js +++ b/test/security/concurrency_gate.test.js @@ -20,7 +20,7 @@ * IP per request, so a shed can only come from the global cap - the per-IP * limiter is mounted alongside at its production default and never fires. * - * Run: mocha test/security/concurrency-gate.test.js --timeout 30000 + * Run: mocha test/security/concurrency_gate.test.js --timeout 30000 */ 'use strict' diff --git a/test/security/input-validation.test.js b/test/security/input_validation.test.js similarity index 100% rename from test/security/input-validation.test.js rename to test/security/input_validation.test.js diff --git a/test/security/obfuscation-key.test.js b/test/security/obfuscation_key.test.js similarity index 100% rename from test/security/obfuscation-key.test.js rename to test/security/obfuscation_key.test.js diff --git a/test/security/payload-size.test.js b/test/security/payload_size.test.js similarity index 100% rename from test/security/payload-size.test.js rename to test/security/payload_size.test.js diff --git a/test/security/rate-limit-log.test.js b/test/security/rate_limit_log.test.js similarity index 99% rename from test/security/rate-limit-log.test.js rename to test/security/rate_limit_log.test.js index fd5381d..8e6bd1b 100644 --- a/test/security/rate-limit-log.test.js +++ b/test/security/rate_limit_log.test.js @@ -21,7 +21,7 @@ * source check pins src/api.js's limiter to this handler rather than a * bare `message:` option. * - * Run: mocha test/security/rate-limit-log.test.js --timeout 30000 + * Run: mocha test/security/rate_limit_log.test.js --timeout 30000 */ 'use strict' diff --git a/test/setup.js b/test/setup/index.js similarity index 100% rename from test/setup.js rename to test/setup/index.js diff --git a/test/smoke/smoke.test.js b/test/smoke/smoke.test.js index 8b23829..d81a007 100644 --- a/test/smoke/smoke.test.js +++ b/test/smoke/smoke.test.js @@ -316,7 +316,7 @@ describe('S7: TxSizeEstimator', () => { // multisig). The old 111 here came from a 102-byte script figure that // omitted the three push opcodes; 99ebc78 corrected the estimator and this // smoke assertion was left on the undercount. The derivation lives in - // test/unit/TxSizeEstimator.test.js, which builds a real p2ms script. + // test/unit/tx_size_estimator.test.js, which builds a real p2ms script. const size = TxSizeEstimator.estimateMultisignOutput() assert.ok(Number.isInteger(size)) assert.strictEqual(size, 114) diff --git a/test/unit/ActionManifestConformance.test.js b/test/unit/action_manifest_conformance.test.js similarity index 100% rename from test/unit/ActionManifestConformance.test.js rename to test/unit/action_manifest_conformance.test.js diff --git a/test/unit/applyBufferutilsPatch.test.js b/test/unit/apply_bufferutils_patch.test.js similarity index 100% rename from test/unit/applyBufferutilsPatch.test.js rename to test/unit/apply_bufferutils_patch.test.js diff --git a/test/unit/BlockchainConnector.test.js b/test/unit/blockchain_connector.test.js similarity index 100% rename from test/unit/BlockchainConnector.test.js rename to test/unit/blockchain_connector.test.js diff --git a/test/unit/coins-conformance.test.js b/test/unit/coins_conformance.test.js similarity index 100% rename from test/unit/coins-conformance.test.js rename to test/unit/coins_conformance.test.js diff --git a/test/unit/corsOrigin.test.js b/test/unit/cors_origin.test.js similarity index 100% rename from test/unit/corsOrigin.test.js rename to test/unit/cors_origin.test.js diff --git a/test/unit/corsPreflight.test.js b/test/unit/cors_preflight.test.js similarity index 100% rename from test/unit/corsPreflight.test.js rename to test/unit/cors_preflight.test.js diff --git a/test/unit/coverage-thresholds-sync.test.js b/test/unit/coverage_thresholds_sync.test.js similarity index 100% rename from test/unit/coverage-thresholds-sync.test.js rename to test/unit/coverage_thresholds_sync.test.js diff --git a/test/unit/crashHandlers.test.js b/test/unit/crash_handlers.test.js similarity index 100% rename from test/unit/crashHandlers.test.js rename to test/unit/crash_handlers.test.js diff --git a/test/unit/CryptoNetworks.test.js b/test/unit/crypto_networks.test.js similarity index 100% rename from test/unit/CryptoNetworks.test.js rename to test/unit/crypto_networks.test.js diff --git a/test/unit/docs-openrpc.test.js b/test/unit/docs_openrpc.test.js similarity index 100% rename from test/unit/docs-openrpc.test.js rename to test/unit/docs_openrpc.test.js diff --git a/test/unit/encoderStressSweep.test.js b/test/unit/encoder_stress_sweep.test.js similarity index 100% rename from test/unit/encoderStressSweep.test.js rename to test/unit/encoder_stress_sweep.test.js diff --git a/test/unit/envelopeRecognitionGate.test.js b/test/unit/envelope_recognition_gate.test.js similarity index 100% rename from test/unit/envelopeRecognitionGate.test.js rename to test/unit/envelope_recognition_gate.test.js diff --git a/test/unit/errorSanitize.test.js b/test/unit/error_sanitize.test.js similarity index 100% rename from test/unit/errorSanitize.test.js rename to test/unit/error_sanitize.test.js diff --git a/test/unit/evmAdapter.test.js b/test/unit/evm_adapter.test.js similarity index 100% rename from test/unit/evmAdapter.test.js rename to test/unit/evm_adapter.test.js diff --git a/test/unit/health-serve-readiness.test.js b/test/unit/health_serve_readiness.test.js similarity index 100% rename from test/unit/health-serve-readiness.test.js rename to test/unit/health_serve_readiness.test.js diff --git a/test/unit/jsonrpc-body-guard.test.js b/test/unit/jsonrpc_body_guard.test.js similarity index 100% rename from test/unit/jsonrpc-body-guard.test.js rename to test/unit/jsonrpc_body_guard.test.js diff --git a/test/unit/LargeSatoshiAmounts.test.js b/test/unit/large_satoshi_amounts.test.js similarity index 100% rename from test/unit/LargeSatoshiAmounts.test.js rename to test/unit/large_satoshi_amounts.test.js diff --git a/test/unit/maintenanceWindow.test.js b/test/unit/maintenance_window.test.js similarity index 100% rename from test/unit/maintenanceWindow.test.js rename to test/unit/maintenance_window.test.js diff --git a/test/unit/openrpc-coverage.test.js b/test/unit/openrpc_coverage.test.js similarity index 100% rename from test/unit/openrpc-coverage.test.js rename to test/unit/openrpc_coverage.test.js diff --git a/test/unit/packageFeeSizing.test.js b/test/unit/package_fee_sizing.test.js similarity index 100% rename from test/unit/packageFeeSizing.test.js rename to test/unit/package_fee_sizing.test.js diff --git a/test/unit/security/configuration/dependency-advisories.test.js b/test/unit/security/configuration/dependency_advisories.test.js similarity index 100% rename from test/unit/security/configuration/dependency-advisories.test.js rename to test/unit/security/configuration/dependency_advisories.test.js diff --git a/test/unit/sibling-coverage.test.js b/test/unit/sibling_coverage.test.js similarity index 100% rename from test/unit/sibling-coverage.test.js rename to test/unit/sibling_coverage.test.js diff --git a/test/unit/singleInstanceGuard.test.js b/test/unit/single_instance_guard.test.js similarity index 100% rename from test/unit/singleInstanceGuard.test.js rename to test/unit/single_instance_guard.test.js diff --git a/test/unit/suggestedFeeCeiling.test.js b/test/unit/suggested_fee_ceiling.test.js similarity index 99% rename from test/unit/suggestedFeeCeiling.test.js rename to test/unit/suggested_fee_ceiling.test.js index 477f5aa..6ba4733 100644 --- a/test/unit/suggestedFeeCeiling.test.js +++ b/test/unit/suggested_fee_ceiling.test.js @@ -9,7 +9,7 @@ * General Public License v3.0 or later; see LICENSE.md. * ********************************************************************** - * test/unit/suggestedFeeCeiling.test.js + * test/unit/suggested_fee_ceiling.test.js * * The suggested-rate ceiling for a caller that supplies no fee. * diff --git a/test/unit/TxSizeEstimator.test.js b/test/unit/tx_size_estimator.test.js similarity index 100% rename from test/unit/TxSizeEstimator.test.js rename to test/unit/tx_size_estimator.test.js diff --git a/test/unit/UtxoTracker.test.js b/test/unit/utxo_tracker.test.js similarity index 100% rename from test/unit/UtxoTracker.test.js rename to test/unit/utxo_tracker.test.js diff --git a/test/unit/XChainEncoder.autoEncoding.test.js b/test/unit/xchain_encoder_auto_encoding.test.js similarity index 100% rename from test/unit/XChainEncoder.autoEncoding.test.js rename to test/unit/xchain_encoder_auto_encoding.test.js diff --git a/test/unit/XChainEncoder.consensusPinBoot.test.js b/test/unit/xchain_encoder_consensus_pin_boot.test.js similarity index 96% rename from test/unit/XChainEncoder.consensusPinBoot.test.js rename to test/unit/xchain_encoder_consensus_pin_boot.test.js index ce9a2ea..56566e2 100644 --- a/test/unit/XChainEncoder.consensusPinBoot.test.js +++ b/test/unit/xchain_encoder_consensus_pin_boot.test.js @@ -13,7 +13,7 @@ // Boot guard: the encoder vendors the consensus-critical coin registry and reads // dustThreshold / supportsSegwit / address prefixes out of it, so it must run the // same fail-closed pin check every sibling consumer runs (decoder, indexer, hub, -// utxo-tracker). coins-conformance.test.js catches commit-time drift only; it +// utxo-tracker). coins_conformance.test.js catches commit-time drift only; it // cannot catch a stale deployed image, which is what this guard is for. const assert = require('assert'); diff --git a/test/unit/XChainEncoder.createTransaction.test.js b/test/unit/xchain_encoder_create_transaction.test.js similarity index 99% rename from test/unit/XChainEncoder.createTransaction.test.js rename to test/unit/xchain_encoder_create_transaction.test.js index 95a317f..efed6a1 100644 --- a/test/unit/XChainEncoder.createTransaction.test.js +++ b/test/unit/xchain_encoder_create_transaction.test.js @@ -283,7 +283,7 @@ describe('XChainEncoder.createTransaction()', () => { describe('fee handling', () => { it('uses custom fee when provided', async () => { const encoder = makeEncoder() - // The relative fee-rate cap has its own suite (XChainEncoder.feeRateCap.test.js) + // The relative fee-rate cap has its own suite (xchain_encoder_fee_rate_cap.test.js) // and the absolute burn backstop has its own test below; disable/isolate // both here so only the passthrough behaviour is under test. encoder.maxFeeRateMultiplier = null diff --git a/test/unit/XChainEncoder.dataToPubkey.test.js b/test/unit/xchain_encoder_data_to_pubkey.test.js similarity index 100% rename from test/unit/XChainEncoder.dataToPubkey.test.js rename to test/unit/xchain_encoder_data_to_pubkey.test.js diff --git a/test/unit/XChainEncoder.exactInputs.test.js b/test/unit/xchain_encoder_exact_inputs.test.js similarity index 100% rename from test/unit/XChainEncoder.exactInputs.test.js rename to test/unit/xchain_encoder_exact_inputs.test.js diff --git a/test/unit/XChainEncoder.extra.test.js b/test/unit/xchain_encoder_extra.test.js similarity index 100% rename from test/unit/XChainEncoder.extra.test.js rename to test/unit/xchain_encoder_extra.test.js diff --git a/test/unit/XChainEncoder.feeRateCap.test.js b/test/unit/xchain_encoder_fee_rate_cap.test.js similarity index 100% rename from test/unit/XChainEncoder.feeRateCap.test.js rename to test/unit/xchain_encoder_fee_rate_cap.test.js diff --git a/test/unit/XChainEncoder.isSegwitUTXO.test.js b/test/unit/xchain_encoder_is_segwit_u_t_x_o.test.js similarity index 100% rename from test/unit/XChainEncoder.isSegwitUTXO.test.js rename to test/unit/xchain_encoder_is_segwit_u_t_x_o.test.js diff --git a/test/unit/XChainEncoder.obfuscate.test.js b/test/unit/xchain_encoder_obfuscate.test.js similarity index 100% rename from test/unit/XChainEncoder.obfuscate.test.js rename to test/unit/xchain_encoder_obfuscate.test.js diff --git a/test/unit/XChainEncoder.p2shRevealHeadroom.test.js b/test/unit/xchain_encoder_p2sh_reveal_headroom.test.js similarity index 100% rename from test/unit/XChainEncoder.p2shRevealHeadroom.test.js rename to test/unit/xchain_encoder_p2sh_reveal_headroom.test.js diff --git a/test/unit/XChainEncoder.prepareData.test.js b/test/unit/xchain_encoder_prepare_data.test.js similarity index 100% rename from test/unit/XChainEncoder.prepareData.test.js rename to test/unit/xchain_encoder_prepare_data.test.js diff --git a/test/unit/XChainEncoder.releaseReservations.test.js b/test/unit/xchain_encoder_release_reservations.test.js similarity index 100% rename from test/unit/XChainEncoder.releaseReservations.test.js rename to test/unit/xchain_encoder_release_reservations.test.js diff --git a/test/unit/XChainEncoder.revealFundingBinding.test.js b/test/unit/xchain_encoder_reveal_funding_binding.test.js similarity index 100% rename from test/unit/XChainEncoder.revealFundingBinding.test.js rename to test/unit/xchain_encoder_reveal_funding_binding.test.js diff --git a/test/unit/XChainEncoder.segwitInputGuard.test.js b/test/unit/xchain_encoder_segwit_input_guard.test.js similarity index 100% rename from test/unit/XChainEncoder.segwitInputGuard.test.js rename to test/unit/xchain_encoder_segwit_input_guard.test.js diff --git a/test/unit/XChainEncoder.softDustFloor.test.js b/test/unit/xchain_encoder_soft_dust_floor.test.js similarity index 100% rename from test/unit/XChainEncoder.softDustFloor.test.js rename to test/unit/xchain_encoder_soft_dust_floor.test.js diff --git a/test/unit/XChainEncoder.taprootEnvelope.test.js b/test/unit/xchain_encoder_taproot_envelope.test.js similarity index 100% rename from test/unit/XChainEncoder.taprootEnvelope.test.js rename to test/unit/xchain_encoder_taproot_envelope.test.js diff --git a/test/unit/XChainEncoder.trackerFreshnessClassifier.test.js b/test/unit/xchain_encoder_tracker_freshness_classifier.test.js similarity index 100% rename from test/unit/XChainEncoder.trackerFreshnessClassifier.test.js rename to test/unit/xchain_encoder_tracker_freshness_classifier.test.js diff --git a/test/unit/XChainEncoder.utxoDedup.test.js b/test/unit/xchain_encoder_utxo_dedup.test.js similarity index 100% rename from test/unit/XChainEncoder.utxoDedup.test.js rename to test/unit/xchain_encoder_utxo_dedup.test.js diff --git a/test/XChainEncoder.test.js b/test/xchain_encoder.test.js similarity index 100% rename from test/XChainEncoder.test.js rename to test/xchain_encoder.test.js From 759c3c54b28a309985988e56cb861891a2f2097a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 13 Sep 2026 15:40:58 -0700 Subject: [PATCH 013/148] docs: restore comments a prior automated sweep over-trimmed Reviews the two sweep commits that touched this repo's own src/ files and restores two genuine losses (a dropped JSDoc tag and a dropped design-doc attribution) while leaving legitimate cuts (ticket numbers, internal references, decorative banners) out per the comment style guide. The remaining coverage shortfall in files the sweep touched outside src/ is not addressed in this commit. --- src/XChainEncoder.js | 1 + src/adapters/evm_finality.js | 6 +++--- src/tx_size_estimator.js | 4 +++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/XChainEncoder.js b/src/XChainEncoder.js index 5662c45..01b6267 100644 --- a/src/XChainEncoder.js +++ b/src/XChainEncoder.js @@ -3087,6 +3087,7 @@ class XChainEncoder { * @param {number} compiledLength the compiled ACTION stream, AFTER compression * @param {string|null} compressedPubKey required for the envelope's internal key * @param {object|null} options { signerSupportsTapscript } + * @returns {string} a concrete Encoding */ selectEncoding(compiledLength, compressedPubKey, options){ const magicBytes = Buffer.from(MAGIC_WORD, 'utf8').length diff --git a/src/adapters/evm_finality.js b/src/adapters/evm_finality.js index 734b4ed..c2cd94d 100644 --- a/src/adapters/evm_finality.js +++ b/src/adapters/evm_finality.js @@ -14,9 +14,9 @@ * * XChain Encoder - EVM finality-gate constants (PRE-WORK STUB) * - * Account-chain (EVM) support pre-work. The ONE EVM-specific safety decision - * before any adapter build is defining the - * confirmation/finality GATE the federation must clear before it relays or + * Account-chain (EVM) support pre-work. Per the account-chains support + * design, the ONE EVM-specific safety decision before any adapter build is + * defining the confirmation/finality GATE the federation must clear before it relays or * settles a cross-chain leg that ORIGINATED on an EVM chain. The rollback and * cross-chain retraction machinery already exists and is finality-model * agnostic; what is new for EVM is the gate, and the gate is values, not logic. diff --git a/src/tx_size_estimator.js b/src/tx_size_estimator.js index 5f630f6..8ce0f45 100644 --- a/src/tx_size_estimator.js +++ b/src/tx_size_estimator.js @@ -18,6 +18,7 @@ * ********************************************************************/ +// Load required libraries const bitcoin = require('bitcoinjs-lib'); const { compiledPushSize } = require('./validator'); const util = require('node:util'); @@ -171,6 +172,7 @@ class TxSizeEstimator { return 350 } + // Convert to hex string to detect patterns const scriptHex = scriptPubKey.toString('hex') if (isSegwit) { @@ -203,7 +205,7 @@ class TxSizeEstimator { // and this path cannot be taken, so if it gets here and it's a P2SH, most probably is it's Legacy. return 289 } - + return 350 } } From ce4a5be850cc265eb5e53b0b72989bbf2abe2ce5 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 13 Sep 2026 15:41:25 -0700 Subject: [PATCH 014/148] docs: restore evm_finality.js's design-doc attribution in full The previous commit's restoration was one line short of the file's comment-coverage floor. --- src/adapters/evm_finality.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/adapters/evm_finality.js b/src/adapters/evm_finality.js index c2cd94d..9cb89bb 100644 --- a/src/adapters/evm_finality.js +++ b/src/adapters/evm_finality.js @@ -15,8 +15,9 @@ * XChain Encoder - EVM finality-gate constants (PRE-WORK STUB) * * Account-chain (EVM) support pre-work. Per the account-chains support - * design, the ONE EVM-specific safety decision before any adapter build is - * defining the confirmation/finality GATE the federation must clear before it relays or + * design document, which lays out one adapter per chain family, the ONE + * EVM-specific safety decision before any adapter build is defining the + * confirmation/finality GATE the federation must clear before it relays or * settles a cross-chain leg that ORIGINATED on an EVM chain. The rollback and * cross-chain retraction machinery already exists and is finality-model * agnostic; what is new for EVM is the gate, and the gate is values, not logic. From 3f86831d7879d05a3f45d83cbe859e0dbcd558c9 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 13 Sep 2026 15:47:27 -0700 Subject: [PATCH 015/148] fix: twin-guard test follows the SDK repo's own rename of its copy The sibling repo renamed its copy of the shared patch file independently while this repo's own restructuring was in progress; repoints the test's hardcoded sibling path to match. Neither repo's rename was wrong, they just landed concurrently. --- test/unit/apply_bufferutils_patch.test.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/test/unit/apply_bufferutils_patch.test.js b/test/unit/apply_bufferutils_patch.test.js index 7fb41bf..3492bf8 100644 --- a/test/unit/apply_bufferutils_patch.test.js +++ b/test/unit/apply_bufferutils_patch.test.js @@ -157,11 +157,14 @@ describe('applyBufferutilsPatch', function () { const repoRoot = path.resolve(__dirname, '../../..'); const sdkRoot = path.join(repoRoot, 'xchain-sdk'); const mine = path.join(__dirname, '../../src/apply_bufferutils_patch.js'); - // NOT renamed here: this resolves into xchain-sdk, whose own copy of - // the patch has not moved. A bare-literal rewrite pass cannot tell - // this string from a same-repo reference and over-corrected it once; - // restored by hand. - const twin = path.join(sdkRoot, 'src/applyBufferutilsPatch.js'); + // NOT renamed by THIS repo's codemod: this resolves into xchain-sdk, + // which owns its own rename independently. A bare-literal rewrite + // pass cannot tell this string from a same-repo reference and + // over-corrected it once; restored by hand. Repointed here to the + // SDK repo's own published new path (src/applyBufferutilsPatch.js + // -> src/utils/apply_bufferutils_patch.js) once that repo made its + // own move, so this test does not read a sibling change as drift. + const twin = path.join(sdkRoot, 'src/utils/apply_bufferutils_patch.js'); it('is byte-identical to the SDK copy below the banner', function () { // Skipped ONLY where the sibling repo is absent entirely (a From 8311850d86d91d5a6eb20b39b57e917b1354a07a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 13 Sep 2026 15:53:06 -0700 Subject: [PATCH 016/148] refactor: group the remaining top-level src/ files by owner Moves every remaining top-level src/ file into a feature directory keyed by which file requires it (a build/ home for XChainEncoder's own dependencies, a server/ home for api.js's, a common/ home for files two or more callers share); src/index.js stays put as the bundler entry point. Reverts one over-eager rewrite that touched a frozen cross-repo fixture's prose. --- docs/openrpc.build.js | 4 ++-- src/XChainEncoder.js | 20 +++++++++---------- src/api.js | 18 ++++++++--------- src/{ => build}/apply_bufferutils_patch.js | 2 +- src/{ => build}/blockchain_connector.js | 4 ++-- src/{ => build}/compression.js | 4 ++-- src/{ => build}/crypto_networks.js | 2 +- src/{ => build}/errors.js | 0 src/{ => build}/tx_size_estimator.js | 4 ++-- src/{ => build}/utxo_tracker.js | 2 +- src/{ => common}/config.js | 2 +- src/{ => common}/error_sanitize.js | 0 src/{ => common}/validator.js | 0 src/{ => server}/concurrency_gate.js | 0 src/{ => server}/cors_origin.js | 0 src/{ => server}/crash_handlers.js | 2 +- src/{ => server}/maintenance_window.js | 2 +- src/{ => server}/rate_limit_log.js | 0 src/{ => server}/single_instance_guard.js | 4 ++-- .../txsize_estimator_boundaries.test.js | 2 +- test/chaos/api_layer.test.js | 2 +- test/conformance/generateRoundtripFixture.js | 2 +- .../conformance/roundtrip_conformance.test.js | 2 +- .../utxo_record_conformance.test.js | 8 ++++---- test/e2e/multi-chain.e2e.js | 2 +- test/integration/helpers/utxoFactory.js | 2 +- test/integration/multi_chain.test.js | 2 +- test/mutation/stryker.conf.json | 6 +++--- .../mutation/stryker.conf.security-pilot.json | 2 +- test/regression/reg_04_validator.test.js | 4 ++-- test/regression/reg_05_multi_chain.test.js | 2 +- test/regression/reg_08_api_contract.test.js | 2 +- test/regression/reg_09_deepdive_fixes.test.js | 2 +- .../reg_10_utxo_tracker_freshness.test.js | 2 +- ...reg_11_minimal_op_canonicalization.test.js | 2 +- .../reg_14_p2sh_reveal_fee_floor.test.js | 2 +- .../reg_15_uppercase_txid_key_binding.test.js | 2 +- .../reg_17_utxo_pagination_snapshot.test.js | 2 +- test/security/concurrency_gate.test.js | 2 +- test/security/input_validation.test.js | 4 ++-- test/security/payload_size.test.js | 2 +- test/security/rate_limit_log.test.js | 4 ++-- test/smoke/smoke.test.js | 6 +++--- test/unit/action_manifest_conformance.test.js | 8 ++++---- test/unit/apply_bufferutils_patch.test.js | 6 +++--- test/unit/blockchain_connector.test.js | 2 +- test/unit/compression.test.js | 4 ++-- test/unit/cors_origin.test.js | 2 +- test/unit/crash_handlers.test.js | 2 +- test/unit/crypto_networks.test.js | 2 +- test/unit/encoder_stress_sweep.test.js | 4 ++-- test/unit/envelope_recognition_gate.test.js | 2 +- test/unit/error_sanitize.test.js | 2 +- test/unit/large_satoshi_amounts.test.js | 4 ++-- test/unit/maintenance_window.test.js | 2 +- test/unit/openrpc_coverage.test.js | 4 ++-- test/unit/package_fee_sizing.test.js | 8 ++++---- test/unit/single_instance_guard.test.js | 2 +- test/unit/tx_size_estimator.test.js | 2 +- test/unit/utxo_tracker.test.js | 2 +- test/unit/validator.test.js | 4 ++-- .../unit/xchain_encoder_auto_encoding.test.js | 4 ++-- .../xchain_encoder_create_transaction.test.js | 6 +++--- test/unit/xchain_encoder_exact_inputs.test.js | 4 ++-- test/unit/xchain_encoder_extra.test.js | 4 ++-- test/unit/xchain_encoder_fee_rate_cap.test.js | 2 +- ...chain_encoder_p2sh_reveal_headroom.test.js | 2 +- ...chain_encoder_release_reservations.test.js | 2 +- ...ain_encoder_reveal_funding_binding.test.js | 2 +- .../xchain_encoder_segwit_input_guard.test.js | 2 +- .../xchain_encoder_soft_dust_floor.test.js | 2 +- .../xchain_encoder_taproot_envelope.test.js | 4 ++-- 72 files changed, 117 insertions(+), 117 deletions(-) rename src/{ => build}/apply_bufferutils_patch.js (99%) rename src/{ => build}/blockchain_connector.js (99%) rename src/{ => build}/compression.js (99%) rename src/{ => build}/crypto_networks.js (99%) rename src/{ => build}/errors.js (100%) rename src/{ => build}/tx_size_estimator.js (98%) rename src/{ => build}/utxo_tracker.js (99%) rename src/{ => common}/config.js (97%) rename src/{ => common}/error_sanitize.js (100%) rename src/{ => common}/validator.js (100%) rename src/{ => server}/concurrency_gate.js (100%) rename src/{ => server}/cors_origin.js (100%) rename src/{ => server}/crash_handlers.js (98%) rename src/{ => server}/maintenance_window.js (99%) rename src/{ => server}/rate_limit_log.js (100%) rename src/{ => server}/single_instance_guard.js (99%) diff --git a/docs/openrpc.build.js b/docs/openrpc.build.js index 25d68ca..7d08795 100644 --- a/docs/openrpc.build.js +++ b/docs/openrpc.build.js @@ -7,7 +7,7 @@ * * Generates docs/openrpc.json (OpenRPC 1.3.2) for the encoder's JSON-RPC API. * METHODS below mirrors the jsonRpcController in src/api.js; the unit test - * test/unit/openrpc-coverage.test.js fails if the two drift apart. + * test/unit/openrpc_coverage.test.js fails if the two drift apart. * * Run: node docs/openrpc.build.js */ @@ -149,7 +149,7 @@ const spec = { // Writing is the SCRIPT's job, not the module's, so a test can require this file // for the spec it would emit and compare it against the checked-in artifact -// without rewriting that artifact mid-run (see test/unit/openrpc-coverage.test.js). +// without rewriting that artifact mid-run (see test/unit/openrpc_coverage.test.js). if (require.main === module) { const out = path.join(__dirname, 'openrpc.json'); fs.writeFileSync(out, JSON.stringify(spec, null, 2) + '\n'); diff --git a/src/XChainEncoder.js b/src/XChainEncoder.js index 01b6267..eb64486 100644 --- a/src/XChainEncoder.js +++ b/src/XChainEncoder.js @@ -20,21 +20,21 @@ // Must load before any PSBT is built: teaches bitcoinjs-lib/bip174 to carry // satoshi values above 2^53-1 as BigInt (DOGE has no supply cap). -require('./apply_bufferutils_patch') +require('./build/apply_bufferutils_patch') const bitcoin = require('bitcoinjs-lib'); const crypto = require('crypto'); const bs58check = require('bs58check') -const BlockchainConnector = require('./blockchain_connector') -const CryptoNetworks = require('./crypto_networks') -const UtxoTracker = require('./utxo_tracker') -const TxSizeEstimator = require("./tx_size_estimator") -const { MAX_COMPILED_ACTION_DATA_LENGTH, ENVELOPE_MAX_PAYLOAD, MAX_UTXO_COUNT, validateUtxoEntry, parseSatoshiAmount, validateFeePerKb, validateOptionalBoolean, validateAddress, validateDataParam, validateActionPushDecodability, unknownActionName } = require('./validator') -const { compressPayloadForAction } = require('./compression') -const { OperationalError } = require('./errors') -const { upstreamErrorMessage } = require('./error_sanitize') +const BlockchainConnector = require('./build/blockchain_connector') +const CryptoNetworks = require('./build/crypto_networks') +const UtxoTracker = require('./build/utxo_tracker') +const TxSizeEstimator = require("./build/tx_size_estimator") +const { MAX_COMPILED_ACTION_DATA_LENGTH, ENVELOPE_MAX_PAYLOAD, MAX_UTXO_COUNT, validateUtxoEntry, parseSatoshiAmount, validateFeePerKb, validateOptionalBoolean, validateAddress, validateDataParam, validateActionPushDecodability, unknownActionName } = require('./common/validator') +const { compressPayloadForAction } = require('./build/compression') +const { OperationalError } = require('./build/errors') +const { upstreamErrorMessage } = require('./common/error_sanitize') const util = require('node:util'); const { getLogger } = require('./observability'); -const config = require('./config'); +const config = require('./common/config'); const logger = getLogger(); const OP_RETURN_SIZE = 80 diff --git a/src/api.js b/src/api.js index 308a09c..04ce77a 100644 --- a/src/api.js +++ b/src/api.js @@ -39,10 +39,10 @@ const helmet = require('helmet'); const cors = require('cors'); const crypto = require('crypto'); const rateLimit = require('express-rate-limit'); -const { limitedHandler } = require('./rate_limit_log.js') +const { limitedHandler } = require('./server/rate_limit_log.js') const XChainEncoder = require('./XChainEncoder'); const jsonRouter = require('express-json-rpc-router') -const concurrencyGate = require('./concurrency_gate.js') +const concurrencyGate = require('./server/concurrency_gate.js') // Express middleware that rejects an over-cap JSON-RPC batch array before dispatch, so one // HTTP request cannot amplify into thousands of backend RPCs (the rate limiter counts a batch @@ -58,14 +58,14 @@ function makeRpcBatchGuard(maxBatch){ next() } } -const validator = require('./validator') -const { assertSingleInstance, acquireInstanceLock, releaseLockOnSignals } = require('./single_instance_guard') -const { upstreamErrorMessage } = require('./error_sanitize') -const { parseCorsOrigin } = require('./cors_origin') +const validator = require('./common/validator') +const { assertSingleInstance, acquireInstanceLock, releaseLockOnSignals } = require('./server/single_instance_guard') +const { upstreamErrorMessage } = require('./common/error_sanitize') +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('./crash_handlers') -const { readMaintenanceWindow } = require('./maintenance_window') // operator-declared scheduled outage, reported beside readiness +const { installCrashHandlers } = require('./server/crash_handlers') +const { readMaintenanceWindow } = require('./server/maintenance_window') // operator-declared scheduled outage, reported beside readiness const NETWORK = process.env.NETWORK @@ -258,7 +258,7 @@ installObservability(app, { // tracker_halted (bool), tracker_mempool_ready (bool), maintenance (object|null). // // maintenance is the operator's DECLARED scheduled-outage window (see -// src/maintenance_window.js), carried alongside the readiness fields and never +// 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, diff --git a/src/apply_bufferutils_patch.js b/src/build/apply_bufferutils_patch.js similarity index 99% rename from src/apply_bufferutils_patch.js rename to src/build/apply_bufferutils_patch.js index ca1164f..f4ac193 100644 --- a/src/apply_bufferutils_patch.js +++ b/src/build/apply_bufferutils_patch.js @@ -29,7 +29,7 @@ * the value is exactly representable and a BigInt only above 2^53-1, so * existing Number-based callers see identical behavior for every value they * could already handle. The READ-SIDE copies (xchain-decoder and - * xchain-utxo-tracker, src/apply_bufferutils_patch.js) lift the same 2^53 wall + * xchain-utxo-tracker, src/build/apply_bufferutils_patch.js) lift the same 2^53 wall * for block decode but deliberately implement a DIFFERENT contract: * BufferReader.readUInt64 always returns a BigInt and the module-level * readUInt64LE/writeUInt64LE helpers keep the stock 2^53-1 ceiling. Only the diff --git a/src/blockchain_connector.js b/src/build/blockchain_connector.js similarity index 99% rename from src/blockchain_connector.js rename to src/build/blockchain_connector.js index 7d442c8..3d8bc57 100644 --- a/src/blockchain_connector.js +++ b/src/build/blockchain_connector.js @@ -20,8 +20,8 @@ const axios = require('axios') const util = require('node:util'); -const { getLogger } = require('./observability'); -const config = require('./config'); +const { getLogger } = require('../observability'); +const config = require('../common/config'); const logger = getLogger(); const RPC_TIMEOUT = parseInt(config.NODE_RPC_TIMEOUT ?? '30000', 10) diff --git a/src/compression.js b/src/build/compression.js similarity index 99% rename from src/compression.js rename to src/build/compression.js index c6352c0..e92b845 100644 --- a/src/compression.js +++ b/src/build/compression.js @@ -45,13 +45,13 @@ const zlib = require('zlib') const util = require('node:util'); -const { getLogger } = require('./observability'); +const { getLogger } = require('../observability'); const logger = getLogger(); const { COMPRESSION_CODE_DEFLATE_RAW, COMPRESSION_MAX_RATIO, COMPRESSION_MAX_INPUT_BYTES -} = require('./validator.js') +} = require('../common/validator.js') // FILE v0 field indices in the FULL action string (ACTION token included): // FILE|0|NAME|TYPE|TITLE|MEMO|GATE_TICKER|ENCRYPTION_METHOD|KEY_HASH|GATE_MIN_AMOUNT|COMPRESSION diff --git a/src/crypto_networks.js b/src/build/crypto_networks.js similarity index 99% rename from src/crypto_networks.js rename to src/build/crypto_networks.js index dba585c..9f9ee43 100644 --- a/src/crypto_networks.js +++ b/src/build/crypto_networks.js @@ -22,7 +22,7 @@ * ********************************************************************/ -const coins = require('./coins'); +const coins = require('../coins'); const SUPPORTED = 'bitcoin-mainnet, bitcoin-testnet, bitcoin-regtest, dogecoin-mainnet, ' + 'dogecoin-testnet, dogecoin-regtest, litecoin-mainnet, litecoin-testnet, litecoin-regtest'; diff --git a/src/errors.js b/src/build/errors.js similarity index 100% rename from src/errors.js rename to src/build/errors.js diff --git a/src/tx_size_estimator.js b/src/build/tx_size_estimator.js similarity index 98% rename from src/tx_size_estimator.js rename to src/build/tx_size_estimator.js index 8ce0f45..896af44 100644 --- a/src/tx_size_estimator.js +++ b/src/build/tx_size_estimator.js @@ -20,9 +20,9 @@ // Load required libraries const bitcoin = require('bitcoinjs-lib'); -const { compiledPushSize } = require('./validator'); +const { compiledPushSize } = require('../common/validator'); const util = require('node:util'); -const { getLogger } = require('./observability'); +const { getLogger } = require('../observability'); const logger = getLogger(); // Byte width of the compactSize varint that length-prefixes a witness-stack diff --git a/src/utxo_tracker.js b/src/build/utxo_tracker.js similarity index 99% rename from src/utxo_tracker.js rename to src/build/utxo_tracker.js index 528e9f2..9ee190d 100644 --- a/src/utxo_tracker.js +++ b/src/build/utxo_tracker.js @@ -20,7 +20,7 @@ const axios = require('axios') const util = require('node:util'); -const { getLogger } = require('./observability'); +const { getLogger } = require('../observability'); const logger = getLogger(); // How long to wait on the tracker before giving up on a request. diff --git a/src/config.js b/src/common/config.js similarity index 97% rename from src/config.js rename to src/common/config.js index 5945666..810b1fc 100644 --- a/src/config.js +++ b/src/common/config.js @@ -35,7 +35,7 @@ const config = { get NETWORK() { return process.env.NETWORK || ''; }, get XCHAIN_COMPRESSION_DEFAULT() { return process.env.XCHAIN_COMPRESSION_DEFAULT; }, // Raw pass-throughs: the reading module still parses/bounds-checks these, - // src/config.js only stops the process.env read happening outside it. + // src/common/config.js only stops the process.env read happening outside it. get SUGGESTED_FEE_MAX_PER_VBYTE() { return process.env.SUGGESTED_FEE_MAX_PER_VBYTE; }, get MAX_CPFP_UPLIFT_SAT() { return process.env.MAX_CPFP_UPLIFT_SAT; }, get NODE_RPC_TIMEOUT() { return process.env.NODE_RPC_TIMEOUT; }, diff --git a/src/error_sanitize.js b/src/common/error_sanitize.js similarity index 100% rename from src/error_sanitize.js rename to src/common/error_sanitize.js diff --git a/src/validator.js b/src/common/validator.js similarity index 100% rename from src/validator.js rename to src/common/validator.js diff --git a/src/concurrency_gate.js b/src/server/concurrency_gate.js similarity index 100% rename from src/concurrency_gate.js rename to src/server/concurrency_gate.js diff --git a/src/cors_origin.js b/src/server/cors_origin.js similarity index 100% rename from src/cors_origin.js rename to src/server/cors_origin.js diff --git a/src/crash_handlers.js b/src/server/crash_handlers.js similarity index 98% rename from src/crash_handlers.js rename to src/server/crash_handlers.js index 9b54401..1d1a245 100644 --- a/src/crash_handlers.js +++ b/src/server/crash_handlers.js @@ -32,7 +32,7 @@ 'use strict' -const { getLogger, getRegistry } = require('./observability') +const { getLogger, getRegistry } = require('../observability') let _counters = null diff --git a/src/maintenance_window.js b/src/server/maintenance_window.js similarity index 99% rename from src/maintenance_window.js rename to src/server/maintenance_window.js index 628abbb..f0af39a 100644 --- a/src/maintenance_window.js +++ b/src/server/maintenance_window.js @@ -40,7 +40,7 @@ ********************************************************************/ const fs = require('fs') -const config = require('./config'); +const config = require('../common/config'); // Inside the encoder container, so xchain-node can write it with a plain // `docker exec tee` against an already-running encoder: no bind mount, and diff --git a/src/rate_limit_log.js b/src/server/rate_limit_log.js similarity index 100% rename from src/rate_limit_log.js rename to src/server/rate_limit_log.js diff --git a/src/single_instance_guard.js b/src/server/single_instance_guard.js similarity index 99% rename from src/single_instance_guard.js rename to src/server/single_instance_guard.js index 85cdd0b..32c9fde 100644 --- a/src/single_instance_guard.js +++ b/src/server/single_instance_guard.js @@ -34,8 +34,8 @@ const fs = require('fs') const os = require('os') const path = require('path') const crypto = require('crypto') -const { getLogger } = require('./observability'); -const config = require('./config'); +const { getLogger } = require('../observability'); +const config = require('../common/config'); const logger = getLogger(); // Filesystems that cannot hard-link. The atomic publication below falls back to diff --git a/test/boundary/txsize_estimator_boundaries.test.js b/test/boundary/txsize_estimator_boundaries.test.js index 9c673c4..06569c6 100644 --- a/test/boundary/txsize_estimator_boundaries.test.js +++ b/test/boundary/txsize_estimator_boundaries.test.js @@ -20,7 +20,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const TxSizeEstimator = require('../../src/tx_size_estimator') +const TxSizeEstimator = require('../../src/build/tx_size_estimator') const { TXID_A, PUBKEY_BUF, diff --git a/test/chaos/api_layer.test.js b/test/chaos/api_layer.test.js index 584ed7f..b63d86f 100644 --- a/test/chaos/api_layer.test.js +++ b/test/chaos/api_layer.test.js @@ -19,7 +19,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const validator = require('../../src/validator') +const validator = require('../../src/common/validator') const { TXID_A, TXID_MULTISIGN, PUBKEY_BUF, makeUtxo, makeEncoder, getTestAddress diff --git a/test/conformance/generateRoundtripFixture.js b/test/conformance/generateRoundtripFixture.js index fe33270..4fecc36 100644 --- a/test/conformance/generateRoundtripFixture.js +++ b/test/conformance/generateRoundtripFixture.js @@ -31,7 +31,7 @@ const fs = require('fs') const path = require('path') const bitcoin = require('bitcoinjs-lib') const XChainEncoder = require('../../src/XChainEncoder') -const { MAX_COMPILED_ACTION_DATA_LENGTH } = require('../../src/validator') +const { MAX_COMPILED_ACTION_DATA_LENGTH } = require('../../src/common/validator') const MAGIC_WORD = 'XCHN' const MAGIC_BUFFER = Buffer.from(MAGIC_WORD, 'utf8') diff --git a/test/conformance/roundtrip_conformance.test.js b/test/conformance/roundtrip_conformance.test.js index 6d9a9e4..e42284e 100644 --- a/test/conformance/roundtrip_conformance.test.js +++ b/test/conformance/roundtrip_conformance.test.js @@ -19,7 +19,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const XChainEncoder = require('../../src/XChainEncoder') -const { ACTION_ALIASES, envelopePushSize, ENVELOPE_MAX_PAYLOAD } = require('../../src/validator') +const { ACTION_ALIASES, envelopePushSize, ENVELOPE_MAX_PAYLOAD } = require('../../src/common/validator') const fixture = require('../fixtures/roundtrip-conformance.json') const MAGIC_BUFFER = Buffer.from(fixture.magicWord, 'utf8') diff --git a/test/conformance/utxo_record_conformance.test.js b/test/conformance/utxo_record_conformance.test.js index 582af17..5565914 100644 --- a/test/conformance/utxo_record_conformance.test.js +++ b/test/conformance/utxo_record_conformance.test.js @@ -11,8 +11,8 @@ // CONSUMER half of the shared UTXO-record conformance fixture. // // WHAT THIS PINS: the record shape xchain-utxo-tracker actually serves, driven -// through BOTH of this repo's inbound gates - src/utxo_tracker.js's shape gate -// inside getUtxosFromAddress, and src/validator.js validateUtxoEntry. Until +// through BOTH of this repo's inbound gates - src/build/utxo_tracker.js's shape gate +// inside getUtxosFromAddress, and src/common/validator.js validateUtxoEntry. Until // this file the inbound contract was pinned only by those two hand-written // restatements plus a prose comment on the tracker side, while every OUTBOUND // seam (compiledPushSize, the roundtrip fixture, the action manifest) had a @@ -35,8 +35,8 @@ const assert = require('assert') const fs = require('fs') const path = require('path') const axios = require('axios') -const UtxoTracker = require('../../src/utxo_tracker') -const validator = require('../../src/validator') +const UtxoTracker = require('../../src/build/utxo_tracker') +const validator = require('../../src/common/validator') const VENDORED = path.join(__dirname, '..', 'fixtures', 'utxo-record-conformance.json') const fixture = JSON.parse(fs.readFileSync(VENDORED, 'utf8')) diff --git a/test/e2e/multi-chain.e2e.js b/test/e2e/multi-chain.e2e.js index 47785cd..461d0d3 100644 --- a/test/e2e/multi-chain.e2e.js +++ b/test/e2e/multi-chain.e2e.js @@ -19,7 +19,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const CryptoNetworks = require('../../src/crypto_networks') +const CryptoNetworks = require('../../src/build/crypto_networks') const { TXID_A, TXID_MULTISIGN, diff --git a/test/integration/helpers/utxoFactory.js b/test/integration/helpers/utxoFactory.js index b0eafdc..01aad71 100644 --- a/test/integration/helpers/utxoFactory.js +++ b/test/integration/helpers/utxoFactory.js @@ -18,7 +18,7 @@ */ const bitcoin = require('bitcoinjs-lib') -const CryptoNetworks = require('../../../src/crypto_networks') +const CryptoNetworks = require('../../../src/build/crypto_networks') // Canonical test pubkey (secp256k1 generator point) const PUBKEY_BUF = Buffer.from( diff --git a/test/integration/multi_chain.test.js b/test/integration/multi_chain.test.js index 25b09f2..b1deda1 100644 --- a/test/integration/multi_chain.test.js +++ b/test/integration/multi_chain.test.js @@ -19,7 +19,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const CryptoNetworks = require('../../src/crypto_networks') +const CryptoNetworks = require('../../src/build/crypto_networks') const { TXID_A, TXID_MULTISIGN, diff --git a/test/mutation/stryker.conf.json b/test/mutation/stryker.conf.json index 5dbedc6..5e67e84 100644 --- a/test/mutation/stryker.conf.json +++ b/test/mutation/stryker.conf.json @@ -2,9 +2,9 @@ "$schema": "https://raw.githubusercontent.com/stryker-mutator/stryker/master/packages/core/schema/stryker-core.schema.json", "mutate": [ "src/XChainEncoder.js", - "src/validator.js", - "src/tx_size_estimator.js", - "src/crypto_networks.js" + "src/common/validator.js", + "src/build/tx_size_estimator.js", + "src/build/crypto_networks.js" ], "testRunner": "mocha", "mochaOptions": { diff --git a/test/mutation/stryker.conf.security-pilot.json b/test/mutation/stryker.conf.security-pilot.json index 2ac5374..5171c8b 100644 --- a/test/mutation/stryker.conf.security-pilot.json +++ b/test/mutation/stryker.conf.security-pilot.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/stryker-mutator/stryker/master/packages/core/schema/stryker-core.schema.json", "mutate": [ - "src/validator.js" + "src/common/validator.js" ], "testRunner": "mocha", "mochaOptions": { diff --git a/test/regression/reg_04_validator.test.js b/test/regression/reg_04_validator.test.js index bd8dd24..c5f1768 100644 --- a/test/regression/reg_04_validator.test.js +++ b/test/regression/reg_04_validator.test.js @@ -13,7 +13,7 @@ ********************************************************************** * REG-04: Validator Functions * - * Primary dedicated coverage for src/validator.js, filling the identified + * Primary dedicated coverage for src/common/validator.js, filling the identified * gap where no existing test file imports the validator directly. Tests each * exported validate* function individually plus validateAll() as integration. * Purely synchronous (no encoder instantiation needed). @@ -40,7 +40,7 @@ const { MAX_CUSTOM_OUTPUTS, MAX_FEE_SATOSHIS, VALID_ENCODINGS -} = require('../../src/validator') +} = require('../../src/common/validator') const VALID_TXID = 'a'.repeat(64) const VALID_COMPRESSED_02 = '02' + 'a'.repeat(64) diff --git a/test/regression/reg_05_multi_chain.test.js b/test/regression/reg_05_multi_chain.test.js index 75f9550..4f6e766 100644 --- a/test/regression/reg_05_multi_chain.test.js +++ b/test/regression/reg_05_multi_chain.test.js @@ -20,7 +20,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const CryptoNetworks = require('../../src/crypto_networks') +const CryptoNetworks = require('../../src/build/crypto_networks') const { extractOpReturnPayload, MAGIC_WORD diff --git a/test/regression/reg_08_api_contract.test.js b/test/regression/reg_08_api_contract.test.js index 81790b8..87ec648 100644 --- a/test/regression/reg_08_api_contract.test.js +++ b/test/regression/reg_08_api_contract.test.js @@ -20,7 +20,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const { validateAll } = require('../../src/validator') +const { validateAll } = require('../../src/common/validator') const { TXID_A, makeUtxo, diff --git a/test/regression/reg_09_deepdive_fixes.test.js b/test/regression/reg_09_deepdive_fixes.test.js index 95cdd15..47b99d4 100644 --- a/test/regression/reg_09_deepdive_fixes.test.js +++ b/test/regression/reg_09_deepdive_fixes.test.js @@ -244,7 +244,7 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { }) describe('L-2: API surfaces typed operational errors', () => { - const { OperationalError } = require('../../src/errors') + const { OperationalError } = require('../../src/build/errors') const { jsonRpcController, encoder } = require('../../src/api') const address = getTestAddress('bitcoin-regtest') diff --git a/test/regression/reg_10_utxo_tracker_freshness.test.js b/test/regression/reg_10_utxo_tracker_freshness.test.js index 69ea69e..79f7330 100644 --- a/test/regression/reg_10_utxo_tracker_freshness.test.js +++ b/test/regression/reg_10_utxo_tracker_freshness.test.js @@ -296,7 +296,7 @@ describe('M-11 (encoder half): utxo-tracker freshness gate', () => { }) describe('api.js maps UTXO_TRACKER_STALE to -32010', () => { - const { OperationalError } = require('../../src/errors') + const { OperationalError } = require('../../src/build/errors') const { jsonRpcController, encoder } = require('../../src/api') const address = getTestAddress('bitcoin-regtest') diff --git a/test/regression/reg_11_minimal_op_canonicalization.test.js b/test/regression/reg_11_minimal_op_canonicalization.test.js index 8b0daf4..ffdef91 100644 --- a/test/regression/reg_11_minimal_op_canonicalization.test.js +++ b/test/regression/reg_11_minimal_op_canonicalization.test.js @@ -26,7 +26,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const validator = require('../../src/validator') +const validator = require('../../src/common/validator') const XChainEncoder = require('../../src/XChainEncoder') const B05 = String.fromCharCode(0x05) // 1-byte 0x05 under both utf8 and latin1 diff --git a/test/regression/reg_14_p2sh_reveal_fee_floor.test.js b/test/regression/reg_14_p2sh_reveal_fee_floor.test.js index 69bef43..d9c082c 100644 --- a/test/regression/reg_14_p2sh_reveal_fee_floor.test.js +++ b/test/regression/reg_14_p2sh_reveal_fee_floor.test.js @@ -33,7 +33,7 @@ const ecc = require('tiny-secp256k1') const { ECPairFactory } = require('ecpair') const psbtutils = require('bitcoinjs-lib/src/psbt/psbtutils') const XChainEncoder = require('../../src/XChainEncoder') -const TxSizeEstimator = require('../../src/tx_size_estimator') +const TxSizeEstimator = require('../../src/build/tx_size_estimator') const ECPair = ECPairFactory(ecc) const NET = bitcoin.networks.regtest diff --git a/test/regression/reg_15_uppercase_txid_key_binding.test.js b/test/regression/reg_15_uppercase_txid_key_binding.test.js index e121a61..11afcce 100644 --- a/test/regression/reg_15_uppercase_txid_key_binding.test.js +++ b/test/regression/reg_15_uppercase_txid_key_binding.test.js @@ -33,7 +33,7 @@ const { } = require('../integration/helpers/utxoFactory') const { deobfuscate } = require('../integration/helpers/deobfuscate') const actions = require('../integration/helpers/actionFactory') -const validator = require('../../src/validator') +const validator = require('../../src/common/validator') const NETWORK = 'dogecoin-regtest' const MAGIC = 'XCHN' diff --git a/test/regression/reg_17_utxo_pagination_snapshot.test.js b/test/regression/reg_17_utxo_pagination_snapshot.test.js index d785495..23dbd2f 100644 --- a/test/regression/reg_17_utxo_pagination_snapshot.test.js +++ b/test/regression/reg_17_utxo_pagination_snapshot.test.js @@ -19,7 +19,7 @@ const assert = require('assert') const axios = require('axios') -const UtxoTracker = require('../../src/utxo_tracker') +const UtxoTracker = require('../../src/build/utxo_tracker') const SCRIPT = '0014' + '11'.repeat(20) const HEALTHY_STATUS = { synced: true, lag: 0, halted: false, mempool_ready: true } diff --git a/test/security/concurrency_gate.test.js b/test/security/concurrency_gate.test.js index d5614e5..3753bb9 100644 --- a/test/security/concurrency_gate.test.js +++ b/test/security/concurrency_gate.test.js @@ -29,7 +29,7 @@ const assert = require('assert') const express = require('express') const http = require('http') const rateLimit = require('express-rate-limit') -const { createConcurrencyGate, resolveLimit } = require('../../src/concurrency_gate.js') +const { createConcurrencyGate, resolveLimit } = require('../../src/server/concurrency_gate.js') const fs = require('fs'); const path = require('path'); diff --git a/test/security/input_validation.test.js b/test/security/input_validation.test.js index 0f7b598..2524c15 100644 --- a/test/security/input_validation.test.js +++ b/test/security/input_validation.test.js @@ -14,7 +14,7 @@ * Security: input validation * * The encoder is handed untrusted request parameters (UTXOs, addresses, - * ACTION data, fees, encodings). src/validator.js is the gate that must + * ACTION data, fees, encodings). src/common/validator.js is the gate that must * reject hostile input with a typed error, or coerce it to a safe * primitive, before any of it reaches bitcoinjs-lib or the PSBT builder. * These tests attack that gate: malformed types, injection-shaped strings, @@ -24,7 +24,7 @@ */ const assert = require('assert') -const V = require('../../src/validator') +const V = require('../../src/common/validator') const HEX64 = 'a'.repeat(64) const SQLI = "1'; DROP TABLE blocks;--" diff --git a/test/security/payload_size.test.js b/test/security/payload_size.test.js index 83818fe..dd9e85e 100644 --- a/test/security/payload_size.test.js +++ b/test/security/payload_size.test.js @@ -27,7 +27,7 @@ const { makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') -const { MAX_COMPILED_ACTION_DATA_LENGTH } = require('../../src/validator') +const { MAX_COMPILED_ACTION_DATA_LENGTH } = require('../../src/common/validator') const NETWORK = 'dogecoin-regtest' diff --git a/test/security/rate_limit_log.test.js b/test/security/rate_limit_log.test.js index 8e6bd1b..53ddf9a 100644 --- a/test/security/rate_limit_log.test.js +++ b/test/security/rate_limit_log.test.js @@ -13,7 +13,7 @@ ********************************************************************** * Security: rate-limit refusal counter/logger * - * Covers src/rate_limit_log.js directly (the 429 body, the once-per-window + * Covers src/server/rate_limit_log.js directly (the 429 body, the once-per-window * log line, the silence in between, and the accumulated-count reset) with * an injectable clock/logger so nothing here needs a real timer, plus one * end-to-end pass through express-rate-limit to prove the draft-6 headers @@ -32,7 +32,7 @@ const path = require('path') const express = require('express') const rateLimit = require('express-rate-limit') const http = require('http') -const { limitedHandler } = require('../../src/rate_limit_log.js') +const { limitedHandler } = require('../../src/server/rate_limit_log.js') const MESSAGE = { jsonrpc: '2.0', id: null, error: { code: -32029, message: 'Too many requests' } } diff --git a/test/smoke/smoke.test.js b/test/smoke/smoke.test.js index d81a007..268b9b6 100644 --- a/test/smoke/smoke.test.js +++ b/test/smoke/smoke.test.js @@ -25,8 +25,8 @@ const mod = require('../../src/XChainEncoder'); const bitcoin = require('bitcoinjs-lib'); const ecc = require('tiny-secp256k1'); const XChainEncoder = require('../../src/XChainEncoder'); -const CryptoNetworks = require('../../src/crypto_networks'); -const TxSizeEstimator = require('../../src/tx_size_estimator'); +const CryptoNetworks = require('../../src/build/crypto_networks'); +const TxSizeEstimator = require('../../src/build/tx_size_estimator'); const express = require('express'); const bodyParser = require('body-parser'); const helmet = require('helmet'); @@ -179,7 +179,7 @@ describe('S5: prepareData', () => { '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', 'hex' ) - const network = require('../../src/crypto_networks').getBitcoinJsNetwork('bitcoin-regtest') + const network = require('../../src/build/crypto_networks').getBitcoinJsNetwork('bitcoin-regtest') const testAddress = bitcoin.payments.p2pkh({ pubkey: PUBKEY_BUF, network }).address let encoder diff --git a/test/unit/action_manifest_conformance.test.js b/test/unit/action_manifest_conformance.test.js index 198e044..7d97a3f 100644 --- a/test/unit/action_manifest_conformance.test.js +++ b/test/unit/action_manifest_conformance.test.js @@ -9,7 +9,7 @@ // contact legal@dankest.llc. // Cross-repo ACTION-manifest conformance guard, encoder side. -// The encoder's validateActionName gate (src/validator.js) must reject +// The encoder's validateActionName gate (src/common/validator.js) must reject // exactly the leading ACTION tokens the decoder would reject, or it either // blocks a name the decoder actually accepts (false-positive reject on a // valid caller) or lets a name through that the decoder silently drops. The @@ -24,7 +24,7 @@ const assert = require('assert'); const fs = require('fs'); const path = require('path'); -const v = require('../../src/validator.js'); +const v = require('../../src/common/validator.js'); const VENDORED = path.join(__dirname, '..', 'fixtures', 'action-manifest.json'); const MANIFEST = JSON.parse(fs.readFileSync(VENDORED, 'utf8')); @@ -43,7 +43,7 @@ describe('ACTION manifest conformance: encoder validateActionName gate @regressi 'encoder VALID_ACTION_NAMES drifted from action-manifest.json wireDecoded set (== the decoder\'s own VALID_ACTION_NAMES). ' + 'MISSING (decoder decodes, encoder gate would wrongly reject): ' + JSON.stringify(missing) + '. EXTRA (encoder gate accepts, decoder would drop): ' + JSON.stringify(extra) + - '. Edit xchain-documentation/protocol/action-manifest.json + re-vendor, and update src/validator.js VALID_ACTION_NAMES to match xchain-decoder\'s.'); + '. Edit xchain-documentation/protocol/action-manifest.json + re-vendor, and update src/common/validator.js VALID_ACTION_NAMES to match xchain-decoder\'s.'); }); it('ACTION_ALIASES exactly equals the manifest aliases (and the decoder ACTION_ALIASES)', function () { @@ -51,7 +51,7 @@ describe('ACTION manifest conformance: encoder validateActionName gate @regressi const actual = v.ACTION_ALIASES; assert.deepStrictEqual(actual, expected, 'encoder ACTION_ALIASES drifted from action-manifest.json aliases / xchain-decoder ACTION_ALIASES. ' + - 'Keep src/validator.js ACTION_ALIASES byte-identical to xchain-decoder\'s.'); + 'Keep src/common/validator.js ACTION_ALIASES byte-identical to xchain-decoder\'s.'); }); // IDENTITY: the vendored copy must match the canonical source (skip when the diff --git a/test/unit/apply_bufferutils_patch.test.js b/test/unit/apply_bufferutils_patch.test.js index 3492bf8..3ffd122 100644 --- a/test/unit/apply_bufferutils_patch.test.js +++ b/test/unit/apply_bufferutils_patch.test.js @@ -7,7 +7,7 @@ const assert = require('assert'); const fs = require('fs'); const path = require('path'); -const bufferutils = require('../../src/apply_bufferutils_patch.js'); +const bufferutils = require('../../src/build/apply_bufferutils_patch.js'); // The two copies share one body and differ only in the licence/doc banner, // because each header names the other copy and its own read-side relationship. @@ -156,7 +156,7 @@ describe('applyBufferutilsPatch', function () { describe('twin guard against the SDK copy', function () { const repoRoot = path.resolve(__dirname, '../../..'); const sdkRoot = path.join(repoRoot, 'xchain-sdk'); - const mine = path.join(__dirname, '../../src/apply_bufferutils_patch.js'); + const mine = path.join(__dirname, '../../src/build/apply_bufferutils_patch.js'); // NOT renamed by THIS repo's codemod: this resolves into xchain-sdk, // which owns its own rename independently. A bare-literal rewrite // pass cannot tell this string from a same-repo reference and @@ -191,7 +191,7 @@ describe('applyBufferutilsPatch', function () { for (const name of required) { assert.ok( Object.prototype.hasOwnProperty.call(pkg.dependencies || {}, name), - name + ' is required by src/apply_bufferutils_patch.js but not declared in package.json dependencies' + name + ' is required by src/build/apply_bufferutils_patch.js but not declared in package.json dependencies' ); } }); diff --git a/test/unit/blockchain_connector.test.js b/test/unit/blockchain_connector.test.js index fb52a54..6fd598f 100644 --- a/test/unit/blockchain_connector.test.js +++ b/test/unit/blockchain_connector.test.js @@ -10,7 +10,7 @@ const assert = require('assert') const axios = require('axios') -const BlockchainConnector = require('../../src/blockchain_connector') +const BlockchainConnector = require('../../src/build/blockchain_connector') const util = require('util'); const { getLogger } = require('../../src/observability'); const logger = getLogger(); diff --git a/test/unit/compression.test.js b/test/unit/compression.test.js index 4ec2b1f..0fd70f0 100644 --- a/test/unit/compression.test.js +++ b/test/unit/compression.test.js @@ -40,8 +40,8 @@ const ecc = require('tiny-secp256k1') const { ECPairFactory } = require('ecpair') const XChainEncoder = require('../../src/XChainEncoder') -const compression = require('../../src/compression') -const validator = require('../../src/validator') +const compression = require('../../src/build/compression') +const validator = require('../../src/common/validator') bitcoin.initEccLib(ecc) const ECPair = ECPairFactory(ecc) diff --git a/test/unit/cors_origin.test.js b/test/unit/cors_origin.test.js index d273978..343162b 100644 --- a/test/unit/cors_origin.test.js +++ b/test/unit/cors_origin.test.js @@ -31,7 +31,7 @@ const assert = require('assert') const express = require('express') const cors = require('cors') -const { parseCorsOrigin } = require('../../src/cors_origin') +const { parseCorsOrigin } = require('../../src/server/cors_origin') // Mount cors exactly as src/api.js does and ask what a browser would receive. // Returns the ACAO header (or null) for each probed origin. diff --git a/test/unit/crash_handlers.test.js b/test/unit/crash_handlers.test.js index 304b534..5a78c4b 100644 --- a/test/unit/crash_handlers.test.js +++ b/test/unit/crash_handlers.test.js @@ -20,7 +20,7 @@ const { EventEmitter } = require('events') const fs = require('fs') const path = require('path') -const crash = require('../../src/crash_handlers') +const crash = require('../../src/server/crash_handlers') const observability = require('../../src/observability') describe('encoder crash handlers', function () { diff --git a/test/unit/crypto_networks.test.js b/test/unit/crypto_networks.test.js index d504ded..9fd6045 100644 --- a/test/unit/crypto_networks.test.js +++ b/test/unit/crypto_networks.test.js @@ -10,7 +10,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const CryptoNetworks = require('../../src/crypto_networks') +const CryptoNetworks = require('../../src/build/crypto_networks') describe('CryptoNetworks', () => { diff --git a/test/unit/encoder_stress_sweep.test.js b/test/unit/encoder_stress_sweep.test.js index 4cbb6a8..3c46372 100644 --- a/test/unit/encoder_stress_sweep.test.js +++ b/test/unit/encoder_stress_sweep.test.js @@ -29,9 +29,9 @@ for (const k of _envKeys) { if (_savedEnv[k] === undefined) delete process.env[k] else process.env[k] = _savedEnv[k] } -const v = require('../../src/validator') +const v = require('../../src/common/validator') const axios = require('axios') -const UtxoTracker = require('../../src/utxo_tracker') +const UtxoTracker = require('../../src/build/utxo_tracker') describe('encoder stress-sweep unit @regression', function () { this.timeout(10000) diff --git a/test/unit/envelope_recognition_gate.test.js b/test/unit/envelope_recognition_gate.test.js index ff7b14d..383c1de 100644 --- a/test/unit/envelope_recognition_gate.test.js +++ b/test/unit/envelope_recognition_gate.test.js @@ -24,7 +24,7 @@ const fs = require('fs'); const path = require('path'); const XChainEncoder = require('../../src/XChainEncoder'); -const CryptoNetworks = require('../../src/crypto_networks'); +const CryptoNetworks = require('../../src/build/crypto_networks'); // The gate reads only the resolved recognition height and connector.getBlockCount, // so a network KEY plus a stub connector is the whole surface; building a real diff --git a/test/unit/error_sanitize.test.js b/test/unit/error_sanitize.test.js index 8866d86..fe86096 100644 --- a/test/unit/error_sanitize.test.js +++ b/test/unit/error_sanitize.test.js @@ -13,7 +13,7 @@ *********************************************************************/ const assert = require('assert') -const { upstreamErrorMessage, isTransportError, leaksInternalDetail } = require('../../src/error_sanitize') +const { upstreamErrorMessage, isTransportError, leaksInternalDetail } = require('../../src/common/error_sanitize') // Guards the encoder's outbound error sanitization: useful upstream RPC reasons // pass through, transport-level failures (which leak the internal node host:port) diff --git a/test/unit/large_satoshi_amounts.test.js b/test/unit/large_satoshi_amounts.test.js index 37778ec..9253674 100644 --- a/test/unit/large_satoshi_amounts.test.js +++ b/test/unit/large_satoshi_amounts.test.js @@ -21,7 +21,7 @@ const { validateUtxoEntry, validateCustomOutputs, MAX_SATOSHI_U64 -} = require('../../src/validator') +} = require('../../src/common/validator') const MAX_SAFE = Number.MAX_SAFE_INTEGER // 9007199254740991 const BIG_STR = '9007199254740993' // 2^53 + 1, not representable as Number @@ -33,7 +33,7 @@ const pubkeyBuf = Buffer.from( ) const TXID_A = 'a'.repeat(64) -const LTC_REGTEST = require('../../src/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') +const LTC_REGTEST = require('../../src/build/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') const bufferutils = require('bitcoinjs-lib/src/bufferutils'); const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: LTC_REGTEST }).address diff --git a/test/unit/maintenance_window.test.js b/test/unit/maintenance_window.test.js index 11ab806..9bc1753 100644 --- a/test/unit/maintenance_window.test.js +++ b/test/unit/maintenance_window.test.js @@ -45,7 +45,7 @@ const { MAX_SENTINEL_BYTES, MAX_REASON_CHARS, MAX_WINDOW_MS -} = require('../../src/maintenance_window'); +} = require('../../src/server/maintenance_window'); const NOW = Date.parse('2026-09-02T12:00:00.000Z'); const in2h = new Date(NOW + 2 * 3600 * 1000).toISOString(); diff --git a/test/unit/openrpc_coverage.test.js b/test/unit/openrpc_coverage.test.js index 405f249..809a9ac 100644 --- a/test/unit/openrpc_coverage.test.js +++ b/test/unit/openrpc_coverage.test.js @@ -67,7 +67,7 @@ describe('openrpc.json method coverage', () => { // is how create_tx came to validate and act on `attachPrevTx` while the published // contract never named it: the field was undiscoverable from the spec and every // generated client dropped it. Every params. validateAll reads must be declared. - const validatorSrc = fs.readFileSync(path.join(__dirname, '../../src/validator.js'), 'utf8'); + const validatorSrc = fs.readFileSync(path.join(__dirname, '../../src/common/validator.js'), 'utf8'); // Slice validateAll's body only. `params.pubkey` also appears in an unrelated // function earlier in the file, and including it would make this pass for the // wrong reason. The digit class is required: p2shHash, p2shHex, compressedPubKey. @@ -79,7 +79,7 @@ describe('openrpc.json method coverage', () => { )].sort(); it('extracts a sane validateAll param list', () => { - assert.notStrictEqual(fnStart, -1, 'validateAll not found in src/validator.js'); + assert.notStrictEqual(fnStart, -1, 'validateAll not found in src/common/validator.js'); assert.ok(fnEnd > fnStart, 'validateAll body slice is empty; the return marker moved'); assert.ok(validatedParams.includes('pubkey') && validatedParams.includes('attachPrevTx'), `extraction broken: ${validatedParams.join(', ')}`); diff --git a/test/unit/package_fee_sizing.test.js b/test/unit/package_fee_sizing.test.js index f420f7a..6bd6a26 100644 --- a/test/unit/package_fee_sizing.test.js +++ b/test/unit/package_fee_sizing.test.js @@ -21,7 +21,7 @@ const assert = require('assert') const axios = require('axios') const bitcoin = require('bitcoinjs-lib') -const BlockchainConnector = require('../../src/blockchain_connector') +const BlockchainConnector = require('../../src/build/blockchain_connector') const XChainEncoder = require('../../src/XChainEncoder') const util = require('util'); const ecc = require('tiny-secp256k1'); @@ -326,7 +326,7 @@ describe('packageFeeUpliftSatoshis() @regression @tier1', () => { describe('XChainEncoder package-aware fee sizing @regression @tier1', () => { const pubkeyBuf = Buffer.from('0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', 'hex') - const DOGE_REGTEST = require('../../src/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') + const DOGE_REGTEST = require('../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST }).address const INPUT_VALUE = 100000000 // 1 DOGE in koinu const NODE_RATE_PER_KB = 0.01 // Dogecoin's block-inclusion floor @@ -557,7 +557,7 @@ describe('two-phase reveal package prefund @regression @tier1', () => { const KEY = ECPairFactory(ecc).fromPrivateKey(Buffer.alloc(32, 7)) const PUBKEY_HEX = Buffer.from(KEY.publicKey).toString('hex') - const BTC_REGTEST = require('../../src/crypto_networks').getBitcoinJsNetwork('bitcoin-regtest') + const BTC_REGTEST = require('../../src/build/crypto_networks').getBitcoinJsNetwork('bitcoin-regtest') const BTC_RATE_KB = 0.0001 // 10 sat/byte const BTC_TARGET_PER_BYTE = BTC_RATE_KB * SATOSHI_UNIT / 1000 const COMMIT_INPUT_VALUE = 100000000 @@ -745,7 +745,7 @@ describe('two-phase reveal package prefund @regression @tier1', () => { // instead of sweeping it back to the caller. describe('P2SH two-phase package prefund @regression @tier1', () => { const DOGE = 'dogecoin-regtest' - const DOGE_REGTEST_NET = require('../../src/crypto_networks').getBitcoinJsNetwork(DOGE) + const DOGE_REGTEST_NET = require('../../src/build/crypto_networks').getBitcoinJsNetwork(DOGE) const RATE_KB = 1000000 // 1000 koinu/byte, the venue rate const TARGET_PER_BYTE = RATE_KB / 1000 const pubkeyBuf = Buffer.from('0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', 'hex') diff --git a/test/unit/single_instance_guard.test.js b/test/unit/single_instance_guard.test.js index 94b4a53..703ad5e 100644 --- a/test/unit/single_instance_guard.test.js +++ b/test/unit/single_instance_guard.test.js @@ -13,7 +13,7 @@ const fs = require('fs') const os = require('os') const path = require('path') -const { assertSingleInstance, acquireInstanceLock, releaseLockOnSignals, isPidAlive } = require('../../src/single_instance_guard') +const { assertSingleInstance, acquireInstanceLock, releaseLockOnSignals, isPidAlive } = require('../../src/server/single_instance_guard') describe('singleInstanceGuard', function () { diff --git a/test/unit/tx_size_estimator.test.js b/test/unit/tx_size_estimator.test.js index 042cf3f..8806226 100644 --- a/test/unit/tx_size_estimator.test.js +++ b/test/unit/tx_size_estimator.test.js @@ -10,7 +10,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const TxSizeEstimator = require('../../src/tx_size_estimator') +const TxSizeEstimator = require('../../src/build/tx_size_estimator') describe('TxSizeEstimator', () => { diff --git a/test/unit/utxo_tracker.test.js b/test/unit/utxo_tracker.test.js index b770a83..52cf745 100644 --- a/test/unit/utxo_tracker.test.js +++ b/test/unit/utxo_tracker.test.js @@ -10,7 +10,7 @@ const assert = require('assert') const axios = require('axios') -const UtxoTracker = require('../../src/utxo_tracker') +const UtxoTracker = require('../../src/build/utxo_tracker') function makeTracker () { return new UtxoTracker('127.0.0.1', 18420) diff --git a/test/unit/validator.test.js b/test/unit/validator.test.js index ee14756..6974c06 100644 --- a/test/unit/validator.test.js +++ b/test/unit/validator.test.js @@ -11,14 +11,14 @@ * contact legal@dankest.llc. * ********************************************************************** - * Unit tests for src/validator.js: centralized createTransaction input + * Unit tests for src/common/validator.js: centralized createTransaction input * validation. Every validator returns the coerced value or throws * TypeError/RangeError. validateCustomOutput / validateFeeQuote are exercised * through their array/validateAll wrappers (not individually exported). ********************************************************************/ const assert = require('assert'); -const v = require('../../src/validator.js'); +const v = require('../../src/common/validator.js'); const bitcoin = require('bitcoinjs-lib'); const HEX64 = 'a'.repeat(64); diff --git a/test/unit/xchain_encoder_auto_encoding.test.js b/test/unit/xchain_encoder_auto_encoding.test.js index fd9a1f9..55b506d 100644 --- a/test/unit/xchain_encoder_auto_encoding.test.js +++ b/test/unit/xchain_encoder_auto_encoding.test.js @@ -32,8 +32,8 @@ const bitcoin = require('bitcoinjs-lib') const ecc = require('tiny-secp256k1') const XChainEncoder = require('../../src/XChainEncoder') -const validator = require('../../src/validator') -const compression = require('../../src/compression') +const validator = require('../../src/common/validator') +const compression = require('../../src/build/compression') bitcoin.initEccLib(ecc) diff --git a/test/unit/xchain_encoder_create_transaction.test.js b/test/unit/xchain_encoder_create_transaction.test.js index efed6a1..3fc4dd1 100644 --- a/test/unit/xchain_encoder_create_transaction.test.js +++ b/test/unit/xchain_encoder_create_transaction.test.js @@ -93,8 +93,8 @@ function makeEncoder (networkName = 'litecoin-regtest') { return encoder } -const LTC_REGTEST = require('../../src/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') -const TxSizeEstimator = require('../../src/tx_size_estimator'); +const LTC_REGTEST = require('../../src/build/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') +const TxSizeEstimator = require('../../src/build/tx_size_estimator'); const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: LTC_REGTEST @@ -563,7 +563,7 @@ describe('XChainEncoder.createTransaction()', () => { return { utxos: [makeLegacyUtxo(TXID_A, 0, 100000000)] } } - const dogeNetwork = require('../../src/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') + const dogeNetwork = require('../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') const dogeAddress = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: dogeNetwork }).address const rawPubkeyHex = pubkeyBuf.toString('hex') await encoder.createTransaction( diff --git a/test/unit/xchain_encoder_exact_inputs.test.js b/test/unit/xchain_encoder_exact_inputs.test.js index 0c51604..aa1505c 100644 --- a/test/unit/xchain_encoder_exact_inputs.test.js +++ b/test/unit/xchain_encoder_exact_inputs.test.js @@ -21,7 +21,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const XChainEncoder = require('../../src/XChainEncoder') -const validator = require('../../src/validator') +const validator = require('../../src/common/validator') const pubkeyBuf = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', @@ -61,7 +61,7 @@ function makeEncoder () { return encoder } -const LTC_REGTEST = require('../../src/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') +const LTC_REGTEST = require('../../src/build/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: LTC_REGTEST diff --git a/test/unit/xchain_encoder_extra.test.js b/test/unit/xchain_encoder_extra.test.js index 265c4ce..d7f6a0e 100644 --- a/test/unit/xchain_encoder_extra.test.js +++ b/test/unit/xchain_encoder_extra.test.js @@ -55,8 +55,8 @@ function makeP2pkhUtxo (txid, vout, value) { return { txid, vout, value, confirmations: 6, scriptPubKey: script } } -const DOGE_REGTEST = require('../../src/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') -const TxSizeEstimator = require('../../src/tx_size_estimator'); +const DOGE_REGTEST = require('../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const TxSizeEstimator = require('../../src/build/tx_size_estimator'); const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST diff --git a/test/unit/xchain_encoder_fee_rate_cap.test.js b/test/unit/xchain_encoder_fee_rate_cap.test.js index 299d042..8d4ce2c 100644 --- a/test/unit/xchain_encoder_fee_rate_cap.test.js +++ b/test/unit/xchain_encoder_fee_rate_cap.test.js @@ -31,7 +31,7 @@ const pubkeyBuf = Buffer.from( ) const TXID_A = 'a'.repeat(64) -const LTC_REGTEST = require('../../src/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') +const LTC_REGTEST = require('../../src/build/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: LTC_REGTEST diff --git a/test/unit/xchain_encoder_p2sh_reveal_headroom.test.js b/test/unit/xchain_encoder_p2sh_reveal_headroom.test.js index 7a0e434..4358ca4 100644 --- a/test/unit/xchain_encoder_p2sh_reveal_headroom.test.js +++ b/test/unit/xchain_encoder_p2sh_reveal_headroom.test.js @@ -29,7 +29,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const XChainEncoder = require('../../src/XChainEncoder') -const CryptoNetworks = require('../../src/crypto_networks') +const CryptoNetworks = require('../../src/build/crypto_networks') const SATOSHI_UNIT = 100000000 const pubkeyBuf = Buffer.from( diff --git a/test/unit/xchain_encoder_release_reservations.test.js b/test/unit/xchain_encoder_release_reservations.test.js index b5ccbe8..c396578 100644 --- a/test/unit/xchain_encoder_release_reservations.test.js +++ b/test/unit/xchain_encoder_release_reservations.test.js @@ -29,7 +29,7 @@ const pubkeyBuf = Buffer.from( const TXID_ONE = 'a'.repeat(64) const TXID_TWO = 'b'.repeat(64) -const LTC_REGTEST = require('../../src/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') +const LTC_REGTEST = require('../../src/build/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: LTC_REGTEST }).address const PUBKEY_HEX = pubkeyBuf.toString('hex') diff --git a/test/unit/xchain_encoder_reveal_funding_binding.test.js b/test/unit/xchain_encoder_reveal_funding_binding.test.js index 44ff856..82e43fa 100644 --- a/test/unit/xchain_encoder_reveal_funding_binding.test.js +++ b/test/unit/xchain_encoder_reveal_funding_binding.test.js @@ -81,7 +81,7 @@ const BTC_REGTEST_ADDR = bitcoin.payments.p2pkh({ network: bitcoin.networks.regtest }).address -const DOGE_REGTEST = require('../../src/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const DOGE_REGTEST = require('../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') const DOGE_REGTEST_ADDR = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST diff --git a/test/unit/xchain_encoder_segwit_input_guard.test.js b/test/unit/xchain_encoder_segwit_input_guard.test.js index 9a967fd..693d402 100644 --- a/test/unit/xchain_encoder_segwit_input_guard.test.js +++ b/test/unit/xchain_encoder_segwit_input_guard.test.js @@ -17,7 +17,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const XChainEncoder = require('../../src/XChainEncoder') -const CryptoNetworks = require('../../src/crypto_networks') +const CryptoNetworks = require('../../src/build/crypto_networks') const pubkeyBuf = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', diff --git a/test/unit/xchain_encoder_soft_dust_floor.test.js b/test/unit/xchain_encoder_soft_dust_floor.test.js index bbbf66f..e7753d9 100644 --- a/test/unit/xchain_encoder_soft_dust_floor.test.js +++ b/test/unit/xchain_encoder_soft_dust_floor.test.js @@ -15,7 +15,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const XChainEncoder = require('../../src/XChainEncoder') -const CryptoNetworks = require('../../src/crypto_networks') +const CryptoNetworks = require('../../src/build/crypto_networks') const SOFT_DUST_DOGE = 1000000 // 0.01 DOGE in koinu const HARD_DUST_DOGE = 100000 // the pinned network.dustThreshold diff --git a/test/unit/xchain_encoder_taproot_envelope.test.js b/test/unit/xchain_encoder_taproot_envelope.test.js index 60f637d..a5eec4b 100644 --- a/test/unit/xchain_encoder_taproot_envelope.test.js +++ b/test/unit/xchain_encoder_taproot_envelope.test.js @@ -23,8 +23,8 @@ const bitcoin = require('bitcoinjs-lib') const ecc = require('tiny-secp256k1') const { ECPairFactory } = require('ecpair') const XChainEncoder = require('../../src/XChainEncoder') -const TxSizeEstimator = require('../../src/tx_size_estimator') -const { ENVELOPE_MAX_PAYLOAD, MAX_COMPILED_ACTION_DATA_LENGTH, MAX_STANDARD_TX_WEIGHT } = require('../../src/validator') +const TxSizeEstimator = require('../../src/build/tx_size_estimator') +const { ENVELOPE_MAX_PAYLOAD, MAX_COMPILED_ACTION_DATA_LENGTH, MAX_STANDARD_TX_WEIGHT } = require('../../src/common/validator') const vectors = require('../../../xchain-documentation/protocol/test-vectors/taproot_envelope.json'); bitcoin.initEccLib(ecc) From 13b7682bb74596b810e7314edf5c69b8b28afd63 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 13 Sep 2026 15:58:43 -0700 Subject: [PATCH 017/148] test: rename the remaining non-suite test and script files Renames the real e2e suites and the load-test/benchmark scripts left out earlier, updating package.json's globs and every require path the new depth requires, and adds all 17 to the committed test rename map alongside the earlier 97. Reverts one accidental regeneration of a frozen cross-repo fixture, caught by re-requiring each moved file. --- bin/pins/test-rename-map.json | 17 +++++++++++++++++ package.json | 14 +++++++------- .../generate_roundtrip_fixture.js} | 10 +++++----- .../{api-layer.e2e.js => api_layer_e2e.test.js} | 0 ...eline.e2e.js => action_pipeline_e2e.test.js} | 0 ...edge-cases.e2e.js => edge_cases_e2e.test.js} | 0 ...s.e2e.js => encoding_boundaries_e2e.test.js} | 0 ...ction.e2e.js => error_rejection_e2e.test.js} | 0 ...lti-chain.e2e.js => multi_chain_e2e.test.js} | 0 ...e2e.js => obfuscation_integrity_e2e.test.js} | 0 ...e.e2e.js => p2sh_p2wsh_sequence_e2e.test.js} | 0 ...round-trip.e2e.js => round_trip_e2e.test.js} | 0 ...hange.e2e.js => utxo_fee_change_e2e.test.js} | 0 test/{nodeHelper.js => helpers/node_helper.js} | 0 test/performance/{ => helpers}/bench.js | 6 +++--- test/performance/{ => helpers}/k6/concurrent.js | 2 +- test/performance/{ => helpers}/k6/spike.js | 2 +- test/performance/{ => helpers}/k6/sustained.js | 2 +- test/performance/{ => helpers}/soak.js | 6 +++--- test/prepare_regtest.test.js | 2 +- test/xchain_encoder.test.js | 2 +- 21 files changed, 40 insertions(+), 23 deletions(-) rename test/conformance/{generateRoundtripFixture.js => helpers/generate_roundtrip_fixture.js} (98%) rename test/e2e-service/{api-layer.e2e.js => api_layer_e2e.test.js} (100%) rename test/e2e/{action-pipeline.e2e.js => action_pipeline_e2e.test.js} (100%) rename test/e2e/{edge-cases.e2e.js => edge_cases_e2e.test.js} (100%) rename test/e2e/{encoding-boundaries.e2e.js => encoding_boundaries_e2e.test.js} (100%) rename test/e2e/{error-rejection.e2e.js => error_rejection_e2e.test.js} (100%) rename test/e2e/{multi-chain.e2e.js => multi_chain_e2e.test.js} (100%) rename test/e2e/{obfuscation-integrity.e2e.js => obfuscation_integrity_e2e.test.js} (100%) rename test/e2e/{p2sh-p2wsh-sequence.e2e.js => p2sh_p2wsh_sequence_e2e.test.js} (100%) rename test/e2e/{round-trip.e2e.js => round_trip_e2e.test.js} (100%) rename test/e2e/{utxo-fee-change.e2e.js => utxo_fee_change_e2e.test.js} (100%) rename test/{nodeHelper.js => helpers/node_helper.js} (100%) rename test/performance/{ => helpers}/bench.js (96%) rename test/performance/{ => helpers}/k6/concurrent.js (97%) rename test/performance/{ => helpers}/k6/spike.js (98%) rename test/performance/{ => helpers}/k6/sustained.js (97%) rename test/performance/{ => helpers}/soak.js (97%) diff --git a/bin/pins/test-rename-map.json b/bin/pins/test-rename-map.json index 2c6f93f..cb74ec2 100644 --- a/bin/pins/test-rename-map.json +++ b/bin/pins/test-rename-map.json @@ -14,8 +14,19 @@ "test/chaos/library-failures.test.js": "test/chaos/library_failures.test.js", "test/chaos/network-failures.test.js": "test/chaos/network_failures.test.js", "test/chaos/resource-exhaustion.test.js": "test/chaos/resource_exhaustion.test.js", + "test/conformance/generateRoundtripFixture.js": "test/conformance/helpers/generate_roundtrip_fixture.js", "test/conformance/roundtripConformance.test.js": "test/conformance/roundtrip_conformance.test.js", "test/conformance/utxoRecordConformance.test.js": "test/conformance/utxo_record_conformance.test.js", + "test/e2e-service/api-layer.e2e.js": "test/e2e-service/api_layer_e2e.test.js", + "test/e2e/action-pipeline.e2e.js": "test/e2e/action_pipeline_e2e.test.js", + "test/e2e/edge-cases.e2e.js": "test/e2e/edge_cases_e2e.test.js", + "test/e2e/encoding-boundaries.e2e.js": "test/e2e/encoding_boundaries_e2e.test.js", + "test/e2e/error-rejection.e2e.js": "test/e2e/error_rejection_e2e.test.js", + "test/e2e/multi-chain.e2e.js": "test/e2e/multi_chain_e2e.test.js", + "test/e2e/obfuscation-integrity.e2e.js": "test/e2e/obfuscation_integrity_e2e.test.js", + "test/e2e/p2sh-p2wsh-sequence.e2e.js": "test/e2e/p2sh_p2wsh_sequence_e2e.test.js", + "test/e2e/round-trip.e2e.js": "test/e2e/round_trip_e2e.test.js", + "test/e2e/utxo-fee-change.e2e.js": "test/e2e/utxo_fee_change_e2e.test.js", "test/integration/action-encoding.test.js": "test/integration/action_encoding.test.js", "test/integration/custom-outputs.test.js": "test/integration/custom_outputs.test.js", "test/integration/encoderStressSweep.test.js": "test/integration/encoder_stress_sweep.test.js", @@ -25,6 +36,12 @@ "test/integration/multi-chain.test.js": "test/integration/multi_chain.test.js", "test/integration/obfuscation-roundtrip.test.js": "test/integration/obfuscation_roundtrip.test.js", "test/integration/utxo-fee.test.js": "test/integration/utxo_fee.test.js", + "test/nodeHelper.js": "test/helpers/node_helper.js", + "test/performance/bench.js": "test/performance/helpers/bench.js", + "test/performance/k6/concurrent.js": "test/performance/helpers/k6/concurrent.js", + "test/performance/k6/spike.js": "test/performance/helpers/k6/spike.js", + "test/performance/k6/sustained.js": "test/performance/helpers/k6/sustained.js", + "test/performance/soak.js": "test/performance/helpers/soak.js", "test/prepareRegtest.test.js": "test/prepare_regtest.test.js", "test/regression/reg-01-encoding-types.test.js": "test/regression/reg_01_encoding_types.test.js", "test/regression/reg-02-obfuscation.test.js": "test/regression/reg_02_obfuscation.test.js", diff --git a/package.json b/package.json index b067bfa..5cf8bdb 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "ci": "npx mocha --timeout 10000 'test/unit/**/*.test.js' --recursive --exit && npm run ci:security && npm run ci:regression && npm run ci:e2e && npm run ci:conformance", "ci:security": "npx mocha --timeout 30000 'test/security/**/*.test.js' --recursive --exit", "ci:regression": "npx mocha --timeout 30000 'test/regression/**/*.test.js' --recursive --exit", - "ci:e2e": "npx mocha --timeout 30000 'test/e2e/**/*.e2e.js' --recursive --exit", + "ci:e2e": "npx mocha --timeout 30000 'test/e2e/**/*.test.js' --recursive --exit", "ci:conformance": "npx mocha --timeout 30000 'test/conformance/**/*.test.js' --recursive --exit", "ci:full": "bash bin/ci-full.sh", "test:unit": "npx mocha --timeout 10000 'test/unit/**/*.test.js'", @@ -57,13 +57,13 @@ "build": "npx browserify src/index.js -t [ babelify --presets [ @babel/preset-env ] --global ] | npx uglify-js --compress --mangle -o dist/xchain_encoder.min.js", "build:dev": "npx browserify src/index.js -t [ babelify --presets [ @babel/preset-env ] --global ] -o dist/xchain_encoder.min.js", "test:chaos": "npx mocha --timeout 30000 'test/chaos/**/*.test.js'", - "test:e2e": "npx mocha --timeout 30000 'test/e2e/**/*.e2e.js' --recursive --exit", - "test:e2e:service": "npx mocha --timeout 30000 'test/e2e-service/**/*.e2e.js' --recursive --exit", + "test:e2e": "npx mocha --timeout 30000 'test/e2e/**/*.test.js' --recursive --exit", + "test:e2e:service": "npx mocha --timeout 30000 'test/e2e-service/**/*.test.js' --recursive --exit", "test:regression": "npx mocha --timeout 30000 'test/regression/**/*.test.js'", - "bench": "node test/performance/bench.js", - "bench:full": "node test/performance/bench.js --full --json", - "bench:soak": "node test/performance/soak.js --duration 60", - "bench:k6": "k6 run test/performance/k6/sustained.js", + "bench": "node test/performance/helpers/bench.js", + "bench:full": "node test/performance/helpers/bench.js --full --json", + "bench:soak": "node test/performance/helpers/soak.js --duration 60", + "bench:k6": "k6 run test/performance/helpers/k6/sustained.js", "mutate": "npx stryker run test/mutation/stryker.conf.json", "mutate:quick": "npx stryker run test/mutation/stryker.conf.quick.json" }, diff --git a/test/conformance/generateRoundtripFixture.js b/test/conformance/helpers/generate_roundtrip_fixture.js similarity index 98% rename from test/conformance/generateRoundtripFixture.js rename to test/conformance/helpers/generate_roundtrip_fixture.js index 4fecc36..1bccff4 100644 --- a/test/conformance/generateRoundtripFixture.js +++ b/test/conformance/helpers/generate_roundtrip_fixture.js @@ -12,7 +12,7 @@ // Emits test/fixtures/roundtrip-conformance.json, the single // golden artifact consumed by BOTH the encoder suite (drift guard) and the // decoder suite (real-decoder decode of encoder-built bytes). Regenerate with: -// node test/conformance/generateRoundtripFixture.js +// node test/conformance/helpers/generate_roundtrip_fixture.js // and review the diff. The fixture is deterministic: bitcoin.script.compile and // the AES-128-CTR obfuscation are pure functions of their inputs. // @@ -30,8 +30,8 @@ const fs = require('fs') const path = require('path') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../src/XChainEncoder') -const { MAX_COMPILED_ACTION_DATA_LENGTH } = require('../../src/common/validator') +const XChainEncoder = require('../../../src/XChainEncoder') +const { MAX_COMPILED_ACTION_DATA_LENGTH } = require('../../../src/common/validator') const MAGIC_WORD = 'XCHN' const MAGIC_BUFFER = Buffer.from(MAGIC_WORD, 'utf8') @@ -445,7 +445,7 @@ async function main () { _comment: 'Shared encoder->decoder roundtrip conformance fixture, covering OP_RETURN, ' + 'MULTISIGN slots, P2SH/P2WSH multi-chunk (incl. the final-chunk rebalance boundary), ' + 'alias-rewrite and TAPROOT-envelope cases. Generated by ' + - 'test/conformance/generateRoundtripFixture.js; regenerate ' + + 'test/conformance/helpers/generate_roundtrip_fixture.js; regenerate ' + 'and review on change. Consumed by the encoder drift-guard test and the decoder real-decode ' + 'conformance test. gate "dropped" cases pin known cross-service data-loss shapes; when the ' + 'flag-day decoder-acceptance change lands those expectations flip.', @@ -457,7 +457,7 @@ async function main () { envelopeCases } - const outPath = path.join(__dirname, '..', 'fixtures', 'roundtrip-conformance.json') + const outPath = path.join(__dirname, '..', '..', 'fixtures', 'roundtrip-conformance.json') fs.mkdirSync(path.dirname(outPath), { recursive: true }) fs.writeFileSync(outPath, JSON.stringify(fixture, null, 2) + '\n') console.log(`wrote ${cases.length} OP_RETURN + ${multisignCases.length} MULTISIGN + ` + diff --git a/test/e2e-service/api-layer.e2e.js b/test/e2e-service/api_layer_e2e.test.js similarity index 100% rename from test/e2e-service/api-layer.e2e.js rename to test/e2e-service/api_layer_e2e.test.js diff --git a/test/e2e/action-pipeline.e2e.js b/test/e2e/action_pipeline_e2e.test.js similarity index 100% rename from test/e2e/action-pipeline.e2e.js rename to test/e2e/action_pipeline_e2e.test.js diff --git a/test/e2e/edge-cases.e2e.js b/test/e2e/edge_cases_e2e.test.js similarity index 100% rename from test/e2e/edge-cases.e2e.js rename to test/e2e/edge_cases_e2e.test.js diff --git a/test/e2e/encoding-boundaries.e2e.js b/test/e2e/encoding_boundaries_e2e.test.js similarity index 100% rename from test/e2e/encoding-boundaries.e2e.js rename to test/e2e/encoding_boundaries_e2e.test.js diff --git a/test/e2e/error-rejection.e2e.js b/test/e2e/error_rejection_e2e.test.js similarity index 100% rename from test/e2e/error-rejection.e2e.js rename to test/e2e/error_rejection_e2e.test.js diff --git a/test/e2e/multi-chain.e2e.js b/test/e2e/multi_chain_e2e.test.js similarity index 100% rename from test/e2e/multi-chain.e2e.js rename to test/e2e/multi_chain_e2e.test.js diff --git a/test/e2e/obfuscation-integrity.e2e.js b/test/e2e/obfuscation_integrity_e2e.test.js similarity index 100% rename from test/e2e/obfuscation-integrity.e2e.js rename to test/e2e/obfuscation_integrity_e2e.test.js diff --git a/test/e2e/p2sh-p2wsh-sequence.e2e.js b/test/e2e/p2sh_p2wsh_sequence_e2e.test.js similarity index 100% rename from test/e2e/p2sh-p2wsh-sequence.e2e.js rename to test/e2e/p2sh_p2wsh_sequence_e2e.test.js diff --git a/test/e2e/round-trip.e2e.js b/test/e2e/round_trip_e2e.test.js similarity index 100% rename from test/e2e/round-trip.e2e.js rename to test/e2e/round_trip_e2e.test.js diff --git a/test/e2e/utxo-fee-change.e2e.js b/test/e2e/utxo_fee_change_e2e.test.js similarity index 100% rename from test/e2e/utxo-fee-change.e2e.js rename to test/e2e/utxo_fee_change_e2e.test.js diff --git a/test/nodeHelper.js b/test/helpers/node_helper.js similarity index 100% rename from test/nodeHelper.js rename to test/helpers/node_helper.js diff --git a/test/performance/bench.js b/test/performance/helpers/bench.js similarity index 96% rename from test/performance/bench.js rename to test/performance/helpers/bench.js index 4c1c8ae..1fae4a7 100644 --- a/test/performance/bench.js +++ b/test/performance/helpers/bench.js @@ -12,8 +12,8 @@ // contact legal@dankest.llc. const path = require('path') -const { BASE, CPLX, UTXO_COUNTS, PAYLOAD_SIZES, makeEncoder } = require('./helpers/fixtures') -const { Histogram, MemoryTracker, GcTracker, formatReport, writeJsonReport } = require('./helpers/metrics') +const { BASE, CPLX, UTXO_COUNTS, PAYLOAD_SIZES, makeEncoder } = require('./fixtures') +const { Histogram, MemoryTracker, GcTracker, formatReport, writeJsonReport } = require('./metrics') // --------------------------------------------------------------------------- // CLI argument parsing (no deps) @@ -121,7 +121,7 @@ async function main () { if (JSON_OUT) { const ts = new Date().toISOString().replace(/[:.]/g, '-') - const outPath = path.join(__dirname, '..', '..', 'reports', `bench-${ts}.json`) + const outPath = path.join(__dirname, '..', '..', '..', 'reports', `bench-${ts}.json`) writeJsonReport(results, outPath) console.log(`JSON report written to: ${outPath}`) } diff --git a/test/performance/k6/concurrent.js b/test/performance/helpers/k6/concurrent.js similarity index 97% rename from test/performance/k6/concurrent.js rename to test/performance/helpers/k6/concurrent.js index 25c7696..87c7c16 100644 --- a/test/performance/k6/concurrent.js +++ b/test/performance/helpers/k6/concurrent.js @@ -17,7 +17,7 @@ * to stress the Node.js event loop under heavy concurrent load. * * Requires a running API server: npm run api - * Run: k6 run test/performance/k6/concurrent.js + * Run: k6 run test/performance/helpers/k6/concurrent.js * Env: ENCODER_URL (default http://localhost:3000) * API_KEY (default empty) */ diff --git a/test/performance/k6/spike.js b/test/performance/helpers/k6/spike.js similarity index 98% rename from test/performance/k6/spike.js rename to test/performance/helpers/k6/spike.js index 347e8de..c4d16db 100644 --- a/test/performance/k6/spike.js +++ b/test/performance/helpers/k6/spike.js @@ -16,7 +16,7 @@ * Simulates idle -> burst -> sustained spike -> recovery. * * Requires a running API server: npm run api - * Run: k6 run test/performance/k6/spike.js + * Run: k6 run test/performance/helpers/k6/spike.js * Env: ENCODER_URL (default http://localhost:3000) * API_KEY (default empty) */ diff --git a/test/performance/k6/sustained.js b/test/performance/helpers/k6/sustained.js similarity index 97% rename from test/performance/k6/sustained.js rename to test/performance/helpers/k6/sustained.js index 7747e20..6237202 100644 --- a/test/performance/k6/sustained.js +++ b/test/performance/helpers/k6/sustained.js @@ -14,7 +14,7 @@ * k6 sustained load test for xchain-encoder JSON-RPC API. * * Requires a running API server: npm run api - * Run: k6 run test/performance/k6/sustained.js + * Run: k6 run test/performance/helpers/k6/sustained.js * Env: ENCODER_URL (default http://localhost:3000) * API_KEY (default empty) */ diff --git a/test/performance/soak.js b/test/performance/helpers/soak.js similarity index 97% rename from test/performance/soak.js rename to test/performance/helpers/soak.js index 7a99759..40bf6a3 100644 --- a/test/performance/soak.js +++ b/test/performance/helpers/soak.js @@ -12,8 +12,8 @@ // contact legal@dankest.llc. const path = require('path') -const { BASE, CPLX, UTXO_COUNTS, PAYLOAD_SIZES, makeEncoder } = require('./helpers/fixtures') -const { Histogram, MemoryTracker, GcTracker, EldTracker, formatReport, writeJsonReport } = require('./helpers/metrics') +const { BASE, CPLX, UTXO_COUNTS, PAYLOAD_SIZES, makeEncoder } = require('./fixtures') +const { Histogram, MemoryTracker, GcTracker, EldTracker, formatReport, writeJsonReport } = require('./metrics') // --------------------------------------------------------------------------- // CLI @@ -156,7 +156,7 @@ async function soak () { if (REPORT) { const ts = new Date().toISOString().replace(/[:.]/g, '-') - const outPath = path.join(__dirname, '..', '..', 'reports', `soak-${ts}.json`) + const outPath = path.join(__dirname, '..', '..', '..', 'reports', `soak-${ts}.json`) writeJsonReport([{ id: scenario.id, label: scenario.label, diff --git a/test/prepare_regtest.test.js b/test/prepare_regtest.test.js index a67d667..5f2c702 100644 --- a/test/prepare_regtest.test.js +++ b/test/prepare_regtest.test.js @@ -13,7 +13,7 @@ ********************************************************************/ const { execSync } = require('child_process'); -const nodeHelper = require('./nodeHelper') +const nodeHelper = require('./helpers/node_helper') const { waitFor } = require('./helpers/timing') function executeCommand(comando) { diff --git a/test/xchain_encoder.test.js b/test/xchain_encoder.test.js index 78c390d..dec91cf 100644 --- a/test/xchain_encoder.test.js +++ b/test/xchain_encoder.test.js @@ -20,7 +20,7 @@ import bitcoin from 'bitcoinjs-lib' import psbtutils from 'bitcoinjs-lib/src/psbt/psbtutils.js' import assert from 'assert' import XChainEncoder from '../src/XChainEncoder.js' -import nodeHelper from './nodeHelper.js' +import nodeHelper from './helpers/node_helper.js' import crypto from 'node:crypto' import {ECPairFactory} from 'ecpair' From 5184e98acec11fd9815b8065774f7018c1c39404 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 13 Sep 2026 16:00:32 -0700 Subject: [PATCH 018/148] docs: restore one renamed test file's dropped header comment One test file was still short of the comment-coverage floor after its rename, because an earlier sweep commit touched it directly; restores its dropped header summary and declares the remaining shortfall (decorative dividers) as an exemption. --- test/unit/xchain_encoder_extra.test.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/unit/xchain_encoder_extra.test.js b/test/unit/xchain_encoder_extra.test.js index d7f6a0e..fe09573 100644 --- a/test/unit/xchain_encoder_extra.test.js +++ b/test/unit/xchain_encoder_extra.test.js @@ -8,6 +8,17 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. +// Additional tests targeting the branches in XChainEncoder.js not yet +// covered by the existing test files, specifically: +// - P2WSH encoding: tx1 (funding), tx2 (spending), segwit-unsupported guard +// - Non-segwit (P2PKH) UTXO path that calls connector.getTransactionHex +// - feeQuote injection into customOutputs +// - payload-too-large guard (MAX_COMPILED_ACTION_DATA_LENGTH) +// - fee=null/false fast-path vs computed fee increment loop +// - changeSatoshis <= 0 / <= dustAmount with no change address (no-throw path) +// - estimateSpendingP2wshTx() across all three push-size brackets +// - maxFeeRateKb cap on computed fee rate + const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const XChainEncoder = require('../../src/XChainEncoder') From 24c004a87f83218f6a7d0c584aeddf595a1cdb37 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 13 Sep 2026 16:07:18 -0700 Subject: [PATCH 019/148] docs: restore comments the sweep dropped across the rest of test/ Restores pure-deletion hunks from the same sweep commit across the remaining short test files, skipping anything the comment style guide already bans (ticket numbers, internal references, decorative banners) and declaring the leftover shortfall as an exemption instead. A handful of lines could not be placed unambiguously and are handled in the next commit. --- test/api.test.js | 2 ++ test/chaos/arithmetic_state.test.js | 1 + test/e2e/edge_cases_e2e.test.js | 5 +++++ test/e2e/error_rejection_e2e.test.js | 2 ++ test/integration/action_encoding.test.js | 1 + test/integration/encoding_types.test.js | 4 ++++ test/integration/error_handling.test.js | 1 + test/integration/multi_chain.test.js | 2 ++ test/integration/obfuscation_roundtrip.test.js | 10 ++++++++++ test/integration/utxo_fee.test.js | 6 ++++++ test/prepare_regtest.test.js | 9 +++++++++ test/xchain_encoder.test.js | 6 ++++++ 12 files changed, 49 insertions(+) diff --git a/test/api.test.js b/test/api.test.js index 4c38f57..38d0e14 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -31,8 +31,10 @@ async function sendToApi(method, params){ id: 1, } + // Realizar la solicitud JSON-RPC al nodo const response = await axios.post("http://"+apiUrl, data) + // Verificar si la solicitud fue exitosa y devolver el hex de la transacción if (response.data.result) { return response.data.result; } else { diff --git a/test/chaos/arithmetic_state.test.js b/test/chaos/arithmetic_state.test.js index e47c040..1479f67 100644 --- a/test/chaos/arithmetic_state.test.js +++ b/test/chaos/arithmetic_state.test.js @@ -224,6 +224,7 @@ describe('Chaos Category D: Arithmetic & State Corruption', () => { null, null, null, true, 0.00001 ) + // Second call with already-sorted array const result2 = await encoder.createTransaction( utxos, ADDRESS, null, actions.makeSend().data, null, 10000, false, null, ADDRESS, diff --git a/test/e2e/edge_cases_e2e.test.js b/test/e2e/edge_cases_e2e.test.js index ccde47d..c740441 100644 --- a/test/e2e/edge_cases_e2e.test.js +++ b/test/e2e/edge_cases_e2e.test.js @@ -135,6 +135,9 @@ describe('E2E-7: Complex Parameter & Edge Case Handling', () => { }) }) + // Note: The pipe character IS the delimiter, so including it in a memo + // would corrupt field parsing. This test documents that behavior. + describe('E2E-7.7: Pipe character in memo field', () => { it('pipe in memo extends the field count (protocol limitation)', async () => { // The pipe is the field delimiter, so a memo containing one is parsed @@ -142,6 +145,8 @@ describe('E2E-7: Complex Parameter & Edge Case Handling', () => { // string as-is. This is a known protocol characteristic, not a bug. const data = 'SEND|0|JDOG|1|mfWxJ45|memo|with|pipes' const { dataString } = await encodeAndExtract(data) + // The encoder preserves the string as-is; it's the decoder that would + // interpret the extra pipes as additional fields assert.strictEqual(dataString, data) }) }) diff --git a/test/e2e/error_rejection_e2e.test.js b/test/e2e/error_rejection_e2e.test.js index ad4e90a..5c52e37 100644 --- a/test/e2e/error_rejection_e2e.test.js +++ b/test/e2e/error_rejection_e2e.test.js @@ -233,6 +233,7 @@ describe('E2E-8: Error Handling & Negative Tests', () => { ) assert.ok(result.psbt) } catch (err) { + // If it throws, the error should be meaningful assert.ok(err.message.length > 0, 'error message should not be empty') } }) @@ -271,6 +272,7 @@ describe('E2E-8: Error Handling & Negative Tests', () => { null, null, null, true, 0.00001 ) + // If it succeeds, no output should have negative value for (const output of result.psbt.txOutputs) { assert.ok(output.value >= 0, `output value ${output.value} should not be negative`) } diff --git a/test/integration/action_encoding.test.js b/test/integration/action_encoding.test.js index a367ba7..57dc8df 100644 --- a/test/integration/action_encoding.test.js +++ b/test/integration/action_encoding.test.js @@ -269,6 +269,7 @@ describe('Category A: ACTION Payload Encoding Fidelity', () => { assert.ok(result.psbt instanceof bitcoin.Psbt) assert.ok(['OP_RETURN', 'P2SH'].includes(result.encoding)) + // For OP_RETURN results, verify full round-trip if (result.encoding === 'OP_RETURN') { const payload = extractOpReturnPayload(result, TXID_A) assert.strictEqual(payload.magic, MAGIC_WORD) diff --git a/test/integration/encoding_types.test.js b/test/integration/encoding_types.test.js index a760d6a..ef145b6 100644 --- a/test/integration/encoding_types.test.js +++ b/test/integration/encoding_types.test.js @@ -58,6 +58,7 @@ describe('Category B: Encoding Type Integration', () => { assert.ok(opReturnOutput, 'should have an OP_RETURN output') assert.strictEqual(opReturnOutput.script[0], bitcoin.opcodes.OP_RETURN) + // Decompile and verify obfuscated data is present const decompiled = bitcoin.script.decompile(opReturnOutput.script) assert.ok(Buffer.isBuffer(decompiled[1]), 'data should be a buffer after OP_RETURN') }) @@ -154,6 +155,7 @@ describe('Category B: Encoding Type Integration', () => { assert.strictEqual(tx2Result.encoding, 'P2SH') + // tx2 should have at least 1 input (the P2SH spend) assert.ok(tx2Result.psbt.data.inputs.length >= 1) const markerOutput = tx2Result.psbt.txOutputs.find(o => o.value === 0) @@ -185,6 +187,7 @@ describe('Category B: Encoding Type Integration', () => { const input = tx2Result.psbt.data.inputs[0] assert.ok(input.redeemScript, 'P2SH input should have redeemScript') + // Decompile the redeemScript to verify it contains data const decompiled = bitcoin.script.decompile(input.redeemScript) assert.ok(Buffer.isBuffer(decompiled[0]), 'first element should be data') assert.strictEqual(decompiled[1], bitcoin.opcodes.OP_DROP) @@ -252,6 +255,7 @@ describe('Category B: Encoding Type Integration', () => { const input = tx2Result.psbt.data.inputs[0] assert.ok(input.witnessScript, 'P2WSH input should have witnessScript') + // Decompile the witnessScript const decompiled = bitcoin.script.decompile(input.witnessScript) assert.ok(Buffer.isBuffer(decompiled[0]), 'first element should be data') assert.strictEqual(decompiled[1], bitcoin.opcodes.OP_DROP) diff --git a/test/integration/error_handling.test.js b/test/integration/error_handling.test.js index ca786c2..e2a6cd7 100644 --- a/test/integration/error_handling.test.js +++ b/test/integration/error_handling.test.js @@ -207,6 +207,7 @@ describe('Category H: Error Handling at Integration Boundaries', () => { true, 0.00001, customDust ) + // MULTISIGN output should use custom dust const msOutput = result.psbt.txOutputs.find(o => o.value === customDust) assert.ok(msOutput, 'MULTISIGN output should use custom dust value') }) diff --git a/test/integration/multi_chain.test.js b/test/integration/multi_chain.test.js index b1deda1..c87cbae 100644 --- a/test/integration/multi_chain.test.js +++ b/test/integration/multi_chain.test.js @@ -77,6 +77,7 @@ describe('Category F: Multi-Chain Network Configs', () => { assert.strictEqual(result.encoding, 'P2SH') + // Verify the P2SH output has value >= dustThreshold const p2shOutput = result.psbt.txOutputs.find(o => o.value > 0 && o.value < 100000000 ) @@ -159,6 +160,7 @@ describe('Category F: Multi-Chain Network Configs', () => { assert.strictEqual(result.encoding, 'P2SH') + // The P2SH output should decode to a valid Dogecoin address const dogeNetwork = CryptoNetworks.getBitcoinJsNetwork('dogecoin-regtest') const p2shOutput = result.psbt.txOutputs.find(o => { if (o.value <= 0 || o.value >= 100000000) return false diff --git a/test/integration/obfuscation_roundtrip.test.js b/test/integration/obfuscation_roundtrip.test.js index 1ef4634..56083d2 100644 --- a/test/integration/obfuscation_roundtrip.test.js +++ b/test/integration/obfuscation_roundtrip.test.js @@ -73,10 +73,12 @@ describe('Category C: Obfuscation Round-Trip', () => { null, null, null, true, 0.00001 ) + // Extract the raw obfuscated data from the OP_RETURN const opReturnOutput = result.psbt.txOutputs.find(o => o.value === 0) const decompiled = bitcoin.script.decompile(opReturnOutput.script) const obfuscatedData = decompiled[1] + // The obfuscated data should NOT start with "XCHN" in plaintext assert.notStrictEqual( obfuscatedData.subarray(0, 4).toString('utf8'), MAGIC_WORD, @@ -115,6 +117,7 @@ describe('Category C: Obfuscation Round-Trip', () => { const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeIssueFull('BIGTOKEN') + // tx1 const tx1Result = await encoder.createTransaction( [utxo], address, null, action.data, null, 10000, false, null, address, @@ -124,12 +127,14 @@ describe('Category C: Obfuscation Round-Trip', () => { const tx1Hex = tx1Result.psbt.__CACHE.__TX.toHex() const tx1Id = tx1Result.psbt.__CACHE.__TX.getId() + // tx2 const tx2Result = await encoder.createTransaction( [utxo], address, null, action.data, null, 10000, false, null, address, tx1Id, tx1Hex, null, true, 0.00001 ) + // Extract OP_RETURN marker from tx2 const markerOutput = tx2Result.psbt.txOutputs.find(o => o.value === 0) assert.ok(markerOutput, 'tx2 should have OP_RETURN marker') @@ -149,6 +154,7 @@ describe('Category C: Obfuscation Round-Trip', () => { const address = getTestAddress(NETWORK) const action = actions.makeSend() + // Encode with TXID_A const encoderA = makeEncoder(NETWORK) const utxoA = makeUtxo(NETWORK, TXID_A, 0, 100000000) const resultA = await encoderA.createTransaction( @@ -157,6 +163,7 @@ describe('Category C: Obfuscation Round-Trip', () => { null, null, null, true, 0.00001 ) + // Encode with TXID_B const encoderB = makeEncoder(NETWORK) const utxoB = makeUtxo(NETWORK, TXID_B, 0, 100000000) const resultB = await encoderB.createTransaction( @@ -165,6 +172,7 @@ describe('Category C: Obfuscation Round-Trip', () => { null, null, null, true, 0.00001 ) + // Extract raw obfuscated data from both const outputA = resultA.psbt.txOutputs.find(o => o.value === 0) const dataA = bitcoin.script.decompile(outputA.script)[1] @@ -174,6 +182,7 @@ describe('Category C: Obfuscation Round-Trip', () => { assert.ok(!dataA.equals(dataB), 'same data obfuscated with different TXIDs should produce different output') + // But both should deobfuscate to the same original data const payloadA = extractOpReturnPayload(resultA, TXID_A) const payloadB = extractOpReturnPayload(resultB, TXID_B) assert.strictEqual(payloadA.magic, MAGIC_WORD) @@ -221,6 +230,7 @@ describe('Category C: Obfuscation Round-Trip', () => { null, null, null, true, 0.00001 ) + // Try to deobfuscate with the wrong TXID const payload = extractOpReturnPayload(result, TXID_B) assert.notStrictEqual(payload.magic, MAGIC_WORD, 'wrong TXID should not produce valid magic word') diff --git a/test/integration/utxo_fee.test.js b/test/integration/utxo_fee.test.js index 3df6d8c..423ff93 100644 --- a/test/integration/utxo_fee.test.js +++ b/test/integration/utxo_fee.test.js @@ -61,6 +61,7 @@ describe('Category D: UTXO & Fee Integration', () => { assert.strictEqual(result.psbt.data.inputs.length, 1) assert.strictEqual(result.psbt.txOutputs.length, 2) + // One OP_RETURN (value=0), one change const opReturn = result.psbt.txOutputs.filter(o => o.value === 0) const change = result.psbt.txOutputs.filter(o => o.value > 0) assert.strictEqual(opReturn.length, 1) @@ -88,6 +89,7 @@ describe('Category D: UTXO & Fee Integration', () => { null, null, null, true, 0.00001 ) + // Should need multiple inputs to cover the fee assert.ok(result.psbt.data.inputs.length >= 2, `expected >= 2 inputs, got ${result.psbt.data.inputs.length}`) }) @@ -110,6 +112,7 @@ describe('Category D: UTXO & Fee Integration', () => { null, null, null, true, 0.00001 ) + // Should have at most 2 unique UTXOs as inputs assert.ok(result.psbt.data.inputs.length <= 2, `expected <= 2 inputs after dedup, got ${result.psbt.data.inputs.length}`) }) @@ -235,6 +238,7 @@ describe('Category D: UTXO & Fee Integration', () => { describe('D-7: Fee capped by maxFeePerBytes', () => { it('limits fee when maxFeeRateKb is set', async () => { + // Create encoder WITH fee cap const capped = new XChainEncoder( NETWORK, '127.0.0.1', '8333', 'rpc', 'rpc', '', '', 1000 // 1000 sat/kB cap ) @@ -258,6 +262,7 @@ describe('Category D: UTXO & Fee Integration', () => { null, null, null, true, 100000000 // very high: 1e8 sat/kB = 100000 sat/byte ) + // Create uncapped encoder for comparison const uncapped = makeEncoder(NETWORK) const resultUncapped = await uncapped.createTransaction( [utxo], address, null, @@ -265,6 +270,7 @@ describe('Category D: UTXO & Fee Integration', () => { null, null, null, true, 100000000 ) + // Capped encoder should produce lower fee (more change) const cappedChange = result.psbt.txOutputs.find(o => o.value > 0) const uncappedChange = resultUncapped.psbt.txOutputs.find(o => o.value > 0) assert.ok(cappedChange.value > uncappedChange.value, diff --git a/test/prepare_regtest.test.js b/test/prepare_regtest.test.js index 5f2c702..a424f6b 100644 --- a/test/prepare_regtest.test.js +++ b/test/prepare_regtest.test.js @@ -16,6 +16,7 @@ const { execSync } = require('child_process'); const nodeHelper = require('./helpers/node_helper') const { waitFor } = require('./helpers/timing') +// Función para ejecutar comandos del sistema function executeCommand(comando) { let options = {stdio : 'pipe' }; return execSync(comando, options); @@ -23,11 +24,14 @@ function executeCommand(comando) { function checkNode(){ try { + // Obtener la información de la red utilizando bitcoin-cli const networkInfo = executeCommand('bitcoin-cli -regtest getnetworkinfo'); + // Analizar la salida para verificar si el nodo regtest está en ejecución const parsedNetworkInfo = JSON.parse(networkInfo); return parsedNetworkInfo.networkactive === true; } catch (error) { + // Manejar errores si es necesario return false } } @@ -35,15 +39,19 @@ function checkNode(){ exports.mochaHooks = { async beforeAll(){ if (checkNode()){ + // Stop regtest node console.log("Stopping node") executeCommand('bitcoin-cli -regtest stop'); } else { console.log("The node is not working, continuing execution") + //Assuming regtest node is not executing } + // Limpiar la cadena de bloques y los datos del nodo regtest (opcional) console.log("Cleaning node") executeCommand('rm -rf ~/.bitcoin/regtest'); + // Inicializar el nodo regtest console.log("Restarting node") executeCommand('bitcoind -regtest -daemon -fallbackfee=1.0 -maxtxfee=1.1'); @@ -57,6 +65,7 @@ exports.mochaHooks = { message: 'regtest bitcoind did not become ready' }) + // Puedes realizar más acciones después de reiniciar el nodo si es necesario console.log('Nodo regtest reset and ready.'); console.log("Creating the wallet 'test-wallet'") diff --git a/test/xchain_encoder.test.js b/test/xchain_encoder.test.js index dec91cf..2a77775 100644 --- a/test/xchain_encoder.test.js +++ b/test/xchain_encoder.test.js @@ -125,6 +125,7 @@ describe('XChainEncoder', () => { const changeAddress = testAddress; const exactFee = 10000; + // Llamar a la función createTransaction console.log("Creating the transaction with XchainEncoder") const psbtResultante = await encoder.createTransaction(utxos, pubkey, customOutputs, data, rawData, exactFee, replacebyfee, outputType, changeAddress); const psbtBase64 = psbtResultante.toBase64() @@ -252,6 +253,7 @@ describe('XChainEncoder', () => { let tx2Hex = tx2.toHex() let tx2Id = tx2.getId() + //Broadcast first p2sh tx console.log("Broadcasting the first p2sh transacion to the node") let txHash = await nodeHelper.broadcastTx(tx1Hex) @@ -263,6 +265,7 @@ describe('XChainEncoder', () => { assert(txObj["vin"].length == 1) assert(txObj["vout"].length == 2) + //Broadcast second p2sh tx console.log("Broadcasting the second p2sh transacion to the node") let tx2Hash = await nodeHelper.broadcastTx(tx2Hex) assert((tx2Hash != null) && (tx2Hash.length == 64)) @@ -362,6 +365,7 @@ describe('XChainEncoder', () => { let tx1Hex = tx1.toHex() let tx1Id = tx1.getId() + //Broadcast multisign tx console.log("Broadcasting the multisign transacion to the node") let txHash = await nodeHelper.broadcastTx(tx1Hex) @@ -482,6 +486,7 @@ describe('XChainEncoder', () => { let tx2Hex = tx2.toHex() let tx2Id = tx2.getId() + //Broadcast first p2wsh tx console.log("Broadcasting the first p2wsh transacion to the node") let txHash = await nodeHelper.broadcastTx(tx1Hex) @@ -493,6 +498,7 @@ describe('XChainEncoder', () => { assert(txObj["vin"].length == 1) assert(txObj["vout"].length == 2) + //Broadcast second p2wsh tx console.log("Broadcasting the second p2wsh transacion to the node") let tx2Hash = await nodeHelper.broadcastTx(tx2Hex) assert((tx2Hash != null) && (tx2Hash.length == 64)) From ca6ae72bbdd687aca88540ef90967730f78523ea Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 13 Sep 2026 16:09:48 -0700 Subject: [PATCH 020/148] docs: place the remaining sweep-restored comment lines Places the comment lines the previous commit's anchor search could not uniquely locate, using each hunk's original top-to-bottom order instead. Drops one restored line that contradicted the unconditional rejection described immediately above it. --- test/chaos/arithmetic_state.test.js | 1 + test/e2e/error_rejection_e2e.test.js | 4 ++++ test/integration/encoding_types.test.js | 9 +++++++++ test/integration/obfuscation_roundtrip.test.js | 3 +++ test/integration/utxo_fee.test.js | 1 + test/xchain_encoder.test.js | 13 +++++++++++++ 6 files changed, 31 insertions(+) diff --git a/test/chaos/arithmetic_state.test.js b/test/chaos/arithmetic_state.test.js index 1479f67..00e5f94 100644 --- a/test/chaos/arithmetic_state.test.js +++ b/test/chaos/arithmetic_state.test.js @@ -199,6 +199,7 @@ describe('Chaos Category D: Arithmetic & State Corruption', () => { ] const orderBefore = utxos.map(u => u.txid) + // First call await encoder.createTransaction( utxos, ADDRESS, null, actions.makeSend().data, null, 10000, false, null, ADDRESS, diff --git a/test/e2e/error_rejection_e2e.test.js b/test/e2e/error_rejection_e2e.test.js index 5c52e37..d53896b 100644 --- a/test/e2e/error_rejection_e2e.test.js +++ b/test/e2e/error_rejection_e2e.test.js @@ -231,6 +231,7 @@ describe('E2E-8: Error Handling & Negative Tests', () => { '', null, 10000, false, null, address, null, null, null, true, 0.00001 ) + // If it succeeds, it should still be a valid PSBT assert.ok(result.psbt) } catch (err) { // If it throws, the error should be meaningful @@ -277,6 +278,7 @@ describe('E2E-8: Error Handling & Negative Tests', () => { assert.ok(output.value >= 0, `output value ${output.value} should not be negative`) } } catch (err) { + // Throwing is also acceptable assert.ok(err) } }) @@ -298,8 +300,10 @@ describe('E2E-8: Error Handling & Negative Tests', () => { null, null, null, true, 0.00001 ) + // If it somehow succeeds, verify structural validity assert.ok(result.psbt) } catch (err) { + // Expected to throw due to insufficient funds assert.ok(err) } }) diff --git a/test/integration/encoding_types.test.js b/test/integration/encoding_types.test.js index ef145b6..4ee0afe 100644 --- a/test/integration/encoding_types.test.js +++ b/test/integration/encoding_types.test.js @@ -138,6 +138,7 @@ describe('Category B: Encoding Type Integration', () => { const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeIssueFull('BIGTOKEN') + // Create tx1 const tx1Result = await encoder.createTransaction( [utxo], address, null, action.data, null, 10000, false, null, address, @@ -147,6 +148,7 @@ describe('Category B: Encoding Type Integration', () => { const tx1Hex = tx1Result.psbt.__CACHE.__TX.toHex() const tx1Id = tx1Result.psbt.__CACHE.__TX.getId() + // Create tx2 const tx2Result = await encoder.createTransaction( [utxo], address, null, action.data, null, 10000, false, null, address, @@ -158,6 +160,7 @@ describe('Category B: Encoding Type Integration', () => { // tx2 should have at least 1 input (the P2SH spend) assert.ok(tx2Result.psbt.data.inputs.length >= 1) + // tx2 should have an OP_RETURN marker output with value 0 const markerOutput = tx2Result.psbt.txOutputs.find(o => o.value === 0) assert.ok(markerOutput, 'tx2 should have OP_RETURN marker') assert.strictEqual(markerOutput.script[0], bitcoin.opcodes.OP_RETURN) @@ -184,6 +187,7 @@ describe('Category B: Encoding Type Integration', () => { tx1Id, tx1Hex, null, true, 0.00001 ) + // The first input should have a redeemScript const input = tx2Result.psbt.data.inputs[0] assert.ok(input.redeemScript, 'P2SH input should have redeemScript') @@ -235,6 +239,7 @@ describe('Category B: Encoding Type Integration', () => { const utxo = makeUtxo(p2wshNetwork, TXID_A, 0, 100000000) const action = actions.makeFileLarge() + // Create tx1 const tx1Result = await encoder.createTransaction( [utxo], address, null, action.data, null, 10000, false, 'P2WSH', address, @@ -244,6 +249,7 @@ describe('Category B: Encoding Type Integration', () => { const tx1Hex = tx1Result.psbt.__CACHE.__TX.toHex() const tx1Id = tx1Result.psbt.__CACHE.__TX.getId() + // Create tx2 const tx2Result = await encoder.createTransaction( [utxo], address, null, action.data, null, 10000, false, 'P2WSH', address, @@ -252,6 +258,7 @@ describe('Category B: Encoding Type Integration', () => { assert.strictEqual(tx2Result.encoding, 'P2WSH') + // tx2 first input should have a witnessScript const input = tx2Result.psbt.data.inputs[0] assert.ok(input.witnessScript, 'P2WSH input should have witnessScript') @@ -260,6 +267,7 @@ describe('Category B: Encoding Type Integration', () => { assert.ok(Buffer.isBuffer(decompiled[0]), 'first element should be data') assert.strictEqual(decompiled[1], bitcoin.opcodes.OP_DROP) + // tx2 should have OP_RETURN marker const markerOutput = tx2Result.psbt.txOutputs.find(o => o.value === 0) assert.ok(markerOutput, 'tx2 should have OP_RETURN marker') }) @@ -284,6 +292,7 @@ describe('Category B: Encoding Type Integration', () => { assert.strictEqual(result.encoding, 'MULTISIGN') + // Find the multisig output (value = dustAmount) const msOutput = result.psbt.txOutputs.find(o => o.value === encoder.dustAmount ) diff --git a/test/integration/obfuscation_roundtrip.test.js b/test/integration/obfuscation_roundtrip.test.js index 56083d2..52ec3c0 100644 --- a/test/integration/obfuscation_roundtrip.test.js +++ b/test/integration/obfuscation_roundtrip.test.js @@ -105,6 +105,7 @@ describe('Category C: Obfuscation Round-Trip', () => { const payload = extractMultisignPayload(result, TXID_MULTISIGN, encoder.dustAmount) assert.strictEqual(payload.magic, MAGIC_WORD) + // The decompiled data should contain our original string const decompiled = decompilePayload(payload.dataBuffer) assert.strictEqual(decompiled[0].toString('utf8'), MS_DATA) }) @@ -210,6 +211,8 @@ describe('Category C: Obfuscation Round-Trip', () => { null, null, null, true, 0.00001 ) + // After sorting, TXID_A (largest) should be the first UTXO + // and therefore the obfuscation key const payload = extractOpReturnPayload(result, TXID_A) assert.strictEqual(payload.magic, MAGIC_WORD, 'obfuscation key should derive from largest UTXO txid (TXID_A)') diff --git a/test/integration/utxo_fee.test.js b/test/integration/utxo_fee.test.js index 423ff93..5ba6de9 100644 --- a/test/integration/utxo_fee.test.js +++ b/test/integration/utxo_fee.test.js @@ -382,6 +382,7 @@ describe('Category D: UTXO & Fee Integration', () => { null, null, null, true, 0.00001 ) + // The large UTXO alone covers everything, so only 1 input needed assert.strictEqual(result.psbt.data.inputs.length, 1) }) }) diff --git a/test/xchain_encoder.test.js b/test/xchain_encoder.test.js index 2a77775..17711ee 100644 --- a/test/xchain_encoder.test.js +++ b/test/xchain_encoder.test.js @@ -115,6 +115,7 @@ describe('XChainEncoder', () => { } } + // Configurar instancias y datos de prueba const encoder = new XChainEncoder("bitcoin-regtest", "127.0.0.1", "8333", "rpc", "rpc", ""); const pubkey = testAddress; const customOutputs = {}; @@ -130,6 +131,7 @@ describe('XChainEncoder', () => { const psbtResultante = await encoder.createTransaction(utxos, pubkey, customOutputs, data, rawData, exactFee, replacebyfee, outputType, changeAddress); const psbtBase64 = psbtResultante.toBase64() + //Cargar el psbt const psbtParaFirmar = bitcoin.Psbt.fromBase64(psbtBase64) var ECPair = ECPairFactory(ecc); @@ -208,6 +210,7 @@ describe('XChainEncoder', () => { } } + // Configurar instancias y datos de prueba const encoder = new XChainEncoder("bitcoin-regtest", "127.0.0.1", "8333", "rpc", "rpc", ""); const pubkey = testAddress; const customOutputs = {}; @@ -218,10 +221,12 @@ describe('XChainEncoder', () => { const changeAddress = testAddress; const exactFee = 10000; + // Llamar a la función createTransaction console.log("Creating the first transaction with XchainEncoder") const psbtResultante = await encoder.createTransaction(utxos, pubkey, customOutputs, data, rawData, exactFee, replacebyfee, outputType, changeAddress); const psbtBase64 = psbtResultante.toBase64() + //Cargar el psbt const psbtParaFirmar = bitcoin.Psbt.fromBase64(psbtBase64) var ECPair = ECPairFactory(ecc); @@ -237,6 +242,7 @@ describe('XChainEncoder', () => { let tx1Hex = tx1.toHex() let tx1Id = tx1.getId() + //Cargar el psbt2 console.log("Creating the second transaction with XchainEncoder") const psbt2Resultante = await encoder.createTransaction(utxos, pubkey, customOutputs, data, rawData, exactFee, replacebyfee, outputType, changeAddress, tx1Id, tx1Hex); const psbt2Base64 = psbt2Resultante.toBase64() @@ -334,6 +340,7 @@ describe('XChainEncoder', () => { } } + // Configurar instancias y datos de prueba const encoder = new XChainEncoder("bitcoin-regtest", "127.0.0.1", "8333", "rpc", "rpc", ""); const pubkey = testAddress; const customOutputs = {}; @@ -346,10 +353,12 @@ describe('XChainEncoder', () => { const compressedPubKey = address.publicKey.toString("hex") + // Llamar a la función createTransaction console.log("Creating the first transaction with XchainEncoder") const psbtResultante = await encoder.createTransaction(utxos, pubkey, customOutputs, data, rawData, exactFee, replacebyfee, outputType, changeAddress, null, null, compressedPubKey); const psbtBase64 = psbtResultante.toBase64() + //Cargar el psbt const psbtParaFirmar = bitcoin.Psbt.fromBase64(psbtBase64) var ECPair = ECPairFactory(ecc); @@ -441,6 +450,7 @@ describe('XChainEncoder', () => { } } + // Configurar instancias y datos de prueba const encoder = new XChainEncoder("bitcoin-regtest", "127.0.0.1", "8333", "rpc", "rpc", ""); const pubkey = testAddress; const customOutputs = {}; @@ -451,10 +461,12 @@ describe('XChainEncoder', () => { const changeAddress = testAddress; const exactFee = 10000; + // Llamar a la función createTransaction console.log("Creating the first transaction with XchainEncoder") const psbtResultante = await encoder.createTransaction(utxos, pubkey, customOutputs, data, rawData, exactFee, replacebyfee, outputType, changeAddress); const psbtBase64 = psbtResultante.toBase64() + //Cargar el psbt const psbtParaFirmar = bitcoin.Psbt.fromBase64(psbtBase64) var ECPair = ECPairFactory(ecc); @@ -470,6 +482,7 @@ describe('XChainEncoder', () => { let tx1Hex = tx1.toHex() let tx1Id = tx1.getId() + //Cargar el psbt2 console.log("Creating the second transaction with XchainEncoder") const psbt2Resultante = await encoder.createTransaction(utxos, pubkey, customOutputs, data, rawData, exactFee, replacebyfee, outputType, changeAddress, tx1Id, tx1Hex); const psbt2Base64 = psbt2Resultante.toBase64() From 94656ca857d0805189b8fc1f1a19b687da8dc3c0 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 13 Sep 2026 16:20:03 -0700 Subject: [PATCH 021/148] test: restore the deepdive regression suite's per-finding summary lines Each finding block regains the one-line summary a comment sweep removed, reworded without the decorative rule characters. --- test/regression/reg_09_deepdive_fixes.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/regression/reg_09_deepdive_fixes.test.js b/test/regression/reg_09_deepdive_fixes.test.js index 47b99d4..714eca2 100644 --- a/test/regression/reg_09_deepdive_fixes.test.js +++ b/test/regression/reg_09_deepdive_fixes.test.js @@ -34,6 +34,7 @@ const BIG_DATA = 'x'.repeat(80) describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { + // Reveal derives inputs from phase 1, never re-queries the tracker. describe('M-5: P2SH reveal does not re-query the UTXO tracker', () => { it('builds the reveal from p2shHex alone, even when the tracker would fail', async () => { const encoder = makeEncoder('dogecoin-regtest') @@ -70,6 +71,7 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { }) }) + // Sub-dust change is folded into the fee, never emitted. describe('M-6: sub-dust change folding', () => { it('folds 1-sat change into the fee (no unbroadcastable dust output)', async () => { const encoder = makeEncoder('bitcoin-regtest') // dust = 546 @@ -113,6 +115,7 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { }) }) + // The 500-UTXO cap gates the SELECTED count, not the fetched set. describe('M-7: MAX_UTXO_COUNT applies to selected inputs, not the fetched set', () => { it('builds from a tracker set larger than 500 UTXOs when a few inputs cover', async () => { const encoder = makeEncoder('bitcoin-regtest') @@ -154,6 +157,7 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { }) }) + // Under-funded selection throws a typed error, not a dead PSBT. describe('M-8: insufficient-funds error', () => { it('throws INSUFFICIENT_FUNDS with a required/available payload', async () => { const encoder = makeEncoder('bitcoin-regtest') @@ -176,6 +180,7 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { }) }) + // Outpoint reservation prevents concurrent same-address double-spends. describe('L-1: in-memory outpoint reservation', () => { function twoUtxoTracker (encoder) { encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ @@ -243,6 +248,7 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { }) }) + // api.js forwards typed operational errors, sanitizes internals. describe('L-2: API surfaces typed operational errors', () => { const { OperationalError } = require('../../src/build/errors') const { jsonRpcController, encoder } = require('../../src/api') From f5d07aa0ea84923c58bb777a7cca40c7664e6e08 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 13 Sep 2026 16:20:06 -0700 Subject: [PATCH 022/148] repoint this repo's own references to sibling repos' renamed files xchain-encoder names xchain-decoder's BlockchainConnector.js and several renamed test files, xchain-sdk's applyBufferutilsPatch.js, compression.js and its own sibling-coverage test, and xchain-node's EncoderMaintenanceWindow.js, in comments and computed sibling-path literals; repoints all of them to those repos' new paths. The hub-vendored src/coins/index.js comment and the utxo-tracker-vendored utxo-record-conformance.json fixture are left untouched, since both must stay byte-identical to their unedited canonical or twin copy until that repo fixes its own self-reference. --- README.md | 2 +- src/build/apply_bufferutils_patch.js | 9 +++++---- src/build/blockchain_connector.js | 2 +- test/conformance/helpers/generate_roundtrip_fixture.js | 4 ++-- test/conformance/roundtrip_conformance.test.js | 4 ++-- test/conformance/utxo_record_conformance.test.js | 2 +- test/unit/apply_bufferutils_patch.test.js | 2 +- test/unit/compression.test.js | 2 +- test/unit/sibling_coverage.test.js | 2 +- test/unit/validator.test.js | 2 +- 10 files changed, 16 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 8f14cf2..a5e0d78 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ indefinitely. A `since` in the future holds the window closed until it opens. `reason` is optional, bounded, and stripped to printable ASCII. `xchain-node` writes and removes this file automatically around a bootstrap -publish (`src/services/EncoderMaintenanceWindow.js`). +publish (`src/services/encoder_maintenance_window.js`). ## Metrics and log shipping (optional, off by default) diff --git a/src/build/apply_bufferutils_patch.js b/src/build/apply_bufferutils_patch.js index f4ac193..9c679b7 100644 --- a/src/build/apply_bufferutils_patch.js +++ b/src/build/apply_bufferutils_patch.js @@ -22,14 +22,15 @@ * single output can legitimately exceed both ceilings (>~90.07M DOGE for * 2^53); without this patch the encoder cannot build such a PSBT at all. * - * This is the WRITE-SIDE contract, shared with xchain-sdk/src/ - * applyBufferutilsPatch.js: the patch teaches the loaded modules to carry a + * This is the WRITE-SIDE contract, shared with xchain-sdk/src/utils/ + * apply_bufferutils_patch.js: the patch teaches the loaded modules to carry a * satoshi value as either a Number (unchanged fast path) or a BigInt up to * 2^64-1 (the wire format's true ceiling). Readers return a Number whenever * the value is exactly representable and a BigInt only above 2^53-1, so * existing Number-based callers see identical behavior for every value they - * could already handle. The READ-SIDE copies (xchain-decoder and - * xchain-utxo-tracker, src/build/apply_bufferutils_patch.js) lift the same 2^53 wall + * could already handle. The READ-SIDE copies (xchain-decoder's + * src/apply_bufferutils_patch.js and xchain-utxo-tracker's + * src/chain/apply_bufferutils_patch.js) lift the same 2^53 wall * for block decode but deliberately implement a DIFFERENT contract: * BufferReader.readUInt64 always returns a BigInt and the module-level * readUInt64LE/writeUInt64LE helpers keep the stock 2^53-1 ceiling. Only the diff --git a/src/build/blockchain_connector.js b/src/build/blockchain_connector.js index 3d8bc57..5c4fbe2 100644 --- a/src/build/blockchain_connector.js +++ b/src/build/blockchain_connector.js @@ -77,7 +77,7 @@ function feeEstimateSanityCeiling(){ // the encoder logs (util.inspect walks error.config.auth). Scrub the credential // fields in place so neither this logger nor any upstream handler leaks them, and // return a compact, credential-free string (error.message never carries auth). -// Kept in sync with xchain-decoder/src/BlockchainConnector.js sanitizeRpcError. +// Kept in sync with xchain-decoder/src/chain/blockchain_connector.js sanitizeRpcError. function sanitizeRpcError(error){ try { if (error && error.config) { diff --git a/test/conformance/helpers/generate_roundtrip_fixture.js b/test/conformance/helpers/generate_roundtrip_fixture.js index 1bccff4..a9478cb 100644 --- a/test/conformance/helpers/generate_roundtrip_fixture.js +++ b/test/conformance/helpers/generate_roundtrip_fixture.js @@ -205,7 +205,7 @@ const ALIAS_CASES = [ // which runs AFTER that gate, so the stored canonical record lands past the // numeric cap. This pins on the encoder half that the cap bounds the WIRE // (alias) form, never the stored record; the decoder half is pinned by - // xchain-decoder/test/unit/aliasExpansionBoundary.test.js. canonicalDataHex + // xchain-decoder/test/unit/alias_expansion_boundary.test.js. canonicalDataHex // below is the >cap BROADCAST record the roundtrip is expected to store. { name: 'alias rewrite CAST -> BROADCAST at the compiled ceiling', data: CAST_CEILING_DATA, rawData: null, expectedRawActionName: 'CAST', expectedActionName: 'BROADCAST' } ] @@ -215,7 +215,7 @@ const ALIAS_CASES = [ // the OP_PUSHDATA4 band in envelopePushSize; the decoder refuses to re-measure // an envelope payload at all), so it is also the lane where an unpinned // roundtrip costs the most. The arithmetic half of that asymmetry is pinned -// directly by xchain-decoder/test/unit/compiledPushSizeConformance.test.js's +// directly by xchain-decoder/test/unit/compiled_push_size_conformance.test.js's // envelope band block; these cases pin the STRUCTURAL half - an // encoder-emitted envelope reaching the stored record - which no test reached // before. Deliberately no case above 65,535 bytes: this artifact is vendored diff --git a/test/conformance/roundtrip_conformance.test.js b/test/conformance/roundtrip_conformance.test.js index e42284e..573465b 100644 --- a/test/conformance/roundtrip_conformance.test.js +++ b/test/conformance/roundtrip_conformance.test.js @@ -13,7 +13,7 @@ // case (a drift guard: any change to the compile construction or obfuscation // step that would desync the decoder fails here) and that bitcoin.script.compile // canonicalization matches the pinned gate expectation. The decoder half -// (xchain-decoder/test/unit/roundtripConformance.test.js) consumes the SAME +// (xchain-decoder/test/unit/roundtrip_conformance.test.js) consumes the SAME // JSON and feeds these bytes through the real decoder deobfuscation + decompile. const assert = require('assert') @@ -280,7 +280,7 @@ describe('roundtrip conformance fixture: TAPROOT envelope emit', () => { // pre-check models an envelope push the way bitcoin.script.compile // frames it (OP_PUSHDATA4 above 0xffff), which compiledPushSize does // not. Both agree below the band; the band itself is swept by - // xchain-decoder/test/unit/compiledPushSizeConformance.test.js. + // xchain-decoder/test/unit/compiled_push_size_conformance.test.js. const dataBytes = Buffer.from(c.inputDataHex, 'hex').length const rawBytes = c.inputRawDataHex == null ? 0 : Buffer.from(c.inputRawDataHex, 'hex').length const measured = envelopePushSize(dataBytes) + (c.inputRawDataHex == null ? 0 : envelopePushSize(rawBytes)) diff --git a/test/conformance/utxo_record_conformance.test.js b/test/conformance/utxo_record_conformance.test.js index 5565914..5a86c9d 100644 --- a/test/conformance/utxo_record_conformance.test.js +++ b/test/conformance/utxo_record_conformance.test.js @@ -218,7 +218,7 @@ describe('utxo-record conformance fixture: byte-identity to the tracker original fs.readFileSync(VENDORED, 'utf8'), fs.readFileSync(TRACKER_FIXTURE, 'utf8'), 'vendored utxo-record-conformance.json drifted from the tracker original; re-run ' + - 'xchain-utxo-tracker/test/conformance/generateUtxoRecordFixture.js and re-vendor the copy here.' + 'xchain-utxo-tracker/test/conformance/support/generate_utxo_record_fixture.js and re-vendor the copy here.' ) }) }) diff --git a/test/unit/apply_bufferutils_patch.test.js b/test/unit/apply_bufferutils_patch.test.js index 3ffd122..9e93ae4 100644 --- a/test/unit/apply_bufferutils_patch.test.js +++ b/test/unit/apply_bufferutils_patch.test.js @@ -149,7 +149,7 @@ describe('applyBufferutilsPatch', function () { }); }); - // Twin guard. This file is a fork of xchain-sdk/src/applyBufferutilsPatch.js + // Twin guard. This file is a fork of xchain-sdk/src/utils/apply_bufferutils_patch.js // and has drifted apart three times. Convergence runs SDK-ward, so pin the // bodies byte-identical and let the fork be a deliberate, visible act // rather than a silent one. diff --git a/test/unit/compression.test.js b/test/unit/compression.test.js index 0fd70f0..df8803f 100644 --- a/test/unit/compression.test.js +++ b/test/unit/compression.test.js @@ -474,7 +474,7 @@ describe('encoder FILE payload compression (spec Part B)', function () { // raw, or vice versa. it('agrees with the SDK helper on every decision (skips without the sdk sibling)', async function () { const SDK = process.env.XCHAIN_SDK_DIR || path.join(__dirname, '..', '..', '..', 'xchain-sdk') - const SDK_COMPRESSION = path.join(SDK, 'src', 'compression.js') + const SDK_COMPRESSION = path.join(SDK, 'src', 'protocol', 'compression.js') if (!fs.existsSync(SDK_COMPRESSION)) { if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') throw new Error('xchain-sdk sibling not found but XCHAIN_REQUIRE_SIBLINGS=1') diff --git a/test/unit/sibling_coverage.test.js b/test/unit/sibling_coverage.test.js index 05ffc51..8434d42 100644 --- a/test/unit/sibling_coverage.test.js +++ b/test/unit/sibling_coverage.test.js @@ -39,7 +39,7 @@ // the full checkout proves it rather than asserting it. Default (unset) stays // permissive so a single-repo clone is green. // -// Ported from xchain-sdk/test/unit/sibling-coverage.test.js. +// Ported from xchain-sdk/test/unit/sibling_coverage.test.js. const assert = require('assert'); const fs = require('fs'); diff --git a/test/unit/validator.test.js b/test/unit/validator.test.js index 6974c06..0d00b37 100644 --- a/test/unit/validator.test.js +++ b/test/unit/validator.test.js @@ -234,7 +234,7 @@ describe('Encoder input validator', function () { // stored as a BROADCAST record a few bytes past the numeric cap. That is // intentional: the cap is a wire-bytes cap, not a stored-record cap. This // pins the boundary on the encoder half; the decoder half is pinned by - // xchain-decoder/test/unit/aliasExpansionBoundary.test.js. + // xchain-decoder/test/unit/alias_expansion_boundary.test.js. const PREFIX = 'CAST|0|'; // MAX_DATA_BYTES == MAX_COMPILED_ACTION_DATA_LENGTH - OP_PUSHDATA2 overhead: // the widest single raw push whose compiled form is exactly the ceiling. From 70286c21f9491cbdbd1a21f9bed0b52105758923 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 13 Sep 2026 16:23:19 -0700 Subject: [PATCH 023/148] test: restore the remaining sweep-removed comments in seven suites The oversized OP_RETURN rationale regains its full wording, with the stale per-coin policy sentence corrected to the unconditional rejection; the finalizer's skipped shape check is described again. --- test/boundary/encoding_chunk_boundaries.test.js | 2 ++ test/e2e/obfuscation_integrity_e2e.test.js | 10 ++++++---- test/e2e/round_trip_e2e.test.js | 9 ++++++--- test/smoke/smoke.test.js | 5 +++-- test/xchain_encoder.test.js | 4 ++++ 5 files changed, 21 insertions(+), 9 deletions(-) diff --git a/test/boundary/encoding_chunk_boundaries.test.js b/test/boundary/encoding_chunk_boundaries.test.js index 2bdf017..35fe9a9 100644 --- a/test/boundary/encoding_chunk_boundaries.test.js +++ b/test/boundary/encoding_chunk_boundaries.test.js @@ -135,6 +135,7 @@ describe('Encoding Chunk Boundaries: Full Pipeline', () => { }) it('150-char data (compiled=152) → rejected', async () => { + // Rejects on every shipped coin; bitcoin-regtest here is a fixture, not a condition const orNet = 'bitcoin-regtest' const encoder = makeEncoder(orNet) const address = getTestAddress(orNet) @@ -150,6 +151,7 @@ describe('Encoding Chunk Boundaries: Full Pipeline', () => { }) it('151-char data (compiled=153) → rejected', async () => { + // Rejects on every shipped coin; bitcoin-regtest here is a fixture, not a condition const orNet = 'bitcoin-regtest' const encoder = makeEncoder(orNet) const address = getTestAddress(orNet) diff --git a/test/e2e/obfuscation_integrity_e2e.test.js b/test/e2e/obfuscation_integrity_e2e.test.js index 14be37a..b8626fd 100644 --- a/test/e2e/obfuscation_integrity_e2e.test.js +++ b/test/e2e/obfuscation_integrity_e2e.test.js @@ -215,10 +215,12 @@ describe('E2E-4: Obfuscation Integrity', () => { describe('E2E-4.7: Oversized OP_RETURN rejected', () => { it('forced OP_RETURN beyond a single output is rejected', async () => { - // Bitcoin permits only one OP_RETURN per transaction, so an oversized - // payload must reject here rather than split into independently - // obfuscated outputs; force the bitcoin network since dogecoin/litecoin - // allow multiple OP_RETURNs. + // A transaction may carry at most one OP_RETURN output; Bitcoin Core + // rejects multi-OP_RETURN transactions as non-standard at broadcast. + // A payload larger than one 76-byte chunk must be rejected at + // construction rather than split into independently-obfuscated outputs. + // The rejection is unconditional on every coin, so the bitcoin network + // here is a fixture, not a condition. const bigData = 'Y'.repeat(200) const orNet = 'bitcoin-regtest' const encoder = makeEncoder(orNet) diff --git a/test/e2e/round_trip_e2e.test.js b/test/e2e/round_trip_e2e.test.js index 45ecc75..07a65eb 100644 --- a/test/e2e/round_trip_e2e.test.js +++ b/test/e2e/round_trip_e2e.test.js @@ -191,9 +191,12 @@ describe('E2E-9: Round-Trip Verification', () => { describe('E2E-9.5: Oversized OP_RETURN rejected', () => { it('forced OP_RETURN beyond a single output is rejected', async () => { - // Bitcoin permits only one OP_RETURN per transaction; large payloads - // round-trip via the P2SH path instead. Force the bitcoin network since - // dogecoin/litecoin allow multiple OP_RETURNs. + // A transaction may carry at most one OP_RETURN output; Bitcoin Core + // rejects multi-OP_RETURN transactions as non-standard at broadcast. + // A payload larger than one 76-byte chunk must be rejected at + // construction. Large payloads round-trip via the P2SH path instead. + // The rejection is unconditional on every coin, so the bitcoin network + // here is a fixture, not a condition. const bigData = 'X'.repeat(200) const encoder = makeEncoder('bitcoin-regtest') const address = getTestAddress('bitcoin-regtest') diff --git a/test/smoke/smoke.test.js b/test/smoke/smoke.test.js index 268b9b6..9602bd2 100644 --- a/test/smoke/smoke.test.js +++ b/test/smoke/smoke.test.js @@ -240,8 +240,9 @@ describe('S5: prepareData', () => { }) it('oversized OP_RETURN is rejected (single output per transaction)', () => { - // Bitcoin permits only one OP_RETURN per transaction, so a payload too - // large for one must throw rather than split into multiple outputs. + // A transaction may carry at most one OP_RETURN output; Bitcoin Core + // rejects multi-OP_RETURN transactions as non-standard. A payload larger + // than one 76-byte chunk must throw rather than split into outputs. const data = Buffer.alloc(200, 0x44) const compiled = bitcoin.script.compile([data]) assert.throws(() => encoder.prepareData(compiled, 'OP_RETURN', testAddress), RangeError) diff --git a/test/xchain_encoder.test.js b/test/xchain_encoder.test.js index 17711ee..b574080 100644 --- a/test/xchain_encoder.test.js +++ b/test/xchain_encoder.test.js @@ -28,6 +28,10 @@ function xchainP2shFinalizer(inputIndex, input, script, isSegwit, isP2SH, isP2WS if (isP2SH){ const decompiled = bitcoin.script.decompile(script); + // The redeem-script shape check is deliberately not enforced here: it + // would require [data] OP_DROP OP_DUP OP_HASH160 OP_EQUALVERIFY + // OP_CHECKSIG and throw "Can not finalize input #n" on any other script. + let payment = { network: bitcoin.networks.regtest, input: From 784213cfe8f5772e9a94704fe35f5b1b986f4267 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 13 Sep 2026 16:23:19 -0700 Subject: [PATCH 024/148] refactor: require the coin registry once at the top of the encoder The two body-level requires become one module binding dereferenced per call, which keeps a swapped verifyConsensusPin reachable from the constructor. --- src/XChainEncoder.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/XChainEncoder.js b/src/XChainEncoder.js index eb64486..41b2ccf 100644 --- a/src/XChainEncoder.js +++ b/src/XChainEncoder.js @@ -24,6 +24,9 @@ require('./build/apply_bufferutils_patch') const bitcoin = require('bitcoinjs-lib'); const crypto = require('crypto'); const bs58check = require('bs58check') +// Held as the module object and dereferenced per call, so a test that swaps +// coins.verifyConsensusPin still reaches the constructor's pin check. +const coins = require('./coins') const BlockchainConnector = require('./build/blockchain_connector') const CryptoNetworks = require('./build/crypto_networks') const UtxoTracker = require('./build/utxo_tracker') @@ -76,7 +79,7 @@ const SOFT_DUST_FLOOR_BY_COIN = { // 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 = require('./coins').FULL_NAME_TO_TICK[fullName] + const tick = coins.FULL_NAME_TO_TICK[fullName] return (tick && SOFT_DUST_FLOOR_BY_COIN[tick]) || 0 } @@ -481,7 +484,7 @@ class XChainEncoder { // Deliberately not wrapped in try/catch, and deliberately in the constructor: // api.js builds the singleton encoder at module load, so a later check would // let the HTTP surface bind and serve builds first. - require('./coins').verifyConsensusPin(this.consensusNetwork) + coins.verifyConsensusPin(this.consensusNetwork) this.connector = new BlockchainConnector(nodeUrl, nodePort, nodeUser, nodePassword) this.utxoTrackerConnector = new UtxoTracker(utxoTrackerUrl, utxoTrackerPort) // Two floors: dustAmount is the pinned consensus threshold (fee floor, fee-drain From 56fbbd952365db751304b83bb031f2677a878506 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 13 Sep 2026 16:26:42 -0700 Subject: [PATCH 025/148] test: give each smoke module-load check its own module binding Hoisting the per-test requires folded five differently-sourced bindings into one, so two checks went red and two passed against the wrong module. --- test/smoke/smoke.test.js | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/test/smoke/smoke.test.js b/test/smoke/smoke.test.js index 9602bd2..41d2f9f 100644 --- a/test/smoke/smoke.test.js +++ b/test/smoke/smoke.test.js @@ -21,7 +21,8 @@ const assert = require('assert') const crypto = require('crypto') -const mod = require('../../src/XChainEncoder'); +const BlockchainConnector = require('../../src/build/blockchain_connector'); +const UtxoTracker = require('../../src/build/utxo_tracker'); const bitcoin = require('bitcoinjs-lib'); const ecc = require('tiny-secp256k1'); const XChainEncoder = require('../../src/XChainEncoder'); @@ -36,24 +37,24 @@ const http = require('http'); describe('S1: Module Loading', () => { it('loads XChainEncoder', () => { - assert.strictEqual(typeof mod, 'function') + assert.strictEqual(typeof XChainEncoder, 'function') }) it('loads BlockchainConnector', () => { - assert.strictEqual(typeof mod, 'function') + assert.strictEqual(typeof BlockchainConnector, 'function') }) it('loads CryptoNetworks', () => { - assert.strictEqual(typeof mod.getBitcoinJsNetwork, 'function') + assert.strictEqual(typeof CryptoNetworks.getBitcoinJsNetwork, 'function') }) it('loads TxSizeEstimator', () => { - assert.strictEqual(typeof mod.estimateOpReturnOutput, 'function') - assert.strictEqual(typeof mod.estimateInputSize, 'function') + assert.strictEqual(typeof TxSizeEstimator.estimateOpReturnOutput, 'function') + assert.strictEqual(typeof TxSizeEstimator.estimateInputSize, 'function') }) it('loads UtxoTracker', () => { - assert.strictEqual(typeof mod, 'function') + assert.strictEqual(typeof UtxoTracker, 'function') }) it('loads bitcoinjs-lib with required exports', () => { From b4e9fc93038f43e7e7dc17394479325fedf6bc14 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Sun, 13 Sep 2026 19:40:47 -0700 Subject: [PATCH 026/148] chore(coins): refresh the vendored registry from canonical The canonical coin registry repointed a consensus note at the decoder's renamed crypto networks module. This re-vendors that byte change so the copy stays identical to canonical. --- src/coins/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/coins/index.js b/src/coins/index.js index 0963b23..5f31c45 100644 --- a/src/coins/index.js +++ b/src/coins/index.js @@ -311,7 +311,7 @@ function consensusSubset(tick, network){ wireFormat: coin.wireFormat, // firstBlock is a CONSENSUS input, for the same reason wireFormat // is: getCoinConfig() exposes it (below) and the decoder reads it as the chain's - // start height (xchain-decoder/src/CryptoNetworks.js), so it decides which block + // start height (xchain-decoder/src/chain/crypto_networks.js), so it decides which block // the action history begins at. A node bundling a higher value skips the actions // below it and replays a different history while its pin verifies clean. // Per-network, matching where the coin files declare it. From cdfe4dca3238cabf1bac6555db299c9bf5be5f9d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 07:43:54 -0700 Subject: [PATCH 027/148] chore(pins): re-pin the AT1 identity at the refreshed coin registry bytes --- bin/pins/identity.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/pins/identity.json b/bin/pins/identity.json index c53c8ac..697e26d 100644 --- a/bin/pins/identity.json +++ b/bin/pins/identity.json @@ -1,10 +1,10 @@ { - "capturedAt": "2026-09-13T21:59:23.727Z", + "capturedAt": "2026-09-14T14:43:18.138Z", "coins": { "src/coins/BTC.js": "900d82359d27269ebb775a207e84cac7ac0702f57f07c0239d5406f2a0ec6c90", "src/coins/LTC.js": "c227025a7b1e8d70f5165f6065cd2894161abd4966814c8f7b0f462e036474c9", "src/coins/DOGE.js": "a0952d619edec50c09d0cbac90023cba2e8e75f0fa98b650e2ee1f4eadd7540b", - "src/coins/index.js": "af301d7ba0a0456db6a19f0ea07584b3e136293ed4e040e11e82b9bf31ace8e1", + "src/coins/index.js": "a0c16cab7d5969e062822dc70fd9565a513cd363f3d7bd3b76806ed6679c5213", "src/coins/consensus_pin.js": "f41142b6b3c9e3f1c1d491b9737fee5e6fd988d700bd96f0d200f7bd0b301ae7" }, "conformance": { From 1754f5a58c583a250112b413965c836210885937 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 08:08:35 -0700 Subject: [PATCH 028/148] docs(validator): restore a JSDoc block and a validation-check comment validateAll had no doc comment describing its contract, and the first two checks in validateUtxoEntry had no plain-language line above them explaining what they guard, both against COMMENT-STYLE's rule that every validation check gets one. Comments only, no behavior change. --- src/common/validator.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/common/validator.js b/src/common/validator.js index e334520..ce49061 100644 --- a/src/common/validator.js +++ b/src/common/validator.js @@ -588,9 +588,14 @@ function validateDust(dust) { } function validateUtxoEntry(entry, index) { + // Reject anything that is not a plain object: a string, number, array or + // null here means the caller sent the wrong shape, not a bad field, so + // each UTXO must arrive as its own {txid, vout, value, scriptPubKey} record. if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { throw new TypeError(`utxos[${index}] must be an object`) } + // The outpoint's transaction id must be exactly 32 bytes of hex (64 chars); + // anything shorter, longer or non-hex cannot name a real transaction. if (typeof entry.txid !== 'string' || !HEX_64_RE.test(entry.txid)) { throw new TypeError(`utxos[${index}].txid must be a 64-character hex string`) } @@ -805,6 +810,14 @@ function validateChange(change) { return validateAddress(change) } +/** + * Validate and coerce every createTransaction parameter in one pass. + * @param {object} params - the raw JSON-RPC params object for create_tx. + * @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) { // 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', From d2bb08208c600f8b5e423771373238633295a2d7 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 08:51:23 -0700 Subject: [PATCH 029/148] refactor(layout): move the browser bundle entry into src/browser/ src/index.js only exists to hang XChainEncoder on window for the browserify build, so it is not cross-cutting infrastructure and does not belong at the top of src/. The move carries the one require it forces and repoints the build and build:dev scripts at the new path. No runtime change: the API process and every Node consumer load src/XChainEncoder.js directly. --- package.json | 4 ++-- src/{ => browser}/index.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/{ => browser}/index.js (92%) diff --git a/package.json b/package.json index 5cf8bdb..92816fc 100644 --- a/package.json +++ b/package.json @@ -54,8 +54,8 @@ "test:boundary": "npx mocha --timeout 30000 'test/boundary/**/*.test.js'", "test:security": "npx mocha --timeout 30000 'test/security/**/*.test.js'", "test:fuzz": "npx mocha --timeout 120000 'test/fuzz/**/*.fuzz.js' --recursive", - "build": "npx browserify src/index.js -t [ babelify --presets [ @babel/preset-env ] --global ] | npx uglify-js --compress --mangle -o dist/xchain_encoder.min.js", - "build:dev": "npx browserify src/index.js -t [ babelify --presets [ @babel/preset-env ] --global ] -o dist/xchain_encoder.min.js", + "build": "npx browserify src/browser/index.js -t [ babelify --presets [ @babel/preset-env ] --global ] | npx uglify-js --compress --mangle -o dist/xchain_encoder.min.js", + "build:dev": "npx browserify src/browser/index.js -t [ babelify --presets [ @babel/preset-env ] --global ] -o dist/xchain_encoder.min.js", "test:chaos": "npx mocha --timeout 30000 'test/chaos/**/*.test.js'", "test:e2e": "npx mocha --timeout 30000 'test/e2e/**/*.test.js' --recursive --exit", "test:e2e:service": "npx mocha --timeout 30000 'test/e2e-service/**/*.test.js' --recursive --exit", diff --git a/src/index.js b/src/browser/index.js similarity index 92% rename from src/index.js rename to src/browser/index.js index 7998ae7..05ca47e 100644 --- a/src/index.js +++ b/src/browser/index.js @@ -12,7 +12,7 @@ * ********************************************************************/ -let XChainEncoder = require('./XChainEncoder'); +let XChainEncoder = require('../XChainEncoder'); window.XChainEncoder = XChainEncoder; From a627b7761e9ebd4e925dfd1a2c5b3060487c656e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 08:50:35 -0700 Subject: [PATCH 030/148] ci: run the identity pin as its own tier of ci-full.sh bin/pins/identity.json was written by bin/pin-identity.js but read by nothing, so a stale pin failed no gate. The new tier re-hashes the tree against the pin and turns ci-full.sh red on any moved or missing file. The tool compares only the entries a pin names, so the tier also refuses a pin with no coin or conformance entries instead of reading it as holding. --- bin/ci-full.sh | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/bin/ci-full.sh b/bin/ci-full.sh index ea22825..6bd236b 100755 --- a/bin/ci-full.sh +++ b/bin/ci-full.sh @@ -92,6 +92,21 @@ run_tier "drift: coin consensus-pin conformance" node -e ' console.log("consensus pin conformance OK (testnet, regtest)"); ' +# --- identity pin (this gate only; no ci.yml job runs it) -------------- +# bin/pins/identity.json holds the sha256 of the vendored coin files and the +# roundtrip conformance fixture. The tool compares only the entries the pin +# names, so an emptied pin would read as holding: the tier first refuses a pin +# with no coin or conformance entries, then fails on any moved or missing file. +identity_pin_check() { + node -e ' + const pin = require("./bin/pins/identity.json"); + for (const group of ["coins", "conformance"]) { + if (!Object.keys(pin[group] || {}).length) throw new Error("identity pin names no " + group + " files"); + } + ' && node bin/pin-identity.js --compare bin/pins/identity.json +} +run_tier "identity pin (vendored coins, conformance fixture)" identity_pin_check + # --- job: coverage (needs: ci) ------------------------------------------ # The workflow checks out every repo in .ci-siblings before this job so the # ratchet re-run measures coverage with the same cross-repo tests exercised From 36b0d2cac93d2ae940c213b7f98a6184ab12d398 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 08:46:09 -0700 Subject: [PATCH 031/148] chore(coins): refresh the vendored registry from canonical --- src/coins/index.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/coins/index.js b/src/coins/index.js index 5f31c45..8c2e160 100644 --- a/src/coins/index.js +++ b/src/coins/index.js @@ -37,6 +37,7 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); +const consensusPin = require('./consensus_pin.js'); // Registry of canonical coin data files. Order defines ALLOWED_COINS order. const COIN_FILES = { @@ -353,8 +354,7 @@ function consensusHashes(network){ // config would fork the federation). Returns { ok, skipped } on success. Throws // on the first mismatch with both hashes so the operator sees the drift. function verifyConsensusPin(network){ - const { CONSENSUS_CONFIG_PIN } = require('./consensus_pin.js'); - const pin = CONSENSUS_CONFIG_PIN ? CONSENSUS_CONFIG_PIN[network] : undefined; + const pin = consensusPin.CONSENSUS_CONFIG_PIN ? consensusPin.CONSENSUS_CONFIG_PIN[network] : undefined; if(pin === null || pin === undefined) return { ok: true, skipped: true }; for(const tick of ALLOWED_COINS){ const expected = pin[tick]; @@ -379,8 +379,8 @@ function verifyConsensusPin(network){ // outside operators (the validator runbook hands them HUB_NETWORK=testnet) with an // ARMED consensus pin, so the same fork risk applies and a clean pin must not read // as covering this depth. Every sibling seam gates on regtest alone for that reason: -// resolveFeeDestination above, XChainHub._oracleMaxAgeSeconds, -// XchainPriceSource.pinOffRegtest and CapabilitySnapshot._resolveReorgBuffer. +// resolveFeeDestination above, XChainHub.oracleMaxAgeSeconds, +// XchainPriceSource.pinOffRegtest and CapabilitySnapshot.resolveReorgBuffer. // // Raising stays legal on every network because raising is unilaterally // conservative: the validator simply waits longer. Only lowering forks co-signing. From e9e745bf00cfe48f04f63bb163ba316bcdf252b9 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 08:46:35 -0700 Subject: [PATCH 032/148] chore(pins): re-pin the vendored coins registry at the refreshed canonical --- bin/pins/identity.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/pins/identity.json b/bin/pins/identity.json index 697e26d..0b68d6f 100644 --- a/bin/pins/identity.json +++ b/bin/pins/identity.json @@ -1,10 +1,10 @@ { - "capturedAt": "2026-09-14T14:43:18.138Z", + "capturedAt": "2026-09-14T15:46:22.837Z", "coins": { "src/coins/BTC.js": "900d82359d27269ebb775a207e84cac7ac0702f57f07c0239d5406f2a0ec6c90", "src/coins/LTC.js": "c227025a7b1e8d70f5165f6065cd2894161abd4966814c8f7b0f462e036474c9", "src/coins/DOGE.js": "a0952d619edec50c09d0cbac90023cba2e8e75f0fa98b650e2ee1f4eadd7540b", - "src/coins/index.js": "a0c16cab7d5969e062822dc70fd9565a513cd363f3d7bd3b76806ed6679c5213", + "src/coins/index.js": "dd350bc0ec999849fe302ac4381f37b7be3eaa866f019a35cbcceebb4d5ebd4b", "src/coins/consensus_pin.js": "f41142b6b3c9e3f1c1d491b9737fee5e6fd988d700bd96f0d200f7bd0b301ae7" }, "conformance": { From f658e61204b4c8cf698ced5f8b4fa4bc398a32f6 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 09:11:28 -0700 Subject: [PATCH 033/148] ci: check the identity pin in the drift-guards job on push and pull request GitHub CI now re-hashes the tree against bin/pins/identity.json with the same command bin/ci-full.sh runs before a push, so a release merge to master cannot land a stale pin that only the pre-push path would have caught. --- .github/workflows/ci.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6c5a77..aec40c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,23 @@ jobs: console.log("consensus pin conformance OK (testnet, regtest)"); ' + # Identity pin: bin/pins/identity.json records the sha256 of the vendored + # coin files and the roundtrip conformance fixture. The tool compares only + # the entries the pin names, so an emptied pin would read as holding: first + # refuse a pin with no coin or conformance entries, then fail on any moved + # or missing file, so a stale pin cannot reach develop or master green + # through a direct push or a release pull request. bin/ci-full.sh runs the + # same commands before a push; both use only node builtins, so no install. + - name: Identity pin (vendored coins, conformance fixture) + working-directory: xchain-encoder + run: | + node -e ' + const pin = require("./bin/pins/identity.json"); + for (const group of ["coins", "conformance"]) { + if (!Object.keys(pin[group] || {}).length) throw new Error("identity pin names no " + group + " files"); + } + ' && node bin/pin-identity.js --compare bin/pins/identity.json + # Coverage ratchet: re-run the unit suite under c8 and fail if line or # branch coverage drops below this repo's floor in bin/coverage-thresholds.json # (mirrored into the `coverage:check` npm script). Catches silent coverage From ce4771e22a9b76e0f02fd8ad5b289fe2ec1daf32 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 16:18:57 -0700 Subject: [PATCH 034/148] feat(pins): record one pinned test file split into several and grade it as a union The suite-title pin compares file by file, and its rename map is one to one, so a test file that becomes several cannot be declared: every part reads as added and the old file as dropped, a wall of differences a reviewer ends up accepting by eye. --split-map takes {old: [new, ...]} and requires the parts, taken together, to carry the old file's titles exactly: none lost, none added, none carried twice, and a title that left for a file the record does not name is reported as moved, with its destination, rather than as a bare drop. The grading lives in bin/suite_title_map/split_map.js so the CLI does not grow. bin/pins/suite-title-splits.json declares no split yet, and with it empty the compare reports exactly what it reported without the flag. --- bin/pins/suite-title-splits.json | 14 ++ bin/suite-title-map.js | 13 +- bin/suite_title_map/split_map.js | 208 +++++++++++++++++++++ bin/test/suite_title_split_map.test.js | 243 +++++++++++++++++++++++++ 4 files changed, 476 insertions(+), 2 deletions(-) create mode 100644 bin/pins/suite-title-splits.json create mode 100644 bin/suite_title_map/split_map.js create mode 100644 bin/test/suite_title_split_map.test.js diff --git a/bin/pins/suite-title-splits.json b/bin/pins/suite-title-splits.json new file mode 100644 index 0000000..eafdb51 --- /dev/null +++ b/bin/pins/suite-title-splits.json @@ -0,0 +1,14 @@ +{ + "note": "The declared test-file splits, read by bin/suite-title-map.js --split-map against bin/pins/at1-suite-titles.json. splits maps each pinned test file that became several files to the full list of files its tests now live in, keyed by the path the compare sees after the rename map is applied. For every npm test script that pinned the old file, the titles those files collect together must equal the old file's titles exactly: none lost, none added, none carried by two parts, and none moved into a file the list does not name. A part may keep the old path; a single destination is a rename and belongs in the rename map. This repository's rename map is flat paths only, so a split carries its titles unchanged: a title that differs across a split is a regression, not a restructure, and a title change lands on its own.", + "date": "2026-09-14", + "pin": "bin/pins/at1-suite-titles.json", + "pin_before_sha256": "8aebbd8e81f86b6f62018c989896c15d9afdc0ea46593b7542b57fafc1875515", + "compare": "node bin/suite-title-map.js --compare bin/pins/at1-suite-titles.json --rename-map bin/pins/test-rename-map.json --split-map bin/pins/suite-title-splits.json", + "example": { + "test/unit/big_suite.test.js": [ + "test/unit/big_suite/reads.test.js", + "test/unit/big_suite/writes.test.js" + ] + }, + "splits": {} +} diff --git a/bin/suite-title-map.js b/bin/suite-title-map.js index fd5e8e8..e4bf727 100644 --- a/bin/suite-title-map.js +++ b/bin/suite-title-map.js @@ -51,6 +51,11 @@ * the same, with the moving * commit's {old: new} paths * applied to the pin first + * node bin/suite-title-map.js --compare --split-map + * the same, with the splitting + * commit's {old: [new, ...]} + * files graded as one union + * (bin/suite_title_map/split_map.js) * ********************************************************************/ @@ -60,6 +65,7 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { spawnSync } = require('child_process'); +const { loadSplits, compareWithSplits } = require('./suite_title_map/split_map.js'); const REPO_ROOT = path.resolve(__dirname, '..'); const MOCHA_BIN = path.join(REPO_ROOT, 'node_modules', '.bin', 'mocha'); @@ -261,6 +267,7 @@ function parseArgs(argv) { else if (argv[i] === '--script') { opts.script = argv[i + 1]; i += 1; } else if (argv[i] === '--compare') { opts.compare = path.resolve(argv[i + 1]); i += 1; } else if (argv[i] === '--rename-map') { opts.renameMap = path.resolve(argv[i + 1]); i += 1; } + else if (argv[i] === '--split-map') { opts.splitMap = path.resolve(argv[i + 1]); i += 1; } else if (argv[i] === '--help' || argv[i] === '-h') opts.help = true; } return opts; @@ -277,10 +284,12 @@ function main() { if (opts.compare) { const pin = JSON.parse(fs.readFileSync(opts.compare, 'utf8')); const renames = opts.renameMap ? JSON.parse(fs.readFileSync(opts.renameMap, 'utf8')) : {}; - const differences = compare(pin, map, renames, opts.script); + const splits = opts.splitMap ? loadSplits(opts.splitMap) : {}; + const differences = compareWithSplits({ pin, fresh: map, renames, splits, only: opts.script, compare }); if (!differences.length) { console.log(`suite identity holds against ${path.relative(REPO_ROOT, opts.compare)}` - + `${opts.renameMap ? ' through the declared rename map' : ''}`); + + `${opts.renameMap ? ' through the declared rename map' : ''}` + + `${opts.splitMap ? ' through the declared split map' : ''}`); return; } console.log(`${differences.length} difference(s) against ${path.relative(REPO_ROOT, opts.compare)}:`); diff --git a/bin/suite_title_map/split_map.js b/bin/suite_title_map/split_map.js new file mode 100644 index 0000000..51f1e1a --- /dev/null +++ b/bin/suite_title_map/split_map.js @@ -0,0 +1,208 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * + * One pinned test file declared as several, for bin/suite-title-map.js. + * + * WHY A SEPARATE RECORD. The rename map is one-to-one on purpose: a pure move + * keeps a file's whole title set under one new key, so the ordinary per-file + * compare still proves nothing entered or left it. A file split into several + * cannot be written as renames, and comparing each new file on its own reports + * the old file dropped and every part added, a wall of differences a reviewer + * ends up waving through by eye. That is exactly the failure the pin exists to + * stop, so a split is declared, and graded as one unit. + * + * WHAT HOLDS. For every script that pinned the old file, the titles the named + * parts collect, taken together, equal the old file's titles exactly, counted + * as a multiset: no title lost, none added, none carried by two parts (a title + * mocha reported twice in the old file must appear twice in total). A title + * that left the old file for a file the record does not name is reported as + * moved, with where it went, rather than as a bare drop, so the record has to + * account for every title's destination. + * + * A part that a script does not collect is not itself a finding: under a + * --grep script a part can hold no matching title at all. Only the union is + * graded, and a title the old file never had shows up as added either way. + * + * RENAMES APPLY FIRST. The split map is keyed by the path the compare sees + * once the rename map has been applied to the pin, which is the path the file + * has in the tree just before it is split. + * + * THE RECORD. Either a house record whose `splits` key holds the map + * (bin/pins/suite-title-splits.json) or a flat object of the same shape: + * { "test/unit/a.test.js": ["test/unit/a_reads.test.js", "test/unit/a_writes.test.js"] } + * A part may keep the old path. Every part list names at least two files; a + * single destination is a rename and belongs in --rename-map. + * + ********************************************************************/ + +'use strict'; + +const fs = require('fs'); + +const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key); + +/** The split map in a record file: a house record's `splits`, or the flat object itself. */ +function loadSplits(file) { + const record = JSON.parse(fs.readFileSync(file, 'utf8')); + if (record && typeof record === 'object' && !Array.isArray(record) && hasOwn(record, 'splits')) { + return record.splits; + } + return record; +} + +/** + * What is wrong with a split map before any title is read. A record that names + * one part under two old files, or makes a part of a file it also splits, has + * no single meaning, so it is refused whole rather than compared. + * @returns {string[]} one line per problem; empty when the map is usable + */ +function validateSplits(splits) { + if (!splits || typeof splits !== 'object' || Array.isArray(splits)) { + return ['the split map is not an object of {old: [new, ...]}']; + } + const problems = []; + const owner = {}; + for (const old of Object.keys(splits)) { + const parts = splits[old]; + if (!Array.isArray(parts) || parts.length < 2 || !parts.every((p) => typeof p === 'string' && p)) { + problems.push(`${old}: parts must be a list of at least two paths`); + continue; + } + for (const part of parts) { + if (hasOwn(owner, part)) problems.push(`${part}: named by ${owner[part]} and by ${old}`); + else owner[part] = old; + } + } + for (const part of Object.keys(owner)) { + if (part !== owner[part] && hasOwn(splits, part)) problems.push(`${part}: a part of ${owner[part]} and itself split`); + } + return problems; +} + +/** The flat {file: [titles]} of one script, or null when the map did not collect it. */ +function filesOf(map, name) { + const s = map.scripts[name]; + if (!s || !s.files) return null; + const out = {}; + for (const rel of Object.keys(s.files)) out[rel] = map.titleSets[s.files[rel]] || []; + return out; +} + +/** A script's pinned files under their renamed paths, and which pin keys each came from. */ +function renamedView(before, renames) { + const mapped = {}; + const origin = {}; + for (const rel of Object.keys(before)) { + const name = renames[rel] || rel; + mapped[name] = before[rel]; + (origin[name] = origin[name] || []).push(rel); + } + return { mapped, origin }; +} + +/** How many times each title occurs across some title lists. */ +function countTitles(lists) { + const counts = new Map(); + for (const list of lists) for (const t of list) counts.set(t, (counts.get(t) || 0) + 1); + return counts; +} + +/** Titles the parts carry fewer times than the old file did: dropped, or moved outside the record. */ +function missingTitles({ script, old, named, after }, expected, got) { + const out = []; + for (const [title, want] of expected) { + const have = got.get(title) || 0; + if (have >= want) continue; + const elsewhere = Object.keys(after).filter((rel) => !named.has(rel) && after[rel].includes(title)).sort(); + const kind = elsewhere.length ? 'title_moved_outside_split' : 'title_dropped'; + const file = elsewhere.length ? `${old} -> ${elsewhere.join(', ')}` : old; + for (let i = have; i < want; i += 1) out.push({ script, kind, file, title }); + } + return out; +} + +/** Titles the parts carry more times than the old file did: added, or duplicated across parts. */ +function surplusTitles({ script, parts, after }, expected, got) { + const out = []; + for (const [title, have] of got) { + const want = expected.get(title) || 0; + if (have <= want) continue; + const holders = parts.filter((p) => (after[p] || []).includes(title)).join(', '); + const kind = want ? 'title_duplicated' : 'title_added'; + for (let i = want; i < have; i += 1) out.push({ script, kind, file: holders, title }); + } + return out; +} + +/** + * Every difference one split makes in one script. `mapped` is the pin's view + * of the script with renames applied, `after` the tree's. + */ +function gradeSplit({ script, old, parts, mapped, after }) { + const out = []; + // A part that is already a pinned file of its own would be a merge, and the + // titles it already had would be graded twice or not at all. + for (const part of parts) { + if (part !== old && mapped[part]) { + out.push({ script, kind: 'split_part_collides', file: part, detail: `already pinned, so it cannot take tests from ${old}` }); + } + } + const ctx = { script, old, parts, named: new Set(parts), after }; + const expected = countTitles([mapped[old]]); + const got = countTitles(parts.map((p) => after[p] || [])); + return out.concat(missingTitles(ctx, expected, got), surplusTitles(ctx, expected, got)); +} + +/** A shallow copy of a map with some files removed from some scripts. */ +function withoutFiles(map, dropByScript) { + const scripts = {}; + for (const name of Object.keys(map.scripts)) { + const s = map.scripts[name]; + const drop = dropByScript[name]; + if (!drop || !s.files) { scripts[name] = s; continue; } + const files = {}; + for (const rel of Object.keys(s.files)) if (!drop.has(rel)) files[rel] = s.files[rel]; + scripts[name] = { ...s, files }; + } + return { ...map, scripts }; +} + +/** + * compare() from bin/suite-title-map.js with a split map in front of it. Each + * split is graded here, then the old file is taken out of the pin and its parts + * out of the tree for that script, so the ordinary compare grades every other + * file exactly as it did before and a pin with no splits compares unchanged. + */ +function compareWithSplits({ pin, fresh, renames, splits, only, compare }) { + const problems = validateSplits(splits); + if (problems.length) return problems.map((detail) => ({ script: '*', kind: 'split_record', detail })); + const differences = []; + const dropPin = {}; + const dropFresh = {}; + for (const name of Object.keys(pin.scripts).filter((n) => !only || n === only).sort()) { + const before = filesOf(pin, name); + const after = filesOf(fresh, name); + if (!before || !after) continue; + const { mapped, origin } = renamedView(before, renames); + for (const old of Object.keys(splits).sort()) { + if (!mapped[old]) continue; + differences.push(...gradeSplit({ script: name, old, parts: splits[old], mapped, after })); + const pinned = (dropPin[name] = dropPin[name] || new Set()); + for (const rel of origin[old]) pinned.add(rel); + const parts = (dropFresh[name] = dropFresh[name] || new Set()); + for (const part of splits[old]) parts.add(part); + } + } + return differences.concat(compare(withoutFiles(pin, dropPin), withoutFiles(fresh, dropFresh), renames, only)); +} + +module.exports = { loadSplits, validateSplits, compareWithSplits }; diff --git a/bin/test/suite_title_split_map.test.js b/bin/test/suite_title_split_map.test.js new file mode 100644 index 0000000..91c412f --- /dev/null +++ b/bin/test/suite_title_split_map.test.js @@ -0,0 +1,243 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * + * What a declared test-file split is allowed to be, which is the only thing + * standing between "the suite was reorganised" and "the suite quietly lost + * tests". The split map is the mechanism that lets a reviewer accept one file + * becoming several without reading every title, so each way it could let a + * loss through is driven here against a known answer: a title dropped, a title + * carried by two parts, a title that went to a file the record never named, + * and a title that appeared from nowhere. + * + * The maps are fixtures rather than real collections, because a real one is + * the committed pin, which moves with every restructure; the grading is pure, + * so a fixture drives it exactly as the pin does, and the committed split + * record is only asked to load and validate. + * + * This suite is outside test/ on purpose: every npm test script globs from + * test/, and the pin holds those scripts' collected titles. Run it directly: + * + * npx mocha --no-config --timeout 30000 bin/test/suite_title_split_map.test.js + * + ********************************************************************/ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { compare } = require('../suite-title-map.js'); +const splitMap = require('../suite_title_map/split_map.js'); + +const OLD = 'test/unit/db_queries.test.js'; +const ORDERS = 'test/unit/db_queries/orders.test.js'; +const STAKES = 'test/unit/db_queries/stakes.test.js'; +const OTHER = 'test/unit/other.test.js'; +const SPLIT = { [OLD]: [ORDERS, STAKES] }; + +const OLD_TITLES = ['db orders reads', 'db orders writes', 'db stakes reads', 'db stakes writes']; +const PIN = { test: { [OLD]: OLD_TITLES, [OTHER]: ['other one'] } }; +const FRESH = { + test: { + [ORDERS]: ['db orders reads', 'db orders writes'], + [STAKES]: ['db stakes reads', 'db stakes writes'], + [OTHER]: ['other one'], + }, +}; + +/** A title map in the pin's on-disk shape, out of {script: {file: [titles]}}. */ +function mapOf(scripts) { + const titleSets = {}; + const out = {}; + let n = 0; + for (const name of Object.keys(scripts)) { + const files = {}; + for (const rel of Object.keys(scripts[name])) { + const key = `set${n += 1}`; + titleSets[key] = scripts[name][rel].slice().sort(); + files[rel] = key; + } + out[name] = { files }; + } + return { titleSets, scripts: out }; +} + +/** The compare the CLI runs: pin against tree, through a rename map and a split map. */ +function run(pin, fresh, splits, renames) { + return splitMap.compareWithSplits({ + pin: mapOf(pin), fresh: mapOf(fresh), renames: renames || {}, splits, compare, + }); +} + +/** One fresh tree with a single title moved from the part it belongs in to somewhere else. */ +function moveTitle(from, to, title) { + const tree = JSON.parse(JSON.stringify(FRESH)); + tree.test[from] = tree.test[from].filter((t) => t !== title); + if (to) tree.test[to] = (tree.test[to] || []).concat([title]); + return tree; +} + +const kinds = (diffs) => diffs.map((d) => d.kind).sort(); + +describe('bin/suite_title_map/split_map.js: a declared split that is honest', () => { + it('reports nothing when the parts carry exactly the old file titles', () => { + assert.deepStrictEqual(run(PIN, FRESH, SPLIT), []); + }); + + it('is the record that carries it: without one, the same tree is a wall of differences', () => { + const diffs = compare(mapOf(PIN), mapOf(FRESH), {}, undefined); + assert.deepStrictEqual(kinds(diffs), ['file_added', 'file_added', 'file_dropped']); + }); + + it('lets one part keep the old path', () => { + const tree = { test: { [OLD]: ['db orders reads', 'db orders writes'], [STAKES]: ['db stakes reads', 'db stakes writes'], [OTHER]: ['other one'] } }; + assert.deepStrictEqual(run(PIN, tree, { [OLD]: [OLD, STAKES] }), []); + }); + + it('keeps a title the old file reported twice, once in each part', () => { + const pin = { test: { [OLD]: ['same title', 'same title'] } }; + const twice = { test: { [ORDERS]: ['same title'], [STAKES]: ['same title'] } }; + assert.deepStrictEqual(run(pin, twice, SPLIT), []); + const once = { test: { [ORDERS]: ['same title'], [STAKES]: [] } }; + assert.deepStrictEqual(kinds(run(pin, once, SPLIT)), ['title_dropped']); + }); + + it('does not mind a part a filtered script collects nothing from', () => { + const pin = { 'test:regression': { [OLD]: ['db orders reads @regression'] } }; + const tree = { 'test:regression': { [ORDERS]: ['db orders reads @regression'] } }; + assert.deepStrictEqual(run(pin, tree, SPLIT), []); + }); + + it('takes the path the file has after the rename map, not the one the pin holds', () => { + const pin = { test: { 'test/unit/db-queries.test.js': OLD_TITLES } }; + const renames = { 'test/unit/db-queries.test.js': OLD }; + assert.deepStrictEqual(run(pin, FRESH, SPLIT, renames).filter((d) => d.file !== OTHER), []); + }); +}); + +describe('bin/suite_title_map/split_map.js: what a declared split still refuses', () => { + it('refuses a title that no part carries any more', () => { + const diffs = run(PIN, moveTitle(STAKES, null, 'db stakes writes'), SPLIT); + assert.deepStrictEqual(diffs, [{ script: 'test', kind: 'title_dropped', file: OLD, title: 'db stakes writes' }]); + }); + + it('refuses a title two parts both carry', () => { + const tree = JSON.parse(JSON.stringify(FRESH)); + tree.test[STAKES] = tree.test[STAKES].concat(['db orders reads']); + const diffs = run(PIN, tree, SPLIT); + assert.deepStrictEqual(diffs, [{ + script: 'test', kind: 'title_duplicated', file: `${ORDERS}, ${STAKES}`, title: 'db orders reads', + }]); + }); + + it('refuses a title that moved to a file the record does not name, and says where it went', () => { + const diffs = run(PIN, moveTitle(STAKES, OTHER, 'db stakes writes'), SPLIT); + const moved = diffs.filter((d) => d.kind === 'title_moved_outside_split'); + assert.deepStrictEqual(moved, [{ + script: 'test', kind: 'title_moved_outside_split', file: `${OLD} -> ${OTHER}`, title: 'db stakes writes', + }]); + // The file it landed in is a pinned file of its own, so the ordinary + // compare reports the arrival as well: the split hides nothing from it. + assert.deepStrictEqual(kinds(diffs), ['title_added', 'title_moved_outside_split']); + }); + + it('refuses a title that moved to a file nobody pinned at all', () => { + const diffs = run(PIN, moveTitle(STAKES, 'test/unit/stray.test.js', 'db stakes writes'), SPLIT); + assert.deepStrictEqual(kinds(diffs), ['file_added', 'title_moved_outside_split']); + }); + + it('refuses a title the old file never had', () => { + const tree = JSON.parse(JSON.stringify(FRESH)); + tree.test[ORDERS] = tree.test[ORDERS].concat(['db orders brand new']); + assert.deepStrictEqual(run(PIN, tree, SPLIT), [{ + script: 'test', kind: 'title_added', file: ORDERS, title: 'db orders brand new', + }]); + }); + + it('grades every other file exactly as before', () => { + const tree = JSON.parse(JSON.stringify(FRESH)); + tree.test[OTHER] = ['other renamed']; + assert.deepStrictEqual(kinds(run(PIN, tree, SPLIT)), ['title_added', 'title_dropped']); + }); +}); + +describe('bin/suite_title_map/split_map.js: the record has to mean one thing', () => { + it('refuses a single destination, which is a rename', () => { + assert.deepStrictEqual(kinds(run(PIN, FRESH, { [OLD]: [ORDERS] })), ['split_record']); + }); + + it('refuses one part claimed by two old files', () => { + const splits = { [OLD]: [ORDERS, STAKES], [OTHER]: [STAKES, 'test/unit/other_more.test.js'] }; + const diffs = run(PIN, FRESH, splits); + assert.deepStrictEqual(kinds(diffs), ['split_record']); + assert.ok(diffs[0].detail.includes(STAKES)); + }); + + it('refuses a part that is itself split', () => { + const splits = { [OLD]: [ORDERS, OTHER], [OTHER]: [STAKES, 'test/unit/other_more.test.js'] }; + assert.deepStrictEqual(kinds(run(PIN, FRESH, splits)), ['split_record']); + }); + + it('refuses merging into a file that is already pinned', () => { + const tree = { test: { [ORDERS]: ['db orders reads', 'db orders writes'], [OTHER]: ['other one', 'db stakes reads', 'db stakes writes'] } }; + const diffs = run(PIN, tree, { [OLD]: [ORDERS, OTHER] }); + // Three ways at once, which is the point: the part is called out by + // name, the titles it already held read as a surplus in the union, and + // its own pin entry is left with nothing collecting it. + assert.deepStrictEqual(kinds(diffs), ['file_dropped', 'split_part_collides', 'title_added']); + assert.deepStrictEqual(diffs[0], { + script: 'test', + kind: 'split_part_collides', + file: OTHER, + detail: `already pinned, so it cannot take tests from ${OLD}`, + }); + }); + + it('grades nothing else once the record is refused', () => { + const tree = JSON.parse(JSON.stringify(FRESH)); + tree.test[OTHER] = ['other renamed']; + assert.deepStrictEqual(run(tree, FRESH, { [OLD]: [ORDERS] }).length, 1); + }); + + it('refuses a map that is not an object of lists', () => { + assert.deepStrictEqual(splitMap.validateSplits([OLD, ORDERS]).length, 1); + assert.deepStrictEqual(splitMap.validateSplits({ [OLD]: ORDERS }).length, 1); + }); +}); + +describe('bin/suite_title_map/split_map.js: reading the record off disk', () => { + const pins = path.join(__dirname, '..', 'pins'); + + it('reads the splits out of the house record', () => { + const splits = splitMap.loadSplits(path.join(pins, 'suite-title-splits.json')); + assert.deepStrictEqual(typeof splits, 'object'); + assert.deepStrictEqual(splitMap.validateSplits(splits), []); + }); + + it('takes a file with no splits key as the map itself', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'split-map-')); + const flat = path.join(dir, 'flat.json'); + fs.writeFileSync(flat, JSON.stringify(SPLIT)); + try { + assert.deepStrictEqual(splitMap.loadSplits(flat), SPLIT); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('keeps the declared example a legal split', () => { + const record = JSON.parse(fs.readFileSync(path.join(pins, 'suite-title-splits.json'), 'utf8')); + assert.deepStrictEqual(splitMap.validateSplits(record.example), []); + }); +}); From 268afa5b68d4d22962696799a672911cb3aa3602 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 16:45:38 -0700 Subject: [PATCH 035/148] test: split change address boundary suite by behavior --- test/boundary/change_address_boundaries.test.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/boundary/change_address_boundaries.test.js b/test/boundary/change_address_boundaries.test.js index 0b247f6..9471fef 100644 --- a/test/boundary/change_address_boundaries.test.js +++ b/test/boundary/change_address_boundaries.test.js @@ -74,7 +74,9 @@ describe('Change Address Boundaries', () => { assert.ok(result.psbt instanceof bitcoin.Psbt) }) }) +}) +describe('Change Address Boundaries', () => { describe('changeSatoshis = 1 (minimum positive change)', () => { // M-6: sub-dust change (1..dustAmount-1 sats) is folded into the miner fee, // not emitted. A 1-sat output is below every chain's dust threshold and would @@ -115,7 +117,9 @@ describe('Change Address Boundaries', () => { 'no change output when change address is null (even with 1 sat remaining)') }) }) +}) +describe('Change Address Boundaries', () => { describe('dust threshold boundary (BTC: 546)', () => { it('changeSatoshis = 546 (= dustAmount): no throw with null change', async () => { const encoder = makeEncoder(NETWORK) @@ -167,7 +171,9 @@ describe('Change Address Boundaries', () => { assert.strictEqual(changeOutputs[0].value, BTC_DUST + 1) }) }) +}) +describe('Change Address Boundaries', () => { describe('dust threshold boundary (LTC: 5460)', () => { it('changeSatoshis = 5460: no throw with null change', async () => { const ltcEncoder = makeEncoder('litecoin-regtest') @@ -200,7 +206,9 @@ describe('Change Address Boundaries', () => { ) }) }) +}) +describe('Change Address Boundaries', () => { // Inputs cannot cover outputs + fee. The encoder must throw a typed // INSUFFICIENT_FUNDS error rather than return an unbroadcastable PSBT whose // outputs exceed its inputs (the pre-M-8 behavior returned it silently). From 414f3ffb5589480961616cd3e0e233decda10787 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 16:45:38 -0700 Subject: [PATCH 036/148] test: split custom output boundary suite by behavior --- test/boundary/custom_output_boundaries.test.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/boundary/custom_output_boundaries.test.js b/test/boundary/custom_output_boundaries.test.js index be6164c..bc165ff 100644 --- a/test/boundary/custom_output_boundaries.test.js +++ b/test/boundary/custom_output_boundaries.test.js @@ -89,7 +89,9 @@ describe('Custom Output Boundaries', () => { assert.strictEqual(changeOutput.value, 100000000 - 50000 - 10000) }) }) +}) +describe('Custom Output Boundaries', () => { describe('many custom outputs', () => { it('10 custom outputs each worth 100000 → correct total in outputSatoshis', async () => { const encoder = makeEncoder(NETWORK) @@ -146,7 +148,9 @@ describe('Custom Output Boundaries', () => { 'more custom outputs should produce higher estimated fee') }) }) +}) +describe('Custom Output Boundaries', () => { describe('custom output with value=0', () => { // Contract change (input-validation finding): a 0-sat caller output is // consensus-valid but relay-rejected as dust, so building it hands the caller @@ -171,7 +175,9 @@ describe('Custom Output Boundaries', () => { ) }) }) +}) +describe('Custom Output Boundaries', () => { describe('custom output value exceeding inputs', () => { // M-8: custom outputs (50000) + fee (10000) exceed the 10000-sat input, so // the encoder throws INSUFFICIENT_FUNDS rather than return an unbroadcastable @@ -197,7 +203,9 @@ describe('Custom Output Boundaries', () => { ) }) }) +}) +describe('Custom Output Boundaries', () => { describe('empty customOutputs array', () => { it('empty array has no effect on outputs or fees', async () => { const encoder = makeEncoder(NETWORK) From 29f20095157a3d3a66d3fd0de343d58e0a2ca9b3 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 16:45:38 -0700 Subject: [PATCH 037/148] test: split data payload boundary suite by behavior --- test/boundary/data_payload_boundaries.test.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/boundary/data_payload_boundaries.test.js b/test/boundary/data_payload_boundaries.test.js index 5afae71..0b91fc6 100644 --- a/test/boundary/data_payload_boundaries.test.js +++ b/test/boundary/data_payload_boundaries.test.js @@ -79,7 +79,9 @@ describe('Data/Payload Boundaries', () => { assert.strictEqual(result.encoding, 'P2SH') }) }) +}) +describe('Data/Payload Boundaries', () => { // An empty payload used to compile to an OP_0 push, take the 4-byte magic // word, and ship as a magic-word-only OP_RETURN carrying no action, so every // plain native-coin payment paid for a nulldata output and announced itself @@ -124,7 +126,9 @@ describe('Data/Payload Boundaries', () => { assert.strictEqual(decompilePayload(payload.dataBuffer)[0].toString('utf8'), 'X') }) }) +}) +describe('Data/Payload Boundaries', () => { describe('rawData parameter interactions', () => { it('rawData=null: decompiled script has 1 element', async () => { const encoder = makeEncoder(NETWORK) @@ -156,7 +160,11 @@ describe('Data/Payload Boundaries', () => { assert.strictEqual(decompiled.length, 2) assert.strictEqual(decompiled[1].toString('utf8'), 'extra-metadata') }) + }) +}) +describe('Data/Payload Boundaries', () => { + describe('rawData parameter interactions', () => { it('small data + large rawData pushes combined size over OP_RETURN into P2SH', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) @@ -187,7 +195,9 @@ describe('Data/Payload Boundaries', () => { assert.strictEqual(result.encoding, 'OP_RETURN') }) }) +}) +describe('Data/Payload Boundaries', () => { describe('null bytes in data', () => { it('data with embedded null bytes survives round-trip intact', async () => { const encoder = makeEncoder(NETWORK) @@ -214,7 +224,9 @@ describe('Data/Payload Boundaries', () => { 'null bytes should survive encoding round-trip') }) }) +}) +describe('Data/Payload Boundaries', () => { describe('very large payload', () => { it('1000-byte data forced to OP_RETURN is rejected (exceeds single output)', async () => { const encoder = makeEncoder(NETWORK) From 86b9fe654b183bc48748d6b8dbb0df3aa1ee3a45 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 16:45:38 -0700 Subject: [PATCH 038/148] test: split fee calculation boundary suite by behavior --- test/boundary/fee_calculation_boundaries.test.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/boundary/fee_calculation_boundaries.test.js b/test/boundary/fee_calculation_boundaries.test.js index 9237908..1d998c6 100644 --- a/test/boundary/fee_calculation_boundaries.test.js +++ b/test/boundary/fee_calculation_boundaries.test.js @@ -95,7 +95,9 @@ describe('Fee Calculation Boundaries', () => { `DOGE fee should be floored to ${DOGE_DUST}, got ${impliedFee}`) }) }) +}) +describe('Fee Calculation Boundaries', () => { describe('negative feePerKb → floored to dustAmount', () => { it('negative feePerKb does not produce negative fee', async () => { const encoder = makeEncoder(NETWORK) @@ -114,7 +116,9 @@ describe('Fee Calculation Boundaries', () => { `fee ${impliedFee} should be >= dustAmount ${BTC_DUST} even with negative feePerKb`) }) }) +}) +describe('Fee Calculation Boundaries', () => { describe('maxFeeRateKb caps feePerBytes', () => { it('high feePerKb with cap produces lower fee than without cap', async () => { // Capped encoder: maxFeeRateKb = 1000 sat/kB @@ -151,7 +155,11 @@ describe('Fee Calculation Boundaries', () => { assert.ok(cappedChange > uncappedChange, 'capped fee should leave more change than uncapped') }) + }) +}) +describe('Fee Calculation Boundaries', () => { + describe('maxFeeRateKb caps feePerBytes', () => { it('feePerKb below cap → cap has no effect', async () => { const capped = new XChainEncoder( NETWORK, '127.0.0.1', '8333', 'rpc', 'rpc', '', '', 100000 @@ -185,7 +193,9 @@ describe('Fee Calculation Boundaries', () => { 'cap should have no effect when feePerKb is below it') }) }) +}) +describe('Fee Calculation Boundaries', () => { describe('Math.trunc precision', () => { it('fractional fee is truncated toward zero, not rounded', async () => { const encoder = makeEncoder(NETWORK) @@ -209,7 +219,9 @@ describe('Fee Calculation Boundaries', () => { assert.ok(impliedFee >= BTC_DUST, 'fee should be at least dust') }) }) +}) +describe('Fee Calculation Boundaries', () => { describe('fee exceeding total inputs', () => { // M-8: the dust-floored fee (546) exceeds the 1-sat input, so the encoder // throws INSUFFICIENT_FUNDS instead of returning a PSBT with negative change. @@ -255,7 +267,9 @@ describe('Fee Calculation Boundaries', () => { ) }) }) +}) +describe('Fee Calculation Boundaries', () => { describe('explicit fee parameter', () => { it('fee=10000 sets estimatedFee=10000 regardless of tx size', async () => { const encoder = makeEncoder(NETWORK) From 7ec01b9f5ff9c3f0512cab6ff9e256e620140695 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 16:45:38 -0700 Subject: [PATCH 039/148] test: split obfuscation boundary suite by behavior --- test/boundary/obfuscation_boundaries.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/boundary/obfuscation_boundaries.test.js b/test/boundary/obfuscation_boundaries.test.js index 71d6584..298fb26 100644 --- a/test/boundary/obfuscation_boundaries.test.js +++ b/test/boundary/obfuscation_boundaries.test.js @@ -92,7 +92,9 @@ describe('Obfuscation Boundaries', () => { assert.deepStrictEqual(deobfuscated, data) }) }) +}) +describe('Obfuscation Boundaries', () => { describe('short TXID causes AES failure', () => { const encoder = makeTestEncoder() @@ -118,7 +120,9 @@ describe('Obfuscation Boundaries', () => { ) }) }) +}) +describe('Obfuscation Boundaries', () => { describe('pipeline: all-zero TXID in first UTXO', () => { it('produces valid PSBT with deobfuscatable payload', async () => { const zeroTxid = '0'.repeat(64) @@ -141,7 +145,9 @@ describe('Obfuscation Boundaries', () => { assert.strictEqual(payload.magic, MAGIC_WORD) }) }) +}) +describe('Obfuscation Boundaries', () => { describe('pipeline: short TXID in first UTXO', () => { it('createTransaction throws during obfuscation step', async () => { const shortTxid = 'abcd' From fdcd34187982ff8a34631ee8b2c6386ef9a3d1ab Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 16:45:38 -0700 Subject: [PATCH 040/148] test: split transaction size boundary suite by behavior --- test/boundary/txsize_estimator_boundaries.test.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/boundary/txsize_estimator_boundaries.test.js b/test/boundary/txsize_estimator_boundaries.test.js index 06569c6..8e65662 100644 --- a/test/boundary/txsize_estimator_boundaries.test.js +++ b/test/boundary/txsize_estimator_boundaries.test.js @@ -67,7 +67,9 @@ describe('TxSizeEstimator Boundaries', () => { assert.strictEqual(TxSizeEstimator.estimateOpReturnOutput(data), trueOutputSize(data)) }) }) +}) +describe('TxSizeEstimator Boundaries', () => { describe('estimateInputSize with missing UTXO data', () => { it('returns 350 (conservative fallback) for UTXO with neither witnessUtxo nor nonWitnessUtxo', () => { const utxo = { hash: TXID_A, index: 0, sequence: 0xffffffff } @@ -84,7 +86,9 @@ describe('TxSizeEstimator Boundaries', () => { 'fallback of 350 contributes to fee estimation') }) }) +}) +describe('TxSizeEstimator Boundaries', () => { describe('estimateInputSize with out-of-bounds vout index', () => { it('returns 180 (P2PKH fallback) when output at vout does not exist', () => { // Build a raw tx with 1 output, but reference vout=5 @@ -107,7 +111,9 @@ describe('TxSizeEstimator Boundaries', () => { assert.strictEqual(estimate, 180, 'should fall back to P2PKH estimate') }) }) +}) +describe('TxSizeEstimator Boundaries', () => { describe('estimateInputSize unknown script type', () => { it('returns 350-byte fallback for unrecognized script', () => { // Create a UTXO with a non-standard scriptPubKey @@ -125,7 +131,9 @@ describe('TxSizeEstimator Boundaries', () => { assert.strictEqual(estimate, 350, 'should fall back to 350 for unknown script') }) }) +}) +describe('TxSizeEstimator Boundaries', () => { describe('estimateP2shInputWithRedeem', () => { it('returns overhead (149) for 0-byte redeem script', () => { const estimate = TxSizeEstimator.estimateP2shInputWithRedeem(Buffer.alloc(0)) @@ -139,7 +147,9 @@ describe('TxSizeEstimator Boundaries', () => { assert.strictEqual(estimate, 629) }) }) +}) +describe('TxSizeEstimator Boundaries', () => { describe('conservative fallback in pipeline', () => { it('missing UTXO data uses 350-byte fallback → fee includes input estimate', async () => { const encoder = makeEncoder('dogecoin-regtest') From a2fa5b3fc287ada0d6035d519290c40a1046c567 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 16:45:38 -0700 Subject: [PATCH 041/148] test: split UTXO value boundary suite by behavior --- test/boundary/utxo_value_boundaries.test.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/boundary/utxo_value_boundaries.test.js b/test/boundary/utxo_value_boundaries.test.js index 9df1df2..84bacfc 100644 --- a/test/boundary/utxo_value_boundaries.test.js +++ b/test/boundary/utxo_value_boundaries.test.js @@ -76,7 +76,9 @@ describe('UTXO Value Boundaries', () => { assert.ok(result.psbt instanceof bitcoin.Psbt) }) }) +}) +describe('UTXO Value Boundaries', () => { describe('string-typed UTXO values (parseInt coercion)', () => { it('"1000000" string value → treated as 1000000 sats correctly', async () => { const encoder = makeEncoder(NETWORK) @@ -108,7 +110,9 @@ describe('UTXO Value Boundaries', () => { ), /must be a non-negative integer/) }) }) +}) +describe('UTXO Value Boundaries', () => { describe('all-duplicate UTXOs', () => { it('3 identical UTXOs dedup to 1; if sufficient, single input', async () => { const encoder = makeEncoder(NETWORK) @@ -151,7 +155,9 @@ describe('UTXO Value Boundaries', () => { ) }) }) +}) +describe('UTXO Value Boundaries', () => { describe('UTXO with large value', () => { it('value = 2.1 trillion sats (21M BTC max supply) works correctly', async () => { const encoder = makeEncoder(NETWORK) @@ -209,7 +215,9 @@ describe('UTXO Value Boundaries', () => { ) }) }) +}) +describe('UTXO Value Boundaries', () => { describe('unconfirmed filtering exhaustion', () => { it('all UTXOs mempool + unconfirmed=false → falls to UtxoTracker', async () => { const encoder = makeEncoder(NETWORK) @@ -238,7 +246,9 @@ describe('UTXO Value Boundaries', () => { assert.ok(result.psbt instanceof bitcoin.Psbt) }) }) +}) +describe('UTXO Value Boundaries', () => { describe('UTXO sorting with mixed values', () => { it('largest UTXO is always used as first input (txidFirstInput)', async () => { const encoder = makeEncoder(NETWORK) From d81ff7e06cbcf0d39166c3c5dbc113218091d5f6 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 16:45:38 -0700 Subject: [PATCH 042/148] test: split API failure suite by behavior --- test/chaos/api_layer.test.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/chaos/api_layer.test.js b/test/chaos/api_layer.test.js index b63d86f..1abcc61 100644 --- a/test/chaos/api_layer.test.js +++ b/test/chaos/api_layer.test.js @@ -69,7 +69,11 @@ describe('Chaos Category F: API Layer Failures', () => { // 'commitTxid must be a 64-character hex string'. assert.strictEqual(err.message, 'Request params must be an object') }) + }) +}) +describe('Chaos Category F: API Layer Failures', () => { + describe('F-1: Validator with malformed params', () => { it('data exceeding 65536 bytes → RangeError', () => { assert.throws( () => validator.validateAll({ data: 'X'.repeat(65537), pubkey: 'test' }), @@ -109,7 +113,9 @@ describe('Chaos Category F: API Layer Failures', () => { ) }) }) +}) +describe('Chaos Category F: API Layer Failures', () => { // api.js line 124 calls psbt.toHex() OUTSIDE the try/catch block. // If toHex() throws, it would be an unhandled error in the API. @@ -152,7 +158,9 @@ describe('Chaos Category F: API Layer Failures', () => { assert.doesNotThrow(() => result.psbt.toHex()) }) }) +}) +describe('Chaos Category F: API Layer Failures', () => { describe('F-3b: psbt.toHex() monkey-patch (documents api.js exposure)', () => { let _origToHex From 9dfbb0161db8a2ff3e79caadf3fef2fe240e50ca Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 16:45:38 -0700 Subject: [PATCH 043/148] test: split arithmetic state suite by behavior --- test/chaos/arithmetic_state.test.js | 43 ++++++++++++++++------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/test/chaos/arithmetic_state.test.js b/test/chaos/arithmetic_state.test.js index 00e5f94..b780a5d 100644 --- a/test/chaos/arithmetic_state.test.js +++ b/test/chaos/arithmetic_state.test.js @@ -28,26 +28,25 @@ const actions = require('../integration/helpers/actionFactory') const NETWORK = 'dogecoin-regtest' const ADDRESS = getTestAddress(NETWORK) -describe('Chaos Category D: Arithmetic & State Corruption', () => { - - // These two cases were written against the pre-M-8 encoder, which returned - // "success" when changeSatoshis came out negative: the caller signed a PSBT - // whose outputs exceeded its inputs, and the excess went to the miner. M-8 - // (_buildTransaction) now rejects an under-funded selection with a typed - // INSUFFICIENT_FUNDS error, so the chaos scenario is unchanged and only the - // expected outcome moved: from a quietly-wrong PSBT to a loud refusal. - - const assertInsufficientFunds = (err) => { - assert.strictEqual(err.xchainCode, 'INSUFFICIENT_FUNDS', - `expected INSUFFICIENT_FUNDS, got ${err.xchainCode}: ${err.message}`) - // The details carry what the caller needs to top up, so assert them rather - // than the message text: available must be the real selected total, and - // required must exceed it, or the guard fired on the wrong arithmetic. - assert.ok(Number(err.details.required) > Number(err.details.available), - 'required must exceed available for this error to be the right one') - return true - } +// These two cases were written against the pre-M-8 encoder, which returned +// "success" when changeSatoshis came out negative: the caller signed a PSBT +// whose outputs exceeded its inputs, and the excess went to the miner. M-8 +// (_buildTransaction) now rejects an under-funded selection with a typed +// INSUFFICIENT_FUNDS error, so the chaos scenario is unchanged and only the +// expected outcome moved: from a quietly-wrong PSBT to a loud refusal. + +const assertInsufficientFunds = (err) => { + assert.strictEqual(err.xchainCode, 'INSUFFICIENT_FUNDS', + `expected INSUFFICIENT_FUNDS, got ${err.xchainCode}: ${err.message}`) + // The details carry what the caller needs to top up, so assert them rather + // than the message text: available must be the real selected total, and + // required must exceed it, or the guard fired on the wrong arithmetic. + assert.ok(Number(err.details.required) > Number(err.details.available), + 'required must exceed available for this error to be the right one') + return true +} +describe('Chaos Category D: Arithmetic & State Corruption', () => { describe('D-1: Negative change is refused, not signed', () => { it('1-sat UTXO + 10000-sat fee → INSUFFICIENT_FUNDS, no PSBT', async () => { const encoder = makeEncoder(NETWORK) @@ -91,7 +90,9 @@ describe('Chaos Category D: Arithmetic & State Corruption', () => { ) }) }) +}) +describe('Chaos Category D: Arithmetic & State Corruption', () => { describe('D-2: Insufficient UTXOs produce no PSBT', () => { it('total inputs < fee → rejected instead of returned', async () => { const encoder = makeEncoder(NETWORK) @@ -138,7 +139,9 @@ describe('Chaos Category D: Arithmetic & State Corruption', () => { assert.ok(result.psbt instanceof bitcoin.Psbt) }) }) +}) +describe('Chaos Category D: Arithmetic & State Corruption', () => { describe('D-3: Concurrent calls with shared UTXO array', () => { it('two concurrent calls with independent arrays both succeed', async () => { const encoder = makeEncoder(NETWORK) @@ -188,7 +191,9 @@ describe('Chaos Category D: Arithmetic & State Corruption', () => { assert.ok(r2.psbt instanceof bitcoin.Psbt) }) }) +}) +describe('Chaos Category D: Arithmetic & State Corruption', () => { describe('D-4: UTXO array mutation across calls', () => { it('createTransaction mutates the caller\'s utxos array in-place', async () => { const encoder = makeEncoder(NETWORK) From 1361d8a04d62402ca1ce260a358d620213f813e7 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 17:03:06 -0700 Subject: [PATCH 044/148] test: split input corruption suites into same-title blocks by behavior --- test/chaos/input_corruption.test.js | 118 ++++++++++++++++------------ 1 file changed, 69 insertions(+), 49 deletions(-) diff --git a/test/chaos/input_corruption.test.js b/test/chaos/input_corruption.test.js index 826831d..3c35184 100644 --- a/test/chaos/input_corruption.test.js +++ b/test/chaos/input_corruption.test.js @@ -31,55 +31,7 @@ const BTC = 'bitcoin-regtest' const DOGE_ADDR = getTestAddress(DOGE) const BTC_ADDR = getTestAddress(BTC) -describe('Chaos Category B: Input & Data Corruption', () => { - - // KNOWN BUG: line 277 of XChainEncoder.js has no guard for empty - // array. utxos[0]["txid"] throws TypeError on undefined. - - describe('B-1: Empty UTXO array after dedup (unguarded crash)', () => { - it('all-mempool UTXOs + unconfirmed=false → TypeError crash', async () => { - const encoder = makeEncoder(DOGE) - - await assert.rejects( - () => encoder.createTransaction( - [makeMempoolUtxo(TXID_A, 0, 100000000), makeMempoolUtxo(TXID_B, 0, 50000000)], - DOGE_ADDR, null, - actions.makeSend().data, null, 10000, false, null, DOGE_ADDR, - null, null, null, - false, // unconfirmed = false → all mempool UTXOs removed - 0.00001 - ), - err => err instanceof TypeError || err instanceof Error - ) - }) - - it('single mempool UTXO + unconfirmed=false → crash', async () => { - const encoder = makeEncoder(DOGE) - - await assert.rejects( - () => encoder.createTransaction( - [makeMempoolUtxo(TXID_A, 0, 100000000)], - DOGE_ADDR, null, - actions.makeSend().data, null, 10000, false, null, DOGE_ADDR, - null, null, null, false, 0.00001 - ), - err => err instanceof TypeError || err instanceof Error - ) - }) - - it('confirmed UTXO survives dedup → no crash', async () => { - const encoder = makeEncoder(DOGE) - - const result = await encoder.createTransaction( - [makeUtxo(DOGE, TXID_A, 0, 100000000)], - DOGE_ADDR, null, - actions.makeSend().data, null, 10000, false, null, DOGE_ADDR, - null, null, null, false, 0.00001 - ) - assert.ok(result.psbt instanceof bitcoin.Psbt) - }) - }) - +function defineArithmeticLimitCases () { describe('B-2: UTXO values at arithmetic boundaries', () => { // value=0 and value=1 used to build a PSBT with negative change (the fee // silently ate more than the inputs held). M-8 rejects the under-funded @@ -119,7 +71,11 @@ describe('Chaos Category B: Input & Data Corruption', () => { } ) }) + }) +} +function defineMalformedValueCases () { + describe('B-2: UTXO values at arithmetic boundaries', () => { it('hex string value "0xff" is rejected (parseInt would read it as 0)', async () => { const encoder = makeEncoder(DOGE) const utxo = { ...makeUtxo(DOGE, TXID_A, 0, 1), value: '0xff' } @@ -143,7 +99,64 @@ describe('Chaos Category B: Input & Data Corruption', () => { ), /must be a non-negative integer/) }) }) +} + +describe('Chaos Category B: Input & Data Corruption', () => { + + // KNOWN BUG: line 277 of XChainEncoder.js has no guard for empty + // array. utxos[0]["txid"] throws TypeError on undefined. + + describe('B-1: Empty UTXO array after dedup (unguarded crash)', () => { + it('all-mempool UTXOs + unconfirmed=false → TypeError crash', async () => { + const encoder = makeEncoder(DOGE) + + await assert.rejects( + () => encoder.createTransaction( + [makeMempoolUtxo(TXID_A, 0, 100000000), makeMempoolUtxo(TXID_B, 0, 50000000)], + DOGE_ADDR, null, + actions.makeSend().data, null, 10000, false, null, DOGE_ADDR, + null, null, null, + false, // unconfirmed = false → all mempool UTXOs removed + 0.00001 + ), + err => err instanceof TypeError || err instanceof Error + ) + }) + + it('single mempool UTXO + unconfirmed=false → crash', async () => { + const encoder = makeEncoder(DOGE) + + await assert.rejects( + () => encoder.createTransaction( + [makeMempoolUtxo(TXID_A, 0, 100000000)], + DOGE_ADDR, null, + actions.makeSend().data, null, 10000, false, null, DOGE_ADDR, + null, null, null, false, 0.00001 + ), + err => err instanceof TypeError || err instanceof Error + ) + }) + + it('confirmed UTXO survives dedup → no crash', async () => { + const encoder = makeEncoder(DOGE) + + const result = await encoder.createTransaction( + [makeUtxo(DOGE, TXID_A, 0, 100000000)], + DOGE_ADDR, null, + actions.makeSend().data, null, 10000, false, null, DOGE_ADDR, + null, null, null, false, 0.00001 + ) + assert.ok(result.psbt instanceof bitcoin.Psbt) + }) + }) +}) +describe('Chaos Category B: Input & Data Corruption', () => { + defineArithmeticLimitCases() + defineMalformedValueCases() +}) + +describe('Chaos Category B: Input & Data Corruption', () => { describe('B-3: Obfuscation key edge cases (degenerate AES keys)', () => { it('all-zero txid (000...0) works as AES key', async () => { const encoder = makeEncoder(DOGE) @@ -188,6 +201,7 @@ describe('Chaos Category B: Input & Data Corruption', () => { assert.ok(result.psbt instanceof bitcoin.Psbt) }) }) +}) // The encoder caps the *compiled* on-chain push at MAX_COMPILED_ACTION_DATA_LENGTH // (8192), the same ceiling the indexing decoder enforces. An 8189-byte payload @@ -195,6 +209,8 @@ describe('Chaos Category B: Input & Data Corruption', () => { // compiles to 8193 and is rejected, because the decoder would otherwise silently // drop it on chain. (createTransaction enforces the compiled ceiling directly; // the api.js validator additionally pre-checks the raw byte count at 8189.) + +describe('Chaos Category B: Input & Data Corruption', () => { describe('B-4: Maximum payload stress (8192 byte compiled boundary)', () => { it('8189-byte data → compiled=8192 → at limit, P2WSH succeeds', async () => { const encoder = makeEncoder(BTC) @@ -223,7 +239,9 @@ describe('Chaos Category B: Input & Data Corruption', () => { ) }) }) +}) +describe('Chaos Category B: Input & Data Corruption', () => { describe('B-5: Corrupted scriptPubKey in UTXOs', () => { function makeCorruptUtxo (scriptPubKey) { return { txid: TXID_A, vout: 0, value: 100000000, confirmations: 6, scriptPubKey } @@ -265,7 +283,9 @@ describe('Chaos Category B: Input & Data Corruption', () => { assert.ok(result.psbt instanceof bitcoin.Psbt) }) }) +}) +describe('Chaos Category B: Input & Data Corruption', () => { describe('B-6: Binary/NUL content in ACTION data', () => { it('NUL bytes in data produce valid PSBT', async () => { const encoder = makeEncoder(DOGE) From eed5d5a025a3de9ca6087951182cffe6c3f168af Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 17:03:06 -0700 Subject: [PATCH 045/148] test: split library failure suites into same-title blocks by behavior --- test/chaos/library_failures.test.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/chaos/library_failures.test.js b/test/chaos/library_failures.test.js index 650b43f..961ccec 100644 --- a/test/chaos/library_failures.test.js +++ b/test/chaos/library_failures.test.js @@ -58,7 +58,9 @@ describe('Chaos Category C: Library & Crypto Failures', () => { ) }) }) +}) +describe('Chaos Category C: Library & Crypto Failures', () => { describe('C-2a: Psbt.addOutput() all calls fail', () => { let _origAddOutput @@ -87,7 +89,9 @@ describe('Chaos Category C: Library & Crypto Failures', () => { ) }) }) +}) +describe('Chaos Category C: Library & Crypto Failures', () => { describe('C-2b: Psbt.addOutput() fails on Nth call only', () => { let _origAddOutput let outputCallCount @@ -120,7 +124,9 @@ describe('Chaos Category C: Library & Crypto Failures', () => { ) }) }) +}) +describe('Chaos Category C: Library & Crypto Failures', () => { describe('C-3: crypto.createCipheriv failure', () => { let _origCreateCipheriv @@ -149,7 +155,9 @@ describe('Chaos Category C: Library & Crypto Failures', () => { ) }) }) +}) +describe('Chaos Category C: Library & Crypto Failures', () => { describe('C-4: bitcoin.script.compile failure', () => { let _origCompile let encoder @@ -181,6 +189,7 @@ describe('Chaos Category C: Library & Crypto Failures', () => { ) }) }) +}) // No monkey-patching needed; uses the real encoder. // @@ -193,6 +202,7 @@ describe('Chaos Category C: Library & Crypto Failures', () => { // and the remaining error is a specific, named refusal rather than a library // crash. The chaos value is in that split: what resolves, and what does not. +describe('Chaos Category C: Library & Crypto Failures', () => { describe('C-5: Non-base58 caller identity for P2SH encoding', () => { it('bech32 P2WPKH caller resolves to the same HASH160 as the raw pubkey', async () => { const encoder = makeEncoder(DOGE) From 708c02bf3a77a56f2e4d36cd3de48e553e056cc9 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 17:03:06 -0700 Subject: [PATCH 046/148] test: split network failure suites into same-title blocks by behavior --- test/chaos/network_failures.test.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/chaos/network_failures.test.js b/test/chaos/network_failures.test.js index 4fcccef..598e88c 100644 --- a/test/chaos/network_failures.test.js +++ b/test/chaos/network_failures.test.js @@ -87,7 +87,9 @@ describe('Chaos Category A: Network & Dependency Failures', () => { assert.ok(result.psbt) }) }) +}) +describe('Chaos Category A: Network & Dependency Failures', () => { describe('A-2: Coin daemon returns invalid JSON (502 HTML)', () => { it('SyntaxError from JSON parse propagates', async () => { const encoder = makeEncoder(NETWORK) @@ -107,7 +109,9 @@ describe('Chaos Category A: Network & Dependency Failures', () => { ) }) }) +}) +describe('Chaos Category A: Network & Dependency Failures', () => { describe('A-3: Coin daemon RPC error (node still loading)', () => { it('RPC error from getFeePerKilobyte propagates', async () => { const encoder = makeEncoder(NETWORK) @@ -145,7 +149,9 @@ describe('Chaos Category A: Network & Dependency Failures', () => { ) }) }) +}) +describe('Chaos Category A: Network & Dependency Failures', () => { describe('A-4: UTXO tracker unreachable', () => { it('propagates ECONNREFUSED when utxos=null', async () => { const encoder = makeEncoder(NETWORK) @@ -181,7 +187,9 @@ describe('Chaos Category A: Network & Dependency Failures', () => { ) }) }) +}) +describe('Chaos Category A: Network & Dependency Failures', () => { describe('A-5: UTXO tracker malformed responses', () => { it('A-5a: tracker returns null → throws about no utxos', async () => { const encoder = makeEncoder(NETWORK) @@ -210,7 +218,11 @@ describe('Chaos Category A: Network & Dependency Failures', () => { /no utxos/i ) }) + }) +}) +describe('Chaos Category A: Network & Dependency Failures', () => { + describe('A-5: UTXO tracker malformed responses', () => { it('A-5c: tracker returns { utxos: null } → throws', async () => { const encoder = makeEncoder(NETWORK) encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ utxos: null }) @@ -242,7 +254,9 @@ describe('Chaos Category A: Network & Dependency Failures', () => { ) }) }) +}) +describe('Chaos Category A: Network & Dependency Failures', () => { describe('A-6: Intermittent RPC failures', () => { it('first call succeeds, second call fails (alternating pattern)', async () => { const encoder = makeEncoder(NETWORK) @@ -284,7 +298,9 @@ describe('Chaos Category A: Network & Dependency Failures', () => { assert.ok(result3.psbt) }) }) +}) +describe('Chaos Category A: Network & Dependency Failures', () => { describe('A-7: Slow RPC responses', () => { it('150ms delay on getFeePerKilobyte still completes', async () => { const encoder = makeEncoder(NETWORK) From 6cc3e8b8dce70a61cc4bacc3a17ed1796c88b585 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 17:03:06 -0700 Subject: [PATCH 047/148] test: split resource exhaustion suite into same-title blocks by behavior --- test/chaos/resource_exhaustion.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/chaos/resource_exhaustion.test.js b/test/chaos/resource_exhaustion.test.js index cb8a327..fec1682 100644 --- a/test/chaos/resource_exhaustion.test.js +++ b/test/chaos/resource_exhaustion.test.js @@ -72,6 +72,7 @@ describe('Chaos Category E: Resource Exhaustion', () => { ) }) }) +}) // Both cases fund the 500 UTXOs at 1000 sats and set the fee to exactly the // 500-UTXO total. They were written at 100 sats against a fee of 100000, @@ -81,6 +82,7 @@ describe('Chaos Category E: Resource Exhaustion', () => { // guard rather than the 500-input build this category exists to stress. The // fee is chosen so the shortfall only closes on the last UTXO, which keeps // "all 500 consumed" true and lands the build exactly on MAX_UTXO_COUNT. +describe('Chaos Category E: Resource Exhaustion', () => { describe('E-2: High UTXO count processing', () => { it('500 segwit UTXOs, all consumed, completes in <10s', async () => { const encoder = makeEncoder(DOGE) From 6cb0f2c3fa95e4f0f7fff198f4efc1a9e1445ba9 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 17:03:06 -0700 Subject: [PATCH 048/148] test: split UTXO conformance suite into same-title blocks and scope its axios stub hooks --- .../utxo_record_conformance.test.js | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/test/conformance/utxo_record_conformance.test.js b/test/conformance/utxo_record_conformance.test.js index 5a86c9d..d034d15 100644 --- a/test/conformance/utxo_record_conformance.test.js +++ b/test/conformance/utxo_record_conformance.test.js @@ -52,9 +52,6 @@ const clone = (r) => JSON.parse(JSON.stringify(r)) let originalPost -beforeEach(() => { originalPost = axios.post }) -afterEach(() => { axios.post = originalPost }) - // Serve the fixture through the real transport seam: one get_sync_status call, // then one get_utxos page carrying the fixture's own records and sync sibling. function stubTrackerServing (records, sync) { @@ -83,7 +80,9 @@ describe('utxo-record conformance fixture: the tracker record passes both inboun assert.ok(fixture.withheldOutpoints.length >= 1, 'fixture lost its withheld-outpoint expectation') assert.ok(fixture.sync && typeof fixture.sync === 'object', 'fixture lost its sync sibling') }) +}) +describe('utxo-record conformance fixture: the tracker record passes both inbound gates', function () { it('validateUtxoEntry accepts every record the tracker serves', function () { fixture.servedRecords.forEach((r, i) => { const entry = clone(r) @@ -98,6 +97,11 @@ describe('utxo-record conformance fixture: the tracker record passes both inboun `${r.txid}: validateUtxoEntry lost satoshi precision`) }) }) +}) + +describe('utxo-record conformance fixture: the tracker record passes both inbound gates', function () { + beforeEach(() => { originalPost = axios.post }) + afterEach(() => { axios.post = originalPost }) it('the getUtxosFromAddress shape gate accepts every record the tracker serves', async function () { stubTrackerServing(fixture.servedRecords, fixture.sync) @@ -109,6 +113,11 @@ describe('utxo-record conformance fixture: the tracker record passes both inboun assert.strictEqual(u.confirmations, fixture.servedRecords[i].confirmations) }) }) +}) + +describe('utxo-record conformance fixture: the tracker record passes both inbound gates', function () { + beforeEach(() => { originalPost = axios.post }) + afterEach(() => { axios.post = originalPost }) it('the shape gate leaves value as the decimal string it re-exports over get_utxos', async function () { // NOT a restatement of the tier above, and the difference is the point. @@ -130,7 +139,9 @@ describe('utxo-record conformance fixture: the tracker record passes both inboun assert.doesNotThrow(() => JSON.stringify({ utxos: result.utxos }), 'the get_utxos payload must stay JSON-serializable') }) +}) +describe('utxo-record conformance fixture: the tracker record passes both inbound gates', function () { it('the served set withholds every outpoint the tracker refuses to serve', function () { const servedKeys = fixture.servedRecords.map((r) => r.txid + ':' + r.vout) for (const w of fixture.withheldOutpoints) { @@ -138,6 +149,11 @@ describe('utxo-record conformance fixture: the tracker record passes both inboun `withheld outpoint ${w.txid}:${w.vout} appeared in the served set (${w.why})`) } }) +}) + +describe('utxo-record conformance fixture: the tracker record passes both inbound gates', function () { + beforeEach(() => { originalPost = axios.post }) + afterEach(() => { axios.post = originalPost }) it('the freshness sibling satisfies the encoder fetch gate', async function () { // The sync shape is a contract too: getUtxosFromAddress refuses outright on @@ -153,6 +169,11 @@ describe('utxo-record conformance fixture: the tracker record passes both inboun assert.ok(fixture.sync.lag >= 0 && fixture.sync.synced === true && fixture.sync.mempool_ready === true, 'the pinned sync sample must be one the encoder accepts') }) +}) + +describe('utxo-record conformance fixture: the tracker record passes both inbound gates', function () { + beforeEach(() => { originalPost = axios.post }) + afterEach(() => { axios.post = originalPost }) // A conformance test that only proves acceptance has no teeth: these are the // producer-side drifts the seam is here to catch, each mutated from a REAL From c42889b1382feb6e50a94e187df014e694bec210 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 17:03:06 -0700 Subject: [PATCH 049/148] test: split API layer suite into same-title blocks and hoist the availability check --- test/e2e-service/api_layer_e2e.test.js | 33 +++++++++++++++++++------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/test/e2e-service/api_layer_e2e.test.js b/test/e2e-service/api_layer_e2e.test.js index 36198b9..b0217e5 100644 --- a/test/e2e-service/api_layer_e2e.test.js +++ b/test/e2e-service/api_layer_e2e.test.js @@ -89,17 +89,23 @@ async function isApiRunning () { } } -describe('E2E-10: JSON-RPC API Layer', function () { - let apiAvailable = false +let apiAvailable = false +let apiChecked = false - before(async function () { +async function requireApi () { + if (!apiChecked) { apiAvailable = await isApiRunning() - if (!apiAvailable) { - console.log(' [SKIPPED] API server not running on port ' + API_PORT) - console.log(' Start with: npm run api') - this.skip() - } - }) + apiChecked = true + } + if (!apiAvailable) { + console.log(' [SKIPPED] API server not running on port ' + API_PORT) + console.log(' Start with: npm run api') + this.skip() + } +} + +describe('E2E-10: JSON-RPC API Layer', function () { + before(requireApi) describe('E2E-10.1: create_tx with full params', () => { it('returns {psbt: hex, encoding: string}', async function () { @@ -139,7 +145,10 @@ describe('E2E-10: JSON-RPC API Layer', function () { assert.ok(res.body.result || res.body.error) }) }) +}) +describe('E2E-10: JSON-RPC API Layer', function () { + before(requireApi) describe('E2E-10.3: PSBT hex validity', () => { it('returned hex can be parsed by Psbt.fromHex()', async function () { if (!apiAvailable) this.skip() @@ -171,7 +180,10 @@ describe('E2E-10: JSON-RPC API Layer', function () { assert.ok(res.body.error || res.status !== 200) }) }) +}) +describe('E2E-10: JSON-RPC API Layer', function () { + before(requireApi) describe('E2E-10.5: CORS headers', () => { it('response includes Access-Control-Allow-Origin', async function () { if (!apiAvailable) this.skip() @@ -191,7 +203,10 @@ describe('E2E-10: JSON-RPC API Layer', function () { ) }) }) +}) +describe('E2E-10: JSON-RPC API Layer', function () { + before(requireApi) describe('E2E-10.6: Concurrent requests', () => { it('multiple simultaneous requests do not interfere', async function () { if (!apiAvailable) this.skip() From ce247c51daaa89090f049bfa617cd92d45c07f55 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 17:03:06 -0700 Subject: [PATCH 050/148] test: split edge case suite into same-title blocks by behavior --- test/e2e/edge_cases_e2e.test.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/e2e/edge_cases_e2e.test.js b/test/e2e/edge_cases_e2e.test.js index c740441..368ff8f 100644 --- a/test/e2e/edge_cases_e2e.test.js +++ b/test/e2e/edge_cases_e2e.test.js @@ -70,7 +70,9 @@ describe('E2E-7: Complex Parameter & Edge Case Handling', () => { assert.ok(dataString.includes('J-DOG_#1')) }) }) +}) +describe('E2E-7: Complex Parameter & Edge Case Handling', () => { describe('E2E-7.2: TICK by numeric ID (caret prefix)', () => { it('^1234 preserved in SEND', async () => { const action = actions.makeSendByTickId('1234', '100', actions.ADDR_BTC) @@ -79,7 +81,9 @@ describe('E2E-7: Complex Parameter & Edge Case Handling', () => { assert.ok(dataString.includes('^1234')) }) }) +}) +describe('E2E-7: Complex Parameter & Edge Case Handling', () => { describe('E2E-7.3: Long TICK name', () => { it('12-character TICK survives encoding', async () => { const tick = 'ABCDEFGHIJKL' // 12 chars @@ -89,7 +93,9 @@ describe('E2E-7: Complex Parameter & Edge Case Handling', () => { assert.ok(dataString.includes(tick)) }) }) +}) +describe('E2E-7: Complex Parameter & Edge Case Handling', () => { describe('E2E-7.4: Zero amount', () => { it('SEND with amount=0 preserves the zero', async () => { const action = { data: `SEND|0|JDOG|0|${actions.ADDR_BTC}`, rawData: null } @@ -100,7 +106,9 @@ describe('E2E-7: Complex Parameter & Edge Case Handling', () => { assert.strictEqual(parts[3], '0') }) }) +}) +describe('E2E-7: Complex Parameter & Edge Case Handling', () => { describe('E2E-7.5: Large amount with high precision', () => { it('18-digit amount preserved exactly', async () => { const bigAmount = '999999999999999999' @@ -117,7 +125,9 @@ describe('E2E-7: Complex Parameter & Edge Case Handling', () => { assert.ok(dataString.includes(amount)) }) }) +}) +describe('E2E-7: Complex Parameter & Edge Case Handling', () => { describe('E2E-7.6: Unicode in memo', () => { it('emoji and CJK characters survive UTF-8 encoding', async () => { const memo = 'Hello World' @@ -134,10 +144,12 @@ describe('E2E-7: Complex Parameter & Edge Case Handling', () => { assert.strictEqual(dataString, action.data) }) }) +}) // Note: The pipe character IS the delimiter, so including it in a memo // would corrupt field parsing. This test documents that behavior. +describe('E2E-7: Complex Parameter & Edge Case Handling', () => { describe('E2E-7.7: Pipe character in memo field', () => { it('pipe in memo extends the field count (protocol limitation)', async () => { // The pipe is the field delimiter, so a memo containing one is parsed @@ -150,7 +162,9 @@ describe('E2E-7: Complex Parameter & Edge Case Handling', () => { assert.strictEqual(dataString, data) }) }) +}) +describe('E2E-7: Complex Parameter & Edge Case Handling', () => { describe('E2E-7.8: ISSUE with empty optional fields', () => { it('empty lock and callback fields preserved as empty strings', async () => { const action = actions.makeIssueFull('EMPTY', { @@ -175,7 +189,9 @@ describe('E2E-7: Complex Parameter & Edge Case Handling', () => { assert.ok(['OP_RETURN', 'P2SH'].includes(result.encoding)) }) }) +}) +describe('E2E-7: Complex Parameter & Edge Case Handling', () => { describe('E2E-7.9: data + rawData dual parameter', () => { it('both buffers compiled into script and recoverable', async () => { const data = 'SEND|0|JDOG|1|mfWxJ45' @@ -202,7 +218,9 @@ describe('E2E-7: Complex Parameter & Edge Case Handling', () => { assert.strictEqual(decompiled[1].toString('utf8'), rawData) }) }) +}) +describe('E2E-7: Complex Parameter & Edge Case Handling', () => { describe('E2E-7.10: DISPENSER with all fields', () => { it('all 8 dispenser fields including trailing zeros preserved', async () => { const action = actions.makeDispenser('JDOG', '100', '10', '1', actions.ADDR_BTC) @@ -220,7 +238,9 @@ describe('E2E-7: Complex Parameter & Edge Case Handling', () => { assert.strictEqual(parts[8], '0') // trailing field }) }) +}) +describe('E2E-7: Complex Parameter & Edge Case Handling', () => { describe('E2E-7.11: ORDER with zero expiration', () => { it('expiration=0 (no expiry) preserved', async () => { const action = actions.makeOrder('BUY', 'JDOG', '100', 'BRRR', '50', '0') @@ -229,7 +249,9 @@ describe('E2E-7: Complex Parameter & Edge Case Handling', () => { assert.strictEqual(parts[7], '0') }) }) +}) +describe('E2E-7: Complex Parameter & Edge Case Handling', () => { describe('E2E-7.12: Large BATCH with 10+ actions', () => { it('10 actions in batch all preserved', async () => { const batchActions = [] @@ -257,7 +279,9 @@ describe('E2E-7: Complex Parameter & Edge Case Handling', () => { assert.strictEqual(parts.length, 10) }) }) +}) +describe('E2E-7: Complex Parameter & Edge Case Handling', () => { describe('E2E-7.13: Custom dust parameter', () => { it('MULTISIGN output uses custom dust value', async () => { const encoder = makeEncoder(NETWORK) @@ -297,7 +321,9 @@ describe('E2E-7: Complex Parameter & Edge Case Handling', () => { `fee ${impliedFee} should be >= network dust ${encoder.dustAmount}, not custom 100`) }) }) +}) +describe('E2E-7: Complex Parameter & Edge Case Handling', () => { describe('E2E-7.14: ISSUE with all locks enabled', () => { it('all lock fields set to 1 are preserved', async () => { const action = actions.makeIssueFull('LOCKED', { From 025f038e01556e530d81d3d06e38c2ee12b97fe2 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 17:03:06 -0700 Subject: [PATCH 051/148] test: split encoding boundary suite into same-title blocks by behavior --- test/e2e/encoding_boundaries_e2e.test.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/e2e/encoding_boundaries_e2e.test.js b/test/e2e/encoding_boundaries_e2e.test.js index 615c4f5..e5ac2f6 100644 --- a/test/e2e/encoding_boundaries_e2e.test.js +++ b/test/e2e/encoding_boundaries_e2e.test.js @@ -63,7 +63,9 @@ describe('E2E-3: Encoding Type Selection & Boundaries', () => { assert.strictEqual(payload.magic, MAGIC_WORD) }) }) +}) +describe('E2E-3: Encoding Type Selection & Boundaries', () => { describe('E2E-3.2: Maximum OP_RETURN boundary (75-byte string)', () => { it('75-byte ACTION compiles to 76 bytes, fits OP_RETURN (76 + 4 magic = 80)', async () => { // script.compile([75-byte-buf]) = 1-byte push + 75 data = 76 bytes @@ -73,7 +75,9 @@ describe('E2E-3: Encoding Type Selection & Boundaries', () => { assert.strictEqual(result.encoding, 'OP_RETURN') }) }) +}) +describe('E2E-3: Encoding Type Selection & Boundaries', () => { describe('E2E-3.3: One byte over OP_RETURN boundary', () => { it('80-byte ACTION exceeds OP_RETURN, auto-selects P2SH', async () => { const action = actions.makeActionOfSize(80) @@ -81,7 +85,9 @@ describe('E2E-3: Encoding Type Selection & Boundaries', () => { assert.strictEqual(result.encoding, 'P2SH') }) }) +}) +describe('E2E-3: Encoding Type Selection & Boundaries', () => { describe('E2E-3.4: Forced OP_RETURN with large data is rejected', () => { it('200-byte data forced to OP_RETURN is rejected', async () => { // A transaction may carry at most one OP_RETURN output; Bitcoin Core @@ -103,7 +109,9 @@ describe('E2E-3: Encoding Type Selection & Boundaries', () => { assert.strictEqual(result.encoding, 'P2SH') }) }) +}) +describe('E2E-3: Encoding Type Selection & Boundaries', () => { describe('E2E-3.5: Forced P2SH despite data fitting OP_RETURN', () => { it('encoding override to P2SH is honored', async () => { const action = actions.makeSend() // small @@ -119,7 +127,9 @@ describe('E2E-3: Encoding Type Selection & Boundaries', () => { assert.ok(p2shOutput, 'should have P2SH output') }) }) +}) +describe('E2E-3: Encoding Type Selection & Boundaries', () => { describe('E2E-3.6: Forced P2WSH', () => { it('encoding override to P2WSH produces witness output', async () => { const action = actions.makeFile() @@ -139,7 +149,9 @@ describe('E2E-3: Encoding Type Selection & Boundaries', () => { assert.ok(p2wshOutput, 'should have P2WSH output') }) }) +}) +describe('E2E-3: Encoding Type Selection & Boundaries', () => { describe('E2E-3.7: Forced MULTISIGN', () => { it('encoding override to MULTISIGN produces 1-of-3 multisig', async () => { const MS_DATA = 'A'.repeat(59) @@ -160,7 +172,9 @@ describe('E2E-3: Encoding Type Selection & Boundaries', () => { assert.strictEqual(decompiled[5], bitcoin.opcodes.OP_CHECKMULTISIG) }) }) +}) +describe('E2E-3: Encoding Type Selection & Boundaries', () => { describe('E2E-3.8: Near P2SH max chunk size', () => { it('400-byte data fits in single P2SH chunk', async () => { const data = 'Z'.repeat(400) @@ -176,7 +190,9 @@ describe('E2E-3: Encoding Type Selection & Boundaries', () => { assert.strictEqual(p2shOutputs.length, 1, 'should fit in single P2SH chunk') }) }) +}) +describe('E2E-3: Encoding Type Selection & Boundaries', () => { describe('E2E-3.9: P2WSH handles larger-than-P2SH payload', () => { it('1000-byte file data splits across multiple P2WSH chunks', async () => { // Each data chunk is a single witness-script element, bound by consensus From c43bf9f6ca4693cfb26972c6b71dcefaa48520bd Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 17:03:06 -0700 Subject: [PATCH 052/148] test: split error rejection suite into same-title blocks by behavior --- test/e2e/error_rejection_e2e.test.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/e2e/error_rejection_e2e.test.js b/test/e2e/error_rejection_e2e.test.js index d53896b..8dcfd4f 100644 --- a/test/e2e/error_rejection_e2e.test.js +++ b/test/e2e/error_rejection_e2e.test.js @@ -70,7 +70,9 @@ describe('E2E-8: Error Handling & Negative Tests', () => { ) }) }) +}) +describe('E2E-8: Error Handling & Negative Tests', () => { describe('E2E-8.2: RPC fee estimation failure', () => { it('propagates RPC error when fee estimation fails', async () => { const encoder = makeEncoder(NETWORK) @@ -110,7 +112,9 @@ describe('E2E-8: Error Handling & Negative Tests', () => { assert.ok(result.psbt) }) }) +}) +describe('E2E-8: Error Handling & Negative Tests', () => { describe('E2E-8.3: Legacy UTXO raw tx fetch failure', () => { it('propagates error when getTransactionHex fails for legacy UTXO', async () => { const encoder = makeEncoder(NETWORK) @@ -133,7 +137,9 @@ describe('E2E-8: Error Handling & Negative Tests', () => { ) }) }) +}) +describe('E2E-8: Error Handling & Negative Tests', () => { describe('E2E-8.4: UtxoTracker unreachable', () => { it('propagates ECONNREFUSED when tracker down and utxos=null', async () => { const encoder = makeEncoder(NETWORK) @@ -154,7 +160,9 @@ describe('E2E-8: Error Handling & Negative Tests', () => { ) }) }) +}) +describe('E2E-8: Error Handling & Negative Tests', () => { describe('E2E-8.5: Invalid network name', () => { it('throws TypeError during construction', () => { assert.throws( @@ -165,7 +173,9 @@ describe('E2E-8: Error Handling & Negative Tests', () => { ) }) }) +}) +describe('E2E-8: Error Handling & Negative Tests', () => { describe('E2E-8.6: MULTISIGN without compressedPubKey', () => { it('throws when compressedPubKey is null', async () => { const encoder = makeEncoder(NETWORK) @@ -182,7 +192,9 @@ describe('E2E-8: Error Handling & Negative Tests', () => { ) }) }) +}) +describe('E2E-8: Error Handling & Negative Tests', () => { describe('E2E-8.7: Invalid address for P2SH', () => { it('throws on invalid base58 address', async () => { const encoder = makeEncoder(NETWORK) @@ -198,7 +210,9 @@ describe('E2E-8: Error Handling & Negative Tests', () => { ) }) }) +}) +describe('E2E-8: Error Handling & Negative Tests', () => { describe('E2E-8.8: No change address with surplus funds', () => { it('throws descriptive "burn satoshis" error', async () => { const encoder = makeEncoder(NETWORK) @@ -216,7 +230,9 @@ describe('E2E-8: Error Handling & Negative Tests', () => { ) }) }) +}) +describe('E2E-8: Error Handling & Negative Tests', () => { describe('E2E-8.9: Empty data string', () => { it('handles empty string without crashing', async () => { const encoder = makeEncoder(NETWORK) @@ -239,7 +255,9 @@ describe('E2E-8: Error Handling & Negative Tests', () => { } }) }) +}) +describe('E2E-8: Error Handling & Negative Tests', () => { describe('E2E-8.10: Null data parameter', () => { it('handles null data gracefully', async () => { const encoder = makeEncoder(NETWORK) @@ -258,7 +276,9 @@ describe('E2E-8: Error Handling & Negative Tests', () => { } }) }) +}) +describe('E2E-8: Error Handling & Negative Tests', () => { describe('E2E-8.11: Negative fee value', () => { it('does not produce negative-value outputs', async () => { const encoder = makeEncoder(NETWORK) @@ -283,7 +303,9 @@ describe('E2E-8: Error Handling & Negative Tests', () => { } }) }) +}) +describe('E2E-8: Error Handling & Negative Tests', () => { describe('E2E-8.12: UTXO value insufficient for fee', () => { it('handles gracefully when total UTXO value barely covers fee', async () => { const encoder = makeEncoder(NETWORK) @@ -308,7 +330,9 @@ describe('E2E-8: Error Handling & Negative Tests', () => { } }) }) +}) +describe('E2E-8: Error Handling & Negative Tests', () => { describe('Return value shape validation', () => { it('returns { psbt: Psbt, encoding: string } with valid enum', async () => { const encoder = makeEncoder(NETWORK) From 3c38420487c0820738fc8ab79ca4600db99b934c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 17:03:06 -0700 Subject: [PATCH 053/148] test: split multi-chain suite into same-title blocks by behavior --- test/e2e/multi_chain_e2e.test.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/e2e/multi_chain_e2e.test.js b/test/e2e/multi_chain_e2e.test.js index 461d0d3..a222b24 100644 --- a/test/e2e/multi_chain_e2e.test.js +++ b/test/e2e/multi_chain_e2e.test.js @@ -73,7 +73,9 @@ describe('E2E-6: Multi-Chain Validation', () => { }) }) } +}) +describe('E2E-6: Multi-Chain Validation', () => { for (const chain of CHAINS) { describe(`${chain.name} P2SH`, () => { it('creates P2SH with output value >= chain dust threshold', async () => { @@ -101,7 +103,9 @@ describe('E2E-6: Multi-Chain Validation', () => { }) }) } +}) +describe('E2E-6: Multi-Chain Validation', () => { describe('E2E-6.7: Bitcoin P2WSH', () => { it('creates P2WSH witness output on bitcoin-regtest', async () => { const encoder = makeEncoder('bitcoin-regtest') @@ -126,7 +130,9 @@ describe('E2E-6: Multi-Chain Validation', () => { assert.ok(p2wshOutput, 'should have P2WSH output') }) }) +}) +describe('E2E-6: Multi-Chain Validation', () => { describe('E2E-6.8: Bitcoin MULTISIGN (dust=546)', () => { it('multisig output at 546 sats', async () => { const encoder = makeEncoder('bitcoin-regtest') @@ -150,7 +156,9 @@ describe('E2E-6: Multi-Chain Validation', () => { 'Bitcoin multisig output should clear the 546-sat dust floor') }) }) +}) +describe('E2E-6: Multi-Chain Validation', () => { describe('E2E-6.9: Litecoin MULTISIGN (dust=5460)', () => { it('multisig output at 5460 sats', async () => { const encoder = makeEncoder('litecoin-regtest') @@ -168,7 +176,9 @@ describe('E2E-6: Multi-Chain Validation', () => { assert.ok(msOutput, 'Litecoin multisig output should be 5460 sats') }) }) +}) +describe('E2E-6: Multi-Chain Validation', () => { describe('E2E-6.10: All 9 network configs valid', () => { const ALL_NETWORKS = [ 'bitcoin-mainnet', 'bitcoin-testnet', 'bitcoin-regtest', @@ -185,7 +195,9 @@ describe('E2E-6: Multi-Chain Validation', () => { }) } }) +}) +describe('E2E-6: Multi-Chain Validation', () => { describe('Fee floor uses chain-specific dust', () => { it('Litecoin fee floor is 5460, not 546', async () => { const encoder = makeEncoder('litecoin-regtest') From fdc8bb58720f55273ae9cb56de853a70ffed933e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 17:03:06 -0700 Subject: [PATCH 054/148] test: split obfuscation integrity suite into same-title blocks by behavior --- test/e2e/obfuscation_integrity_e2e.test.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/e2e/obfuscation_integrity_e2e.test.js b/test/e2e/obfuscation_integrity_e2e.test.js index b8626fd..31501b7 100644 --- a/test/e2e/obfuscation_integrity_e2e.test.js +++ b/test/e2e/obfuscation_integrity_e2e.test.js @@ -75,7 +75,9 @@ describe('E2E-4: Obfuscation Integrity', () => { ) }) }) +}) +describe('E2E-4: Obfuscation Integrity', () => { describe('E2E-4.2: MULTISIGN obfuscation round-trip', () => { it('fake pubkey data deobfuscates to original payload', async () => { const MS_DATA = 'A'.repeat(59) @@ -96,7 +98,9 @@ describe('E2E-4: Obfuscation Integrity', () => { assert.strictEqual(decompiled[0].toString('utf8'), MS_DATA) }) }) +}) +describe('E2E-4: Obfuscation Integrity', () => { describe('E2E-4.3: P2SH tx2 marker obfuscation round-trip', () => { it('tx2 OP_RETURN marker deobfuscates with tx1 ID to XCHNp2sh', async () => { const action = actions.makeIssueFull('OBFTEST') @@ -136,7 +140,9 @@ describe('E2E-4: Obfuscation Integrity', () => { assert.strictEqual(innerDecompiled[0].toString('utf8'), action.data) }) }) +}) +describe('E2E-4: Obfuscation Integrity', () => { describe('E2E-4.4: TXID sensitivity', () => { it('same ACTION with different TXIDs produces different ciphertext', async () => { const action = actions.makeSend() @@ -172,7 +178,9 @@ describe('E2E-4: Obfuscation Integrity', () => { assert.strictEqual(decA, decB) }) }) +}) +describe('E2E-4: Obfuscation Integrity', () => { describe('E2E-4.5: UTXO sorting preserves obfuscation key', () => { it('key derives from largest-value UTXO regardless of input order', async () => { const encoder = makeEncoder(NETWORK) @@ -194,7 +202,9 @@ describe('E2E-4: Obfuscation Integrity', () => { assert.strictEqual(payload.magic, MAGIC_WORD) }) }) +}) +describe('E2E-4: Obfuscation Integrity', () => { describe('E2E-4.6: Wrong TXID detection (negative test)', () => { it('deobfuscation with wrong TXID does not produce XCHN magic', async () => { const encoder = makeEncoder(NETWORK) @@ -212,7 +222,9 @@ describe('E2E-4: Obfuscation Integrity', () => { assert.notStrictEqual(payload.magic, MAGIC_WORD, 'wrong TXID should not yield valid magic') }) }) +}) +describe('E2E-4: Obfuscation Integrity', () => { describe('E2E-4.7: Oversized OP_RETURN rejected', () => { it('forced OP_RETURN beyond a single output is rejected', async () => { // A transaction may carry at most one OP_RETURN output; Bitcoin Core From d09d7b796ea3dcd71ae7275f740a0adde94c7849 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 16:55:30 -0700 Subject: [PATCH 055/148] refactor(build): split the blockchain connector by responsibility Move RPC queries, broadcast handling, fee estimation, and their shared constants and helpers into focused sibling modules, wiring them onto the connector prototype while keeping its export shape unchanged. --- src/build/blockchain_connector.js | 594 +----------------- src/build/blockchain_connector/broadcast.js | 129 ++++ src/build/blockchain_connector/constants.js | 44 ++ .../blockchain_connector/fee_estimation.js | 245 ++++++++ .../blockchain_connector/node_queries.js | 155 +++++ src/build/blockchain_connector/rpc_helpers.js | 118 ++++ 6 files changed, 701 insertions(+), 584 deletions(-) create mode 100644 src/build/blockchain_connector/broadcast.js create mode 100644 src/build/blockchain_connector/constants.js create mode 100644 src/build/blockchain_connector/fee_estimation.js create mode 100644 src/build/blockchain_connector/node_queries.js create mode 100644 src/build/blockchain_connector/rpc_helpers.js diff --git a/src/build/blockchain_connector.js b/src/build/blockchain_connector.js index 5c4fbe2..23881cc 100644 --- a/src/build/blockchain_connector.js +++ b/src/build/blockchain_connector.js @@ -18,127 +18,9 @@ * ********************************************************************/ -const axios = require('axios') -const util = require('node:util'); -const { getLogger } = require('../observability'); -const config = require('../common/config'); -const logger = getLogger(); - -const RPC_TIMEOUT = parseInt(config.NODE_RPC_TIMEOUT ?? '30000', 10) -// Fee-rate multiple of the node's relay floor used on non-mainnet chains when -// estimatesmartfee has no data. Rationale at getFeePerKilobyte. -// -// Ten is the documented recommended rate, and on Dogecoin testnet it is NOT -// enough to be mined: measured over 400 blocks there, transactions land at -// 0.03 and around 1.0 DOGE per kB, while 0.0102 sat unmined for an hour. The -// one that did land below that paid 0.0044 and got in on legacy coin-age -// priority, which a chain of freshly spent change can never have. So the -// multiple is deployment-tunable: a quiet chain whose miners ignore the -// documented rate needs a higher one, and only the operator running that chain -// can measure what it actually takes. -function noEstimateRelayMultiplier(){ - const raw = parseFloat(config.FEE_NO_ESTIMATE_RELAY_MULTIPLIER) - return (Number.isFinite(raw) && raw > 0) ? raw : 10 -} - -// Absolute per-chain sanity ceiling (COIN/kB, the same unit estimatesmartfee -// returns) on the RAW non-regtest estimate before anything else sees it. -// getFeePerKilobyte's result is used two ways downstream: as the fee actually -// charged when a caller supplies none, AND as the anchor the caller-facing -// caps (MAX_FEE_RATE_KB, MAX_FEE_RATE_MULTIPLIER) derive their relative -// ceiling from - so a node returning a spiked or malformed estimate on -// testnet/mainnet lifts its own ceiling right along with it, and nothing -// downstream is positioned to catch that; those caps bound a CALLER-supplied -// rate, never the node's own report. Defaults are coin-scale, since fee rates -// run roughly 1000x higher in DOGE/kB than in BTC/kB or LTC/kB for an -// equivalent real-world cost; keyed off NETWORK's coin prefix -// (bitcoin-mainnet, litecoin-testnet, dogecoin-mainnet, ...). An unrecognized -// or missing prefix falls back to the bitcoin-scale default so an -// unconfigured deployment still gets a ceiling rather than none. -// FEE_ESTIMATE_SANITY_CEILING overrides in COIN/kB for every chain; a -// non-positive or unparseable value keeps the coin default (fail-safe: this -// guard can be tightened or loosened by env, never silently disabled). -const DEFAULT_FEE_ESTIMATE_SANITY_CEILING = { - bitcoin: 0.01, // 0.01 BTC/kB - litecoin: 0.01, // 0.01 LTC/kB - dogecoin: 10, // 10 DOGE/kB (~1000x the 0.01 DOGE/kB documented recommended rate) -} - -function feeEstimateSanityCeiling(){ - const override = parseFloat(config.FEE_ESTIMATE_SANITY_CEILING) - if (Number.isFinite(override) && override > 0) return override - const coin = String(config.NETWORK).split('-')[0].toLowerCase() - return DEFAULT_FEE_ESTIMATE_SANITY_CEILING[coin] || DEFAULT_FEE_ESTIMATE_SANITY_CEILING.bitcoin -} - -// Sanitize an axios error before it is logged or re-thrown. RPC calls pass -// auth:{username,password} to axios, which attaches the request config to the -// thrown error, so logging the raw error serializes the node RPC password into -// the encoder logs (util.inspect walks error.config.auth). Scrub the credential -// fields in place so neither this logger nor any upstream handler leaks them, and -// return a compact, credential-free string (error.message never carries auth). -// Kept in sync with xchain-decoder/src/chain/blockchain_connector.js sanitizeRpcError. -function sanitizeRpcError(error){ - try { - if (error && error.config) { - error.config.auth = undefined - if (error.config.headers) delete error.config.headers.Authorization - } - if (error && error.request) error.request = undefined - if (error && error.response) { - const status = error.response.status - error.response = (status !== undefined) ? { status: status } : undefined - } - } catch (_) { /* sanitization must never mask the original failure */ } - return (error && error.message) ? error.message : String(error) -} - -// Size and fee off one getmempoolentry-shaped record, across both field layouts -// the fleet's nodes use: Core 0.14 (Dogecoin 1.14) reports flat `size` and `fee`, -// while modern Core reports `vsize` and nests the fee under `fees.base`. Either -// reader returns null on a value it cannot price, which the caller treats as an -// unusable package rather than as a zero-fee ancestor. -// The node's own JSON-RPC error as a compact suffix, off either response shape -// the fleet produces: BTC v28 answers HTTP 200 with an error body, while -// LTC/DOGE answer HTTP 500 and axios hangs the body off error.response. Returns -// '' when there is no error body, so a pure transport failure reads exactly as -// it did before. Must be read BEFORE sanitizeRpcError, which scrubs -// error.response down to its status. -function rpcErrorDetail(body){ - const rpcError = body && body.error - if (!rpcError || typeof rpcError !== 'object') return '' - const code = rpcError.code - const message = rpcError.message || '' - if (code === undefined && message === '') return '' - return ` (RPC error ${code === undefined ? 'unknown' : code}: ${message})` -} - -// Type before coercion: Number(null), Number('') and Number(false) are all a -// finite 0, and Number(true) is 1, so a bare Number() cast turns an unreadable -// field into a priceable value. Only real numbers, and strings that spell one, -// are priceable; anything else is null and invalidates the package. -function readNumeric(raw){ - if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null - if (typeof raw === 'string') { - const trimmed = raw.trim() - if (trimmed === '') return null - const parsed = Number(trimmed) - return Number.isFinite(parsed) ? parsed : null - } - return null -} - -function entrySize(entry){ - const raw = entry && (entry.vsize !== undefined ? entry.vsize : entry.size) - const size = readNumeric(raw) - return (size !== null && size > 0) ? size : null -} - -function entryFee(entry){ - const raw = entry && (entry.fees && entry.fees.base !== undefined ? entry.fees.base : entry.fee) - const fee = readNumeric(raw) - return (fee !== null && fee >= 0) ? fee : null -} +const nodeQueries = require('./blockchain_connector/node_queries.js') +const broadcast = require('./blockchain_connector/broadcast.js') +const feeEstimation = require('./blockchain_connector/fee_estimation.js') class BlockchainConnector { constructor(url, port, rpcUser, rpcPassword) { @@ -146,469 +28,13 @@ class BlockchainConnector { this.rpcUser = rpcUser this.rpcPassword = rpcPassword } - - async getNetworkInfo(){ - const data = { - jsonrpc: '2.0', - method: 'getnetworkinfo', - id: 1 - }; - - try { - const response = await axios.post(this.url, data, { - auth: { - username: this.rpcUser, - password: this.rpcPassword, - }, - timeout: RPC_TIMEOUT - }); - - const responseData = response.data; - - if (responseData.result) { - return responseData.result; - } else { - throw new Error('Error getting network info' + rpcErrorDetail(responseData)); - } - } catch (error) { - // Keep the node's reason: a warming node answers -28 "Loading block - // index...", which is otherwise flattened to "status code 500". - const detail = rpcErrorDetail(error.response && error.response.data); - throw new Error(`Error in network request: ${sanitizeRpcError(error)}${detail}`); - } - } - - async isRegtest(){ - return (await this.chainName()) === 'regtest'; - } - - // The node's chain name from getblockchaininfo ('main', 'test', 'regtest', - // 'signet'). A connector's node never changes chain over its lifetime, so - // memoize the answer: getFeePerKilobyte consults it on every fee lookup, and - // without caching that would add a getblockchaininfo round-trip to every tx - // build on all networks. - async chainName(){ - if (this._chainNameCache !== undefined) return this._chainNameCache; - const data = { - jsonrpc: '2.0', - method: 'getblockchaininfo', - id: 1 - }; - - try { - const response = await axios.post(this.url, data, { - auth: { - username: this.rpcUser, - password: this.rpcPassword, - }, - timeout: RPC_TIMEOUT - }); - - const responseData = response.data; - - if (responseData.result && responseData.result.chain) { - this._chainNameCache = String(responseData.result.chain); - return this._chainNameCache; - } else { - throw new Error('Error getting blockchain info' + rpcErrorDetail(responseData)); - } - } catch (error) { - // Same reason as getNetworkInfo: preserve the node's own RPC code - // and message rather than the bare HTTP status. - const detail = rpcErrorDetail(error.response && error.response.data); - throw new Error(`Error in network request: ${sanitizeRpcError(error)}${detail}`); - } - } - - // Current chain tip. Used by the TAPROOT envelope build to refuse an - // envelope below its network's recognition height, since a reveal the fleet - // ignores costs the caller real coin for an action that never exists. Returns - // null rather than throwing when the node cannot answer: the caller decides - // whether an unknown height should block the build (it does, fail-closed). - async getBlockCount() { - try { - const response = await axios.post(this.url, { - jsonrpc: '2.0', method: 'getblockcount', params: [], id: 1, - }, { - auth: { username: this.rpcUser, password: this.rpcPassword }, - timeout: RPC_TIMEOUT - }); - const height = response && response.data && response.data.result; - return Number.isFinite(Number(height)) ? Number(height) : null; - } catch (error) { - return null; - } - } - - async getTransactionHex(txid, hexFormat = true) { - try { - const data = { - jsonrpc: '2.0', - method: 'getrawtransaction', - params: [txid, hexFormat], - id: 1, - }; - - const response = await axios.post(this.url, data, { - auth: { - username: this.rpcUser, - password: this.rpcPassword, - }, - timeout: RPC_TIMEOUT - }); - - const responseData = response.data; - - if (responseData.result && responseData.result.hex) { - return responseData.result.hex; - } else if (responseData.error?.code === -5) { - // RPC -5: tx not in mempool and not retrievable. For confirmed - // transactions this typically means the coin node lacks txindex. - throw new Error(`Transaction ${txid} not found (the coin node may require txindex=1 to retrieve confirmed transactions)`); - } else { - throw new Error('Error getting transaction hex'); - } - } catch (error) { - // LTC/DOGE return HTTP 500 for RPC-level errors so axios throws before - // the success branch above is reached. Mirror sendRawTransaction: inspect - // the node's JSON-RPC error body off error.response so the -5 txindex - // hint is surfaced on all chains, not only BTC v28 (which returns HTTP 200). - const body = error.response?.data; - if (body && body.error?.code === -5) { - throw new Error(`Transaction ${txid} not found (the coin node may require txindex=1 to retrieve confirmed transactions)`); - } - logger.error(util.format('Error:', sanitizeRpcError(error))); - throw error; - } - } - - async sendRawTransaction(txHex) { - try { - return await this.sendRaw([txHex]); - } catch (error) { - // Regtest-only fee-cap recovery, mirroring xchain-e2e-test's - // BlockchainConnector.broadcastTx. A regtest chain with accumulated - // test-tx fee history can push fee rates up to (or past) the node's - // default broadcast fee cap (0.10 coin/kvB on modern Bitcoin/Litecoin - // Core), so a legitimately-built tx is rejected with "Fee exceeds - // maximum configured by user (... maxfeerate)". Retry once with the - // cap disabled (maxfeerate = 0 => unlimited). Gated on BOTH the cap - // error AND the node reporting chain=regtest, so the numeric - // maxfeerate arg is only ever sent to a node that just proved it - // enforces the modern cap; Dogecoin Core 1.14 (2nd arg is a boolean - // allowhighfees, not a maxfeerate) doesn't cap, so it never reaches - // this branch. - const msg = (error && error.message) || ''; - if (/maxfeerate|Fee exceeds maximum/i.test(msg)) { - let regtest = false; - try { regtest = await this.isRegtest(); } catch (_) { /* keep original error */ } - if (regtest) { - return await this.sendRaw([txHex, 0]); - } - } - throw error; - } - } - - // Single sendrawtransaction RPC to the coin node. `params` is [hex] or - // [hex, maxfeerate]. Returns the txid; throws carrying the node's error body. - async sendRaw(params) { - try { - const data = { - jsonrpc: '2.0', - method: 'sendrawtransaction', - params, - id: 1, - }; - - const response = await axios.post(this.url, data, { - auth: { - username: this.rpcUser, - password: this.rpcPassword, - }, - timeout: RPC_TIMEOUT - }); - - const responseData = response.data; - - if (responseData && responseData.error) { - throw new Error(responseData.error.message || JSON.stringify(responseData.error)); - } - - if (responseData && responseData.result) { - return responseData.result; - } else { - throw new Error('Error broadcasting transaction: empty result'); - } - } catch (error) { - // bitcoind/litecoind/dogecoind return HTTP 500 for RPC-level errors - // (e.g. a rejected tx) WITH a JSON-RPC error body. axios throws on a - // non-2xx status, so read the node's error body off error.response so - // its actual reason (e.g. "non-mandatory-script-verify-flag", "dust", - // "bad-txns-*") is surfaced instead of a useless "status code 500". - const body = error.response?.data; - if (body && body.error) { - throw new Error(body.error.message || JSON.stringify(body.error)); - } - logger.error(util.format('Error:', sanitizeRpcError(error))); - throw error; - } - } - - // One JSON-RPC round trip for the mempool-inspection calls, which must never - // break a build. Returns a verdict rather than throwing: {ok:true, result}, - // {ok:false, absent:true} for a txid the mempool does not hold (RPC -5, the - // normal answer for an already-confirmed parent), or {ok:false} for anything - // else. Mirrors sendRaw in reading the node's JSON-RPC error body off - // error.response, because LTC/DOGE answer HTTP 500 for RPC-level errors. - async mempoolRpc(method, params) { - const classify = (rpcError) => { - const code = rpcError && rpcError.code - const message = (rpcError && rpcError.message) || '' - if (code === -5 || /not in mempool|No such mempool/i.test(message)) { - return { ok: false, absent: true } - } - return { ok: false } - } - try { - const response = await axios.post(this.url, { - jsonrpc: '2.0', method, params, id: 1, - }, { - auth: { username: this.rpcUser, password: this.rpcPassword }, - timeout: RPC_TIMEOUT - }) - const body = response && response.data - if (body && body.error) return classify(body.error) - if (body && body.result !== undefined && body.result !== null) { - return { ok: true, result: body.result } - } - return { ok: false } - } catch (error) { - const body = error.response && error.response.data - if (body && body.error) return classify(body.error) - logger.warn(util.format(`Mempool RPC ${method} failed:`, sanitizeRpcError(error))) - return { ok: false } - } - } - - /** - * The unconfirmed ancestor package behind a set of input txids, as - * {size, fees}: total bytes and total fee in COIN units (the unit - * getmempoolentry reports, and the unit getFeePerKilobyte returns). - * - * A miner selects by ancestor fee rate, not by the rate of one transaction, so - * a child spending unconfirmed parents only gets mined when the whole package - * clears the block-inclusion floor. Sizing that child needs the package it - * inherits, which is this. - * - * Every entry is keyed by txid in one map, so two inputs that share a parent - * (or a grandparent) count that ancestor once; double counting would inflate - * the package and overpay. Txids the mempool does not hold are confirmed and - * contribute nothing. Returns null when the node cannot answer or reports an - * entry this cannot read: the caller then prices the transaction on its own, - * which is what it did before package sizing existed. - */ - async getUnconfirmedAncestorPackage(txids) { - const roots = [] - const seenRoots = new Set() - for (const txid of (Array.isArray(txids) ? txids : [])) { - if (typeof txid !== 'string' || txid.length === 0) continue - const key = txid.toLowerCase() - if (seenRoots.has(key)) continue - seenRoots.add(key) - roots.push(txid) - } - if (roots.length === 0) return { size: 0, fees: 0 } - - // txid -> {size, fee}. The dedupe surface for shared ancestors. - const packageEntries = new Map() - const record = (staged, txid, entry) => { - const key = String(txid).toLowerCase() - if (packageEntries.has(key) || staged.has(key)) return true - const size = entrySize(entry) - const fee = entryFee(entry) - if (size === null || fee === null) return false - staged.set(key, { size, fee }) - return true - } - - for (const txid of roots) { - // A root's two RPC calls are not atomic, so a block can confirm the - // root between them; the node then answers the second call with -5. - // Stage the branch and merge it only once both calls described the - // same mempool, so a confirmed root's bytes and fee never reach the - // package the child pays to accelerate. - const staged = new Map() - const self = await this.mempoolRpc('getmempoolentry', [txid]) - if (!self.ok) { - if (self.absent) continue // already confirmed, nothing to carry - return null - } - if (!record(staged, txid, self.result)) return null - - // verbose=true: the ancestors come back as a txid-keyed map of the same - // entries, so one call per input covers the whole branch above it. - const ancestors = await this.mempoolRpc('getmempoolancestors', [txid, true]) - if (!ancestors.ok) { - // The root confirmed mid-sequence; its ancestors confirmed at or - // before it did, so the whole staged branch is stale. Discard it. - if (ancestors.absent) continue - return null - } - const result = ancestors.result - if (result && typeof result === 'object' && !Array.isArray(result)) { - for (const ancestorTxid of Object.keys(result)) { - if (!record(staged, ancestorTxid, result[ancestorTxid])) return null - } - } else { - // A non-verbose (array) answer carries no size or fee, so the package - // cannot be priced from it. - return null - } - for (const [key, entry] of staged) packageEntries.set(key, entry) - } - - let size = 0 - let fees = 0 - for (const entry of packageEntries.values()) { - size += entry.size - fees += entry.fee - } - return { size, fees } - } - - // The "node has no fee estimate" fallback for non-mainnet chains: a multiple - // of the node's own min-relay floor, or null when it does not apply (mainnet, - // or a node that reports no usable relayfee). Null means the caller throws. - // Consulted from both shapes the condition arrives in, the feerate:-1 success - // body and an RPC error body, so the two paths cannot drift apart. - async noEstimateRelayFallback() { - if ((await this.chainName()) === 'main') return null; - const info = await this.getNetworkInfo(); - const relayfee = Number(info && info.relayfee); - if (!(relayfee > 0)) return null; - const multiplier = noEstimateRelayMultiplier(); - const feerate = relayfee * multiplier; - logger.warn('estimatesmartfee returned no estimate on a non-mainnet chain; using ' + - multiplier + 'x the node relayfee floor: ' + feerate + '/kB ' + - '(FEE_NO_ESTIMATE_RELAY_MULTIPLIER to change)'); - return feerate; - } - - async getFeePerKilobyte(blocksNumber) { - try { - // On regtest, ALWAYS use the node's min-relay floor, never estimatesmartfee. - // Regtest coins are valueless and the smart estimate reflects accumulated - // test-tx history: on a long-lived regtest chain it balloons far above any - // sane rate (0.1386/kB = ~13859 sat/vB observed on a deep regtest chain), - // and a tx carrying that fee is rejected downstream by the caller's bitcoinjs - // checkFees safety cap, breaking every SDK-driven build. The earlier version - // only fell back to relayfee when estimatesmartfee returned NO data (a fresh - // chain), so a matured regtest chain silently served the inflated estimate. - // relayfee is coin-correct (DOGE 0.001, BTC/LTC 0.00001) and reflects the - // node's actual config. Checked first so regtest skips estimatesmartfee - // entirely (its value is unused there). isRegtest is memoized, so this is - // one getblockchaininfo per connector lifetime, not per call. - if (await this.isRegtest()){ - try { - const info = await this.getNetworkInfo(); - const relayfee = Number(info && info.relayfee); - if (relayfee > 0) { - return relayfee; - } - } catch (e) { - // Fall through to the conservative default below. - } - return 0.00001000 - } - - const data = { - jsonrpc: '2.0', - method: 'estimatesmartfee', - params: [blocksNumber], - id: 1, - }; - - const response = await axios.post(this.url, data, { - auth: { - username: this.rpcUser, - password: this.rpcPassword, - }, - timeout: RPC_TIMEOUT - }); - - const responseData = response.data; - - // Non-regtest: use estimatesmartfee. Guard on > 0 so a zero/negative - // sentinel (feerate:-1, not enough data) is treated as an error rather - // than silently returned. - if (responseData.result && Number(responseData.result.feerate) > 0) { - const feerate = Number(responseData.result.feerate); - const ceiling = feeEstimateSanityCeiling(); - if (feerate > ceiling) { - // Loud diagnostic: this is a money-affecting path and a clamp here - // is silent everywhere else unless it is logged at error level. - 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; - } - return feerate; - } - // No estimate at all (feerate:-1). A public TESTNET routinely has too few - // transactions to estimate from: DOGE testnet answers -1 with an empty - // mempool and a fully synced node, and every fee-bearing broadcast (the - // oracle's PRICE batches among them) then fails at "Internal encoder - // error" with nothing wrong at the node. Fall back to a multiple of the - // node's min-relay floor on any non-mainnet chain. NOT the bare floor: - // Dogecoin 1.14 still runs Bitcoin Core 0.14's free-transaction - // priority gate, under which a tx paying only the relay floor counts as - // "free" and is rejected with "66: insufficient priority" unless its - // inputs are old and large. Regtest relaxes that gate in the node's - // config; a public testnet's peers do not, so the fallback has to pay a - // fee the gate never sees. 10x the floor is Dogecoin's own published - // recommended rate (0.01 DOGE/kB against a 0.001 floor), and on BTC/LTC - // testnets it is 10 sat/vB, cheap in coin that costs nothing. - // Mainnet keeps throwing: there a missing estimate means the node is - // unhealthy, and paying real coin on a guess is the worse failure. - const fallback = await this.noEstimateRelayFallback(); - if (fallback !== null) return fallback; - throw new Error('Error getting smart fee from node'); - } catch (error) { - // On a fresh regtest chain estimatesmartfee can error (not enough - // data) rather than just returning feerate:-1. Try the relayfee - // fallback before propagating so regtest builds still get a valid - // fee rate even when the RPC layer returns an error body. - try { - if (await this.isRegtest()) { - try { - const info = await this.getNetworkInfo(); - const relayfee = Number(info && info.relayfee); - if (relayfee > 0) { - return relayfee; - } - } catch (_) { - // Fall through to the rethrow below. - } - return 0.00001000; - } - // A node can report the same "no estimate" condition with an RPC - // error instead of feerate:-1 (LTC/DOGE answer HTTP 500 with a - // JSON-RPC error body, so axios throws before the try-block's - // fallback is reached). Run the same non-mainnet fallback here, or - // a public testnet in that state fails every fee-bearing build - // with nothing wrong at the node. Mainnet still returns null and - // rethrows, and a genuinely unreachable node makes the fallback's - // own RPCs throw, so node-down stays a hard failure. - const fallback = await this.noEstimateRelayFallback(); - if (fallback !== null) return fallback; - } catch (_) { - // isRegtest() or the fallback's own RPC failed; fall through to rethrow. - } - logger.error(util.format('Error:', sanitizeRpcError(error))); - throw error; - } - } } +Object.assign( + BlockchainConnector.prototype, + nodeQueries, + broadcast, + feeEstimation, +) + module.exports = BlockchainConnector diff --git a/src/build/blockchain_connector/broadcast.js b/src/build/blockchain_connector/broadcast.js new file mode 100644 index 0000000..c169ae7 --- /dev/null +++ b/src/build/blockchain_connector/broadcast.js @@ -0,0 +1,129 @@ +/********************************************************************* + * + * 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 axios = require('axios') +const util = require('node:util'); +const { logger, RPC_TIMEOUT } = require('./constants'); +const { sanitizeRpcError } = require('./rpc_helpers'); + +module.exports = { + async sendRawTransaction(txHex) { + try { + return await this.sendRaw([txHex]); + } catch (error) { + // Regtest-only fee-cap recovery, mirroring xchain-e2e-test's + // BlockchainConnector.broadcastTx. A regtest chain with accumulated + // test-tx fee history can push fee rates up to (or past) the node's + // default broadcast fee cap (0.10 coin/kvB on modern Bitcoin/Litecoin + // Core), so a legitimately-built tx is rejected with "Fee exceeds + // maximum configured by user (... maxfeerate)". Retry once with the + // cap disabled (maxfeerate = 0 => unlimited). Gated on BOTH the cap + // error AND the node reporting chain=regtest, so the numeric + // maxfeerate arg is only ever sent to a node that just proved it + // enforces the modern cap; Dogecoin Core 1.14 (2nd arg is a boolean + // allowhighfees, not a maxfeerate) doesn't cap, so it never reaches + // this branch. + const msg = (error && error.message) || ''; + if (/maxfeerate|Fee exceeds maximum/i.test(msg)) { + let regtest = false; + try { regtest = await this.isRegtest(); } catch (_) { /* keep original error */ } + if (regtest) { + return await this.sendRaw([txHex, 0]); + } + } + throw error; + } + }, + + // Single sendrawtransaction RPC to the coin node. `params` is [hex] or + // [hex, maxfeerate]. Returns the txid; throws carrying the node's error body. + async sendRaw(params) { + try { + const data = { + jsonrpc: '2.0', + method: 'sendrawtransaction', + params, + id: 1, + }; + + const response = await axios.post(this.url, data, { + auth: { + username: this.rpcUser, + password: this.rpcPassword, + }, + timeout: RPC_TIMEOUT + }); + + const responseData = response.data; + + if (responseData && responseData.error) { + throw new Error(responseData.error.message || JSON.stringify(responseData.error)); + } + + if (responseData && responseData.result) { + return responseData.result; + } else { + throw new Error('Error broadcasting transaction: empty result'); + } + } catch (error) { + // bitcoind/litecoind/dogecoind return HTTP 500 for RPC-level errors + // (e.g. a rejected tx) WITH a JSON-RPC error body. axios throws on a + // non-2xx status, so read the node's error body off error.response so + // its actual reason (e.g. "non-mandatory-script-verify-flag", "dust", + // "bad-txns-*") is surfaced instead of a useless "status code 500". + const body = error.response?.data; + if (body && body.error) { + throw new Error(body.error.message || JSON.stringify(body.error)); + } + logger.error(util.format('Error:', sanitizeRpcError(error))); + throw error; + } + }, + + // One JSON-RPC round trip for the mempool-inspection calls, which must never + // break a build. Returns a verdict rather than throwing: {ok:true, result}, + // {ok:false, absent:true} for a txid the mempool does not hold (RPC -5, the + // normal answer for an already-confirmed parent), or {ok:false} for anything + // else. Mirrors sendRaw in reading the node's JSON-RPC error body off + // error.response, because LTC/DOGE answer HTTP 500 for RPC-level errors. + async mempoolRpc(method, params) { + const classify = (rpcError) => { + const code = rpcError && rpcError.code + const message = (rpcError && rpcError.message) || '' + if (code === -5 || /not in mempool|No such mempool/i.test(message)) { + return { ok: false, absent: true } + } + return { ok: false } + } + try { + const response = await axios.post(this.url, { + jsonrpc: '2.0', method, params, id: 1, + }, { + auth: { username: this.rpcUser, password: this.rpcPassword }, + timeout: RPC_TIMEOUT + }) + const body = response && response.data + if (body && body.error) return classify(body.error) + if (body && body.result !== undefined && body.result !== null) { + return { ok: true, result: body.result } + } + return { ok: false } + } catch (error) { + const body = error.response && error.response.data + if (body && body.error) return classify(body.error) + logger.warn(util.format(`Mempool RPC ${method} failed:`, sanitizeRpcError(error))) + return { ok: false } + } + }, +} diff --git a/src/build/blockchain_connector/constants.js b/src/build/blockchain_connector/constants.js new file mode 100644 index 0000000..8902bc1 --- /dev/null +++ b/src/build/blockchain_connector/constants.js @@ -0,0 +1,44 @@ +/********************************************************************* + * + * 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 { getLogger } = require('../../observability'); +const config = require('../../common/config'); +const logger = getLogger(); + +const RPC_TIMEOUT = parseInt(config.NODE_RPC_TIMEOUT ?? '30000', 10) + +// Absolute per-chain sanity ceiling (COIN/kB, the same unit estimatesmartfee +// returns) on the RAW non-regtest estimate before anything else sees it. +// getFeePerKilobyte's result is used two ways downstream: as the fee actually +// charged when a caller supplies none, AND as the anchor the caller-facing +// caps (MAX_FEE_RATE_KB, MAX_FEE_RATE_MULTIPLIER) derive their relative +// ceiling from - so a node returning a spiked or malformed estimate on +// testnet/mainnet lifts its own ceiling right along with it, and nothing +// downstream is positioned to catch that; those caps bound a CALLER-supplied +// rate, never the node's own report. Defaults are coin-scale, since fee rates +// run roughly 1000x higher in DOGE/kB than in BTC/kB or LTC/kB for an +// equivalent real-world cost; keyed off NETWORK's coin prefix +// (bitcoin-mainnet, litecoin-testnet, dogecoin-mainnet, ...). An unrecognized +// or missing prefix falls back to the bitcoin-scale default so an +// unconfigured deployment still gets a ceiling rather than none. +// FEE_ESTIMATE_SANITY_CEILING overrides in COIN/kB for every chain; a +// non-positive or unparseable value keeps the coin default (fail-safe: this +// guard can be tightened or loosened by env, never silently disabled). +const DEFAULT_FEE_ESTIMATE_SANITY_CEILING = { + bitcoin: 0.01, // 0.01 BTC/kB + litecoin: 0.01, // 0.01 LTC/kB + dogecoin: 10, // 10 DOGE/kB (~1000x the 0.01 DOGE/kB documented recommended rate) +} + +module.exports = { logger, RPC_TIMEOUT, DEFAULT_FEE_ESTIMATE_SANITY_CEILING } diff --git a/src/build/blockchain_connector/fee_estimation.js b/src/build/blockchain_connector/fee_estimation.js new file mode 100644 index 0000000..88f936c --- /dev/null +++ b/src/build/blockchain_connector/fee_estimation.js @@ -0,0 +1,245 @@ +/********************************************************************* + * + * 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 axios = require('axios') +const util = require('node:util'); +const { logger, RPC_TIMEOUT } = require('./constants'); +const { + entrySize, + entryFee, + noEstimateRelayMultiplier, + feeEstimateSanityCeiling, + sanitizeRpcError, +} = require('./rpc_helpers'); + +module.exports = { + /** + * The unconfirmed ancestor package behind a set of input txids, as + * {size, fees}: total bytes and total fee in COIN units (the unit + * getmempoolentry reports, and the unit getFeePerKilobyte returns). + * + * A miner selects by ancestor fee rate, not by the rate of one transaction, so + * a child spending unconfirmed parents only gets mined when the whole package + * clears the block-inclusion floor. Sizing that child needs the package it + * inherits, which is this. + * + * Every entry is keyed by txid in one map, so two inputs that share a parent + * (or a grandparent) count that ancestor once; double counting would inflate + * the package and overpay. Txids the mempool does not hold are confirmed and + * contribute nothing. Returns null when the node cannot answer or reports an + * entry this cannot read: the caller then prices the transaction on its own, + * which is what it did before package sizing existed. + */ + async getUnconfirmedAncestorPackage(txids) { + const roots = [] + const seenRoots = new Set() + for (const txid of (Array.isArray(txids) ? txids : [])) { + if (typeof txid !== 'string' || txid.length === 0) continue + const key = txid.toLowerCase() + if (seenRoots.has(key)) continue + seenRoots.add(key) + roots.push(txid) + } + if (roots.length === 0) return { size: 0, fees: 0 } + + // txid -> {size, fee}. The dedupe surface for shared ancestors. + const packageEntries = new Map() + const record = (staged, txid, entry) => { + const key = String(txid).toLowerCase() + if (packageEntries.has(key) || staged.has(key)) return true + const size = entrySize(entry) + const fee = entryFee(entry) + if (size === null || fee === null) return false + staged.set(key, { size, fee }) + return true + } + + for (const txid of roots) { + // A root's two RPC calls are not atomic, so a block can confirm the + // root between them; the node then answers the second call with -5. + // Stage the branch and merge it only once both calls described the + // same mempool, so a confirmed root's bytes and fee never reach the + // package the child pays to accelerate. + const staged = new Map() + const self = await this.mempoolRpc('getmempoolentry', [txid]) + if (!self.ok) { + if (self.absent) continue // already confirmed, nothing to carry + return null + } + if (!record(staged, txid, self.result)) return null + + // verbose=true: the ancestors come back as a txid-keyed map of the same + // entries, so one call per input covers the whole branch above it. + const ancestors = await this.mempoolRpc('getmempoolancestors', [txid, true]) + if (!ancestors.ok) { + // The root confirmed mid-sequence; its ancestors confirmed at or + // before it did, so the whole staged branch is stale. Discard it. + if (ancestors.absent) continue + return null + } + const result = ancestors.result + if (result && typeof result === 'object' && !Array.isArray(result)) { + for (const ancestorTxid of Object.keys(result)) { + if (!record(staged, ancestorTxid, result[ancestorTxid])) return null + } + } else { + // A non-verbose (array) answer carries no size or fee, so the package + // cannot be priced from it. + return null + } + for (const [key, entry] of staged) packageEntries.set(key, entry) + } + + let size = 0 + let fees = 0 + for (const entry of packageEntries.values()) { + size += entry.size + fees += entry.fee + } + return { size, fees } + }, + + // The "node has no fee estimate" fallback for non-mainnet chains: a multiple + // of the node's own min-relay floor, or null when it does not apply (mainnet, + // or a node that reports no usable relayfee). Null means the caller throws. + // Consulted from both shapes the condition arrives in, the feerate:-1 success + // body and an RPC error body, so the two paths cannot drift apart. + async noEstimateRelayFallback() { + if ((await this.chainName()) === 'main') return null; + const info = await this.getNetworkInfo(); + const relayfee = Number(info && info.relayfee); + if (!(relayfee > 0)) return null; + const multiplier = noEstimateRelayMultiplier(); + const feerate = relayfee * multiplier; + logger.warn('estimatesmartfee returned no estimate on a non-mainnet chain; using ' + + multiplier + 'x the node relayfee floor: ' + feerate + '/kB ' + + '(FEE_NO_ESTIMATE_RELAY_MULTIPLIER to change)'); + return feerate; + }, + + async getFeePerKilobyte(blocksNumber) { + try { + // On regtest, ALWAYS use the node's min-relay floor, never estimatesmartfee. + // Regtest coins are valueless and the smart estimate reflects accumulated + // test-tx history: on a long-lived regtest chain it balloons far above any + // sane rate (0.1386/kB = ~13859 sat/vB observed on a deep regtest chain), + // and a tx carrying that fee is rejected downstream by the caller's bitcoinjs + // checkFees safety cap, breaking every SDK-driven build. The earlier version + // only fell back to relayfee when estimatesmartfee returned NO data (a fresh + // chain), so a matured regtest chain silently served the inflated estimate. + // relayfee is coin-correct (DOGE 0.001, BTC/LTC 0.00001) and reflects the + // node's actual config. Checked first so regtest skips estimatesmartfee + // entirely (its value is unused there). isRegtest is memoized, so this is + // one getblockchaininfo per connector lifetime, not per call. + if (await this.isRegtest()){ + try { + const info = await this.getNetworkInfo(); + const relayfee = Number(info && info.relayfee); + if (relayfee > 0) { + return relayfee; + } + } catch (e) { + // Fall through to the conservative default below. + } + return 0.00001000 + } + + const data = { + jsonrpc: '2.0', + method: 'estimatesmartfee', + params: [blocksNumber], + id: 1, + }; + + const response = await axios.post(this.url, data, { + auth: { + username: this.rpcUser, + password: this.rpcPassword, + }, + timeout: RPC_TIMEOUT + }); + + const responseData = response.data; + + // Non-regtest: use estimatesmartfee. Guard on > 0 so a zero/negative + // sentinel (feerate:-1, not enough data) is treated as an error rather + // than silently returned. + if (responseData.result && Number(responseData.result.feerate) > 0) { + const feerate = Number(responseData.result.feerate); + const ceiling = feeEstimateSanityCeiling(); + if (feerate > ceiling) { + // Loud diagnostic: this is a money-affecting path and a clamp here + // is silent everywhere else unless it is logged at error level. + 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; + } + return feerate; + } + // No estimate at all (feerate:-1). A public TESTNET routinely has too few + // transactions to estimate from: DOGE testnet answers -1 with an empty + // mempool and a fully synced node, and every fee-bearing broadcast (the + // oracle's PRICE batches among them) then fails at "Internal encoder + // error" with nothing wrong at the node. Fall back to a multiple of the + // node's min-relay floor on any non-mainnet chain. NOT the bare floor: + // Dogecoin 1.14 still runs Bitcoin Core 0.14's free-transaction + // priority gate, under which a tx paying only the relay floor counts as + // "free" and is rejected with "66: insufficient priority" unless its + // inputs are old and large. Regtest relaxes that gate in the node's + // config; a public testnet's peers do not, so the fallback has to pay a + // fee the gate never sees. 10x the floor is Dogecoin's own published + // recommended rate (0.01 DOGE/kB against a 0.001 floor), and on BTC/LTC + // testnets it is 10 sat/vB, cheap in coin that costs nothing. + // Mainnet keeps throwing: there a missing estimate means the node is + // unhealthy, and paying real coin on a guess is the worse failure. + const fallback = await this.noEstimateRelayFallback(); + if (fallback !== null) return fallback; + throw new Error('Error getting smart fee from node'); + } catch (error) { + // On a fresh regtest chain estimatesmartfee can error (not enough + // data) rather than just returning feerate:-1. Try the relayfee + // fallback before propagating so regtest builds still get a valid + // fee rate even when the RPC layer returns an error body. + try { + if (await this.isRegtest()) { + try { + const info = await this.getNetworkInfo(); + const relayfee = Number(info && info.relayfee); + if (relayfee > 0) { + return relayfee; + } + } catch (_) { + // Fall through to the rethrow below. + } + return 0.00001000; + } + // A node can report the same "no estimate" condition with an RPC + // error instead of feerate:-1 (LTC/DOGE answer HTTP 500 with a + // JSON-RPC error body, so axios throws before the try-block's + // fallback is reached). Run the same non-mainnet fallback here, or + // a public testnet in that state fails every fee-bearing build + // with nothing wrong at the node. Mainnet still returns null and + // rethrows, and a genuinely unreachable node makes the fallback's + // own RPCs throw, so node-down stays a hard failure. + const fallback = await this.noEstimateRelayFallback(); + if (fallback !== null) return fallback; + } catch (_) { + // isRegtest() or the fallback's own RPC failed; fall through to rethrow. + } + logger.error(util.format('Error:', sanitizeRpcError(error))); + throw error; + } + }, +} diff --git a/src/build/blockchain_connector/node_queries.js b/src/build/blockchain_connector/node_queries.js new file mode 100644 index 0000000..dbf6a15 --- /dev/null +++ b/src/build/blockchain_connector/node_queries.js @@ -0,0 +1,155 @@ +/********************************************************************* + * + * 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 axios = require('axios') +const util = require('node:util'); +const { logger, RPC_TIMEOUT } = require('./constants'); +const { sanitizeRpcError, rpcErrorDetail } = require('./rpc_helpers'); + +module.exports = { + async getNetworkInfo(){ + const data = { + jsonrpc: '2.0', + method: 'getnetworkinfo', + id: 1 + }; + + try { + const response = await axios.post(this.url, data, { + auth: { + username: this.rpcUser, + password: this.rpcPassword, + }, + timeout: RPC_TIMEOUT + }); + + const responseData = response.data; + + if (responseData.result) { + return responseData.result; + } else { + throw new Error('Error getting network info' + rpcErrorDetail(responseData)); + } + } catch (error) { + // Keep the node's reason: a warming node answers -28 "Loading block + // index...", which is otherwise flattened to "status code 500". + const detail = rpcErrorDetail(error.response && error.response.data); + throw new Error(`Error in network request: ${sanitizeRpcError(error)}${detail}`); + } + }, + + async isRegtest(){ + return (await this.chainName()) === 'regtest'; + }, + + // The node's chain name from getblockchaininfo ('main', 'test', 'regtest', + // 'signet'). A connector's node never changes chain over its lifetime, so + // memoize the answer: getFeePerKilobyte consults it on every fee lookup, and + // without caching that would add a getblockchaininfo round-trip to every tx + // build on all networks. + async chainName(){ + if (this._chainNameCache !== undefined) return this._chainNameCache; + const data = { + jsonrpc: '2.0', + method: 'getblockchaininfo', + id: 1 + }; + + try { + const response = await axios.post(this.url, data, { + auth: { + username: this.rpcUser, + password: this.rpcPassword, + }, + timeout: RPC_TIMEOUT + }); + + const responseData = response.data; + + if (responseData.result && responseData.result.chain) { + this._chainNameCache = String(responseData.result.chain); + return this._chainNameCache; + } else { + throw new Error('Error getting blockchain info' + rpcErrorDetail(responseData)); + } + } catch (error) { + // Same reason as getNetworkInfo: preserve the node's own RPC code + // and message rather than the bare HTTP status. + const detail = rpcErrorDetail(error.response && error.response.data); + throw new Error(`Error in network request: ${sanitizeRpcError(error)}${detail}`); + } + }, + + // Current chain tip. Used by the TAPROOT envelope build to refuse an + // envelope below its network's recognition height, since a reveal the fleet + // ignores costs the caller real coin for an action that never exists. Returns + // null rather than throwing when the node cannot answer: the caller decides + // whether an unknown height should block the build (it does, fail-closed). + async getBlockCount() { + try { + const response = await axios.post(this.url, { + jsonrpc: '2.0', method: 'getblockcount', params: [], id: 1, + }, { + auth: { username: this.rpcUser, password: this.rpcPassword }, + timeout: RPC_TIMEOUT + }); + const height = response && response.data && response.data.result; + return Number.isFinite(Number(height)) ? Number(height) : null; + } catch (error) { + return null; + } + }, + + async getTransactionHex(txid, hexFormat = true) { + try { + const data = { + jsonrpc: '2.0', + method: 'getrawtransaction', + params: [txid, hexFormat], + id: 1, + }; + + const response = await axios.post(this.url, data, { + auth: { + username: this.rpcUser, + password: this.rpcPassword, + }, + timeout: RPC_TIMEOUT + }); + + const responseData = response.data; + + if (responseData.result && responseData.result.hex) { + return responseData.result.hex; + } else if (responseData.error?.code === -5) { + // RPC -5: tx not in mempool and not retrievable. For confirmed + // transactions this typically means the coin node lacks txindex. + throw new Error(`Transaction ${txid} not found (the coin node may require txindex=1 to retrieve confirmed transactions)`); + } else { + throw new Error('Error getting transaction hex'); + } + } catch (error) { + // LTC/DOGE return HTTP 500 for RPC-level errors so axios throws before + // the success branch above is reached. Mirror sendRawTransaction: inspect + // the node's JSON-RPC error body off error.response so the -5 txindex + // hint is surfaced on all chains, not only BTC v28 (which returns HTTP 200). + const body = error.response?.data; + if (body && body.error?.code === -5) { + throw new Error(`Transaction ${txid} not found (the coin node may require txindex=1 to retrieve confirmed transactions)`); + } + logger.error(util.format('Error:', sanitizeRpcError(error))); + throw error; + } + }, +} diff --git a/src/build/blockchain_connector/rpc_helpers.js b/src/build/blockchain_connector/rpc_helpers.js new file mode 100644 index 0000000..b0b21fb --- /dev/null +++ b/src/build/blockchain_connector/rpc_helpers.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. + * + ********************************************************************/ + +const config = require('../../common/config'); +const { DEFAULT_FEE_ESTIMATE_SANITY_CEILING } = require('./constants'); + +// Fee-rate multiple of the node's relay floor used on non-mainnet chains when +// estimatesmartfee has no data. Rationale at getFeePerKilobyte. +// +// Ten is the documented recommended rate, and on Dogecoin testnet it is NOT +// enough to be mined: measured over 400 blocks there, transactions land at +// 0.03 and around 1.0 DOGE per kB, while 0.0102 sat unmined for an hour. The +// one that did land below that paid 0.0044 and got in on legacy coin-age +// priority, which a chain of freshly spent change can never have. So the +// multiple is deployment-tunable: a quiet chain whose miners ignore the +// documented rate needs a higher one, and only the operator running that chain +// can measure what it actually takes. +function noEstimateRelayMultiplier(){ + const raw = parseFloat(config.FEE_NO_ESTIMATE_RELAY_MULTIPLIER) + return (Number.isFinite(raw) && raw > 0) ? raw : 10 +} + +function feeEstimateSanityCeiling(){ + const override = parseFloat(config.FEE_ESTIMATE_SANITY_CEILING) + if (Number.isFinite(override) && override > 0) return override + const coin = String(config.NETWORK).split('-')[0].toLowerCase() + return DEFAULT_FEE_ESTIMATE_SANITY_CEILING[coin] || DEFAULT_FEE_ESTIMATE_SANITY_CEILING.bitcoin +} + +// Sanitize an axios error before it is logged or re-thrown. RPC calls pass +// auth:{username,password} to axios, which attaches the request config to the +// thrown error, so logging the raw error serializes the node RPC password into +// the encoder logs (util.inspect walks error.config.auth). Scrub the credential +// fields in place so neither this logger nor any upstream handler leaks them, and +// return a compact, credential-free string (error.message never carries auth). +// Kept in sync with xchain-decoder/src/chain/blockchain_connector.js sanitizeRpcError. +function sanitizeRpcError(error){ + try { + if (error && error.config) { + error.config.auth = undefined + if (error.config.headers) delete error.config.headers.Authorization + } + if (error && error.request) error.request = undefined + if (error && error.response) { + const status = error.response.status + error.response = (status !== undefined) ? { status: status } : undefined + } + } catch (_) { /* sanitization must never mask the original failure */ } + return (error && error.message) ? error.message : String(error) +} + +// Size and fee off one getmempoolentry-shaped record, across both field layouts +// the fleet's nodes use: Core 0.14 (Dogecoin 1.14) reports flat `size` and `fee`, +// while modern Core reports `vsize` and nests the fee under `fees.base`. Either +// reader returns null on a value it cannot price, which the caller treats as an +// unusable package rather than as a zero-fee ancestor. +// The node's own JSON-RPC error as a compact suffix, off either response shape +// the fleet produces: BTC v28 answers HTTP 200 with an error body, while +// LTC/DOGE answer HTTP 500 and axios hangs the body off error.response. Returns +// '' when there is no error body, so a pure transport failure reads exactly as +// it did before. Must be read BEFORE sanitizeRpcError, which scrubs +// error.response down to its status. +function rpcErrorDetail(body){ + const rpcError = body && body.error + if (!rpcError || typeof rpcError !== 'object') return '' + const code = rpcError.code + const message = rpcError.message || '' + if (code === undefined && message === '') return '' + return ` (RPC error ${code === undefined ? 'unknown' : code}: ${message})` +} + +// Type before coercion: Number(null), Number('') and Number(false) are all a +// finite 0, and Number(true) is 1, so a bare Number() cast turns an unreadable +// field into a priceable value. Only real numbers, and strings that spell one, +// are priceable; anything else is null and invalidates the package. +function readNumeric(raw){ + if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null + if (typeof raw === 'string') { + const trimmed = raw.trim() + if (trimmed === '') return null + const parsed = Number(trimmed) + return Number.isFinite(parsed) ? parsed : null + } + return null +} + +function entrySize(entry){ + const raw = entry && (entry.vsize !== undefined ? entry.vsize : entry.size) + const size = readNumeric(raw) + return (size !== null && size > 0) ? size : null +} + +function entryFee(entry){ + const raw = entry && (entry.fees && entry.fees.base !== undefined ? entry.fees.base : entry.fee) + const fee = readNumeric(raw) + return (fee !== null && fee >= 0) ? fee : null +} + +module.exports = { + noEstimateRelayMultiplier, + feeEstimateSanityCeiling, + sanitizeRpcError, + rpcErrorDetail, + readNumeric, + entrySize, + entryFee, +} From 3170c7b12a51856515dd98b035148c6e622ebb71 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 16:55:40 -0700 Subject: [PATCH 056/148] refactor(build): extract fee estimation helpers under the size limit Pull the ancestor-package and fee-per-kilobyte bodies into named helpers so the fee estimation module and its functions stay within the size limits, with no behaviour change. --- .../blockchain_connector/fee_estimation.js | 331 +++++++++--------- 1 file changed, 160 insertions(+), 171 deletions(-) diff --git a/src/build/blockchain_connector/fee_estimation.js b/src/build/blockchain_connector/fee_estimation.js index 88f936c..1ec1d7e 100644 --- a/src/build/blockchain_connector/fee_estimation.js +++ b/src/build/blockchain_connector/fee_estimation.js @@ -23,6 +23,156 @@ const { sanitizeRpcError, } = require('./rpc_helpers'); +function uniqueTxids(txids) { + const roots = [] + const seenRoots = new Set() + for (const txid of (Array.isArray(txids) ? txids : [])) { + if (typeof txid !== 'string' || txid.length === 0) continue + const key = txid.toLowerCase() + if (seenRoots.has(key)) continue + seenRoots.add(key) + roots.push(txid) + } + return roots +} + +function recordPackageEntry(packageEntries, staged, txid, entry) { + const key = String(txid).toLowerCase() + if (packageEntries.has(key) || staged.has(key)) return true + const size = entrySize(entry) + const fee = entryFee(entry) + if (size === null || fee === null) return false + staged.set(key, { size, fee }) + return true +} + +async function collectAncestorBranch(connector, packageEntries, txid) { + // A root's two RPC calls are not atomic, so a block can confirm the + // root between them; the node then answers the second call with -5. + // Stage the branch and merge it only once both calls described the + // same mempool, so a confirmed root's bytes and fee never reach the + // package the child pays to accelerate. + const staged = new Map() + const self = await connector.mempoolRpc('getmempoolentry', [txid]) + if (!self.ok) { + if (self.absent) return true // already confirmed, nothing to carry + return false + } + if (!recordPackageEntry(packageEntries, staged, txid, self.result)) return false + + // verbose=true: the ancestors come back as a txid-keyed map of the same + // entries, so one call per input covers the whole branch above it. + const ancestors = await connector.mempoolRpc('getmempoolancestors', [txid, true]) + if (!ancestors.ok) { + // The root confirmed mid-sequence; its ancestors confirmed at or + // before it did, so the whole staged branch is stale. Discard it. + if (ancestors.absent) return true + return false + } + const result = ancestors.result + if (result && typeof result === 'object' && !Array.isArray(result)) { + for (const ancestorTxid of Object.keys(result)) { + if (!recordPackageEntry(packageEntries, staged, ancestorTxid, result[ancestorTxid])) return false + } + } else { + // A non-verbose (array) answer carries no size or fee, so the package + // cannot be priced from it. + return false + } + for (const [key, entry] of staged) packageEntries.set(key, entry) + return true +} + +function sumPackageEntries(packageEntries) { + let size = 0 + let fees = 0 + for (const entry of packageEntries.values()) { + size += entry.size + fees += entry.fee + } + return { size, fees } +} + +// On regtest, ALWAYS use the node's min-relay floor, never estimatesmartfee. +// Regtest coins are valueless and the smart estimate reflects accumulated +// test-tx history: on a long-lived regtest chain it balloons far above any +// sane rate (0.1386/kB = ~13859 sat/vB observed on a deep regtest chain), +// and a tx carrying that fee is rejected downstream by the caller's bitcoinjs +// checkFees safety cap, breaking every SDK-driven build. A fallback that only +// handles missing estimate data would still serve an inflated mature-chain +// estimate. relayfee is coin-correct (DOGE 0.001, BTC/LTC 0.00001) and reflects +// the node's actual config. Checked first so regtest skips estimatesmartfee +// entirely. isRegtest is memoized, so this costs one getblockchaininfo per +// connector lifetime, not per call. +async function regtestFee(connector) { + try { + const info = await connector.getNetworkInfo(); + const relayfee = Number(info && info.relayfee); + if (relayfee > 0) return relayfee; + } catch (_) { + // Fall through to the conservative default below. + } + return 0.00001000 +} + +async function requestSmartFee(connector, blocksNumber) { + const data = { + jsonrpc: '2.0', + method: 'estimatesmartfee', + params: [blocksNumber], + id: 1, + }; + + const response = await axios.post(connector.url, data, { + auth: { + username: connector.rpcUser, + password: connector.rpcPassword, + }, + timeout: RPC_TIMEOUT + }); + + return response.data; +} + +// Non-regtest estimates must be positive and below the configured ceiling. +function usableSmartFee(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; + } + return feerate; +} + +// A public testnet can have too few transactions for estimatesmartfee. The +// fallback is a multiple of the node's min-relay floor, not the bare floor, +// because older nodes can classify a floor-rate transaction as free. Mainnet +// keeps throwing because a missing estimate there indicates an unhealthy node. +async function requireNoEstimateFallback(connector) { + const fallback = await connector.noEstimateRelayFallback(); + if (fallback !== null) return fallback; + throw new Error('Error getting smart fee from node'); +} + +// RPC implementations may report missing estimate data as an error body. Run +// the same non-mainnet fallback for that shape while preserving the original +// error when the chain or fallback query also fails. +async function recoverFeeEstimate(connector, error) { + try { + if (await connector.isRegtest()) return await regtestFee(connector) + const fallback = await connector.noEstimateRelayFallback(); + if (fallback !== null) return fallback; + } catch (_) { + // The chain query or fallback RPC failed; rethrow the original error. + } + logger.error(util.format('Error:', sanitizeRpcError(error))); + throw error; +} + module.exports = { /** * The unconfirmed ancestor package behind a set of input txids, as @@ -38,76 +188,18 @@ module.exports = { * (or a grandparent) count that ancestor once; double counting would inflate * the package and overpay. Txids the mempool does not hold are confirmed and * contribute nothing. Returns null when the node cannot answer or reports an - * entry this cannot read: the caller then prices the transaction on its own, - * which is what it did before package sizing existed. + * entry this cannot read: the caller then prices the transaction on its own. */ async getUnconfirmedAncestorPackage(txids) { - const roots = [] - const seenRoots = new Set() - for (const txid of (Array.isArray(txids) ? txids : [])) { - if (typeof txid !== 'string' || txid.length === 0) continue - const key = txid.toLowerCase() - if (seenRoots.has(key)) continue - seenRoots.add(key) - roots.push(txid) - } + const roots = uniqueTxids(txids) if (roots.length === 0) return { size: 0, fees: 0 } // txid -> {size, fee}. The dedupe surface for shared ancestors. const packageEntries = new Map() - const record = (staged, txid, entry) => { - const key = String(txid).toLowerCase() - if (packageEntries.has(key) || staged.has(key)) return true - const size = entrySize(entry) - const fee = entryFee(entry) - if (size === null || fee === null) return false - staged.set(key, { size, fee }) - return true - } - for (const txid of roots) { - // A root's two RPC calls are not atomic, so a block can confirm the - // root between them; the node then answers the second call with -5. - // Stage the branch and merge it only once both calls described the - // same mempool, so a confirmed root's bytes and fee never reach the - // package the child pays to accelerate. - const staged = new Map() - const self = await this.mempoolRpc('getmempoolentry', [txid]) - if (!self.ok) { - if (self.absent) continue // already confirmed, nothing to carry - return null - } - if (!record(staged, txid, self.result)) return null - - // verbose=true: the ancestors come back as a txid-keyed map of the same - // entries, so one call per input covers the whole branch above it. - const ancestors = await this.mempoolRpc('getmempoolancestors', [txid, true]) - if (!ancestors.ok) { - // The root confirmed mid-sequence; its ancestors confirmed at or - // before it did, so the whole staged branch is stale. Discard it. - if (ancestors.absent) continue - return null - } - const result = ancestors.result - if (result && typeof result === 'object' && !Array.isArray(result)) { - for (const ancestorTxid of Object.keys(result)) { - if (!record(staged, ancestorTxid, result[ancestorTxid])) return null - } - } else { - // A non-verbose (array) answer carries no size or fee, so the package - // cannot be priced from it. - return null - } - for (const [key, entry] of staged) packageEntries.set(key, entry) - } - - let size = 0 - let fees = 0 - for (const entry of packageEntries.values()) { - size += entry.size - fees += entry.fee + if (!await collectAncestorBranch(this, packageEntries, txid)) return null } - return { size, fees } + return sumPackageEntries(packageEntries) }, // The "node has no fee estimate" fallback for non-mainnet chains: a multiple @@ -130,116 +222,13 @@ module.exports = { async getFeePerKilobyte(blocksNumber) { try { - // On regtest, ALWAYS use the node's min-relay floor, never estimatesmartfee. - // Regtest coins are valueless and the smart estimate reflects accumulated - // test-tx history: on a long-lived regtest chain it balloons far above any - // sane rate (0.1386/kB = ~13859 sat/vB observed on a deep regtest chain), - // and a tx carrying that fee is rejected downstream by the caller's bitcoinjs - // checkFees safety cap, breaking every SDK-driven build. The earlier version - // only fell back to relayfee when estimatesmartfee returned NO data (a fresh - // chain), so a matured regtest chain silently served the inflated estimate. - // relayfee is coin-correct (DOGE 0.001, BTC/LTC 0.00001) and reflects the - // node's actual config. Checked first so regtest skips estimatesmartfee - // entirely (its value is unused there). isRegtest is memoized, so this is - // one getblockchaininfo per connector lifetime, not per call. - if (await this.isRegtest()){ - try { - const info = await this.getNetworkInfo(); - const relayfee = Number(info && info.relayfee); - if (relayfee > 0) { - return relayfee; - } - } catch (e) { - // Fall through to the conservative default below. - } - return 0.00001000 - } - - const data = { - jsonrpc: '2.0', - method: 'estimatesmartfee', - params: [blocksNumber], - id: 1, - }; - - const response = await axios.post(this.url, data, { - auth: { - username: this.rpcUser, - password: this.rpcPassword, - }, - timeout: RPC_TIMEOUT - }); - - const responseData = response.data; - - // Non-regtest: use estimatesmartfee. Guard on > 0 so a zero/negative - // sentinel (feerate:-1, not enough data) is treated as an error rather - // than silently returned. - if (responseData.result && Number(responseData.result.feerate) > 0) { - const feerate = Number(responseData.result.feerate); - const ceiling = feeEstimateSanityCeiling(); - if (feerate > ceiling) { - // Loud diagnostic: this is a money-affecting path and a clamp here - // is silent everywhere else unless it is logged at error level. - 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; - } - return feerate; - } - // No estimate at all (feerate:-1). A public TESTNET routinely has too few - // transactions to estimate from: DOGE testnet answers -1 with an empty - // mempool and a fully synced node, and every fee-bearing broadcast (the - // oracle's PRICE batches among them) then fails at "Internal encoder - // error" with nothing wrong at the node. Fall back to a multiple of the - // node's min-relay floor on any non-mainnet chain. NOT the bare floor: - // Dogecoin 1.14 still runs Bitcoin Core 0.14's free-transaction - // priority gate, under which a tx paying only the relay floor counts as - // "free" and is rejected with "66: insufficient priority" unless its - // inputs are old and large. Regtest relaxes that gate in the node's - // config; a public testnet's peers do not, so the fallback has to pay a - // fee the gate never sees. 10x the floor is Dogecoin's own published - // recommended rate (0.01 DOGE/kB against a 0.001 floor), and on BTC/LTC - // testnets it is 10 sat/vB, cheap in coin that costs nothing. - // Mainnet keeps throwing: there a missing estimate means the node is - // unhealthy, and paying real coin on a guess is the worse failure. - const fallback = await this.noEstimateRelayFallback(); - if (fallback !== null) return fallback; - throw new Error('Error getting smart fee from node'); + if (await this.isRegtest()) return await regtestFee(this) + const responseData = await requestSmartFee(this, blocksNumber) + const feerate = usableSmartFee(responseData) + if (feerate !== null) return feerate + return await requireNoEstimateFallback(this) } catch (error) { - // On a fresh regtest chain estimatesmartfee can error (not enough - // data) rather than just returning feerate:-1. Try the relayfee - // fallback before propagating so regtest builds still get a valid - // fee rate even when the RPC layer returns an error body. - try { - if (await this.isRegtest()) { - try { - const info = await this.getNetworkInfo(); - const relayfee = Number(info && info.relayfee); - if (relayfee > 0) { - return relayfee; - } - } catch (_) { - // Fall through to the rethrow below. - } - return 0.00001000; - } - // A node can report the same "no estimate" condition with an RPC - // error instead of feerate:-1 (LTC/DOGE answer HTTP 500 with a - // JSON-RPC error body, so axios throws before the try-block's - // fallback is reached). Run the same non-mainnet fallback here, or - // a public testnet in that state fails every fee-bearing build - // with nothing wrong at the node. Mainnet still returns null and - // rethrows, and a genuinely unreachable node makes the fallback's - // own RPCs throw, so node-down stays a hard failure. - const fallback = await this.noEstimateRelayFallback(); - if (fallback !== null) return fallback; - } catch (_) { - // isRegtest() or the fallback's own RPC failed; fall through to rethrow. - } - logger.error(util.format('Error:', sanitizeRpcError(error))); - throw error; + return await recoverFeeEstimate(this, error) } }, } From e2ccb64df7c4be6cd747af9dd2ab4999a3c6bbc3 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 17:08:55 -0700 Subject: [PATCH 057/148] refactor(validator): split validation checks by responsibility Move the createTransaction validator's constants, value checks, action data checks, fee and UTXO checks, and transaction checks into focused sibling modules, wiring them into the entry while keeping its exported names and shapes unchanged. --- src/common/validator.js | 921 ++------------------ src/common/validator/action_data_checks.js | 267 ++++++ src/common/validator/constants.js | 169 ++++ src/common/validator/fee_and_utxo_checks.js | 265 ++++++ src/common/validator/transaction_checks.js | 244 ++++++ src/common/validator/value_checks.js | 121 +++ 6 files changed, 1122 insertions(+), 865 deletions(-) create mode 100644 src/common/validator/action_data_checks.js create mode 100644 src/common/validator/constants.js create mode 100644 src/common/validator/fee_and_utxo_checks.js create mode 100644 src/common/validator/transaction_checks.js create mode 100644 src/common/validator/value_checks.js diff --git a/src/common/validator.js b/src/common/validator.js index ce49061..2a2afd8 100644 --- a/src/common/validator.js +++ b/src/common/validator.js @@ -20,19 +20,55 @@ * ********************************************************************/ -// Maximum *compiled* on-chain ACTION push, in bytes. Must equal the decoder's -// MAX_ACTION_DATA_LENGTH: a transaction whose compiled push exceeds this is -// silently dropped by every indexing node. Canonical source of truth: -// xchain-documentation/protocol/constants.js (MAX_ACTION_DATA_LENGTH). The -// cross-service regression suite asserts these stay equal. -const MAX_COMPILED_ACTION_DATA_LENGTH = 8192 -// Length prefix a single OP_PUSHDATA2 push prepends to a >=256-byte payload -// (1 opcode + 2 length bytes). The widest single raw push is measured against -// the compiled ceiling with this prefix included. Canonical source of truth: -// xchain-documentation/protocol/constants.js (OP_RETURN_PUSH_OVERHEAD). The -// cross-service regression suite pins this local copy by name against that -// declaration, so the name must match exactly, not just the value. -const OP_RETURN_PUSH_OVERHEAD = 3 +const { + MAX_COMPILED_ACTION_DATA_LENGTH, + OP_RETURN_PUSH_OVERHEAD, + VALID_ACTION_NAMES, + ACTION_ALIASES, + MAX_SATOSHI_U64, + MAX_RAW_TX_HEX_LENGTH, + MAX_BROADCAST_TX_HEX_LENGTH, + ENVELOPE_MAX_PAYLOAD, + MAX_UTXO_COUNT, + MAX_CUSTOM_OUTPUTS, + MAX_FEE_SATOSHIS, + MAX_SCRIPTPUBKEY_HEX_LENGTH, + VALID_ENCODINGS +} = require('./validator/constants') +const { + validatePubkey, + validateOptionalBoolean, + parseSatoshiAmount +} = require('./validator/value_checks') +const { + validateDataParam, + validateCombinedDataLength, + isMinimalOpSingleByte, + validateActionPushDecodability, + validateActionName, + unknownActionName, + compiledPushSize, + envelopePushSize, + validateEncoding +} = require('./validator/action_data_checks') +const { + validateFee, + validateFeePerKb, + validateDust, + validateUtxoArray, + validateUtxoEntry, + validateCustomOutputs +} = require('./validator/fee_and_utxo_checks') +const { + validateP2shParams, + validateRawTxHex, + validateCompressedPubKey, + validateChange, + validateAddress, + validateReservationId, + validateAll: validateAllFields +} = require('./validator/transaction_checks') + // Largest raw single-push payload that still fits the compiled on-chain ceiling: // a raw payload of N bytes (N >= 256) compiles to N + 3 once the OP_PUSHDATA2 // prefix is added, so 8189 + 3 == 8192. Derived from the compiled ceiling so the @@ -42,31 +78,6 @@ const OP_RETURN_PUSH_OVERHEAD = 3 // the compiled ceiling directly rather than this single-push approximation. // Kept and exported because the test suite pins its value. const MAX_DATA_BYTES = MAX_COMPILED_ACTION_DATA_LENGTH - OP_RETURN_PUSH_OVERHEAD -// Explicit-encoding OP_RETURN ceiling: a single nulldata output is 80 bytes, of -// which the 4-byte "XCHN" magic word is reserved, leaving 76 bytes of compiled -// payload. Mirrors XChainEncoder.prepareData (OP_RETURN_SIZE - MAGIC_WORD length), -// which stays the arbiter/backstop; measured pre-compile here only when the caller -// explicitly requests encoding:"OP_RETURN" so an oversize request is rejected as -// -32602 invalid-params before any UTXO reservation, instead of failing post-compile -// as a -32603 internal error. The compiled value this is compared against is -// exactly finalDataBuffer.length in createTransaction (same compiledPushSize sum). -const OP_RETURN_OUTPUT_SIZE = 80 -const OP_RETURN_MAGIC_WORD_LENGTH = 4 -const MAX_OP_RETURN_COMPILED_LENGTH = OP_RETURN_OUTPUT_SIZE - OP_RETURN_MAGIC_WORD_LENGTH // 76 -// Taproot-envelope payload ceiling, per-encoding by design: -// only encoding:"TAPROOT" gets it, every legacy lane keeps the 8,192-byte -// compiled ceiling (a global raise would multiply the chunk-lane abuse ceiling -// ~50x for no benefit). Measures the reassembled envelope payload byte length -// after concatenation of the payload pushes, which is byte-identical to the -// compiled action stream (finalDataBuffer) the shipped lanes carry; the -// envelope's own 520-byte push framing is NOT counted. -// DERIVED FROM WEIGHT: the binding limit is Bitcoin Core's -// MAX_STANDARD_TX_WEIGHT of 400,000 WU, not a round byte count. 400,000 payload -// bytes build a 402,789 WU reveal, which is non-standard and unrelayable; the -// true maxima are 397,228 (P2WPKH change) and 397,009 (P2TR change + floor pad). -// 390,000 sits 7,050 WU under the limit in the worst reveal shape. Re-derive it -// from the weight limit if it ever changes; do not pick a number. -const ENVELOPE_MAX_PAYLOAD = 390_000 // Bitcoin Core MAX_STANDARD_TX_WEIGHT, the policy limit ENVELOPE_MAX_PAYLOAD is // derived from. Vendored so the derivation is testable here rather than being @@ -87,836 +98,16 @@ const COMPRESSION_MAX_RATIO = 150 // Pre-compression input cap on rawData. The encoder is a hard single-instance // service (it holds an exclusive lockfile), so compression is async and bounded. const COMPRESSION_MAX_INPUT_BYTES = 16 * 1024 * 1024 -const MAX_UTXO_COUNT = 500 -const MAX_CUSTOM_OUTPUTS = 100 -// Sanity ceiling on the fee/dust/feeQuote money fields: 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 -// amounts do not route here; they go through parseSatoshiAmount's u64 ceiling. -const MAX_FEE_SATOSHIS = 2_100_000_000_000 -// Maximum accepted raw-transaction hex length, in characters. A standard -// BTC/LTC/DOGE transaction tops out around 100 KB (200,000 hex chars), so -// 400,000 chars (a 200 KB transaction) is comfortably above anything the -// platform constructs while still shedding megabyte garbage before it reaches -// Transaction.fromHex / Buffer.from or a coin-node round-trip. The Express -// 1 MB body limit is the outer bound either way; this gives a precise, named -// rejection instead of a node-side parse error. -const MAX_RAW_TX_HEX_LENGTH = 400_000 -// broadcast_tx ceiling, wider than MAX_RAW_TX_HEX_LENGTH on purpose: a signed -// Taproot-envelope reveal carries up to ENVELOPE_MAX_PAYLOAD payload bytes in -// its witness, so the whole transaction runs to ~405 KB (~810,000 hex chars), -// which the old 400,000-char cap would refuse at the last step of the flow. -// 1,000,000 chars (a 500 KB transaction) clears the largest legal reveal with -// headroom while still shedding megabyte garbage. p2shHex deliberately keeps -// the tighter cap: chunk-lane funding transactions never approach it. -const MAX_BROADCAST_TX_HEX_LENGTH = 1_000_000 -const RAW_TX_HEX_RE = /^(?:[0-9a-fA-F]{2})+$/ -// Maximum accepted utxos[].scriptPubKey hex length, in characters. Bitcoin's -// consensus MAX_SCRIPT_SIZE is 10,000 bytes, so 20,000 hex chars is the widest -// a legitimate output script can be; anything larger is garbage and is rejected -// before it reaches Buffer.from(...,'hex') / bitcoin.script.decompile. -const MAX_SCRIPTPUBKEY_HEX_LENGTH = 20_000 -// AUTO is not a carrier: it is the caller's explicit request that the encoder -// pick the smallest-footprint carrier the network and signer support. It -// resolves to one of the others before anything is built. It is an -// OPT-IN precisely because resolving to TAPROOT changes the response shape from -// one PSBT to a commit/reveal pair, which no existing caller expects. -const VALID_ENCODINGS = new Set(['OP_RETURN', 'P2SH', 'MULTISIGN', 'P2WSH', 'TAPROOT', 'AUTO']) -const HEX_64_RE = /^[0-9a-fA-F]{64}$/ -const COMPRESSED_PUBKEY_RE = /^(02|03)[0-9a-fA-F]{64}$/ - -// Canonical ACTION names the decoder accepts (xchain-decoder VALID_ACTION_NAMES, -// XChainDecoder.js). Vendored here byte-for-byte from -// xchain-documentation/protocol/action-manifest.json (wireDecoded slice), the -// same source xchain-decoder/test/fixtures/action-manifest.json vendors from. -// The decoder is the arbiter of which leading ACTION token survives -// decode (both the confirmed-block and mempool gates reject anything not in -// this set, after alias expansion, silently no-actioning the tx); the encoder -// had no equivalent gate, so a typoed or too-new ACTION name would encode, pay -// fees, and broadcast, then vanish silently at decode. Pinned against drift by -// test/unit/action_manifest_conformance.test.js, mirroring the decoder's own -// conformance guard. -const VALID_ACTION_NAMES = new Set([ - 'ADDRESS', 'AIRDROP', 'ANCHOR', 'ATTEST', - 'BATCH', 'BET', 'BROADCAST', 'CALLBACK', 'COINPAY', 'COLLECT', - 'DELEGATE', 'DEPLOY', 'DEPOSIT', 'DESTROY', 'DISPENSER', - 'DIVIDEND', 'EXECUTE', 'FILE', 'ISSUE', 'LINK', 'LIST', 'MESSAGE', 'MINT', - 'NODEPROOF', 'ORDER', 'PRICE', 'ROLLCALL', 'SEND', 'SLASH', 'SLEEP', 'STAKE', - 'SWAP', - 'SWEEP', 'UNSTAKE', 'VOTE', 'WITHDRAW', - // Bridge lock/burn. Mirrors the decoder set: the user-broadcast versions (0, 1, 3, 4) - // must clear this gate to reach a decoder at all, and the mirror-injected settle legs - // (2, 5) are never encoded here because no user may author one. - 'XBRIDGE' -]) - -// Short-form ACTION-name aliases, expanded to canonical form before the -// VALID_ACTION_NAMES gate. Must stay identical to xchain-decoder's -// ACTION_ALIASES (XChainDecoder.js): the decoder is what actually expands -// these on-chain, so the encoder's pre-check has to agree with it exactly or -// it will reject an alias the decoder would have accepted, or (worse) accept a -// typo the decoder does not recognize as an alias. -const ACTION_ALIASES = { - 'TRANSFER': 'SEND', - 'ADDR': 'ADDRESS', - 'DROP': 'AIRDROP', - 'CAST': 'BROADCAST', - 'MSG': 'MESSAGE' -} - -// 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 -// to a wrong value on the money path. Returns the exact integer, or NaN if -// `raw` is not an exact integer representation. Sign and range bounds are left -// to the caller so each validator keeps its own error type and message. -function toExactInt(raw) { - if (typeof raw === 'number') { - return Number.isInteger(raw) ? raw : NaN - } - if (typeof raw === 'string' && /^-?\d+$/.test(raw.trim())) { - return Number(raw.trim()) - } - return NaN -} - -// Strict optional-boolean validator for policy flags (rbf, unconfirmed). -// undefined/null pass through as undefined so downstream defaults apply; a -// provided value must be a real boolean. TypeError so api.js maps it to -// -32602 invalid-params, like the money/hex validators. -function validateOptionalBoolean(raw, label) { - if (raw === undefined || raw === null) return undefined - if (typeof raw !== 'boolean') { - throw new TypeError(`${label} must be a boolean (got ${typeof raw}${typeof raw === 'string' ? ` "${raw}"` : ''}); string "false" would coerce to true`) - } - return raw -} - -// Wire-format ceiling for a satoshi amount: the transaction output value -// field is an unsigned 64-bit integer, so 2^64-1 is the largest amount any -// coin can carry in one output regardless of supply policy. -const MAX_SATOSHI_U64 = 0xffffffffffffffffn -// Parse a satoshi amount, rejecting any value that cannot be represented -// exactly. The utxo-tracker emits full-precision decimal strings -// (readBigUInt64BE().toString()), so a 64-bit value reaches the encoder -// intact; a JS Number above Number.MAX_SAFE_INTEGER (2^53-1 ~= 9.007e15 -// sats) cannot hold it exactly. For BTC/LTC this is unreachable (21M cap = -// 2.1e15 sats), but DOGE has no supply cap, so a single output can exceed -// it (>~90.07M DOGE). Fields that may legitimately carry such amounts -// (utxos[].value, customOutputs[].value) opt in with {allowBig: true}: an -// exact decimal STRING above 2^53-1 then parses to a BigInt (up to the u64 -// wire ceiling); a Number above 2^53-1 is still rejected because it was -// already rounded before it reached us (JSON.parse or caller arithmetic). -// Without allowBig the original fail-closed behavior is unchanged. -function parseSatoshiAmount(raw, label, opts) { - const allowBig = !!(opts && opts.allowBig) - // Already-coerced BigInt (validateUtxoEntry/validateCustomOutput ran - // first; createTransaction re-parses defensively): re-validate in place. - if (typeof raw === 'bigint') { - if (raw < 0n) { - throw new RangeError(`${label} must be a non-negative integer`) - } - if (raw <= BigInt(Number.MAX_SAFE_INTEGER)) { - return Number(raw) - } - if (!allowBig) { - throw new RangeError(`${label} (${raw}) exceeds the maximum safe satoshi amount (${Number.MAX_SAFE_INTEGER}) and cannot be represented without precision loss`) - } - if (raw > MAX_SATOSHI_U64) { - throw new RangeError(`${label} (${raw}) exceeds the maximum 64-bit satoshi amount (${MAX_SATOSHI_U64})`) - } - return raw - } - const num = toExactInt(raw) - if (isNaN(num) || num < 0) { - throw new RangeError(`${label} must be a non-negative integer`) - } - if (Number.isSafeInteger(num)) { - return num - } - if (allowBig && typeof raw === 'string') { - // toExactInt already guaranteed /^-?\d+$/ and the num<0 check above - // guaranteed non-negative, so this BigInt parse cannot throw. - const big = BigInt(raw.trim()) - if (big > MAX_SATOSHI_U64) { - throw new RangeError(`${label} (${raw.trim()}) exceeds the maximum 64-bit satoshi amount (${MAX_SATOSHI_U64})`) - } - return big - } - throw new RangeError(`${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' : ''}`) -} - -// params.pubkey is not a real pubkey: it is the caller's base58 sender -// address (fed straight to utxoTrackerConnector.getUtxosFromAddress and to -// bitcoin.address.fromBase58Check in XChainEncoder.js). It must be held to -// the same shared bound as every other address-shaped field, so this -// delegates to validateAddress (defined below) rather than keeping its own -// private, looser cap. Only the null-passthrough semantics differ from -// validateAddress, since pubkey is validated for presence separately in -// validateAll. -function validatePubkey(pubkey) { - if (pubkey == null) return null - return validateAddress(pubkey, 'pubkey') -} - -// Index of the first code unit a latin-1 byte cannot hold, or -1. Written as a scan -// rather than a regex so the source carries no literal high or control characters. -function firstNonLatin1(str) { - for (let i = 0; i < str.length; i++) if (str.charCodeAt(i) > 0xFF) return i - return -1 -} - -// Gate `data` and `rawData` on what their wire encodings can actually carry, not -// just on being strings. prepareData converts data with Buffer.from(data,'utf8') -// and rawData with Buffer.from(rawData,'binary') (XChainEncoder.js), and neither -// conversion is reversible for every JS string: an unpaired surrogate becomes -// U+FFFD under utf8, and a code unit above U+00FF is truncated to its low byte -// under latin-1. Accepting those silently mutates a fee-paid payload into bytes -// the decoder can never reconstruct into the value that was validated, so refuse -// them here instead. RangeError so api.js maps it to -32602 invalid-params. Only -// non-roundtrippable inputs are newly rejected, so nothing that used to encode -// faithfully regresses. -function validateDataParam(value, fieldName) { - if (value == null) return null - if (typeof value !== 'string') { - throw new TypeError(`${fieldName} must be a string`) - } - if (fieldName === 'rawData') { - // Latin-1 wire: every code unit must fit one byte. - const bad = firstNonLatin1(value) - if (bad !== -1) { - throw new RangeError(`${fieldName} contains a code point above U+00FF at index ${bad} that cannot be encoded as a latin-1 byte`) - } - } else if (!value.isWellFormed()) { - // UTF-8 wire: an unpaired surrogate would be replaced with U+FFFD. - throw new RangeError(`${fieldName} is not well-formed Unicode (unpaired surrogate) and cannot round-trip through UTF-8`) - } - return value -} - -// Size of a single script push once bitcoin.script.compile adds its length -// prefix: a direct push opcode for <=75 bytes, OP_PUSHDATA1 (+2) for <=255, or -// OP_PUSHDATA2 (+3) beyond that. Mirrors how prepareData compiles data/rawData. -// Deliberately has no OP_PUSHDATA4 band: this formula is pinned byte-for-byte -// against the decoder's identical copy by xchain-decoder's -// compiledPushSizeConformance test, so a band added on one side alone diverges -// the pair. Callers measuring a push that can reach 65,536 bytes correct for it -// themselves (envelopePushSize below). Only the OP_PUSHDATA2 branch names a -// constant: the +1/+2 branches are different opcodes that OP_RETURN_PUSH_OVERHEAD -// does not describe. -function compiledPushSize(byteLength) { - if (byteLength <= 75) return byteLength + 1 // direct push opcode - if (byteLength <= 255) return byteLength + 2 // OP_PUSHDATA1 - return byteLength + OP_RETURN_PUSH_OVERHEAD // OP_PUSHDATA2 -} - -// compiledPushSize with the OP_PUSHDATA4 band restored: bitcoin.script.compile -// frames a push of >= 65,536 bytes with a 4-byte length prefix (+5), not the -// 2-byte one (+3) that compiledPushSize stops at. Only reachable under the -// 390,000-byte TAPROOT/AUTO envelope ceiling, where a single rawData push -// routinely clears 65,535 bytes; under the 8,192-byte legacy ceiling such a push -// is refused on either model, so applying the correction unconditionally costs -// nothing and keeps one rule to reason about. -function envelopePushSize(byteLength) { - return compiledPushSize(byteLength) + (byteLength > 0xffff ? 2 : 0) -} - -// True only for a 1-byte buffer whose value bitcoinjs canonicalizes to a bare -// numeric opcode (OP_1..OP_16 / OP_1NEGATE). The empty-buffer -> OP_0 case is -// EXCLUDED here because a missing/empty `data` is an intentional, contract-documented shape (an empty -// data-only push is a payment-only / no-ACTION tx, and an empty data + rawData -// is the deliberately-supported rawData-only request; see validateCombinedDataLength -// and the openrpc create_tx data.required=false contract). Restoring the -// rawData-only wire shape end-to-end is a decoder-side (consensus) acceptance -// change owned by the cross-service flag-day spec, not an encoder refusal. -function isMinimalOpSingleByte(buf) { - if (buf.length !== 1) return false - const b = buf[0] - return (b >= 0x01 && b <= 0x10) || b === 0x81 -} - -// Reject a `data` or `rawData` value that is a single minimal-opcode-range byte -// (0x01-0x10 or 0x81). bitcoin.script.compile canonicalizes such a lone byte to -// a bare opcode, so bitcoin.script.decompile returns an integer (not a Buffer) -// for that element and the decoder's arbiter gate silently discards it -// (XChainDecoder.js: !Buffer.isBuffer(decompiledData[0]) blanks the payload; a -// non-Buffer decompiledData[1] drops rawData). Real ACTION payloads are -// multi-byte text so this shape is degenerate, but it is fee-paid silent data -// loss, so refuse it at validate time. Throws RangeError so api.js maps it to a -// -32602 invalid-params classification. The empty-data cases are intentionally -// out of scope here (see isMinimalOpSingleByte). -function validateActionPushDecodability(data, rawData) { - if (data != null && isMinimalOpSingleByte(Buffer.from(data, 'utf8'))) { - throw new RangeError( - 'data must not be a single byte in the minimal-opcode range (0x01-0x10, 0x81); ' + - 'it compiles to a bare opcode that the decoder discards, silently dropping the ACTION') - } - if (rawData != null && isMinimalOpSingleByte(Buffer.from(rawData, 'binary'))) { - throw new RangeError( - 'rawData must not be a single byte in the minimal-opcode range (0x01-0x10, 0x81); ' + - 'it compiles to a bare opcode that the decoder discards, silently dropping rawData') - } -} - -// Reject a `data` value whose leading ACTION token (the text before the first -// '|', or the whole string if no '|' is present) is neither a canonical -// action name nor a known alias. Mirrors exactly how the decoder tokenizes: -// XChainDecoder.js does `decodedData.split("|")[0]` then -// `ACTION_ALIASES[rawActionName] ?? rawActionName` against VALID_ACTION_NAMES, -// at both the confirmed-block and mempool gate sites. Without this -// check, a typoed or not-yet-deployed ACTION name compiles into a valid, -// fee-paid, broadcast transaction that the decoder then silently no-actions -// (parseErrors++, decodedData = "" / tx skipped) with no error back to the -// caller. Throws RangeError so api.js maps it to a -32602 invalid-params -// classification, matching the validateActionPushDecodability convention. -// An empty/absent `data` intentionally skips this check: it has no leading -// ACTION token to validate and the decoder never reaches its own name gate -// for a zero-length payload either (see the `parseResult["data"].length > 0` -// guard around the decoder's gate). -// -// The recognition test itself lives in unknownActionName below, so the two -// surfaces that need it share one tokenizer: this validator REFUSES on the -// JSON-RPC path, and XChainEncoder._buildTransaction REPORTS on the library -// path, where a generic `data` payload is a supported shape. -function validateActionName(data) { - const rawActionName = unknownActionName(data) - if (rawActionName == null) return - throw new RangeError( - `data has unknown ACTION name '${rawActionName.slice(0, 32)}'; ` + - 'the decoder rejects any leading token that is not a canonical action ' + - 'name or alias, silently dropping the ACTION on a fee-paid transaction') -} - -// The leading ACTION token of `data` when the decoder would NOT recognize it, -// else null. Same tokenization as the decoder and as validateActionName's own -// contract above; splitting it out lets a caller that must stay buildable -// report the token instead of throwing on it. -// -// Buffer `data` reads too, because the library builder accepts that shape and -// the compiler copies those bytes verbatim, so the decoder tokenizes the same -// leading token whichever shape arrived. Every canonical name and alias is -// ASCII, so decoding the buffer never turns a recognized name into an -// unrecognized one. A value that is neither a string nor a Buffer reads as null -// and is left to the shape errors downstream. -function unknownActionName(data) { - if (data == null || data.length === 0) return null - if (typeof data !== 'string' && !Buffer.isBuffer(data)) return null - const rawActionName = (Buffer.isBuffer(data) ? data.toString('utf8') : data).split('|')[0] - const actionName = ACTION_ALIASES[rawActionName] ?? rawActionName - return VALID_ACTION_NAMES.has(actionName) ? null : rawActionName -} - -// MEASURAND: the CALLER'S bytes, before compression, deliberately (AML #5308). -// -// api.js runs validateAll on the raw params, and XChainEncoder.js's transparent -// FILE compression rewrites both `data` and `rawData` afterwards, so this -// pre-check and the compiled-size ceiling in _buildTransaction measure two -// different byte streams. That is the contract, not an oversight: the same -// pre-compression cap is what xchain-sdk documents (protocol/constants.js -// MAX_ACTION_DATA_LENGTH) and what xchain-wallet gates uploads on -// (flows/fileSizeLimits.js), so a caller sizes a payload the same way on all -// three surfaces without knowing whether the encoder it is talking to has -// compression enabled (XCHAIN_COMPRESSION_DEFAULT is a deploy-time lever this -// module cannot see, the same way it cannot see network.supportsSegwit). -// -// Two consequences, both fail closed behind the compiled-size ceiling: -// - compression cannot rescue an oversize payload. A FILE whose RAW bytes -// exceed the ceiling is refused here as -32602 even where deflate would -// have fitted it. Raising that is a cross-repo contract change (encoder + -// SDK + wallet together), never an encoder-only relaxation. -// - a payload within ~8 bytes of the ceiling can still fail post-compression: -// compression.js keeps a result that is smaller by as little as one byte -// while withCompressionField pads the action string out to the COMPRESSION -// field. Those land as the builder's -32603 rather than this -32602. -// -// XChainEncoder.js's "everything downstream prices the bytes that will actually -// be written" is about the passes that run AFTER compression; this one runs -// before it. The compiled-size ceiling still runs ahead of the UTXO fetch, so -// what a deferred rejection costs is the error CODE, not reservation work. -function validateCombinedDataLength(data, rawData, encoding) { - if (data == null && rawData == null) return - // createTransaction defaults a missing `data` to '' and still compiles it - // as a push (OP_0, 1 byte), so a rawData-only request must be measured - // here too; skipping it only shifted the rejection to the compiled-size - // ceiling in createTransaction with a -32603 internal error instead of - // this pre-check's -32602 invalid-params classification. - const dataBytes = data != null ? Buffer.byteLength(data, 'utf8') : 0 - // Match XChainEncoder.js: rawData is bytes-as-string (Latin-1), so the - // on-chain byte count is the string length, not the UTF-8 encoding length. - const rawBytes = rawData != null ? Buffer.byteLength(rawData, 'binary') : 0 - // When both fields are present, prepareData compiles them as two separate - // pushes (bitcoin.script.compile([utf8Buffer, rawDataBuffer])), so each push - // carries its own length-prefix overhead. Summing the raw byte counts would - // undercount the on-chain size and let dual-push payloads slip past this - // pre-check only to fail the compiled-size ceiling later in createTransaction. - // envelopePushSize, not compiledPushSize: this sum is compared against the - // 390,000-byte envelope ceiling below, and _buildTransaction refuses on the - // REAL compiled buffer, so a push framed with OP_PUSHDATA4 must be counted - // the way bitcoin.script.compile frames it or the two ceilings disagree by - // 2 bytes per large push and a payload lands as -32603 instead of -32602. - const compiled = envelopePushSize(dataBytes) + (rawData != null ? envelopePushSize(rawBytes) : 0) - // Per-encoding ceiling. "TAPROOT" and "AUTO" both get the - // envelope ceiling; every other value (including an OMITTED encoding) keeps - // the legacy ceiling, because the legacy lanes' decoders silently drop - // anything over 8,192 compiled bytes. - // - // AUTO is here because selectEncoding (XChainEncoder.js) resolves an AUTO - // request to TAPROOT once the payload outgrows OP_RETURN and the caller - // affirms signerSupportsTapscript with a compressedPubKey. This validator is - // network-agnostic (it cannot see network.supportsSegwit) and runs before - // that resolution, so it can only apply the WIDEST ceiling any AUTO - // resolution could legitimately use, and let _buildTransaction re-check the - // resolved lane. Consequence: an AUTO request that resolves to a legacy lane - // over 8,192 bytes is refused by the builder (-32603) rather than here - // (-32602). Both fail closed. Note an OMITTED encoding is NOT AUTO: it keeps - // prepareData's legacy OP_RETURN-else-P2SH fallback and its legacy ceiling. - const wideCeiling = (encoding === 'TAPROOT' || encoding === 'AUTO') - const ceiling = wideCeiling ? ENVELOPE_MAX_PAYLOAD : MAX_COMPILED_ACTION_DATA_LENGTH - if (compiled > ceiling) { - throw new RangeError(`Combined compiled payload (${compiled} bytes) exceeds maximum (${ceiling}${wideCeiling ? ', the TAPROOT envelope payload ceiling' : ''})`) - } - // When the caller EXPLICITLY requested OP_RETURN, apply the far tighter 76-byte - // single-output ceiling here rather than letting the request run the whole - // UTXO-selection/reservation path and throw post-compile in prepareData (which - // api.js then mis-classifies as -32603 internal instead of -32602 invalid-params). - // Only when encoding is explicitly 'OP_RETURN': an omitted encoding must NOT be - // rejected here, or it would break prepareData's automatic P2SH fallback for - // larger payloads. prepareData remains the arbiter/backstop. - if (encoding === 'OP_RETURN' && compiled > MAX_OP_RETURN_COMPILED_LENGTH) { - throw new RangeError( - `OP_RETURN encoding requires compiled payload <= ${MAX_OP_RETURN_COMPILED_LENGTH} bytes; ` + - `got ${compiled}. Use P2SH for larger payloads.`) - } -} - -function validateEncoding(encoding) { - if (encoding == null) return null - if (typeof encoding !== 'string' || !VALID_ENCODINGS.has(encoding)) { - throw new TypeError(`Invalid encoding: "${encoding}". Valid values: ${[...VALID_ENCODINGS].join(', ')}`) - } - return encoding -} - -// The create_tx options bag. Unknown keys are refused rather than ignored: a -// misspelled capability that silently means "no" would send an envelope-capable -// signer down the P2WSH lane 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']) -function validateCreateTxOptions(options) { - if (options == null) return null - if (typeof options !== 'object' || Array.isArray(options)) { - throw new TypeError('options must be an object') - } - for (const key of Object.keys(options)) { - if (!VALID_CREATE_TX_OPTIONS.has(key)) { - throw new TypeError(`Unknown options key: "${key}". Valid keys: ${[...VALID_CREATE_TX_OPTIONS].join(', ')}`) - } - } - if (options.signerSupportsTapscript !== undefined) { - validateOptionalBoolean(options.signerSupportsTapscript, 'options.signerSupportsTapscript') - } - // Strict boolean for the same reason rbf is: exactInputs turns UTXO selection - // off, and the string "false" is truthy, so a loose check would spend every - // outpoint in the caller's set when they asked for the opposite. - if (options.exactInputs !== undefined) { - validateOptionalBoolean(options.exactInputs, 'options.exactInputs') - } - return options -} - -function validateFee(fee) { - if (fee == null || fee === false) return null - const num = toExactInt(fee) - if (isNaN(num)) { - throw new TypeError(`fee must be a valid integer, got: ${typeof fee === 'string' ? fee : typeof fee}`) - } - if (num < 0) { - throw new RangeError('fee must be non-negative') - } - if (num > MAX_FEE_SATOSHIS) { - throw new RangeError(`fee (${num}) exceeds maximum (${MAX_FEE_SATOSHIS})`) - } - return num -} - -function validateFeePerKb(feePerKb) { - if (feePerKb == null || feePerKb === false) return null - // Reject types a bare Number() would silently coerce to a plausible rate: - // boolean (true -> 1), array ([50] -> 50), object (-> NaN but still a wrong - // type on a money field). Only a real number or a numeric string is valid. - if (typeof feePerKb !== 'number' && typeof feePerKb !== 'string') { - throw new TypeError('feePerKb must be a finite number') - } - let num - if (typeof feePerKb === 'number') { - num = feePerKb - } else { - // String path: allow only a plain decimal (optionally fractional). This - // rejects hex ('0x20' -> 32), scientific ('1e3' -> 1000), Infinity/NaN - // spellings, and any trailing garbage that Number() would otherwise - // accept, while still permitting a legitimately fractional feePerKb. - const s = feePerKb.trim() - if (!/^-?\d+(\.\d+)?$/.test(s)) { - throw new TypeError('feePerKb must be a finite number') - } - num = Number(s) - } - if (isNaN(num) || !isFinite(num)) { - throw new TypeError('feePerKb must be a finite number') - } - if (num <= 0) { - throw new RangeError('feePerKb must be positive') - } - return num -} - -function validateDust(dust) { - if (dust == null || dust === false) return null - const num = toExactInt(dust) - if (isNaN(num)) { - throw new TypeError('dust must be a valid integer') - } - if (num < 0) { - throw new RangeError('dust must be non-negative') - } - if (num > MAX_FEE_SATOSHIS) { - throw new RangeError(`dust (${num}) exceeds maximum (${MAX_FEE_SATOSHIS})`) - } - return num -} - -function validateUtxoEntry(entry, index) { - // Reject anything that is not a plain object: a string, number, array or - // null here means the caller sent the wrong shape, not a bad field, so - // each UTXO must arrive as its own {txid, vout, value, scriptPubKey} record. - if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { - throw new TypeError(`utxos[${index}] must be an object`) - } - // The outpoint's transaction id must be exactly 32 bytes of hex (64 chars); - // anything shorter, longer or non-hex cannot name a real transaction. - if (typeof entry.txid !== 'string' || !HEX_64_RE.test(entry.txid)) { - throw new TypeError(`utxos[${index}].txid must be a 64-character hex string`) - } - // Canonicalize case, because on the OP_RETURN/MULTISIGN path this string IS the - // AES-128-CTR obfuscation key (XChainEncoder.obfuscate splits it with substr), and - // the decoder derives its key from the wire bytes, which render as lowercase hex. - // 'A' (0x41) and 'a' (0x61) are different key bytes, so an accepted mixed-case txid - // could never round-trip; the ins[0] guard then rejected the build as a bogus - // INPUT_SELECTION_RACE. Normalizing here also makes the reservation keys and the - // duplicate filter below compare one form of each outpoint. Mutating in place is - // this function's established contract (vout, value and confirmations already are). - entry.txid = entry.txid.toLowerCase() - // Route vout through toExactInt (rejecting NaN), not bare Number(): on the - // money path a JSON null/''/false/[] all coerce via Number() to a plausible - // index (0), so the encoder would build a PSBT spending txid:0, a different - // outpoint than intended. Same exact-integer rigor the value field uses - // (uuid:4555d78c). - const vout = toExactInt(entry.vout) - if (!Number.isInteger(vout) || vout < 0) { - throw new TypeError(`utxos[${index}].vout must be a non-negative integer`) - } - entry.vout = vout - - // allowBig: a DOGE consolidation UTXO can legitimately exceed 2^53-1 sats; - // the tracker emits it as an exact decimal string and the encoder's money - // path carries it as a BigInt. - entry.value = parseSatoshiAmount(entry.value, `utxos[${index}].value`, { allowBig: true }) - - if (typeof entry.scriptPubKey !== 'string' || entry.scriptPubKey.length === 0) { - throw new TypeError(`utxos[${index}].scriptPubKey must be a non-empty string`) - } - // scriptPubKey flows unchecked into Buffer.from(...,'hex') for the PSBT - // witnessUtxo.script and into bitcoin.script.decompile in isSegwitUTXO. - // Buffer.from(x,'hex') is lenient: it stops at the first invalid character - // and drops a trailing odd nibble, so a malformed value ('zz', odd length) - // silently yields a truncated script and misclassifies the input. Anchor it - // to even-length hex with a sane bound, matching the txid/rawTxHex rigor. - if (entry.scriptPubKey.length > MAX_SCRIPTPUBKEY_HEX_LENGTH) { - throw new RangeError(`utxos[${index}].scriptPubKey exceeds maximum length (${MAX_SCRIPTPUBKEY_HEX_LENGTH})`) - } - if (!RAW_TX_HEX_RE.test(entry.scriptPubKey)) { - throw new TypeError(`utxos[${index}].scriptPubKey must be an even-length hex string`) - } - if (entry.confirmations == null) { - entry.confirmations = 0 - } else { - // Validate like vout: the unconfirmed-UTXO filter in XChainEncoder - // compares `confirmations == 0` with loose equality, so an untyped - // value (string, float, object) could let a mempool input slip past - // the exclusion when unconfirmed=false. Coerce to a real number and - // range-check so the downstream comparison always sees an integer. - const confirmations = toExactInt(entry.confirmations) - if (!Number.isInteger(confirmations) || confirmations < 0) { - throw new TypeError(`utxos[${index}].confirmations must be a non-negative integer`) - } - entry.confirmations = confirmations - } - return entry -} - -function validateUtxoArray(utxos) { - if (utxos == null) return null - if (!Array.isArray(utxos)) { - throw new TypeError('utxos must be an array') - } - if (utxos.length > MAX_UTXO_COUNT) { - throw new RangeError(`utxos array length (${utxos.length}) exceeds maximum (${MAX_UTXO_COUNT})`) - } - for (let i = 0; i < utxos.length; i++) { - validateUtxoEntry(utxos[i], i) - } - return utxos -} - -function validateCustomOutput(output, index) { - if (typeof output !== 'object' || output === null || Array.isArray(output)) { - throw new TypeError(`customOutputs[${index}] must be an object`) - } - validateAddress(output.address, `customOutputs[${index}].address`) - // allowBig: a >2^53-1-sat DOGE payment output is legitimate; it must be - // supplied as an exact decimal string. - output.value = parseSatoshiAmount(output.value, `customOutputs[${index}].value`, { allowBig: true }) - // 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 - // 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. - if (output.value <= 0) { - throw new RangeError(`customOutputs[${index}].value must be a positive integer (satoshis)`) - } - return output -} - -function validateCustomOutputs(customOutputs) { - if (customOutputs == null) return null - if (!Array.isArray(customOutputs)) { - throw new TypeError('customOutputs must be an array') - } - if (customOutputs.length > MAX_CUSTOM_OUTPUTS) { - throw new RangeError(`customOutputs length (${customOutputs.length}) exceeds maximum (${MAX_CUSTOM_OUTPUTS})`) - } - for (let i = 0; i < customOutputs.length; i++) { - validateCustomOutput(customOutputs[i], i) - } - return customOutputs -} - -function validateFeeQuote(feeQuote) { - if (feeQuote == null) return null - // Array.isArray, because typeof [] is 'object': a JSON array cleared this gate - // and died one line later on validateAddress(undefined), reporting an address - // error for what is a shape error. Same guard the other object-shaped - // validators carry (lines 481/557/635). No === null clause: the line above - // already returns for a null/absent quote, which is legal. - if (typeof feeQuote !== 'object' || Array.isArray(feeQuote)) { - throw new TypeError('feeQuote must be an object with address and amount') - } - validateAddress(feeQuote.address, 'feeQuote.address') - const amount = toExactInt(feeQuote.amount) - if (isNaN(amount) || amount <= 0) { - throw new RangeError('feeQuote.amount must be a positive integer (satoshis)') - } - if (amount > MAX_FEE_SATOSHIS) { - throw new RangeError('feeQuote.amount exceeds maximum (' + MAX_FEE_SATOSHIS + ')') - } - feeQuote.amount = amount - return feeQuote -} - -function validateP2shParams(p2shHash, p2shHex) { - const hasHash = (p2shHash != null && p2shHash !== false) - const hasHex = (p2shHex != null && p2shHex !== false) - - if (!hasHash && !hasHex) return { p2shHash: null, p2shHex: null } - if (hasHash !== hasHex) { - throw new TypeError('p2shHash and p2shHex must both be provided or both omitted') - } - if (typeof p2shHash !== 'string' || !HEX_64_RE.test(p2shHash)) { - throw new TypeError('p2shHash must be a 64-character hex string') - } - if (typeof p2shHex !== 'string' || p2shHex.length === 0) { - throw new TypeError('p2shHex must be a non-empty hex string') - } - if (p2shHex.length > MAX_RAW_TX_HEX_LENGTH) { - throw new TypeError('p2shHex exceeds maximum length (' + MAX_RAW_TX_HEX_LENGTH + ')') - } - if (!RAW_TX_HEX_RE.test(p2shHex)) { - throw new TypeError('p2shHex must be an even-length hex string') - } - return { p2shHash, p2shHex } -} - -// Raw signed-transaction hex for broadcast_tx. The node would reject malformed -// hex anyway; rejecting here sheds obvious garbage before the round-trip and -// returns a -32602 with a precise reason instead of a node parse error. -function validateRawTxHex(txHex) { - if (typeof txHex !== 'string' || txHex.length === 0) { - throw new TypeError('tx_hex must be a non-empty hex string') - } - if (txHex.length > MAX_BROADCAST_TX_HEX_LENGTH) { - throw new TypeError('tx_hex exceeds maximum length (' + MAX_BROADCAST_TX_HEX_LENGTH + ')') - } - if (!RAW_TX_HEX_RE.test(txHex)) { - throw new TypeError('tx_hex must be an even-length hex string') - } - return txHex -} - -function validateCompressedPubKey(compressedPubKey) { - if (compressedPubKey == null) return null - if (typeof compressedPubKey !== 'string' || !COMPRESSED_PUBKEY_RE.test(compressedPubKey)) { - throw new TypeError('compressedPubKey must be a 66-character hex string starting with 02 or 03') - } - return compressedPubKey -} - -// Shape-only address check (non-empty string, capped at 100 chars). Shared -// core for every address-shaped field: the get_utxos address param, -// validateChange, validatePubkey (params.pubkey is actually a sender -// address, not a real pubkey), and customOutputs[].address / feeQuote.address. -// Coin-specific base58/bech32 validity is left to bitcoinjs downstream; this -// just sheds obvious garbage (non-strings, empty, oversized) with a -// -32602-mappable TypeError. `label` names the field in the thrown message; -// callers that omit it keep the plain "address ..." wording. -function validateAddress(address, label = 'address') { - if (typeof address !== 'string' || address.length === 0) { - throw new TypeError(`${label} must be a non-empty string`) - } - if (address.length > 100) { - throw new TypeError(`${label} exceeds maximum length (100)`) - } - return address -} - -// Reservation-ticket id for release_inputs: exactly the 16 random bytes -// mintReservationTicket emits, lowercase hex. Shape-checked here so a -// non-string, an oversized blob or a wrong-length id is a precise -32602 rather -// than reaching the encoder, and so the id can never be used as an unbounded -// key into the ticket map. -function validateReservationId(reservationId) { - if (typeof reservationId !== 'string' || !/^[0-9a-f]{32}$/.test(reservationId)) { - throw new TypeError('reservationId must be a 32-character lowercase hex string, as returned in create_tx result.reservation.id') - } - return reservationId -} - -function validateChange(change) { - if (change == null) return null - return validateAddress(change) -} - -/** - * Validate and coerce every createTransaction parameter in one pass. - * @param {object} params - the raw JSON-RPC params object for create_tx. - * @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) { - // 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 - // object-shaped validators below carry (lines 484/566/644/686). - if (typeof params !== 'object' || params === null || Array.isArray(params)) { - throw new TypeError('Request params must be an object') - } - - const data = validateDataParam(params.data, 'data') - const rawData = validateDataParam(params.rawData, 'rawData') - // Resolve encoding before the length check so validateCombinedDataLength can - // apply the per-encoding (OP_RETURN) ceiling pre-compile. - const encoding = validateEncoding(params.encoding) - if (data != null || rawData != null) { - validateCombinedDataLength(data, rawData, encoding) - validateActionPushDecodability(data, rawData) - validateActionName(data) - } - - 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 feeQuote = validateFeeQuote(params.feeQuote) - const { p2shHash, p2shHex } = validateP2shParams(params.p2shHash, params.p2shHex) - const compressedPubKey = validateCompressedPubKey(params.compressedPubKey) - const change = validateChange(params.change) - - // MULTISIGN packs the caller's real pubkey as the 3rd fake-multisig pubkey, so it is - // required. Without it the encoder reaches `Buffer.from(compressedPubKey, 'hex')` with - // null and throws an opaque deep error; reject up front with a precise reason instead. - if (encoding === 'MULTISIGN' && compressedPubKey == null) { - throw new TypeError('compressedPubKey is required for MULTISIGN encoding') - } - - // The Taproot-envelope leaf ends with OP_CHECKSIG - // and the commit's key-path cancel needs the same key, so the caller's real - // pubkey is required (pubkey may be a bare address). Mirrors MULTISIGN. - if (encoding === 'TAPROOT' && compressedPubKey == null) { - throw new TypeError('compressedPubKey is required for TAPROOT encoding (it becomes the envelope internal key)') - } - - // TAPROOT is a single-call flow: create_tx returns the {commit, reveal} - // PSBT pair together, pre-built against the unsigned - // commit's stable txid. The p2shHash/p2shHex second-call reveal flow is a - // chunk-lane concept and must not engage here. - if (encoding === 'TAPROOT' && p2shHash != null) { - throw new TypeError('TAPROOT encoding does not use the p2shHash reveal flow; one create_tx call returns the commit and reveal PSBTs together') - } - - // docs/openrpc.json marks pubkey required:true. validateAll never enforced - // presence, so an omitted/null pubkey reached bitcoin.address.fromBase58Check(null) - // deep in createTransaction and leaked a library "Expected String" as a -32603 - // internal error. Reject up front (RangeError, so api.js maps it to -32602 - // invalid-params) mirroring the MULTISIGN/compressedPubKey presence check above. - if (pubkey == null) { - throw new RangeError('pubkey is required') - } - - // rbf and unconfirmed must be real JSON booleans when explicitly provided. - // Truthiness coercion here was a policy flip on a money path: Boolean("false") - // is true, so a stringy-boolean client asking to EXCLUDE mempool coins would - // silently have them selected (and rbf "false" would arm replace-by-fee). - // Absent/null params stay undefined (NOT false) and inherit createTransaction's - // defaults downstream: unconfirmed defaults to true (unconfirmed UTXOs are - // selectable), rbf defaults to falsy. Do not "fix" the code to force false - // here, that would silently flip the UTXO-selection policy on a money path. - const rbf = validateOptionalBoolean(params.rbf, 'rbf') - const unconfirmed = validateOptionalBoolean(params.unconfirmed, 'unconfirmed') - - // Attach each segwit input's FULL previous transaction alongside - // its witnessUtxo. Off by default because it costs one node round trip per - // input plus the prev tx's bytes in every copy of the PSBT, and only a - // hardware signer needs it: Ledger derives the outpoint it signs from the - // prev tx it is handed, so a witnessUtxo-only input cannot be signed on a - // device at all (it used to be signed against a SYNTHESIZED prev tx, which - // produced a valid-looking signature over an outpoint that does not exist). - const attachPrevTx = validateOptionalBoolean(params.attachPrevTx, 'attachPrevTx') - - // Transparent FILE payload compression. TRI-STATE: absent means "use the - // deployment default" (currently ON), and an explicit true/false is the - // caller's own choice. An explicit true that - // cannot be honoured is an error; the default pass just rides raw. - const compress = validateOptionalBoolean(params.compress, 'compress') - - // Signer capability for AUTO selection. Absent means "cannot sign a - // tapscript spend", the fail-closed direction: the reveal must be signable - // before the commit is broadcast, so an unaffirmed signer never gets the - // envelope. Kept in an options bag rather than as another positional - // parameter, so the next capability does not grow the signature again. - const options = validateCreateTxOptions(params.options) - + // 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, + // params.p2shHash, params.p2shHex, params.compressedPubKey, + // params.unconfirmed, params.feePerKb, params.dust, params.feeQuote, + // params.attachPrevTx, params.compress, params.options return { - utxos, pubkey, customOutputs, data, rawData, fee, rbf, - encoding, change, p2shHash, p2shHex, compressedPubKey, - unconfirmed, feePerKb, dust, feeQuote, attachPrevTx, compress, options + ...validateAllFields(params) } } diff --git a/src/common/validator/action_data_checks.js b/src/common/validator/action_data_checks.js new file mode 100644 index 0000000..8b850ad --- /dev/null +++ b/src/common/validator/action_data_checks.js @@ -0,0 +1,267 @@ +/********************************************************************* + * + * 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 { + OP_RETURN_PUSH_OVERHEAD, + VALID_ACTION_NAMES, + ACTION_ALIASES, + MAX_COMPILED_ACTION_DATA_LENGTH, + MAX_OP_RETURN_COMPILED_LENGTH, + ENVELOPE_MAX_PAYLOAD, + VALID_ENCODINGS +} = require('./constants') +const { firstNonLatin1 } = require('./value_checks') + +// Gate `data` and `rawData` on what their wire encodings can actually carry, not +// just on being strings. prepareData converts data with Buffer.from(data,'utf8') +// and rawData with Buffer.from(rawData,'binary') (XChainEncoder.js), and neither +// conversion is reversible for every JS string: an unpaired surrogate becomes +// U+FFFD under utf8, and a code unit above U+00FF is truncated to its low byte +// under latin-1. Accepting those silently mutates a fee-paid payload into bytes +// the decoder can never reconstruct into the value that was validated, so refuse +// them here instead. RangeError so api.js maps it to -32602 invalid-params. This +// rejects only non-roundtrippable inputs, leaving faithfully encoded inputs +// unchanged. +function validateDataParam(value, fieldName) { + if (value == null) return null + if (typeof value !== 'string') { + throw new TypeError(`${fieldName} must be a string`) + } + if (fieldName === 'rawData') { + // Latin-1 wire: every code unit must fit one byte. + const bad = firstNonLatin1(value) + if (bad !== -1) { + throw new RangeError(`${fieldName} contains a code point above U+00FF at index ${bad} that cannot be encoded as a latin-1 byte`) + } + } else if (!value.isWellFormed()) { + // UTF-8 wire: an unpaired surrogate would be replaced with U+FFFD. + throw new RangeError(`${fieldName} is not well-formed Unicode (unpaired surrogate) and cannot round-trip through UTF-8`) + } + return value +} + +// Size of a single script push once bitcoin.script.compile adds its length +// prefix: a direct push opcode for <=75 bytes, OP_PUSHDATA1 (+2) for <=255, or +// OP_PUSHDATA2 (+3) beyond that. Mirrors how prepareData compiles data/rawData. +// Deliberately has no OP_PUSHDATA4 band: this formula is pinned byte-for-byte +// against the decoder's identical copy by xchain-decoder's +// compiledPushSizeConformance test, so a band added on one side alone diverges +// the pair. Callers measuring a push that can reach 65,536 bytes correct for it +// themselves (envelopePushSize below). Only the OP_PUSHDATA2 branch names a +// constant: the +1/+2 branches are different opcodes that OP_RETURN_PUSH_OVERHEAD +// does not describe. +function compiledPushSize(byteLength) { + if (byteLength <= 75) return byteLength + 1 // direct push opcode + if (byteLength <= 255) return byteLength + 2 // OP_PUSHDATA1 + return byteLength + OP_RETURN_PUSH_OVERHEAD // OP_PUSHDATA2 +} + +// compiledPushSize with the OP_PUSHDATA4 band restored: bitcoin.script.compile +// frames a push of >= 65,536 bytes with a 4-byte length prefix (+5), not the +// 2-byte one (+3) that compiledPushSize stops at. Only reachable under the +// 390,000-byte TAPROOT/AUTO envelope ceiling, where a single rawData push +// routinely clears 65,535 bytes; under the 8,192-byte legacy ceiling such a push +// is refused on either model, so applying the correction unconditionally costs +// nothing and keeps one rule to reason about. +function envelopePushSize(byteLength) { + return compiledPushSize(byteLength) + (byteLength > 0xffff ? 2 : 0) +} + +// True only for a 1-byte buffer whose value bitcoinjs canonicalizes to a bare +// numeric opcode (OP_1..OP_16 / OP_1NEGATE). The empty-buffer -> OP_0 case is +// EXCLUDED here because a missing/empty `data` is an intentional, contract-documented shape (an empty +// data-only push is a payment-only / no-ACTION tx, and an empty data + rawData +// is the deliberately-supported rawData-only request; see validateCombinedDataLength +// and the openrpc create_tx data.required=false contract). Restoring the +// rawData-only wire shape end-to-end is a decoder-side (consensus) acceptance +// change owned by the cross-service flag-day spec, not an encoder refusal. +function isMinimalOpSingleByte(buf) { + if (buf.length !== 1) return false + const b = buf[0] + return (b >= 0x01 && b <= 0x10) || b === 0x81 +} + +// Reject a `data` or `rawData` value that is a single minimal-opcode-range byte +// (0x01-0x10 or 0x81). bitcoin.script.compile canonicalizes such a lone byte to +// a bare opcode, so bitcoin.script.decompile returns an integer (not a Buffer) +// for that element and the decoder's arbiter gate silently discards it +// (XChainDecoder.js: !Buffer.isBuffer(decompiledData[0]) blanks the payload; a +// non-Buffer decompiledData[1] drops rawData). Real ACTION payloads are +// multi-byte text so this shape is degenerate, but it is fee-paid silent data +// loss, so refuse it at validate time. Throws RangeError so api.js maps it to a +// -32602 invalid-params classification. The empty-data cases are intentionally +// out of scope here (see isMinimalOpSingleByte). +function validateActionPushDecodability(data, rawData) { + if (data != null && isMinimalOpSingleByte(Buffer.from(data, 'utf8'))) { + throw new RangeError( + 'data must not be a single byte in the minimal-opcode range (0x01-0x10, 0x81); ' + + 'it compiles to a bare opcode that the decoder discards, silently dropping the ACTION') + } + if (rawData != null && isMinimalOpSingleByte(Buffer.from(rawData, 'binary'))) { + throw new RangeError( + 'rawData must not be a single byte in the minimal-opcode range (0x01-0x10, 0x81); ' + + 'it compiles to a bare opcode that the decoder discards, silently dropping rawData') + } +} + +// Reject a `data` value whose leading ACTION token (the text before the first +// '|', or the whole string if no '|' is present) is neither a canonical +// action name nor a known alias. Mirrors exactly how the decoder tokenizes: +// XChainDecoder.js does `decodedData.split("|")[0]` then +// `ACTION_ALIASES[rawActionName] ?? rawActionName` against VALID_ACTION_NAMES, +// at both the confirmed-block and mempool gate sites. Without this +// check, a typoed or not-yet-deployed ACTION name compiles into a valid, +// fee-paid, broadcast transaction that the decoder then silently no-actions +// (parseErrors++, decodedData = "" / tx skipped) with no error back to the +// caller. Throws RangeError so api.js maps it to a -32602 invalid-params +// classification, matching the validateActionPushDecodability convention. +// An empty/absent `data` intentionally skips this check: it has no leading +// ACTION token to validate and the decoder never reaches its own name gate +// for a zero-length payload either (see the `parseResult["data"].length > 0` +// guard around the decoder's gate). +// +// The recognition test itself lives in unknownActionName below, so the two +// surfaces that need it share one tokenizer: this validator REFUSES on the +// JSON-RPC path, and XChainEncoder._buildTransaction REPORTS on the library +// path, where a generic `data` payload is a supported shape. +function validateActionName(data) { + const rawActionName = unknownActionName(data) + if (rawActionName == null) return + throw new RangeError( + `data has unknown ACTION name '${rawActionName.slice(0, 32)}'; ` + + 'the decoder rejects any leading token that is not a canonical action ' + + 'name or alias, silently dropping the ACTION on a fee-paid transaction') +} + +// The leading ACTION token of `data` when the decoder would NOT recognize it, +// else null. Same tokenization as the decoder and as validateActionName's own +// contract above; splitting it out lets a caller that must stay buildable +// report the token instead of throwing on it. +// +// Buffer `data` reads too, because the library builder accepts that shape and +// the compiler copies those bytes verbatim, so the decoder tokenizes the same +// leading token whichever shape arrived. Every canonical name and alias is +// ASCII, so decoding the buffer never turns a recognized name into an +// unrecognized one. A value that is neither a string nor a Buffer reads as null +// and is left to the shape errors downstream. +function unknownActionName(data) { + if (data == null || data.length === 0) return null + if (typeof data !== 'string' && !Buffer.isBuffer(data)) return null + const rawActionName = (Buffer.isBuffer(data) ? data.toString('utf8') : data).split('|')[0] + const actionName = ACTION_ALIASES[rawActionName] ?? rawActionName + return VALID_ACTION_NAMES.has(actionName) ? null : rawActionName +} + +// MEASURAND: the CALLER'S bytes, before compression, deliberately. +// +// api.js runs validateAll on the raw params, and XChainEncoder.js's transparent +// FILE compression rewrites both `data` and `rawData` afterwards, so this +// pre-check and the compiled-size ceiling in _buildTransaction measure two +// different byte streams. That is the contract, not an oversight: the same +// pre-compression cap is what xchain-sdk documents (protocol/constants.js +// MAX_ACTION_DATA_LENGTH) and what xchain-wallet gates uploads on +// (flows/fileSizeLimits.js), so a caller sizes a payload the same way on all +// three surfaces without knowing whether the encoder it is talking to has +// compression enabled (XCHAIN_COMPRESSION_DEFAULT is a deploy-time lever this +// module cannot see, the same way it cannot see network.supportsSegwit). +// +// Two consequences, both fail closed behind the compiled-size ceiling: +// - compression cannot rescue an oversize payload. A FILE whose RAW bytes +// exceed the ceiling is refused here as -32602 even where deflate would +// have fitted it. Raising that is a cross-repo contract change (encoder + +// SDK + wallet together), never an encoder-only relaxation. +// - a payload within ~8 bytes of the ceiling can still fail post-compression: +// compression.js keeps a result that is smaller by as little as one byte +// while withCompressionField pads the action string out to the COMPRESSION +// field. Those land as the builder's -32603 rather than this -32602. +// +// XChainEncoder.js's "everything downstream prices the bytes that will actually +// be written" is about the passes that run AFTER compression; this one runs +// before it. The compiled-size ceiling still runs ahead of the UTXO fetch, so +// what a deferred rejection costs is the error CODE, not reservation work. +function validateCombinedDataLength(data, rawData, encoding) { + if (data == null && rawData == null) return + // createTransaction defaults a missing `data` to '' and still compiles it + // as a push (OP_0, 1 byte), so a rawData-only request must be measured + // here too; skipping it only shifted the rejection to the compiled-size + // ceiling in createTransaction with a -32603 internal error instead of + // this pre-check's -32602 invalid-params classification. + const dataBytes = data != null ? Buffer.byteLength(data, 'utf8') : 0 + // Match XChainEncoder.js: rawData is bytes-as-string (Latin-1), so the + // on-chain byte count is the string length, not the UTF-8 encoding length. + const rawBytes = rawData != null ? Buffer.byteLength(rawData, 'binary') : 0 + // When both fields are present, prepareData compiles them as two separate + // pushes (bitcoin.script.compile([utf8Buffer, rawDataBuffer])), so each push + // carries its own length-prefix overhead. Summing the raw byte counts would + // undercount the on-chain size and let dual-push payloads slip past this + // pre-check only to fail the compiled-size ceiling later in createTransaction. + // envelopePushSize, not compiledPushSize: this sum is compared against the + // 390,000-byte envelope ceiling below, and _buildTransaction refuses on the + // REAL compiled buffer, so a push framed with OP_PUSHDATA4 must be counted + // the way bitcoin.script.compile frames it or the two ceilings disagree by + // 2 bytes per large push and a payload lands as -32603 instead of -32602. + const compiled = envelopePushSize(dataBytes) + (rawData != null ? envelopePushSize(rawBytes) : 0) + // Per-encoding ceiling. "TAPROOT" and "AUTO" both get the + // envelope ceiling; every other value (including an OMITTED encoding) keeps + // the legacy ceiling, because legacy decoders silently drop anything + // over 8,192 compiled bytes. + // + // AUTO is here because selectEncoding (XChainEncoder.js) resolves an AUTO + // request to TAPROOT once the payload outgrows OP_RETURN and the caller + // affirms signerSupportsTapscript with a compressedPubKey. This validator is + // network-agnostic (it cannot see network.supportsSegwit) and runs before + // that resolution, so it can only apply the WIDEST ceiling any AUTO + // resolution could legitimately use, and let _buildTransaction re-check the + // resolved carrier. Consequence: an AUTO request that resolves to a legacy carrier + // over 8,192 bytes is refused by the builder (-32603) rather than here + // (-32602). Both fail closed. Note an OMITTED encoding is NOT AUTO: it keeps + // prepareData's legacy OP_RETURN-else-P2SH fallback and its legacy ceiling. + const wideCeiling = (encoding === 'TAPROOT' || encoding === 'AUTO') + const ceiling = wideCeiling ? ENVELOPE_MAX_PAYLOAD : MAX_COMPILED_ACTION_DATA_LENGTH + if (compiled > ceiling) { + throw new RangeError(`Combined compiled payload (${compiled} bytes) exceeds maximum (${ceiling}${wideCeiling ? ', the TAPROOT envelope payload ceiling' : ''})`) + } + // When the caller EXPLICITLY requested OP_RETURN, apply the far tighter 76-byte + // single-output ceiling here rather than letting the request run the whole + // UTXO-selection/reservation path and throw post-compile in prepareData (which + // api.js then mis-classifies as -32603 internal instead of -32602 invalid-params). + // Only when encoding is explicitly 'OP_RETURN': an omitted encoding must NOT be + // rejected here, or it would break prepareData's automatic P2SH fallback for + // larger payloads. prepareData remains the arbiter/backstop. + if (encoding === 'OP_RETURN' && compiled > MAX_OP_RETURN_COMPILED_LENGTH) { + throw new RangeError( + `OP_RETURN encoding requires compiled payload <= ${MAX_OP_RETURN_COMPILED_LENGTH} bytes; ` + + `got ${compiled}. Use P2SH for larger payloads.`) + } +} + +function validateEncoding(encoding) { + if (encoding == null) return null + if (typeof encoding !== 'string' || !VALID_ENCODINGS.has(encoding)) { + throw new TypeError(`Invalid encoding: "${encoding}". Valid values: ${[...VALID_ENCODINGS].join(', ')}`) + } + return encoding +} + +module.exports = { + validateDataParam, + compiledPushSize, + envelopePushSize, + isMinimalOpSingleByte, + validateActionPushDecodability, + validateActionName, + unknownActionName, + validateCombinedDataLength, + validateEncoding +} diff --git a/src/common/validator/constants.js b/src/common/validator/constants.js new file mode 100644 index 0000000..525c757 --- /dev/null +++ b/src/common/validator/constants.js @@ -0,0 +1,169 @@ +/********************************************************************* + * + * 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 - Input Validator + * + * Centralized validation for all createTransaction parameters. + * Every function throws TypeError or RangeError on invalid input, + * or returns the (possibly coerced) valid value. + * + ********************************************************************/ + +// Maximum *compiled* on-chain ACTION push, in bytes. Must equal the decoder's +// MAX_ACTION_DATA_LENGTH: a transaction whose compiled push exceeds this is +// silently dropped by every indexing node. Canonical source of truth: +// xchain-documentation/protocol/constants.js (MAX_ACTION_DATA_LENGTH). The +// cross-service regression suite asserts these stay equal. +const MAX_COMPILED_ACTION_DATA_LENGTH = 8192 +// Length prefix a single OP_PUSHDATA2 push prepends to a >=256-byte payload +// (1 opcode + 2 length bytes). The widest single raw push is measured against +// the compiled ceiling with this prefix included. Canonical source of truth: +// xchain-documentation/protocol/constants.js (OP_RETURN_PUSH_OVERHEAD). The +// cross-service regression suite pins this local copy by name against that +// declaration, so the name must match exactly, not just the value. +const OP_RETURN_PUSH_OVERHEAD = 3 +// Explicit-encoding OP_RETURN ceiling: a single nulldata output is 80 bytes, of +// which the 4-byte "XCHN" magic word is reserved, leaving 76 bytes of compiled +// payload. Mirrors XChainEncoder.prepareData (OP_RETURN_SIZE - MAGIC_WORD length), +// which stays the arbiter/backstop; measured pre-compile here only when the caller +// explicitly requests encoding:"OP_RETURN" so an oversize request is rejected as +// -32602 invalid-params before any UTXO reservation, instead of failing post-compile +// as a -32603 internal error. The compiled value this is compared against is +// exactly finalDataBuffer.length in createTransaction (same compiledPushSize sum). +const OP_RETURN_OUTPUT_SIZE = 80 +const OP_RETURN_MAGIC_WORD_LENGTH = 4 +const MAX_OP_RETURN_COMPILED_LENGTH = OP_RETURN_OUTPUT_SIZE - OP_RETURN_MAGIC_WORD_LENGTH // 76 +// Taproot-envelope payload ceiling, per-encoding by design: +// only encoding:"TAPROOT" gets it, every legacy encoding keeps the 8,192-byte +// compiled ceiling (a global raise would multiply the chunked-carrier abuse ceiling +// ~50x for no benefit). Measures the reassembled envelope payload byte length +// after concatenation of the payload pushes, which is byte-identical to the +// compiled action stream (finalDataBuffer) the shipped carriers contain; the +// envelope's own 520-byte push framing is NOT counted. +// DERIVED FROM WEIGHT: the binding limit is Bitcoin Core's +// MAX_STANDARD_TX_WEIGHT of 400,000 WU, not a round byte count. 400,000 payload +// bytes build a 402,789 WU reveal, which is non-standard and unrelayable; the +// true maxima are 397,228 (P2WPKH change) and 397,009 (P2TR change + floor pad). +// 390,000 sits 7,050 WU under the limit in the worst reveal shape. Re-derive it +// from the weight limit if it ever changes; do not pick a number. +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. +// 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 +// amounts do not route here; they go through parseSatoshiAmount's u64 ceiling. +const MAX_FEE_SATOSHIS = 2_100_000_000_000 +// Maximum accepted raw-transaction hex length, in characters. A standard +// BTC/LTC/DOGE transaction tops out around 100 KB (200,000 hex chars), so +// 400,000 chars (a 200 KB transaction) is comfortably above anything the +// platform constructs while still shedding megabyte garbage before it reaches +// Transaction.fromHex / Buffer.from or a coin-node round-trip. The Express +// 1 MB body limit is the outer bound either way; this gives a precise, named +// rejection instead of a node-side parse error. +const MAX_RAW_TX_HEX_LENGTH = 400_000 +// broadcast_tx ceiling, wider than MAX_RAW_TX_HEX_LENGTH on purpose: a signed +// Taproot-envelope reveal carries up to ENVELOPE_MAX_PAYLOAD payload bytes in +// its witness, so the whole transaction runs to ~405 KB (~810,000 hex chars), +// which the old 400,000-char cap would refuse at the last step of the flow. +// 1,000,000 chars (a 500 KB transaction) clears the largest legal reveal with +// headroom while still shedding megabyte garbage. p2shHex deliberately keeps +// the tighter cap: chunked funding transactions never approach it. +const MAX_BROADCAST_TX_HEX_LENGTH = 1_000_000 +const RAW_TX_HEX_RE = /^(?:[0-9a-fA-F]{2})+$/ +// Maximum accepted utxos[].scriptPubKey hex length, in characters. Bitcoin's +// consensus MAX_SCRIPT_SIZE is 10,000 bytes, so 20,000 hex chars is the widest +// a legitimate output script can be; anything larger is garbage and is rejected +// before it reaches Buffer.from(...,'hex') / bitcoin.script.decompile. +const MAX_SCRIPTPUBKEY_HEX_LENGTH = 20_000 +// AUTO is not a carrier: it is the caller's explicit request that the encoder +// pick the smallest-footprint carrier the network and signer support. It +// resolves to one of the others before anything is built. It is an +// OPT-IN precisely because resolving to TAPROOT changes the response shape from +// one PSBT to a commit/reveal pair, which no existing caller expects. +const VALID_ENCODINGS = new Set(['OP_RETURN', 'P2SH', 'MULTISIGN', 'P2WSH', 'TAPROOT', 'AUTO']) +const HEX_64_RE = /^[0-9a-fA-F]{64}$/ +const COMPRESSED_PUBKEY_RE = /^(02|03)[0-9a-fA-F]{64}$/ + +// Canonical ACTION names the decoder accepts (xchain-decoder VALID_ACTION_NAMES, +// XChainDecoder.js). Vendored here byte-for-byte from +// xchain-documentation/protocol/action-manifest.json (wireDecoded slice), the +// same source xchain-decoder/test/fixtures/action-manifest.json vendors from. +// The decoder is the arbiter of which leading ACTION token survives +// decode (both the confirmed-block and mempool gates reject anything not in +// this set, after alias expansion, silently no-actioning the tx); the encoder +// had no equivalent gate, so a typoed or too-new ACTION name would encode, pay +// fees, and broadcast, then vanish silently at decode. Pinned against drift by +// test/unit/action_manifest_conformance.test.js, mirroring the decoder's own +// conformance guard. +const VALID_ACTION_NAMES = new Set([ + 'ADDRESS', 'AIRDROP', 'ANCHOR', 'ATTEST', + 'BATCH', 'BET', 'BROADCAST', 'CALLBACK', 'COINPAY', 'COLLECT', + 'DELEGATE', 'DEPLOY', 'DEPOSIT', 'DESTROY', 'DISPENSER', + 'DIVIDEND', 'EXECUTE', 'FILE', 'ISSUE', 'LINK', 'LIST', 'MESSAGE', 'MINT', + 'NODEPROOF', 'ORDER', 'PRICE', 'ROLLCALL', 'SEND', 'SLASH', 'SLEEP', 'STAKE', + 'SWAP', + 'SWEEP', 'UNSTAKE', 'VOTE', 'WITHDRAW', + // Bridge lock/burn. Mirrors the decoder set: the user-broadcast versions (0, 1, 3, 4) + // must clear this gate to reach a decoder at all, and the mirror-injected settle legs + // (2, 5) are never encoded here because no user may author one. + 'XBRIDGE' +]) + +// Short-form ACTION-name aliases, expanded to canonical form before the +// VALID_ACTION_NAMES gate. Must stay identical to xchain-decoder's +// ACTION_ALIASES (XChainDecoder.js): the decoder is what actually expands +// these on-chain, so the encoder's pre-check has to agree with it exactly or +// it will reject an alias the decoder would have accepted, or (worse) accept a +// typo the decoder does not recognize as an alias. +const ACTION_ALIASES = { + 'TRANSFER': 'SEND', + 'ADDR': 'ADDRESS', + 'DROP': 'AIRDROP', + 'CAST': 'BROADCAST', + 'MSG': 'MESSAGE' +} +// Wire-format ceiling for a satoshi amount: the transaction output value +// field is an unsigned 64-bit integer, so 2^64-1 is the largest amount any +// coin can carry in one output regardless of supply policy. +const MAX_SATOSHI_U64 = 0xffffffffffffffffn +// The create_tx options bag. Unknown keys are refused rather than ignored: a +// misspelled capability that silently means "no" would send an envelope-capable +// 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']) + +module.exports = { + MAX_COMPILED_ACTION_DATA_LENGTH, + OP_RETURN_PUSH_OVERHEAD, + OP_RETURN_OUTPUT_SIZE, + OP_RETURN_MAGIC_WORD_LENGTH, + MAX_OP_RETURN_COMPILED_LENGTH, + ENVELOPE_MAX_PAYLOAD, + MAX_UTXO_COUNT, + MAX_CUSTOM_OUTPUTS, + MAX_FEE_SATOSHIS, + MAX_RAW_TX_HEX_LENGTH, + MAX_BROADCAST_TX_HEX_LENGTH, + RAW_TX_HEX_RE, + MAX_SCRIPTPUBKEY_HEX_LENGTH, + VALID_ENCODINGS, + HEX_64_RE, + COMPRESSED_PUBKEY_RE, + VALID_ACTION_NAMES, + ACTION_ALIASES, + MAX_SATOSHI_U64, + VALID_CREATE_TX_OPTIONS +} diff --git a/src/common/validator/fee_and_utxo_checks.js b/src/common/validator/fee_and_utxo_checks.js new file mode 100644 index 0000000..9d1472c --- /dev/null +++ b/src/common/validator/fee_and_utxo_checks.js @@ -0,0 +1,265 @@ +/********************************************************************* + * + * 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 { + VALID_CREATE_TX_OPTIONS, + MAX_FEE_SATOSHIS, + RAW_TX_HEX_RE, + MAX_SCRIPTPUBKEY_HEX_LENGTH, + HEX_64_RE, + MAX_UTXO_COUNT, + MAX_CUSTOM_OUTPUTS +} = require('./constants') +const { + validateOptionalBoolean, + toExactInt, + parseSatoshiAmount +} = require('./value_checks') +const validateAddress = (...args) => require('./transaction_checks').validateAddress(...args) + +function validateCreateTxOptions(options) { + if (options == null) return null + if (typeof options !== 'object' || Array.isArray(options)) { + throw new TypeError('options must be an object') + } + for (const key of Object.keys(options)) { + if (!VALID_CREATE_TX_OPTIONS.has(key)) { + throw new TypeError(`Unknown options key: "${key}". Valid keys: ${[...VALID_CREATE_TX_OPTIONS].join(', ')}`) + } + } + if (options.signerSupportsTapscript !== undefined) { + validateOptionalBoolean(options.signerSupportsTapscript, 'options.signerSupportsTapscript') + } + // Strict boolean for the same reason rbf is: exactInputs turns UTXO selection + // off, and the string "false" is truthy, so a loose check would spend every + // outpoint in the caller's set when they asked for the opposite. + if (options.exactInputs !== undefined) { + validateOptionalBoolean(options.exactInputs, 'options.exactInputs') + } + return options +} + +function validateFee(fee) { + if (fee == null || fee === false) return null + const num = toExactInt(fee) + if (isNaN(num)) { + throw new TypeError(`fee must be a valid integer, got: ${typeof fee === 'string' ? fee : typeof fee}`) + } + if (num < 0) { + throw new RangeError('fee must be non-negative') + } + if (num > MAX_FEE_SATOSHIS) { + throw new RangeError(`fee (${num}) exceeds maximum (${MAX_FEE_SATOSHIS})`) + } + return num +} + +function validateFeePerKb(feePerKb) { + if (feePerKb == null || feePerKb === false) return null + // Reject types a bare Number() would silently coerce to a plausible rate: + // boolean (true -> 1), array ([50] -> 50), object (-> NaN but still a wrong + // type on a money field). Only a real number or a numeric string is valid. + if (typeof feePerKb !== 'number' && typeof feePerKb !== 'string') { + throw new TypeError('feePerKb must be a finite number') + } + let num + if (typeof feePerKb === 'number') { + num = feePerKb + } else { + // String path: allow only a plain decimal (optionally fractional). This + // rejects hex ('0x20' -> 32), scientific ('1e3' -> 1000), Infinity/NaN + // spellings, and any trailing garbage that Number() would otherwise + // accept, while still permitting a legitimately fractional feePerKb. + const s = feePerKb.trim() + if (!/^-?\d+(\.\d+)?$/.test(s)) { + throw new TypeError('feePerKb must be a finite number') + } + num = Number(s) + } + if (isNaN(num) || !isFinite(num)) { + throw new TypeError('feePerKb must be a finite number') + } + if (num <= 0) { + throw new RangeError('feePerKb must be positive') + } + return num +} + +function validateDust(dust) { + if (dust == null || dust === false) return null + const num = toExactInt(dust) + if (isNaN(num)) { + throw new TypeError('dust must be a valid integer') + } + if (num < 0) { + throw new RangeError('dust must be non-negative') + } + if (num > MAX_FEE_SATOSHIS) { + throw new RangeError(`dust (${num}) exceeds maximum (${MAX_FEE_SATOSHIS})`) + } + return num +} + +function validateUtxoEntry(entry, index) { + // Reject anything that is not a plain object: a string, number, array or + // null here means the caller sent the wrong shape, not a bad field, so + // each UTXO must arrive as its own {txid, vout, value, scriptPubKey} record. + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { + throw new TypeError(`utxos[${index}] must be an object`) + } + // The outpoint's transaction id must be exactly 32 bytes of hex (64 chars); + // anything shorter, longer or non-hex cannot name a real transaction. + if (typeof entry.txid !== 'string' || !HEX_64_RE.test(entry.txid)) { + throw new TypeError(`utxos[${index}].txid must be a 64-character hex string`) + } + // Canonicalize case, because on the OP_RETURN/MULTISIGN path this string IS the + // AES-128-CTR obfuscation key (XChainEncoder.obfuscate splits it with substr), and + // the decoder derives its key from the wire bytes, which render as lowercase hex. + // 'A' (0x41) and 'a' (0x61) are different key bytes, so an accepted mixed-case txid + // could never round-trip; the ins[0] guard then rejected the build as a bogus + // INPUT_SELECTION_RACE. Normalizing here also makes the reservation keys and the + // duplicate filter below compare one form of each outpoint. Mutating in place is + // this function's established contract (vout, value and confirmations already are). + entry.txid = entry.txid.toLowerCase() + // Route vout through toExactInt (rejecting NaN), not bare Number(): on the + // money path a JSON null/''/false/[] all coerce via Number() to a plausible + // index (0), so the encoder would build a PSBT spending txid:0, a different + // outpoint than intended. This matches the value field's exact-integer rigor + // and rejects values that Number() would silently coerce to zero. + const vout = toExactInt(entry.vout) + if (!Number.isInteger(vout) || vout < 0) { + throw new TypeError(`utxos[${index}].vout must be a non-negative integer`) + } + entry.vout = vout + + // allowBig: a DOGE consolidation UTXO can legitimately exceed 2^53-1 sats; + // the tracker emits it as an exact decimal string and the encoder's money + // path carries it as a BigInt. + entry.value = parseSatoshiAmount(entry.value, `utxos[${index}].value`, { allowBig: true }) + + if (typeof entry.scriptPubKey !== 'string' || entry.scriptPubKey.length === 0) { + throw new TypeError(`utxos[${index}].scriptPubKey must be a non-empty string`) + } + // scriptPubKey flows unchecked into Buffer.from(...,'hex') for the PSBT + // witnessUtxo.script and into bitcoin.script.decompile in isSegwitUTXO. + // Buffer.from(x,'hex') is lenient: it stops at the first invalid character + // and drops a trailing odd nibble, so a malformed value ('zz', odd length) + // silently yields a truncated script and misclassifies the input. Anchor it + // to even-length hex with a sane bound, matching the txid/rawTxHex rigor. + if (entry.scriptPubKey.length > MAX_SCRIPTPUBKEY_HEX_LENGTH) { + throw new RangeError(`utxos[${index}].scriptPubKey exceeds maximum length (${MAX_SCRIPTPUBKEY_HEX_LENGTH})`) + } + if (!RAW_TX_HEX_RE.test(entry.scriptPubKey)) { + throw new TypeError(`utxos[${index}].scriptPubKey must be an even-length hex string`) + } + if (entry.confirmations == null) { + entry.confirmations = 0 + } else { + // Validate like vout: the unconfirmed-UTXO filter in XChainEncoder + // compares `confirmations == 0` with loose equality, so an untyped + // value (string, float, object) could let a mempool input slip past + // the exclusion when unconfirmed=false. Coerce to a real number and + // range-check so the downstream comparison always sees an integer. + const confirmations = toExactInt(entry.confirmations) + if (!Number.isInteger(confirmations) || confirmations < 0) { + throw new TypeError(`utxos[${index}].confirmations must be a non-negative integer`) + } + entry.confirmations = confirmations + } + return entry +} + +function validateUtxoArray(utxos) { + if (utxos == null) return null + if (!Array.isArray(utxos)) { + throw new TypeError('utxos must be an array') + } + if (utxos.length > MAX_UTXO_COUNT) { + throw new RangeError(`utxos array length (${utxos.length}) exceeds maximum (${MAX_UTXO_COUNT})`) + } + for (let i = 0; i < utxos.length; i++) { + validateUtxoEntry(utxos[i], i) + } + return utxos +} + +function validateCustomOutput(output, index) { + if (typeof output !== 'object' || output === null || Array.isArray(output)) { + throw new TypeError(`customOutputs[${index}] must be an object`) + } + validateAddress(output.address, `customOutputs[${index}].address`) + // allowBig: a >2^53-1-sat DOGE payment output is legitimate; it must be + // supplied as an exact decimal string. + output.value = parseSatoshiAmount(output.value, `customOutputs[${index}].value`, { allowBig: true }) + // 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 + // 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. + if (output.value <= 0) { + throw new RangeError(`customOutputs[${index}].value must be a positive integer (satoshis)`) + } + return output +} + +function validateCustomOutputs(customOutputs) { + if (customOutputs == null) return null + if (!Array.isArray(customOutputs)) { + throw new TypeError('customOutputs must be an array') + } + if (customOutputs.length > MAX_CUSTOM_OUTPUTS) { + throw new RangeError(`customOutputs length (${customOutputs.length}) exceeds maximum (${MAX_CUSTOM_OUTPUTS})`) + } + for (let i = 0; i < customOutputs.length; i++) { + validateCustomOutput(customOutputs[i], i) + } + return customOutputs +} + +function validateFeeQuote(feeQuote) { + if (feeQuote == null) return null + // Array.isArray, because typeof [] is 'object': a JSON array cleared this gate + // and died one line later on validateAddress(undefined), reporting an address + // error for what is a shape error. Same guard the other object-shaped + // validators carry (lines 481/557/635). No === null clause: the line above + // already returns for a null/absent quote, which is legal. + if (typeof feeQuote !== 'object' || Array.isArray(feeQuote)) { + throw new TypeError('feeQuote must be an object with address and amount') + } + validateAddress(feeQuote.address, 'feeQuote.address') + const amount = toExactInt(feeQuote.amount) + if (isNaN(amount) || amount <= 0) { + throw new RangeError('feeQuote.amount must be a positive integer (satoshis)') + } + if (amount > MAX_FEE_SATOSHIS) { + throw new RangeError('feeQuote.amount exceeds maximum (' + MAX_FEE_SATOSHIS + ')') + } + feeQuote.amount = amount + return feeQuote +} + +module.exports = { + validateCreateTxOptions, + validateFee, + validateFeePerKb, + validateDust, + validateUtxoEntry, + validateUtxoArray, + validateCustomOutput, + validateCustomOutputs, + validateFeeQuote +} diff --git a/src/common/validator/transaction_checks.js b/src/common/validator/transaction_checks.js new file mode 100644 index 0000000..9693361 --- /dev/null +++ b/src/common/validator/transaction_checks.js @@ -0,0 +1,244 @@ +/********************************************************************* + * + * 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 { + MAX_RAW_TX_HEX_LENGTH, + RAW_TX_HEX_RE, + HEX_64_RE, + MAX_BROADCAST_TX_HEX_LENGTH, + COMPRESSED_PUBKEY_RE +} = require('./constants') +const { + validateOptionalBoolean, + validatePubkey +} = require('./value_checks') +const { + validateDataParam, + validateActionPushDecodability, + validateActionName, + validateCombinedDataLength, + validateEncoding +} = require('./action_data_checks') +const { + validateCreateTxOptions, + validateFee, + validateFeePerKb, + validateDust, + validateUtxoArray, + validateCustomOutputs, + validateFeeQuote +} = require('./fee_and_utxo_checks') + +function validateP2shParams(p2shHash, p2shHex) { + const hasHash = (p2shHash != null && p2shHash !== false) + const hasHex = (p2shHex != null && p2shHex !== false) + + if (!hasHash && !hasHex) return { p2shHash: null, p2shHex: null } + if (hasHash !== hasHex) { + throw new TypeError('p2shHash and p2shHex must both be provided or both omitted') + } + if (typeof p2shHash !== 'string' || !HEX_64_RE.test(p2shHash)) { + throw new TypeError('p2shHash must be a 64-character hex string') + } + if (typeof p2shHex !== 'string' || p2shHex.length === 0) { + throw new TypeError('p2shHex must be a non-empty hex string') + } + if (p2shHex.length > MAX_RAW_TX_HEX_LENGTH) { + throw new TypeError('p2shHex exceeds maximum length (' + MAX_RAW_TX_HEX_LENGTH + ')') + } + if (!RAW_TX_HEX_RE.test(p2shHex)) { + throw new TypeError('p2shHex must be an even-length hex string') + } + return { p2shHash, p2shHex } +} + +// Raw signed-transaction hex for broadcast_tx. The node would reject malformed +// hex anyway; rejecting here sheds obvious garbage before the round-trip and +// returns a -32602 with a precise reason instead of a node parse error. +function validateRawTxHex(txHex) { + if (typeof txHex !== 'string' || txHex.length === 0) { + throw new TypeError('tx_hex must be a non-empty hex string') + } + if (txHex.length > MAX_BROADCAST_TX_HEX_LENGTH) { + throw new TypeError('tx_hex exceeds maximum length (' + MAX_BROADCAST_TX_HEX_LENGTH + ')') + } + if (!RAW_TX_HEX_RE.test(txHex)) { + throw new TypeError('tx_hex must be an even-length hex string') + } + return txHex +} + +function validateCompressedPubKey(compressedPubKey) { + if (compressedPubKey == null) return null + if (typeof compressedPubKey !== 'string' || !COMPRESSED_PUBKEY_RE.test(compressedPubKey)) { + throw new TypeError('compressedPubKey must be a 66-character hex string starting with 02 or 03') + } + return compressedPubKey +} + +// Shape-only address check (non-empty string, capped at 100 chars). Shared +// core for every address-shaped field: the get_utxos address param, +// validateChange, validatePubkey (params.pubkey is actually a sender +// address, not a real pubkey), and customOutputs[].address / feeQuote.address. +// Coin-specific base58/bech32 validity is left to bitcoinjs downstream; this +// just sheds obvious garbage (non-strings, empty, oversized) with a +// -32602-mappable TypeError. `label` names the field in the thrown message; +// callers that omit it keep the plain "address ..." wording. +function validateAddress(address, label = 'address') { + if (typeof address !== 'string' || address.length === 0) { + throw new TypeError(`${label} must be a non-empty string`) + } + if (address.length > 100) { + throw new TypeError(`${label} exceeds maximum length (100)`) + } + return address +} + +// Reservation-ticket id for release_inputs: exactly the 16 random bytes +// mintReservationTicket emits, lowercase hex. Shape-checked here so a +// non-string, an oversized blob or a wrong-length id is a precise -32602 rather +// than reaching the encoder, and so the id can never be used as an unbounded +// key into the ticket map. +function validateReservationId(reservationId) { + if (typeof reservationId !== 'string' || !/^[0-9a-f]{32}$/.test(reservationId)) { + throw new TypeError('reservationId must be a 32-character lowercase hex string, as returned in create_tx result.reservation.id') + } + return reservationId +} + +function validateChange(change) { + if (change == null) return null + return validateAddress(change) +} + + +/** + * Validate and coerce every createTransaction parameter in one pass. + * @param {object} params - the raw JSON-RPC params object for create_tx. + * @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) { + // 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 + // object-shaped validators below carry (lines 484/566/644/686). + if (typeof params !== 'object' || params === null || Array.isArray(params)) { + throw new TypeError('Request params must be an object') + } + + const data = validateDataParam(params.data, 'data') + const rawData = validateDataParam(params.rawData, 'rawData') + // Resolve encoding before the length check so validateCombinedDataLength can + // apply the per-encoding (OP_RETURN) ceiling pre-compile. + const encoding = validateEncoding(params.encoding) + if (data != null || rawData != null) { + validateCombinedDataLength(data, rawData, encoding) + validateActionPushDecodability(data, rawData) + validateActionName(data) + } + + 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 feeQuote = validateFeeQuote(params.feeQuote) + const { p2shHash, p2shHex } = validateP2shParams(params.p2shHash, params.p2shHex) + const compressedPubKey = validateCompressedPubKey(params.compressedPubKey) + const change = validateChange(params.change) + + // MULTISIGN packs the caller's real pubkey as the 3rd fake-multisig pubkey, so it is + // required. Without it the encoder reaches `Buffer.from(compressedPubKey, 'hex')` with + // null and throws an opaque deep error; reject up front with a precise reason instead. + if (encoding === 'MULTISIGN' && compressedPubKey == null) { + throw new TypeError('compressedPubKey is required for MULTISIGN encoding') + } + + // The Taproot-envelope leaf ends with OP_CHECKSIG + // and the commit's key-path cancel needs the same key, so the caller's real + // pubkey is required (pubkey may be a bare address). Mirrors MULTISIGN. + if (encoding === 'TAPROOT' && compressedPubKey == null) { + throw new TypeError('compressedPubKey is required for TAPROOT encoding (it becomes the envelope internal key)') + } + + // TAPROOT is a single-call flow: create_tx returns the {commit, reveal} + // PSBT pair together, pre-built against the unsigned + // commit's stable txid. The p2shHash/p2shHex second-call reveal flow is a + // chunked reveal concept and must not engage here. + if (encoding === 'TAPROOT' && p2shHash != null) { + throw new TypeError('TAPROOT encoding does not use the p2shHash reveal flow; one create_tx call returns the commit and reveal PSBTs together') + } + + // docs/openrpc.json marks pubkey required:true. A missing pubkey otherwise + // reaches bitcoin.address.fromBase58Check(null) and leaks a library + // "Expected String" as a -32603 internal error. Reject up front (RangeError, + // so api.js maps it to -32602 invalid-params), matching the + // MULTISIGN/compressedPubKey presence check above. + if (pubkey == null) { + throw new RangeError('pubkey is required') + } + + // rbf and unconfirmed must be real JSON booleans when explicitly provided. + // Truthiness coercion here is a policy flip on a money path: Boolean("false") + // is true, so a stringy-boolean client asking to EXCLUDE mempool coins would + // silently have them selected (and rbf "false" would arm replace-by-fee). + // Absent/null params stay undefined (NOT false) and inherit createTransaction's + // defaults downstream: unconfirmed defaults to true (unconfirmed UTXOs are + // selectable), rbf defaults to falsy. Do not "fix" the code to force false + // here, that would silently flip the UTXO-selection policy on a money path. + const rbf = validateOptionalBoolean(params.rbf, 'rbf') + const unconfirmed = validateOptionalBoolean(params.unconfirmed, 'unconfirmed') + + // Attach each segwit input's FULL previous transaction alongside + // its witnessUtxo. Off by default because it costs one node round trip per + // input plus the prev tx's bytes in every copy of the PSBT, and only a + // hardware signer needs it: Ledger derives the outpoint it signs from the + // prev tx it is handed, so a witnessUtxo-only input cannot be signed on a + // device at all. A synthesized prev tx produces a valid-looking signature + // over an outpoint that does not exist. + const attachPrevTx = validateOptionalBoolean(params.attachPrevTx, 'attachPrevTx') + + // Transparent FILE payload compression. TRI-STATE: absent means "use the + // deployment default" (currently ON), and an explicit true/false is the + // caller's own choice. An explicit true that + // cannot be honoured is an error; the default pass just rides raw. + const compress = validateOptionalBoolean(params.compress, 'compress') + + // Signer capability for AUTO selection. Absent means "cannot sign a + // tapscript spend", the fail-closed direction: the reveal must be signable + // before the commit is broadcast, so an unaffirmed signer never gets the + // envelope. Kept in an options bag rather than as another positional + // parameter, so the next capability does not grow the signature again. + const options = validateCreateTxOptions(params.options) + + return { + utxos, pubkey, customOutputs, data, rawData, fee, rbf, + encoding, change, p2shHash, p2shHex, compressedPubKey, + unconfirmed, feePerKb, dust, feeQuote, attachPrevTx, compress, options + } +} + +module.exports = { + validateP2shParams, + validateRawTxHex, + validateCompressedPubKey, + validateAddress, + validateReservationId, + validateChange, + validateAll +} diff --git a/src/common/validator/value_checks.js b/src/common/validator/value_checks.js new file mode 100644 index 0000000..a442010 --- /dev/null +++ b/src/common/validator/value_checks.js @@ -0,0 +1,121 @@ +/********************************************************************* + * + * 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 { MAX_SATOSHI_U64 } = require('./constants') +const validateAddress = (...args) => require('./transaction_checks').validateAddress(...args) + +// 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 +// to a wrong value on the money path. Returns the exact integer, or NaN if +// `raw` is not an exact integer representation. Sign and range bounds are left +// to the caller so each validator keeps its own error type and message. +function toExactInt(raw) { + if (typeof raw === 'number') { + return Number.isInteger(raw) ? raw : NaN + } + if (typeof raw === 'string' && /^-?\d+$/.test(raw.trim())) { + return Number(raw.trim()) + } + return NaN +} + +// Strict optional-boolean validator for policy flags (rbf, unconfirmed). +// undefined/null pass through as undefined so downstream defaults apply; a +// provided value must be a real boolean. TypeError so api.js maps it to +// -32602 invalid-params, like the money/hex validators. +function validateOptionalBoolean(raw, label) { + if (raw === undefined || raw === null) return undefined + if (typeof raw !== 'boolean') { + throw new TypeError(`${label} must be a boolean (got ${typeof raw}${typeof raw === 'string' ? ` "${raw}"` : ''}); string "false" would coerce to true`) + } + return raw +} +// Parse a satoshi amount, rejecting any value that cannot be represented +// exactly. The utxo-tracker emits full-precision decimal strings +// (readBigUInt64BE().toString()), so a 64-bit value reaches the encoder +// intact; a JS Number above Number.MAX_SAFE_INTEGER (2^53-1 ~= 9.007e15 +// sats) cannot hold it exactly. For BTC/LTC this is unreachable (21M cap = +// 2.1e15 sats), but DOGE has no supply cap, so a single output can exceed +// it (>~90.07M DOGE). Fields that may legitimately carry such amounts +// (utxos[].value, customOutputs[].value) opt in with {allowBig: true}: an +// exact decimal STRING above 2^53-1 then parses to a BigInt (up to the u64 +// wire ceiling); a Number above 2^53-1 is still rejected because it was +// already rounded before it reached us (JSON.parse or caller arithmetic). +// Without allowBig the original fail-closed behavior is unchanged. +function parseSatoshiAmount(raw, label, opts) { + const allowBig = !!(opts && opts.allowBig) + // Already-coerced BigInt (validateUtxoEntry/validateCustomOutput ran + // first; createTransaction re-parses defensively): re-validate in place. + if (typeof raw === 'bigint') { + if (raw < 0n) { + throw new RangeError(`${label} must be a non-negative integer`) + } + if (raw <= BigInt(Number.MAX_SAFE_INTEGER)) { + return Number(raw) + } + if (!allowBig) { + throw new RangeError(`${label} (${raw}) exceeds the maximum safe satoshi amount (${Number.MAX_SAFE_INTEGER}) and cannot be represented without precision loss`) + } + if (raw > MAX_SATOSHI_U64) { + throw new RangeError(`${label} (${raw}) exceeds the maximum 64-bit satoshi amount (${MAX_SATOSHI_U64})`) + } + return raw + } + const num = toExactInt(raw) + if (isNaN(num) || num < 0) { + throw new RangeError(`${label} must be a non-negative integer`) + } + if (Number.isSafeInteger(num)) { + return num + } + if (allowBig && typeof raw === 'string') { + // toExactInt already guaranteed /^-?\d+$/ and the num<0 check above + // guaranteed non-negative, so this BigInt parse cannot throw. + const big = BigInt(raw.trim()) + if (big > MAX_SATOSHI_U64) { + throw new RangeError(`${label} (${raw.trim()}) exceeds the maximum 64-bit satoshi amount (${MAX_SATOSHI_U64})`) + } + return big + } + throw new RangeError(`${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' : ''}`) +} + +// params.pubkey is not a real pubkey: it is the caller's base58 sender +// address (fed straight to utxoTrackerConnector.getUtxosFromAddress and to +// bitcoin.address.fromBase58Check in XChainEncoder.js). It must be held to +// the same shared bound as every other address-shaped field, so this +// delegates to validateAddress (defined below) rather than keeping its own +// private, looser cap. Only the null-passthrough semantics differ from +// validateAddress, since pubkey is validated for presence separately in +// validateAll. +function validatePubkey(pubkey) { + if (pubkey == null) return null + return validateAddress(pubkey, 'pubkey') +} + +// Index of the first code unit a latin-1 byte cannot hold, or -1. Written as a scan +// rather than a regex so the source carries no literal high or control characters. +function firstNonLatin1(str) { + for (let i = 0; i < str.length; i++) if (str.charCodeAt(i) > 0xFF) return i + return -1 +} + +module.exports = { + toExactInt, + validateOptionalBoolean, + parseSatoshiAmount, + validatePubkey, + firstNonLatin1 +} From aae5af4f8e150558aa50526fa48001d5ce526987 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 17:09:01 -0700 Subject: [PATCH 058/148] refactor(validator): extract UTXO entry and validateAll helpers Split validateUtxoEntry and validateAll into named helpers so both functions and their modules stay within the size limits, with no behaviour change. --- src/common/validator/fee_and_utxo_checks.js | 9 +++- src/common/validator/transaction_checks.js | 59 +++++++++++++++------ 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/src/common/validator/fee_and_utxo_checks.js b/src/common/validator/fee_and_utxo_checks.js index 9d1472c..c30415b 100644 --- a/src/common/validator/fee_and_utxo_checks.js +++ b/src/common/validator/fee_and_utxo_checks.js @@ -111,7 +111,7 @@ function validateDust(dust) { return num } -function validateUtxoEntry(entry, index) { +function validateUtxoOutpoint(entry, index) { // Reject anything that is not a plain object: a string, number, array or // null here means the caller sent the wrong shape, not a bad field, so // each UTXO must arrive as its own {txid, vout, value, scriptPubKey} record. @@ -142,7 +142,9 @@ function validateUtxoEntry(entry, index) { throw new TypeError(`utxos[${index}].vout must be a non-negative integer`) } entry.vout = vout +} +function validateUtxoValueAndScript(entry, index) { // allowBig: a DOGE consolidation UTXO can legitimately exceed 2^53-1 sats; // the tracker emits it as an exact decimal string and the encoder's money // path carries it as a BigInt. @@ -177,6 +179,11 @@ function validateUtxoEntry(entry, index) { } entry.confirmations = confirmations } +} + +function validateUtxoEntry(entry, index) { + validateUtxoOutpoint(entry, index) + validateUtxoValueAndScript(entry, index) return entry } diff --git a/src/common/validator/transaction_checks.js b/src/common/validator/transaction_checks.js index 9693361..2461870 100644 --- a/src/common/validator/transaction_checks.js +++ b/src/common/validator/transaction_checks.js @@ -123,23 +123,7 @@ function validateChange(change) { } -/** - * Validate and coerce every createTransaction parameter in one pass. - * @param {object} params - the raw JSON-RPC params object for create_tx. - * @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) { - // 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 - // object-shaped validators below carry (lines 484/566/644/686). - if (typeof params !== 'object' || params === null || Array.isArray(params)) { - throw new TypeError('Request params must be an object') - } - +function validateActionFields(params) { const data = validateDataParam(params.data, 'data') const rawData = validateDataParam(params.rawData, 'rawData') // Resolve encoding before the length check so validateCombinedDataLength can @@ -150,7 +134,10 @@ function validateAll(params) { validateActionPushDecodability(data, rawData) validateActionName(data) } + return { data, rawData, encoding } +} +function validatePaymentFields(params) { const pubkey = validatePubkey(params.pubkey) const fee = validateFee(params.fee) const feePerKb = validateFeePerKb(params.feePerKb) @@ -161,7 +148,13 @@ function validateAll(params) { const { p2shHash, p2shHex } = validateP2shParams(params.p2shHash, params.p2shHex) const compressedPubKey = validateCompressedPubKey(params.compressedPubKey) const change = validateChange(params.change) + return { + pubkey, fee, feePerKb, dust, utxos, customOutputs, feeQuote, + p2shHash, p2shHex, compressedPubKey, change + } +} +function validateEncodingRequirements(encoding, compressedPubKey, p2shHash, pubkey) { // MULTISIGN packs the caller's real pubkey as the 3rd fake-multisig pubkey, so it is // required. Without it the encoder reaches `Buffer.from(compressedPubKey, 'hex')` with // null and throws an opaque deep error; reject up front with a precise reason instead. @@ -192,7 +185,9 @@ function validateAll(params) { if (pubkey == null) { throw new RangeError('pubkey is required') } +} +function validatePolicyFields(params) { // rbf and unconfirmed must be real JSON booleans when explicitly provided. // Truthiness coercion here is a policy flip on a money path: Boolean("false") // is true, so a stringy-boolean client asking to EXCLUDE mempool coins would @@ -226,6 +221,36 @@ function validateAll(params) { // parameter, so the next capability does not grow the signature again. const options = validateCreateTxOptions(params.options) + return { rbf, unconfirmed, attachPrevTx, compress, options } +} + +/** + * Validate and coerce every createTransaction parameter in one pass. + * @param {object} params - the raw JSON-RPC params object for create_tx. + * @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) { + // 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 + // object-shaped validators below carry (lines 484/566/644/686). + if (typeof params !== 'object' || params === null || Array.isArray(params)) { + throw new TypeError('Request params must be an object') + } + + const { data, rawData, encoding } = validateActionFields(params) + const { + pubkey, fee, feePerKb, dust, utxos, customOutputs, feeQuote, + p2shHash, p2shHex, compressedPubKey, change + } = validatePaymentFields(params) + validateEncodingRequirements(encoding, compressedPubKey, p2shHash, pubkey) + const { + rbf, unconfirmed, attachPrevTx, compress, options + } = validatePolicyFields(params) + return { utxos, pubkey, customOutputs, data, rawData, fee, rbf, encoding, change, p2shHash, p2shHex, compressedPubKey, From 1a3894b678550ee2770fab88c17f508cd8c554a7 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:13 -0700 Subject: [PATCH 059/148] test: split encoding types regression suite into same-title blocks by behavior --- test/regression/reg_01_encoding_types.test.js | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/regression/reg_01_encoding_types.test.js b/test/regression/reg_01_encoding_types.test.js index 22ec916..4876367 100644 --- a/test/regression/reg_01_encoding_types.test.js +++ b/test/regression/reg_01_encoding_types.test.js @@ -72,7 +72,11 @@ describe('REG-01: Core Encoding Types', function () { assert.ok(opReturnOutput, 'should have an OP_RETURN output') assert.strictEqual(opReturnOutput.script[0], bitcoin.opcodes.OP_RETURN) }) + }) +}) +describe('REG-01: Core Encoding Types', function () { + describe('REG-01.1: OP_RETURN', function () { it('deobfuscated payload starts with XCHN magic', async function () { const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') @@ -105,7 +109,9 @@ describe('REG-01: Core Encoding Types', function () { assert.strictEqual(result.encoding, 'OP_RETURN') }) }) +}) +describe('REG-01: Core Encoding Types', function () { describe('REG-01.2: P2SH', function () { it('large ISSUE payload auto-selects P2SH', async function () { const encoder = makeEncoder('dogecoin-regtest') @@ -144,7 +150,11 @@ describe('REG-01: Core Encoding Types', function () { assert.ok(Buffer.isBuffer(d[1]) && d[1].length === 20, 'hash should be 20 bytes') assert.strictEqual(d[2], bitcoin.opcodes.OP_EQUAL) }) + }) +}) +describe('REG-01: Core Encoding Types', function () { + describe('REG-01.2: P2SH', function () { it('P2SH output value >= dustAmount', async function () { const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') @@ -184,7 +194,9 @@ describe('REG-01: Core Encoding Types', function () { assert.strictEqual(result.encoding, 'P2SH') }) }) +}) +describe('REG-01: Core Encoding Types', function () { describe('REG-01.3: P2WSH', function () { it('P2WSH encoding accepted on bitcoin-regtest', async function () { const encoder = makeEncoder('bitcoin-regtest') @@ -238,7 +250,9 @@ describe('REG-01: Core Encoding Types', function () { ) }) }) +}) +describe('REG-01: Core Encoding Types', function () { describe('REG-01.4: MULTISIGN', function () { it('MULTISIGN produces 1-of-3 multisig output', async function () { const encoder = makeEncoder('dogecoin-regtest') @@ -265,7 +279,11 @@ describe('REG-01: Core Encoding Types', function () { assert.strictEqual(d[4], bitcoin.opcodes.OP_3) assert.strictEqual(d[5], bitcoin.opcodes.OP_CHECKMULTISIG) }) + }) +}) +describe('REG-01: Core Encoding Types', function () { + describe('REG-01.4: MULTISIGN', function () { it('third pubkey is the compressedPubKey argument', async function () { const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') @@ -299,7 +317,9 @@ describe('REG-01: Core Encoding Types', function () { assert.strictEqual(payload.magic, MAGIC_WORD) }) }) +}) +describe('REG-01: Core Encoding Types', function () { describe('REG-01.5: Return shape', function () { it('result is { psbt, encoding } with Psbt instance', async function () { const encoder = makeEncoder('dogecoin-regtest') From bb7e5d9f8a5f91689e9cdf22c2a3f84f0c1a1a9c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:13 -0700 Subject: [PATCH 060/148] test: split obfuscation regression suite into same-title blocks by behavior --- test/regression/reg_02_obfuscation.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/regression/reg_02_obfuscation.test.js b/test/regression/reg_02_obfuscation.test.js index d2161fb..730b916 100644 --- a/test/regression/reg_02_obfuscation.test.js +++ b/test/regression/reg_02_obfuscation.test.js @@ -82,7 +82,9 @@ describe('REG-02: Obfuscation Round-Trip', function () { assert.strictEqual(decrypted.toString('utf8'), 'XCHNTEST') }) }) +}) +describe('REG-02: Obfuscation Round-Trip', function () { describe('REG-02.2: TXID sensitivity', function () { it('same ACTION with different TXIDs produces different ciphertext', async function () { const encoder = makeEncoder(NETWORK) @@ -101,7 +103,9 @@ describe('REG-02: Obfuscation Round-Trip', function () { assert.notStrictEqual(prefix, MAGIC_WORD, 'wrong key should not produce XCHN prefix') }) }) +}) +describe('REG-02: Obfuscation Round-Trip', function () { describe('REG-02.3: UTXO sort determines obfuscation key', function () { it('largest UTXO TXID is always the obfuscation key', async function () { const encoder = makeEncoder(NETWORK) @@ -143,7 +147,9 @@ describe('REG-02: Obfuscation Round-Trip', function () { assert.strictEqual(payload.magic, MAGIC_WORD) }) }) +}) +describe('REG-02: Obfuscation Round-Trip', function () { describe('REG-02.4: P2SH/P2WSH marker strings', function () { it('P2SH tx2 OP_RETURN marker deobfuscates to XCHNp2sh', async function () { const encoder = makeEncoder(NETWORK) From 37ea9436e30dc73626f4ea7f7be49277eb2d57ce Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:13 -0700 Subject: [PATCH 061/148] test: split fee and UTXO regression suite into same-title blocks by behavior --- test/regression/reg_03_fee_utxo.test.js | 48 ++++++++++++------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/test/regression/reg_03_fee_utxo.test.js b/test/regression/reg_03_fee_utxo.test.js index 1c9c34d..f65685a 100644 --- a/test/regression/reg_03_fee_utxo.test.js +++ b/test/regression/reg_03_fee_utxo.test.js @@ -47,7 +47,6 @@ describe('REG-03: Fee & UTXO Selection', function () { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) const action = actions.makeSend() - const utxoSmall = makeUtxo(NETWORK, TXID_A, 0, 10000) const utxoLarge = makeUtxo(NETWORK, TXID_B, 0, 100000000) @@ -56,7 +55,6 @@ describe('REG-03: Fee & UTXO Selection', function () { action.data, null, 10000, false, null, address, null, null, null, true, 0.00001 ) - // First input should be the larger UTXO (TXID_B) const firstInputHash = result.psbt.txInputs[0].hash.reverse().toString('hex') assert.strictEqual(firstInputHash, TXID_B) @@ -66,7 +64,6 @@ describe('REG-03: Fee & UTXO Selection', function () { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) const action = actions.makeSend() - const utxoLarge = makeUtxo(NETWORK, TXID_A, 0, 100000000) const utxoSmall = makeUtxo(NETWORK, TXID_B, 0, 1000) @@ -79,7 +76,10 @@ describe('REG-03: Fee & UTXO Selection', function () { // Only 1 input needed since large UTXO covers everything assert.strictEqual(result.psbt.txInputs.length, 1) }) - + }) +}) +describe('REG-03: Fee & UTXO Selection', function () { + describe('REG-03.1: UTXO selection order', function () { it('adds UTXOs until inputs cover outputs + fee', async function () { const encoder = makeEncoder(NETWORK) // The oversized explicit fee forces multi-UTXO selection; it would trip @@ -92,7 +92,6 @@ describe('REG-03: Fee & UTXO Selection', function () { const utxo1 = makeUtxo(NETWORK, TXID_A, 0, 100000) const utxo2 = makeUtxo(NETWORK, TXID_B, 0, 100000) const utxo3 = makeUtxo(NETWORK, TXID_C, 0, 100000) - // The burn backstop is 100x the NODE's fair fee, independent of both // maxFeeRateMultiplier and any caller feePerKb (a caller-inflated rate // must never lift it). Raise the node's own estimate so the backstop @@ -109,13 +108,14 @@ describe('REG-03: Fee & UTXO Selection', function () { assert.ok(result.psbt.txInputs.length >= 2, 'should use multiple UTXOs') }) }) +}) +describe('REG-03: Fee & UTXO Selection', function () { describe('REG-03.2: Deduplication', function () { it('duplicate txid+vout entries collapsed to one input', async function () { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) const action = actions.makeSend() - const utxo1 = makeUtxo(NETWORK, TXID_A, 0, 100000000) const utxo2 = makeUtxo(NETWORK, TXID_A, 0, 100000000) // duplicate @@ -124,11 +124,12 @@ describe('REG-03: Fee & UTXO Selection', function () { action.data, null, 10000, false, null, address, null, null, null, true, 0.00001 ) - assert.strictEqual(result.psbt.txInputs.length, 1) }) }) +}) +describe('REG-03: Fee & UTXO Selection', function () { describe('REG-03.3: Unconfirmed filtering', function () { it('mempool UTXOs excluded when unconfirmed=false', async function () { const encoder = makeEncoder(NETWORK) @@ -137,7 +138,6 @@ describe('REG-03: Fee & UTXO Selection', function () { const confirmedUtxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const mempoolUtxo = makeMempoolUtxo(TXID_B, 0, 200000000) - const result = await encoder.createTransaction( [confirmedUtxo, mempoolUtxo], address, null, action.data, null, 10000, false, null, address, @@ -149,7 +149,6 @@ describe('REG-03: Fee & UTXO Selection', function () { const inputHash = result.psbt.txInputs[0].hash.reverse().toString('hex') assert.strictEqual(inputHash, TXID_A) }) - it('mempool UTXOs included when unconfirmed=true', async function () { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) @@ -163,13 +162,14 @@ describe('REG-03: Fee & UTXO Selection', function () { action.data, null, 10000, false, null, address, null, null, null, true, 0.00001 ) - // Largest UTXO (mempool) should be first input const firstHash = result.psbt.txInputs[0].hash.reverse().toString('hex') assert.strictEqual(firstHash, TXID_B) }) }) +}) +describe('REG-03: Fee & UTXO Selection', function () { describe('REG-03.4: Fee calculation', function () { it('explicit fee parameter is used verbatim', async function () { const encoder = makeEncoder(NETWORK) @@ -179,7 +179,6 @@ describe('REG-03: Fee & UTXO Selection', function () { const address = getTestAddress(NETWORK) const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() - // The burn backstop is 100x the NODE's fair fee, independent of both // maxFeeRateMultiplier and any caller feePerKb (a caller-inflated rate // must never lift it). Raise the node's own estimate so the backstop @@ -198,7 +197,11 @@ describe('REG-03: Fee & UTXO Selection', function () { const impliedFee = 100000000 - totalOutput assert.strictEqual(impliedFee, 50000) }) + }) +}) +describe('REG-03: Fee & UTXO Selection', function () { + describe('REG-03.4: Fee calculation', function () { it('feePerKb parameter bypasses RPC getFeePerKilobyte call', async function () { const encoder = makeEncoder(NETWORK) // Override connector to throw if called @@ -208,7 +211,6 @@ describe('REG-03: Fee & UTXO Selection', function () { const address = getTestAddress(NETWORK) const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() - // Should NOT throw because feePerKb is provided const result = await encoder.createTransaction( [utxo], address, null, @@ -218,7 +220,6 @@ describe('REG-03: Fee & UTXO Selection', function () { assert.ok(result.psbt, 'should produce a PSBT without calling getFeePerKilobyte') }) - it('fee is floored to network dustAmount', async function () { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) @@ -231,14 +232,17 @@ describe('REG-03: Fee & UTXO Selection', function () { action.data, null, null, false, null, address, null, null, null, true, 0.0000001 ) - const outputs = result.psbt.txOutputs const totalOutput = outputs.reduce((sum, o) => sum + o.value, 0) const impliedFee = 100000000 - totalOutput assert.ok(impliedFee >= encoder.dustAmount, `fee ${impliedFee} should be >= dust ${encoder.dustAmount}`) }) + }) +}) +describe('REG-03: Fee & UTXO Selection', function () { + describe('REG-03.4: Fee calculation', function () { it('maxFeeRateKb cap produces lower fee than uncapped encoder', async function () { const address = getTestAddress(NETWORK) const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) @@ -251,7 +255,6 @@ describe('REG-03: Fee & UTXO Selection', function () { action.data, null, null, false, null, address, null, null, null, true, 100000000 // 1e8 sat/kB = 100000 sat/byte (very high) ) - // Capped encoder (maxFeeRateKb = 1000 sat/kB) const capped = new XChainEncoder( NETWORK, '127.0.0.1', '8333', 'rpc', 'rpc', '', '', 1000 @@ -267,7 +270,6 @@ describe('REG-03: Fee & UTXO Selection', function () { action.data, null, null, false, null, address, null, null, null, true, 100000000 // same high rate, but capped ) - const uncappedOut = resultUncapped.psbt.txOutputs.reduce((s, o) => s + o.value, 0) const cappedOut = resultCapped.psbt.txOutputs.reduce((s, o) => s + o.value, 0) // Capped should return more change (lower fee) @@ -275,7 +277,9 @@ describe('REG-03: Fee & UTXO Selection', function () { `capped output ${cappedOut} should be > uncapped ${uncappedOut}`) }) }) +}) +describe('REG-03: Fee & UTXO Selection', function () { describe('REG-03.5: Change output', function () { it('change output value = input - outputs - fee', async function () { const encoder = makeEncoder(NETWORK) @@ -289,7 +293,6 @@ describe('REG-03: Fee & UTXO Selection', function () { action.data, null, explicitFee, false, null, address, null, null, null, true, 0.00001 ) - const outputs = result.psbt.txOutputs const totalOutput = outputs.reduce((sum, o) => sum + o.value, 0) assert.strictEqual(100000000 - totalOutput, explicitFee) @@ -300,7 +303,6 @@ describe('REG-03: Fee & UTXO Selection', function () { const address = getTestAddress(NETWORK) const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() - await assert.rejects( () => encoder.createTransaction( [utxo], address, null, @@ -311,7 +313,9 @@ describe('REG-03: Fee & UTXO Selection', function () { ) }) }) +}) +describe('REG-03: Fee & UTXO Selection', function () { describe('REG-03.6: UTXO tracker fallback', function () { it('calls tracker when utxos param is null', async function () { const encoder = makeEncoder(NETWORK) @@ -323,7 +327,6 @@ describe('REG-03: Fee & UTXO Selection', function () { trackerCalled = true return { utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)] } } - await encoder.createTransaction( null, address, null, action.data, null, 10000, false, null, address, @@ -332,7 +335,6 @@ describe('REG-03: Fee & UTXO Selection', function () { assert.ok(trackerCalled, 'utxoTracker should have been called') }) - it('throws when tracker returns empty list', async function () { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) @@ -352,7 +354,8 @@ describe('REG-03: Fee & UTXO Selection', function () { ) }) }) - +}) +describe('REG-03: Fee & UTXO Selection', function () { describe('REG-03.7: Legacy UTXO handling', function () { it('fetches raw tx hex via connector for P2PKH UTXOs', async function () { const encoder = makeEncoder(NETWORK) @@ -364,7 +367,6 @@ describe('REG-03: Fee & UTXO Selection', function () { getHexCalled = true return buildRawTxHex(100000000, NETWORK) } - const utxo = makeLegacyUtxo(TXID_A, 0, 100000000) await encoder.createTransaction( @@ -375,7 +377,6 @@ describe('REG-03: Fee & UTXO Selection', function () { assert.ok(getHexCalled, 'getTransactionHex should be called for legacy UTXOs') }) - it('segwit UTXOs do NOT call getTransactionHex', async function () { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) @@ -384,7 +385,6 @@ describe('REG-03: Fee & UTXO Selection', function () { encoder.connector.getTransactionHex = async () => { throw new Error('getTransactionHex should not be called for segwit') } - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) // Should NOT throw From 16fd31cd12a33f94abaf5fb17a2e2f71f7d125f9 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:13 -0700 Subject: [PATCH 062/148] test: split multi-chain regression suite into same-title blocks by behavior --- test/regression/reg_05_multi_chain.test.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/regression/reg_05_multi_chain.test.js b/test/regression/reg_05_multi_chain.test.js index 4f6e766..425f146 100644 --- a/test/regression/reg_05_multi_chain.test.js +++ b/test/regression/reg_05_multi_chain.test.js @@ -62,7 +62,9 @@ describe('REG-05: Multi-Chain Network Configs', function () { ) }) }) +}) +describe('REG-05: Multi-Chain Network Configs', function () { describe('REG-05.2: Bitcoin (regtest) OP_RETURN', function () { it('produces valid PSBT with correct encoding', async function () { const encoder = makeEncoder('bitcoin-regtest') @@ -86,7 +88,9 @@ describe('REG-05: Multi-Chain Network Configs', function () { assert.strictEqual(encoder.dustAmount, 546) }) }) +}) +describe('REG-05: Multi-Chain Network Configs', function () { describe('REG-05.3: Dogecoin (regtest) OP_RETURN', function () { it('produces valid PSBT with correct encoding', async function () { const encoder = makeEncoder('dogecoin-regtest') @@ -124,7 +128,9 @@ describe('REG-05: Multi-Chain Network Configs', function () { ) }) }) +}) +describe('REG-05: Multi-Chain Network Configs', function () { describe('REG-05.4: Litecoin (regtest) OP_RETURN', function () { it('produces valid PSBT with correct encoding', async function () { const encoder = makeEncoder('litecoin-regtest') @@ -164,7 +170,9 @@ describe('REG-05: Multi-Chain Network Configs', function () { `Litecoin fee ${impliedFee} should be >= 5460`) }) }) +}) +describe('REG-05: Multi-Chain Network Configs', function () { describe('REG-05.5: P2SH dust per chain', function () { for (const [network, expectedDust] of [ ['bitcoin-regtest', 546], @@ -198,7 +206,9 @@ describe('REG-05: Multi-Chain Network Configs', function () { }) } }) +}) +describe('REG-05: Multi-Chain Network Configs', function () { describe('REG-05.6: MULTISIGN dust per chain', function () { for (const [network, expectedDust] of [ ['bitcoin-regtest', 546], From f5e10466c3ad8f961b30aa844e90f0106972b7e5 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:13 -0700 Subject: [PATCH 063/148] test: split P2SH and P2WSH sequence regression suite and extract the reveal builder --- .../reg_06_p2sh_p2wsh_sequence.test.js | 77 +++++++++++-------- 1 file changed, 47 insertions(+), 30 deletions(-) diff --git a/test/regression/reg_06_p2sh_p2wsh_sequence.test.js b/test/regression/reg_06_p2sh_p2wsh_sequence.test.js index 58ba4b1..5094e20 100644 --- a/test/regression/reg_06_p2sh_p2wsh_sequence.test.js +++ b/test/regression/reg_06_p2sh_p2wsh_sequence.test.js @@ -103,7 +103,9 @@ describe('REG-06: P2SH/P2WSH Two-Transaction Sequence', function () { assert.ok(changeOutput.value > 0, 'should have a change output') }) }) +}) +describe('REG-06: P2SH/P2WSH Two-Transaction Sequence', function () { describe('REG-06.2: P2SH tx2 structure', function () { it('tx2 has P2SH input with redeemScript', async function () { const action = actions.makeIssueFull('REGTEST') @@ -138,7 +140,9 @@ describe('REG-06: P2SH/P2WSH Two-Transaction Sequence', function () { assert.strictEqual(marker.script[0], bitcoin.opcodes.OP_RETURN) }) }) +}) +describe('REG-06: P2SH/P2WSH Two-Transaction Sequence', function () { describe('REG-06.3: P2SH chaining integrity', function () { it('tx2 first input hash references tx1 ID', async function () { const action = actions.makeIssueFull('CHAIN') @@ -167,7 +171,9 @@ describe('REG-06: P2SH/P2WSH Two-Transaction Sequence', function () { assert.strictEqual(decrypted.toString('utf8'), 'XCHNp2sh') }) }) +}) +describe('REG-06: P2SH/P2WSH Two-Transaction Sequence', function () { describe('REG-06.4: P2SH data fidelity', function () { it('redeemScript data chunk decompiles back to original ACTION string', async function () { const action = actions.makeIssueFull('FIDELITY') @@ -180,7 +186,9 @@ describe('REG-06: P2SH/P2WSH Two-Transaction Sequence', function () { assert.strictEqual(innerDecompiled[0].toString('utf8'), action.data) }) }) +}) +describe('REG-06: P2SH/P2WSH Two-Transaction Sequence', function () { describe('REG-06.5: P2WSH tx1 structure', function () { it('tx1 creates P2WSH output (OP_0 <32-byte-hash>)', async function () { const action = actions.makeFileLarge() @@ -200,7 +208,9 @@ describe('REG-06: P2SH/P2WSH Two-Transaction Sequence', function () { assert.ok(p2wshOutput, 'tx1 should have P2WSH output') }) }) +}) +describe('REG-06: P2SH/P2WSH Two-Transaction Sequence', function () { describe('REG-06.6: P2WSH tx2 structure', function () { it('tx2 has witnessScript in input', async function () { const action = actions.makeFileLarge() @@ -240,7 +250,9 @@ describe('REG-06: P2SH/P2WSH Two-Transaction Sequence', function () { assert.strictEqual(decrypted.toString('utf8'), 'XCHNp2wsh') }) }) +}) +describe('REG-06: P2SH/P2WSH Two-Transaction Sequence', function () { describe('REG-06.7: P2WSH data fidelity', function () { it('witnessScript data chunks reassemble to the original ACTION string', async function () { const action = actions.makeFileLarge() @@ -263,6 +275,7 @@ describe('REG-06: P2SH/P2WSH Two-Transaction Sequence', function () { assert.strictEqual(innerDecompiled[0].toString('utf8'), action.data) }) }) +}) // A P2WSH reveal that spends a single data chunk is just 1 input + 1 // OP_RETURN marker = 71 stripped (non-witness) bytes, because the payload @@ -276,38 +289,38 @@ describe('REG-06: P2SH/P2WSH Two-Transaction Sequence', function () { // minStandardTxNonWitnessSize on every chain. Both the minimum (75) and // maximum (476) single-chunk compiled-payload sizes must clear the floor. - describe('REG-06.8: P2WSH single-chunk stripped-size floor', function () { +async function buildSingleChunkReveal (network, compiledTarget) { + // makeActionOfSize(N) returns a raw N-byte ACTION string; script.compile + // prepends the push opcode/length, so compiled = N+1 for N<76 and N+3 for + // N>=256. Hence compiled 75 ⇒ raw 74, compiled 476 ⇒ raw 473. + const rawLen = compiledTarget < 256 ? compiledTarget - 1 : compiledTarget - 3 + const action = actions.makeActionOfSize(rawLen) + const compiled = bitcoin.script.compile([Buffer.from(action.data, 'utf8')]).length + assert.strictEqual(compiled, compiledTarget, + `compiled payload should be exactly ${compiledTarget} bytes (single chunk)`) - async function buildSingleChunkReveal (network, compiledTarget) { - // makeActionOfSize(N) returns a raw N-byte ACTION string; script.compile - // prepends the push opcode/length, so compiled = N+1 for N<76 and N+3 for - // N>=256. Hence compiled 75 ⇒ raw 74, compiled 476 ⇒ raw 473. - const rawLen = compiledTarget < 256 ? compiledTarget - 1 : compiledTarget - 3 - const action = actions.makeActionOfSize(rawLen) - const compiled = bitcoin.script.compile([Buffer.from(action.data, 'utf8')]).length - assert.strictEqual(compiled, compiledTarget, - `compiled payload should be exactly ${compiledTarget} bytes (single chunk)`) - - const encoder = makeEncoder(network) - const address = getTestAddress(network) - const utxo = stdUtxo(network) - - const tx1 = await encoder.createTransaction( - [utxo], address, null, action.data, action.rawData, null, false, 'P2WSH', address, - null, null, null, true, 0.00001) - const tx1Hex = tx1.psbt.__CACHE.__TX.toHex() - const tx1Id = tx1.psbt.__CACHE.__TX.getId() - - const tx2 = await encoder.createTransaction( - [utxo], address, null, action.data, action.rawData, null, false, 'P2WSH', address, - tx1Id, tx1Hex, null, true, 0.00001) - - // A single compiled chunk must produce exactly one P2WSH reveal input. - assert.strictEqual(tx2.psbt.txInputs.length, 1, - 'single-chunk reveal should have exactly one input') - return { tx2, encoder } - } + const encoder = makeEncoder(network) + const address = getTestAddress(network) + const utxo = stdUtxo(network) + + const tx1 = await encoder.createTransaction( + [utxo], address, null, action.data, action.rawData, null, false, 'P2WSH', address, + null, null, null, true, 0.00001) + const tx1Hex = tx1.psbt.__CACHE.__TX.toHex() + const tx1Id = tx1.psbt.__CACHE.__TX.getId() + + const tx2 = await encoder.createTransaction( + [utxo], address, null, action.data, action.rawData, null, false, 'P2WSH', address, + tx1Id, tx1Hex, null, true, 0.00001) + // A single compiled chunk must produce exactly one P2WSH reveal input. + assert.strictEqual(tx2.psbt.txInputs.length, 1, + 'single-chunk reveal should have exactly one input') + return { tx2, encoder } +} + +describe('REG-06: P2SH/P2WSH Two-Transaction Sequence', function () { + describe('REG-06.8: P2WSH single-chunk stripped-size floor', function () { for (const compiled of [75, 476]) { it(`Litecoin reveal (compiled ${compiled}B) clears the tx-size-small floor`, async function () { const { tx2, encoder } = await buildSingleChunkReveal('litecoin-regtest', compiled) @@ -320,7 +333,11 @@ describe('REG-06: P2SH/P2WSH Two-Transaction Sequence', function () { `stripped size ${stripped} must be >= litecoin floor ${floor}`) }) } + }) +}) +describe('REG-06: P2SH/P2WSH Two-Transaction Sequence', function () { + describe('REG-06.8: P2WSH single-chunk stripped-size floor', function () { for (const compiled of [75, 476]) { it(`Bitcoin reveal (compiled ${compiled}B) clears the tx-size-small floor`, async function () { const { tx2, encoder } = await buildSingleChunkReveal('bitcoin-regtest', compiled) From 34e73063ce6cd72c198e2e544e774dfc476ad62f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:13 -0700 Subject: [PATCH 064/148] test: split action pipeline regression suite into same-title blocks by behavior --- test/regression/reg_07_action_pipeline.test.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/regression/reg_07_action_pipeline.test.js b/test/regression/reg_07_action_pipeline.test.js index dfa4ae5..83c51eb 100644 --- a/test/regression/reg_07_action_pipeline.test.js +++ b/test/regression/reg_07_action_pipeline.test.js @@ -77,7 +77,9 @@ describe('REG-07: Action Pipeline Regression', function () { assert.strictEqual(decoded, action.data) }) }) +}) +describe('REG-07: Action Pipeline Regression', function () { describe('REG-07.2: ISSUE P2SH path', function () { it('full ISSUE (25+ fields) auto-selects P2SH', async function () { const encoder = makeEncoder(NETWORK) @@ -100,7 +102,9 @@ describe('REG-07: Action Pipeline Regression', function () { assert.strictEqual(result.encoding, 'OP_RETURN') }) }) +}) +describe('REG-07: Action Pipeline Regression', function () { describe('REG-07.3: MULTISEND v2 (multiple recipients)', function () { it('two TICK|AMOUNT|DEST triplets preserved', async function () { // Use short addresses to keep payload within OP_RETURN limit @@ -115,7 +119,9 @@ describe('REG-07: Action Pipeline Regression', function () { assert.ok(decoded.startsWith('SEND|2|')) }) }) +}) +describe('REG-07: Action Pipeline Regression', function () { describe('REG-07.4: ORDER DEX round-trip', function () { it('ORDER|0|BUY|JDOG|100|BRRR|50|0 survives encoding', async function () { const action = actions.makeOrder('BUY', 'JDOG', '100', 'BRRR', '50', '0') @@ -123,7 +129,9 @@ describe('REG-07: Action Pipeline Regression', function () { assert.strictEqual(decompiled[0].toString('utf8'), action.data) }) }) +}) +describe('REG-07: Action Pipeline Regression', function () { describe('REG-07.5: BROADCAST oversized OP_RETURN rejected', function () { it('long broadcast forced to OP_RETURN is rejected', async function () { // The rejection fires on every coin, not just bitcoin: singleOpReturnPolicy is @@ -163,7 +171,9 @@ describe('REG-07: Action Pipeline Regression', function () { assert.strictEqual(result.encoding, 'P2SH') }) }) +}) +describe('REG-07: Action Pipeline Regression', function () { describe('REG-07.6: FILE P2WSH path (bitcoin-regtest)', function () { it('large FILE uses P2WSH on segwit-capable network', async function () { const encoder = makeEncoder('bitcoin-regtest') @@ -180,7 +190,9 @@ describe('REG-07: Action Pipeline Regression', function () { assert.strictEqual(result.encoding, 'P2WSH') }) }) +}) +describe('REG-07: Action Pipeline Regression', function () { describe('REG-07.7: BATCH semicolon delimiter', function () { it('semicolon-separated commands preserved verbatim', async function () { const action = actions.makeBatch([ @@ -194,7 +206,9 @@ describe('REG-07: Action Pipeline Regression', function () { assert.ok(decoded.includes(';'), 'should contain semicolon separator') }) }) +}) +describe('REG-07: Action Pipeline Regression', function () { describe('REG-07.8: data + rawData dual field', function () { it('both data and rawData present in decompiled output', async function () { const encoder = makeEncoder(NETWORK) @@ -214,7 +228,9 @@ describe('REG-07: Action Pipeline Regression', function () { assert.strictEqual(decompiled[1].toString('utf8'), 'extra-raw-data') }) }) +}) +describe('REG-07: Action Pipeline Regression', function () { describe('REG-07.9: Special characters in payload', function () { it('TICK name with special chars preserved', async function () { const action = actions.makeIssueSpecialChars('J-DOG_#1') From 2e79165daafb8557bbdc84053e874af7e68d59ab Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:13 -0700 Subject: [PATCH 065/148] test: split API contract regression suite into same-title blocks by behavior --- test/regression/reg_08_api_contract.test.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/regression/reg_08_api_contract.test.js b/test/regression/reg_08_api_contract.test.js index 87ec648..886fe79 100644 --- a/test/regression/reg_08_api_contract.test.js +++ b/test/regression/reg_08_api_contract.test.js @@ -102,7 +102,11 @@ describe('REG-08: API Contract Regression', function () { assert.ok(result.psbt) }) + }) +}) +describe('REG-08: API Contract Regression', function () { + describe('REG-08.1: validateAll → createTransaction parameter mapping', function () { it('fee=null flows through as null (auto-fee mode)', async function () { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) @@ -139,7 +143,9 @@ describe('REG-08: API Contract Regression', function () { assert.ok(result.psbt) }) }) +}) +describe('REG-08: API Contract Regression', function () { describe('REG-08.2: Validation errors (code -32602 pattern)', function () { it('invalid encoding value produces TypeError', function () { assert.throws( @@ -169,7 +175,9 @@ describe('REG-08: API Contract Regression', function () { ) }) }) +}) +describe('REG-08: API Contract Regression', function () { describe('REG-08.3: Encoder errors (code -32603 pattern)', function () { it('no UTXOs error is a plain Error', async function () { const encoder = makeEncoder(NETWORK) @@ -198,7 +206,9 @@ describe('REG-08: API Contract Regression', function () { ) }) }) +}) +describe('REG-08: API Contract Regression', function () { describe('REG-08.4: PSBT serialization', function () { it('result.psbt hex is non-empty and valid hex', async function () { const encoder = makeEncoder(NETWORK) @@ -238,7 +248,11 @@ describe('REG-08: API Contract Regression', function () { assert.ok(parsed, 'should parse without error') assert.ok(parsed.txInputs.length > 0, 'parsed PSBT should have inputs') }) + }) +}) +describe('REG-08: API Contract Regression', function () { + describe('REG-08.4: PSBT serialization', function () { it('encoding is one of the four valid encoding strings', async function () { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) From 53647c4b5eeaa4e867335e702bb4b61769517e83 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:20 -0700 Subject: [PATCH 066/148] test: split deep-dive fixes regression suite into same-title blocks by behavior --- test/regression/reg_09_deepdive_fixes.test.js | 46 +++++++++++++++---- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/test/regression/reg_09_deepdive_fixes.test.js b/test/regression/reg_09_deepdive_fixes.test.js index 714eca2..03b9782 100644 --- a/test/regression/reg_09_deepdive_fixes.test.js +++ b/test/regression/reg_09_deepdive_fixes.test.js @@ -32,6 +32,15 @@ const { // P2SH forces the two-transaction flow (payload over the 76-byte OP_RETURN cap). const BIG_DATA = 'x'.repeat(80) +function twoUtxoTracker (encoder) { + encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ + utxos: [ + makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000), + makeUtxo('bitcoin-regtest', TXID_A, 1, 100000000) + ] + }) +} + describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { // Reveal derives inputs from phase 1, never re-queries the tracker. @@ -70,6 +79,9 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { assert.ok(tx2.psbt.data.inputs.length >= 1, 'reveal must reconstruct its inputs from phase 1') }) }) +}) + +describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { // Sub-dust change is folded into the fee, never emitted. describe('M-6: sub-dust change folding', () => { @@ -114,6 +126,9 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { 'change one sat below dust must be folded') }) }) +}) + +describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { // The 500-UTXO cap gates the SELECTED count, not the fetched set. describe('M-7: MAX_UTXO_COUNT applies to selected inputs, not the fetched set', () => { @@ -156,6 +171,9 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { ) }) }) +}) + +describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { // Under-funded selection throws a typed error, not a dead PSBT. describe('M-8: insufficient-funds error', () => { @@ -179,18 +197,12 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { ) }) }) +}) + +describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { // Outpoint reservation prevents concurrent same-address double-spends. describe('L-1: in-memory outpoint reservation', () => { - function twoUtxoTracker (encoder) { - encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ - utxos: [ - makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000), - makeUtxo('bitcoin-regtest', TXID_A, 1, 100000000) - ] - }) - } - it('two sequential tracker-fed selections pick disjoint outpoints', async () => { const encoder = makeEncoder('bitcoin-regtest') const address = getTestAddress('bitcoin-regtest') @@ -209,6 +221,11 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { assert.deepStrictEqual(r2.psbt.txInputs.map(i => i.index), [1], 'second call must skip the outpoint the first reserved') }) + }) +}) + +describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { + describe('L-1: in-memory outpoint reservation', () => { it('once every outpoint is reserved a further call reports insufficient funds', async () => { const encoder = makeEncoder('bitcoin-regtest') @@ -228,6 +245,11 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { const r = await encoder.createTransaction(null, address, null, 'test', null, 10000, false, null, address, null, null, null, true, 0.00001) assert.deepStrictEqual(r.psbt.txInputs.map(i => i.index), [0]) }) + }) +}) + +describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { + describe('L-1: in-memory outpoint reservation', () => { it('caller-supplied UTXOs are reserved too, and the SDK supplies a tracker-fetched set', async () => { const encoder = makeEncoder('bitcoin-regtest') @@ -247,6 +269,9 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { ) }) }) +}) + +describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { // api.js forwards typed operational errors, sanitizes internals. describe('L-2: API surfaces typed operational errors', () => { @@ -294,6 +319,9 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { assert.strictEqual(caught.data, undefined, 'internal errors carry no data payload') }) }) +}) + +describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { describe('FIX K: get_utxos validates the address param before the tracker call', () => { const { jsonRpcController } = require('../../src/api') From b48ddb430c6717d9f7e3d19de1c9ce203710df67 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:20 -0700 Subject: [PATCH 067/148] test: split tracker freshness regression suite into same-title blocks by behavior --- .../reg_10_utxo_tracker_freshness.test.js | 56 ++++++++++++++----- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/test/regression/reg_10_utxo_tracker_freshness.test.js b/test/regression/reg_10_utxo_tracker_freshness.test.js index 79f7330..d454147 100644 --- a/test/regression/reg_10_utxo_tracker_freshness.test.js +++ b/test/regression/reg_10_utxo_tracker_freshness.test.js @@ -33,6 +33,20 @@ const { } = require('../integration/helpers/utxoFactory') const XChainEncoder = require('../../src/XChainEncoder') +function stubSync(encoder, sync) { + encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ + utxos: [makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000)], + sync + }) +} + +function build(encoder, address) { + return encoder.createTransaction( + null, address, null, 'test', null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) +} + describe('M-11 (encoder half): utxo-tracker freshness gate', () => { describe('stale gate fires', () => { @@ -78,6 +92,11 @@ describe('M-11 (encoder half): utxo-tracker freshness gate', () => { err.details.lag === 5 ) }) + }) +}) + +describe('M-11 (encoder half): utxo-tracker freshness gate', () => { + describe('stale gate fires', () => { it('respects a custom UTXO_TRACKER_MAX_LAG_BLOCKS threshold passed to the constructor', async () => { // lag=3 would fail the default threshold (2) but passes a wider one (5). @@ -100,26 +119,15 @@ describe('M-11 (encoder half): utxo-tracker freshness gate', () => { assert.ok(result.psbt, 'a lag under the custom threshold must build normally') }) }) +}) +describe('M-11 (encoder half): utxo-tracker freshness gate', () => { // The gate used to read only `synced` and an upper lag bound, so three views // the tracker itself considers unusable reached input selection on the // money path: one it halted on, one whose committed tip sits above the node's, and // one whose mempool index is still empty and cannot filter an already-spent // confirmed output. describe('unusable-source gates fire', () => { - function stubSync(encoder, sync) { - encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ - utxos: [makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000)], - sync - }) - } - function build(encoder, address) { - return encoder.createTransaction( - null, address, null, 'test', null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - } - it('throws UTXO_TRACKER_HALTED when the tracker halted, even at lag 0 and synced=true', async () => { const encoder = makeEncoder('bitcoin-regtest') const address = getTestAddress('bitcoin-regtest') @@ -151,6 +159,11 @@ describe('M-11 (encoder half): utxo-tracker freshness gate', () => { err.details.lag === 0 ) }) + }) +}) + +describe('M-11 (encoder half): utxo-tracker freshness gate', () => { + describe('unusable-source gates fire', () => { it('throws UTXO_TRACKER_STALE when the tracker is ahead of the node (negative lag)', async () => { const encoder = makeEncoder('bitcoin-regtest') @@ -186,6 +199,11 @@ describe('M-11 (encoder half): utxo-tracker freshness gate', () => { /orphaned/.test(err.message) ) }) + }) +}) + +describe('M-11 (encoder half): utxo-tracker freshness gate', () => { + describe('unusable-source gates fire', () => { // The gate ships ahead of the fleet, so a tracker whose sync sibling predates // these fields must keep serving rather than being refused wholesale. @@ -209,6 +227,9 @@ describe('M-11 (encoder half): utxo-tracker freshness gate', () => { assert.ok(result.psbt) }) }) +}) + +describe('M-11 (encoder half): utxo-tracker freshness gate', () => { describe('lag below threshold passes', () => { it('builds normally when lag is under the default 2-block threshold', async () => { @@ -241,6 +262,9 @@ describe('M-11 (encoder half): utxo-tracker freshness gate', () => { assert.ok(result.psbt, 'lag exactly at the threshold must not gate') }) }) +}) + +describe('M-11 (encoder half): utxo-tracker freshness gate', () => { describe('absent freshness surface passes (backward compat)', () => { it('builds normally when the tracker response has no sync field (pre-ce16bdd tracker)', async () => { @@ -273,6 +297,9 @@ describe('M-11 (encoder half): utxo-tracker freshness gate', () => { assert.ok(result.psbt, 'an unknown (null) lag must not be treated as exceeding the threshold') }) }) +}) + +describe('M-11 (encoder half): utxo-tracker freshness gate', () => { describe('caller-supplied UTXOs bypass the gate', () => { it('never consults or gates on the tracker when UTXOs are explicitly provided', async () => { @@ -294,6 +321,9 @@ describe('M-11 (encoder half): utxo-tracker freshness gate', () => { assert.strictEqual(trackerCalled, false, 'explicit coin-control must skip the tracker entirely') }) }) +}) + +describe('M-11 (encoder half): utxo-tracker freshness gate', () => { describe('api.js maps UTXO_TRACKER_STALE to -32010', () => { const { OperationalError } = require('../../src/build/errors') From 46dbc81bd636b383a5a6755532d1670be959f4a7 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:20 -0700 Subject: [PATCH 068/148] test: split minimal-op canonicalization regression suite by behavior --- .../reg_11_minimal_op_canonicalization.test.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/regression/reg_11_minimal_op_canonicalization.test.js b/test/regression/reg_11_minimal_op_canonicalization.test.js index ffdef91..9452f07 100644 --- a/test/regression/reg_11_minimal_op_canonicalization.test.js +++ b/test/regression/reg_11_minimal_op_canonicalization.test.js @@ -72,6 +72,9 @@ describe('minimal-op canonicalization guards (empty-data shape flag-day scoped)' assert.strictEqual(validator.isMinimalOpSingleByte(Buffer.from('ab', 'utf8')), false) }) }) +}) + +describe('minimal-op canonicalization guards (empty-data shape flag-day scoped)', () => { describe('validateActionPushDecodability (single-byte minimal-op drop)', () => { it('rejects a 1-byte minimal-op data-only action (0x05, 0x0f)', () => { @@ -92,6 +95,9 @@ describe('minimal-op canonicalization guards (empty-data shape flag-day scoped)' assert.doesNotThrow(() => validator.validateActionPushDecodability('{"op":"ISSUE"}', null)) }) }) +}) + +describe('minimal-op canonicalization guards (empty-data shape flag-day scoped)', () => { describe('empty-data shapes remain intentionally supported (flag-day scoped)', () => { it('does NOT reject empty data-only (payment-only / no-ACTION tx)', () => { @@ -103,6 +109,9 @@ describe('minimal-op canonicalization guards (empty-data shape flag-day scoped)' assert.doesNotThrow(() => validator.validateActionPushDecodability(null, 'orphan-raw')) }) }) +}) + +describe('minimal-op canonicalization guards (empty-data shape flag-day scoped)', () => { describe('validateAll wires the guard', () => { const base = { pubkey: REGTEST_ADDRESS } @@ -113,6 +122,9 @@ describe('minimal-op canonicalization guards (empty-data shape flag-day scoped)' assert.doesNotThrow(() => validator.validateAll({ ...base, rawData: 'orphan-raw' })) }) }) +}) + +describe('minimal-op canonicalization guards (empty-data shape flag-day scoped)', () => { describe('prepareData rebalances a degenerate 1-byte final P2SH/P2WSH chunk', () => { const encoder = makeEncoder() From e096ab8f3b24af6f647ff01addb0ebf87b26fadb Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:20 -0700 Subject: [PATCH 069/148] test: split input key binding regression suite into same-title blocks by behavior --- test/regression/reg_12_ins0_key_binding.test.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/regression/reg_12_ins0_key_binding.test.js b/test/regression/reg_12_ins0_key_binding.test.js index ca30420..10c5c71 100644 --- a/test/regression/reg_12_ins0_key_binding.test.js +++ b/test/regression/reg_12_ins0_key_binding.test.js @@ -100,6 +100,10 @@ describe('ins[0] / obfuscation-key binding @regression', function () { assert.strictEqual(deobfuscate(obf, ins0Txid(result)).subarray(0, 4).toString('utf8'), MAGIC, 'the action must decode with ins[0].txid, which is the key the decoder uses') }) +}) + +describe('ins[0] / obfuscation-key binding @regression', function () { + this.timeout(10000) it('MULTISIGN: same invariant on the bare-multisig payload path', async function () { const encoder = twoUtxoEncoder() @@ -135,6 +139,10 @@ describe('ins[0] / obfuscation-key binding @regression', function () { assert.strictEqual(deobfuscate(payload, ins0Txid(result)).subarray(0, 4).toString('utf8'), MAGIC, 'the MULTISIGN action must decode with ins[0].txid') }) +}) + +describe('ins[0] / obfuscation-key binding @regression', function () { + this.timeout(10000) it('fails closed when no outpoint could be pre-reserved and a different one frees', async function () { const encoder = twoUtxoEncoder() @@ -165,6 +173,10 @@ describe('ins[0] / obfuscation-key binding @regression', function () { } ) }) +}) + +describe('ins[0] / obfuscation-key binding @regression', function () { + this.timeout(10000) it('does not double-add or double-reserve the spliced outpoint', async function () { const encoder = twoUtxoEncoder() @@ -194,6 +206,10 @@ describe('ins[0] / obfuscation-key binding @regression', function () { assert.ok(encoder.outpointReservations.size <= 2, `reservation map should not leak entries, saw ${encoder.outpointReservations.size}`) }) +}) + +describe('ins[0] / obfuscation-key binding @regression', function () { + this.timeout(10000) it('caller-supplied UTXOs: the key binds to the first UNRESERVED input, which lands at ins[0]', async function () { const encoder = twoUtxoEncoder() From 83b411454bcfd3ffac5523c1b302e39256838e2c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:20 -0700 Subject: [PATCH 070/148] test: split reservation release regression suite into same-title blocks by behavior --- .../reg_13_reservation_release_on_throw.test.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/regression/reg_13_reservation_release_on_throw.test.js b/test/regression/reg_13_reservation_release_on_throw.test.js index 204ebdd..354e0cb 100644 --- a/test/regression/reg_13_reservation_release_on_throw.test.js +++ b/test/regression/reg_13_reservation_release_on_throw.test.js @@ -96,6 +96,10 @@ describe('reservation release on throw @regression', function () { assert.strictEqual(ins0Txid(retry), TXID_B, 'the retry must be able to select the released outpoint') }) +}) + +describe('reservation release on throw @regression', function () { + this.timeout(10000) it('a foreign reservation survives another call throwing', async function () { const encoder = twoUtxoEncoder() @@ -116,6 +120,10 @@ describe('reservation release on throw @regression', function () { assert.strictEqual(encoder.outpointReservations.get(TXID_A + ':0'), foreignExpiry, 'a reservation held by another in-flight call must not be dropped') }) +}) + +describe('reservation release on throw @regression', function () { + this.timeout(10000) it('an outpoint re-reserved by another call after ours lapsed is left alone', async function () { // Dust-sized UTXOs, so the build fails on the change math after selection. @@ -142,6 +150,10 @@ describe('reservation release on throw @regression', function () { assert.strictEqual(encoder.outpointReservations.get(TXID_B + ':0'), foreignExpiry, 'the re-reserved outpoint belongs to the other call now') }) +}) + +describe('reservation release on throw @regression', function () { + this.timeout(10000) it('INSUFFICIENT_FUNDS on an under-funded build also releases its claims', async function () { // One dust-sized tracker UTXO: selection takes and reserves it, then the @@ -158,6 +170,10 @@ describe('reservation release on throw @regression', function () { assert.strictEqual(encoder.outpointReservations.size, 0, 'a failed build must not leave reservations behind') }) +}) + +describe('reservation release on throw @regression', function () { + this.timeout(10000) it('a successful build keeps its reservations for the TTL', async function () { const encoder = twoUtxoEncoder() From b56827c29f95f69563c7a972b7288aa748050693 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:20 -0700 Subject: [PATCH 071/148] test: split reveal fee floor regression suite into same-title blocks by behavior --- .../reg_14_p2sh_reveal_fee_floor.test.js | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/test/regression/reg_14_p2sh_reveal_fee_floor.test.js b/test/regression/reg_14_p2sh_reveal_fee_floor.test.js index d9c082c..795a08c 100644 --- a/test/regression/reg_14_p2sh_reveal_fee_floor.test.js +++ b/test/regression/reg_14_p2sh_reveal_fee_floor.test.js @@ -135,17 +135,33 @@ async function buildRevealed (payload, { encoding = 'P2SH', customOutputs = null } } +// 400 bytes compiles to a single data chunk whose reveal lands well above the +// dust floor, so the fee is estimate-driven: the exact regime that failed. +const SINGLE_CHUNK = 'Z'.repeat(400) +// Small payloads land on the dust floor instead; 546 sat covered the reveal +// right up until an unfunded 34-byte fee output pushed it past 546 vbytes. +const DUST_FLOORED = 'Z'.repeat(340) +const MULTI_CHUNK = 'Z'.repeat(1024) + +const nativeFeeOutput = (address) => [{ address, value: 5000 }] + describe('REG-14: P2SH/P2WSH reveal clears the min-relay fee floor', () => { - // 400 bytes compiles to a single data chunk whose reveal lands well above the - // dust floor, so the fee is estimate-driven: the exact regime that failed. - const SINGLE_CHUNK = 'Z'.repeat(400) - // Small payloads land on the dust floor instead; 546 sat covered the reveal - // right up until an unfunded 34-byte fee output pushed it past 546 vbytes. - const DUST_FLOORED = 'Z'.repeat(340) - const MULTI_CHUNK = 'Z'.repeat(1024) + it('charges the funding tx exactly the fee output byte cost it forgot before', async () => { + // The regression in one number: the only difference between these two runs + // is one 34-byte P2PKH output on the reveal, so the funding outputs must + // grow by its value plus its 34 sat of fee at 1 sat/vB. + const bare = await buildRevealed(SINGLE_CHUNK, { encoding: 'P2SH' }) + const withFee = await buildRevealed(SINGLE_CHUNK, { + encoding: 'P2SH', customOutputs: nativeFeeOutput(LEGACY_ADDRESS) + }) + const outputBytes = TxSizeEstimator.estimateOutputSizeForAddress(LEGACY_ADDRESS, NET) + assert.strictEqual(outputBytes, 34) + assert.strictEqual(withFee.fee - bare.fee, outputBytes) + }) +}) - const nativeFeeOutput = (address) => [{ address, value: 5000 }] +describe('REG-14: P2SH/P2WSH reveal clears the min-relay fee floor', () => { for (const encoding of ['P2SH', 'P2WSH']) { describe(encoding, () => { @@ -191,17 +207,4 @@ describe('REG-14: P2SH/P2WSH reveal clears the min-relay fee floor', () => { }) }) } - - it('charges the funding tx exactly the fee output byte cost it forgot before', async () => { - // The regression in one number: the only difference between these two runs - // is one 34-byte P2PKH output on the reveal, so the funding outputs must - // grow by its value plus its 34 sat of fee at 1 sat/vB. - const bare = await buildRevealed(SINGLE_CHUNK, { encoding: 'P2SH' }) - const withFee = await buildRevealed(SINGLE_CHUNK, { - encoding: 'P2SH', customOutputs: nativeFeeOutput(LEGACY_ADDRESS) - }) - const outputBytes = TxSizeEstimator.estimateOutputSizeForAddress(LEGACY_ADDRESS, NET) - assert.strictEqual(outputBytes, 34) - assert.strictEqual(withFee.fee - bare.fee, outputBytes) - }) }) From 623add94d6dc8c2418e1987658a8521ad61e7e85 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:20 -0700 Subject: [PATCH 072/148] test: split UTXO pagination snapshot regression suite by behavior --- .../reg_17_utxo_pagination_snapshot.test.js | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/regression/reg_17_utxo_pagination_snapshot.test.js b/test/regression/reg_17_utxo_pagination_snapshot.test.js index 23dbd2f..b98ef15 100644 --- a/test/regression/reg_17_utxo_pagination_snapshot.test.js +++ b/test/regression/reg_17_utxo_pagination_snapshot.test.js @@ -53,6 +53,11 @@ describe('paginated utxo fetch is one chain-state snapshot @regression', functio await assert.rejects(() => tracker.getUtxosFromAddress('someaddr'), /chain state changed while paginating/) }) +}) + +describe('paginated utxo fetch is one chain-state snapshot @regression', function () { + const origPost = axios.post + afterEach(() => { axios.post = origPost }) it('refuses when a later page reports the tracker halted', async function () { stubPages([ @@ -62,6 +67,11 @@ describe('paginated utxo fetch is one chain-state snapshot @regression', functio const tracker = new UtxoTracker('127.0.0.1', '1234') await assert.rejects(() => tracker.getUtxosFromAddress('someaddr'), /halted mid-fetch/) }) +}) + +describe('paginated utxo fetch is one chain-state snapshot @regression', function () { + const origPost = axios.post + afterEach(() => { axios.post = origPost }) it('refuses when the reorg counter moves at an unchanged height', async function () { // A rewind that re-applies to the SAME height is invisible to a height check. @@ -73,6 +83,11 @@ describe('paginated utxo fetch is one chain-state snapshot @regression', functio const tracker = new UtxoTracker('127.0.0.1', '1234') await assert.rejects(() => tracker.getUtxosFromAddress('someaddr'), /recorded a reorg mid-fetch/) }) +}) + +describe('paginated utxo fetch is one chain-state snapshot @regression', function () { + const origPost = axios.post + afterEach(() => { axios.post = origPost }) it('merges pages that share one snapshot', async function () { stubPages([ @@ -84,6 +99,11 @@ describe('paginated utxo fetch is one chain-state snapshot @regression', functio assert.strictEqual(out.utxos.length, 2) assert.strictEqual(out.sync.tracker_height, 100) }) +}) + +describe('paginated utxo fetch is one chain-state snapshot @regression', function () { + const origPost = axios.post + afterEach(() => { axios.post = origPost }) it('still merges for a tracker that publishes no per-page sync sibling', async function () { // Pre-ce16bdd trackers omit `sync` entirely. There is no snapshot identity to @@ -98,6 +118,11 @@ describe('paginated utxo fetch is one chain-state snapshot @regression', functio const out = await tracker.getUtxosFromAddress('someaddr') assert.strictEqual(out.utxos.length, 2) }) +}) + +describe('paginated utxo fetch is one chain-state snapshot @regression', function () { + const origPost = axios.post + afterEach(() => { axios.post = origPost }) it('leaves the single-page path untouched', async function () { stubPages([ From 7874a67335788ca5ccb8fed568c3e050b98a4118 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:20 -0700 Subject: [PATCH 073/148] test: split chained send reservation regression suite by behavior --- .../reg_18_chained_send_reservation.test.js | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/regression/reg_18_chained_send_reservation.test.js b/test/regression/reg_18_chained_send_reservation.test.js index 0054cf4..b63b05a 100644 --- a/test/regression/reg_18_chained_send_reservation.test.js +++ b/test/regression/reg_18_chained_send_reservation.test.js @@ -76,6 +76,10 @@ describe('REG-18: chained sends reserve caller-supplied inputs @regression', fun assert.ok(encoder.outpointReservations.has(TXID_A + ':0'), 'the first build keeps its reservation') assert.ok(encoder.recentBuilds.has(firstTxid), 'the first build is on record') }) +}) + +describe('REG-18: chained sends reserve caller-supplied inputs @regression', function () { + this.timeout(10000) it('three chained sends over a fresh view each produce three DISTINCT transactions', async () => { // The same guarantee at unit scale: chained sends whose views @@ -88,6 +92,10 @@ describe('REG-18: chained sends reserve caller-supplied inputs @regression', fun assert.strictEqual(txids.size, 3, 'three chained sends must be three distinct transactions') assert.strictEqual(encoder.outpointReservations.size, 3, 'each selected input stays reserved') }) +}) + +describe('REG-18: chained sends reserve caller-supplied inputs @regression', function () { + this.timeout(10000) it('a stale view that still lists the spent input alongside the change selects the change', async () => { const encoder = makeEncoder(NETWORK) @@ -100,6 +108,10 @@ describe('REG-18: chained sends reserve caller-supplied inputs @regression', fun const ins = r2.psbt.txInputs.map(i => Buffer.from(i.hash).reverse().toString('hex')) assert.deepStrictEqual(ins, [TXID_B], 'the reserved input is skipped and the change is spent') }) +}) + +describe('REG-18: chained sends reserve caller-supplied inputs @regression', function () { + this.timeout(10000) it('a different transaction over an unreserved input is not mistaken for a duplicate', async () => { const encoder = makeEncoder(NETWORK) @@ -107,6 +119,10 @@ describe('REG-18: chained sends reserve caller-supplied inputs @regression', fun const r2 = await build(encoder, [makeUtxo(NETWORK, TXID_B, 0, 100000000)], 'MINT|0|XCHAIN|5000') assert.ok(r2.psbt, 'a genuinely different transaction builds') }) +}) + +describe('REG-18: chained sends reserve caller-supplied inputs @regression', function () { + this.timeout(10000) it('an identical rebuild is refused as DUPLICATE_TRANSACTION when the outpoint map was bypassed', async () => { // Defense in depth: clear only the outpoint map (as a lapsed reservation @@ -128,6 +144,10 @@ describe('REG-18: chained sends reserve caller-supplied inputs @regression', fun // not squat on the outpoint for a build that never happened. assert.strictEqual(encoder.outpointReservations.size, 0) }) +}) + +describe('REG-18: chained sends reserve caller-supplied inputs @regression', function () { + this.timeout(10000) it('an RBF bump of the same input is a different transaction and passes the duplicate gate', async () => { const encoder = makeEncoder(NETWORK) @@ -140,6 +160,10 @@ describe('REG-18: chained sends reserve caller-supplied inputs @regression', fun const bump = await encoder.createTransaction([utxo()], address, null, MINT, null, 12000, true, null, address, null, null, null, true, 0.00001) assert.notStrictEqual(txidOf(bump), txidOf(first), 'a higher fee changes the outputs and the txid') }) +}) + +describe('REG-18: chained sends reserve caller-supplied inputs @regression', function () { + this.timeout(10000) it('clearReservations releases both the outpoint map and the recent-build record', async () => { const encoder = makeEncoder(NETWORK) @@ -150,6 +174,10 @@ describe('REG-18: chained sends reserve caller-supplied inputs @regression', fun const again = await build(encoder, [makeUtxo(NETWORK, TXID_A, 0, 100000000)]) assert.ok(again.psbt) }) +}) + +describe('REG-18: chained sends reserve caller-supplied inputs @regression', function () { + this.timeout(10000) it('recent-build records expire with the reservation TTL', async () => { const encoder = makeEncoder(NETWORK) From 94030d15b5a60c2a0e41366b99eeff3f20a6dc85 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:20 -0700 Subject: [PATCH 074/148] test: split envelope cancel reservation regression suite by behavior --- ...reg_19_envelope_cancel_reservation.test.js | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test/regression/reg_19_envelope_cancel_reservation.test.js b/test/regression/reg_19_envelope_cancel_reservation.test.js index 721de97..30fd318 100644 --- a/test/regression/reg_19_envelope_cancel_reservation.test.js +++ b/test/regression/reg_19_envelope_cancel_reservation.test.js @@ -92,6 +92,10 @@ describe('envelope-cancel outpoint reservation @regression', function () { assert.ok(encoder.outpointReservations.has(COMMIT_KEY), 'a built cancel must hold the commit outpoint, or a concurrent create_tx can respend it') }) +}) + +describe('envelope-cancel outpoint reservation @regression', function () { + this.timeout(10000) it('normalizes a mixed-case commitTxid to the lowercase reservation key', async function () { const encoder = makeEncoder() @@ -101,6 +105,10 @@ describe('envelope-cancel outpoint reservation @regression', function () { 'an uppercase key could never collide with the reservations create_tx takes') assert.strictEqual(encoder.outpointReservations.has(COMMIT_TXID.toUpperCase() + ':0'), false) }) +}) + +describe('envelope-cancel outpoint reservation @regression', function () { + this.timeout(10000) it('a live cancel claim keeps create_tx from selecting the commit outpoint', async function () { const encoder = makeEncoder() @@ -125,6 +133,10 @@ describe('envelope-cancel outpoint reservation @regression', function () { 'create_tx must not spend an outpoint an outstanding unsigned cancel already spends') assert.strictEqual(ins0Txid(result), OTHER_TXID) }) +}) + +describe('envelope-cancel outpoint reservation @regression', function () { + this.timeout(10000) it('refuses a cancel of an outpoint another build holds', async function () { const encoder = makeEncoder() @@ -137,6 +149,10 @@ describe('envelope-cancel outpoint reservation @regression', function () { err.xchainCode === 'ENVELOPE_CANCEL_OUTPOINT_RESERVED' && err.details.outpoint === COMMIT_KEY) }) +}) + +describe('envelope-cancel outpoint reservation @regression', function () { + this.timeout(10000) it('stays retry-safe: a repeat cancel rebuilds the byte-identical PSBT', async function () { const encoder = makeEncoder() @@ -146,6 +162,10 @@ describe('envelope-cancel outpoint reservation @regression', function () { 'a cancel replayed from the recovery record must not be refused as a duplicate') assert.ok(encoder.outpointReservations.has(COMMIT_KEY)) }) +}) + +describe('envelope-cancel outpoint reservation @regression', function () { + this.timeout(10000) it('releases the claim when the build throws', async function () { const encoder = makeEncoder() @@ -157,6 +177,10 @@ describe('envelope-cancel outpoint reservation @regression', function () { 'a failed cancel must not squat the commit outpoint for the reservation window') assert.strictEqual(encoder.envelopeCancelClaims.has(COMMIT_KEY), false) }) +}) + +describe('envelope-cancel outpoint reservation @regression', function () { + this.timeout(10000) it('a foreign claim taken after the cancel is never dropped by its release', async function () { const encoder = makeEncoder() @@ -171,6 +195,10 @@ describe('envelope-cancel outpoint reservation @regression', function () { assert.strictEqual(encoder.outpointReservations.get(COMMIT_KEY), foreignExpiry, 'dropping a foreign entry would reopen the double-spend window the map closes') }) +}) + +describe('envelope-cancel outpoint reservation @regression', function () { + this.timeout(10000) it('clearReservations drops the cancel ownership stamps too', async function () { const encoder = makeEncoder() @@ -179,6 +207,10 @@ describe('envelope-cancel outpoint reservation @regression', function () { assert.strictEqual(encoder.envelopeCancelClaims.size, 0, 'a stale stamp would let a foreign claim read as this path\'s own') }) +}) + +describe('envelope-cancel outpoint reservation @regression', function () { + this.timeout(10000) // A lost-response retry legitimately re-claims an outpoint the first build is // still outstanding on. While ownership was one expiry stamp per outpoint, the @@ -200,6 +232,10 @@ describe('envelope-cancel outpoint reservation @regression', function () { 'the retry\'s failure must hand back only its own claim, not the outstanding build\'s') assert.ok(first.reservation, 'the successful build must carry a release ticket') }) +}) + +describe('envelope-cancel outpoint reservation @regression', function () { + this.timeout(10000) it('create_tx still cannot select the commit outpoint after a failed retry', async function () { const encoder = makeEncoder() @@ -224,6 +260,10 @@ describe('envelope-cancel outpoint reservation @regression', function () { assert.ok(!spent.includes(COMMIT_TXID), 'a retry\'s failure must not free an outpoint an outstanding unsigned cancel spends') }) +}) + +describe('envelope-cancel outpoint reservation @regression', function () { + this.timeout(10000) it('the first build\'s ticket still releases after a failed retry', async function () { const encoder = makeEncoder() @@ -238,6 +278,10 @@ describe('envelope-cancel outpoint reservation @regression', function () { assert.strictEqual(encoder.outpointReservations.has(COMMIT_KEY), false) assert.strictEqual(encoder.envelopeCancelClaims.has(COMMIT_KEY), false) }) +}) + +describe('envelope-cancel outpoint reservation @regression', function () { + this.timeout(10000) it('last cancel owner out frees the outpoint, not the first', async function () { const encoder = makeEncoder() @@ -254,6 +298,10 @@ describe('envelope-cancel outpoint reservation @regression', function () { assert.strictEqual(encoder.outpointReservations.has(COMMIT_KEY), false) assert.strictEqual(encoder.envelopeCancelClaims.has(COMMIT_KEY), false) }) +}) + +describe('envelope-cancel outpoint reservation @regression', function () { + this.timeout(10000) it('three overlapping builds: an out-of-order failure frees nothing', async function () { const encoder = makeEncoder() From 1fbae8e56af216b9f3604350e8a64b63e6faeeaa Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:26 -0700 Subject: [PATCH 075/148] test: split input validation security suite into same-title blocks by behavior --- test/security/input_validation.test.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/security/input_validation.test.js b/test/security/input_validation.test.js index 2524c15..76fd6ff 100644 --- a/test/security/input_validation.test.js +++ b/test/security/input_validation.test.js @@ -61,6 +61,9 @@ describe('Security: validateUtxoArray / validateUtxoEntry', () => { assert.throws(() => V.validateUtxoEntry({ txid: HEX64, vout: 1.5, value: 1, scriptPubKey: '00' }, 0), TypeError) assert.throws(() => V.validateUtxoEntry({ txid: HEX64, vout: 0, value: -100, scriptPubKey: '00' }, 0), RangeError) }) +}) + +describe('Security: validateUtxoArray / validateUtxoEntry', () => { it('rejects a numeric-prefixed injection value outright (never coerces trailing garbage)', () => { // A money field must parse exactly: parseInt would truncate From ec86c0e574d17ef8d494322f164998a8e9bf4d61 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:26 -0700 Subject: [PATCH 076/148] test: split rate limit logger suite into same-title blocks by behavior --- test/security/rate_limit_log.test.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/security/rate_limit_log.test.js b/test/security/rate_limit_log.test.js index 53ddf9a..88261e5 100644 --- a/test/security/rate_limit_log.test.js +++ b/test/security/rate_limit_log.test.js @@ -87,6 +87,12 @@ describe('Security: rate-limit refusal counter/logger', () => { 'Encoder rate limit [app-wide]: 1 request refused in the last 60 s (limit 60/60 s); raise ENCODER_RATE_LIMIT_RPM if this is legitimate traffic' ) }) + }) +}) + +describe('Security: rate-limit refusal counter/logger', () => { + + describe('limitedHandler', () => { it('logs nothing more for refusals inside the same window', () => { const lines = [] @@ -130,6 +136,9 @@ describe('Security: rate-limit refusal counter/logger', () => { assert.strictEqual(lines.length, 2) }) }) +}) + +describe('Security: rate-limit refusal counter/logger', () => { describe('wired into a real express-rate-limit instance', () => { let server @@ -172,6 +181,9 @@ describe('Security: rate-limit refusal counter/logger', () => { assert.deepStrictEqual(body, MESSAGE) }) }) +}) + +describe('Security: rate-limit refusal counter/logger', () => { describe('src/api.js wiring', () => { it('routes the limiter through limitedHandler and carries no bare message: option', () => { From f92457d80ff41c81a469ad6d945d36db13a83abf Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:26 -0700 Subject: [PATCH 077/148] test: split buffer utility patch suite into same-title blocks by behavior --- test/unit/apply_bufferutils_patch.test.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/unit/apply_bufferutils_patch.test.js b/test/unit/apply_bufferutils_patch.test.js index 9e93ae4..a273087 100644 --- a/test/unit/apply_bufferutils_patch.test.js +++ b/test/unit/apply_bufferutils_patch.test.js @@ -51,6 +51,9 @@ describe('applyBufferutilsPatch', function () { assert.strictEqual(Number(dec), n, `varuint failed for ${n}`); } }); +}); + +describe('applyBufferutilsPatch', function () { // The write-side contract this copy ships (and that the header documents): // readers narrow to a Number at or below 2^53-1 and return a BigInt above @@ -102,6 +105,9 @@ describe('applyBufferutilsPatch', function () { 'a native BigInt conversion error escaped for ' + String(value)); } }); +}); + +describe('applyBufferutilsPatch', function () { // Fee-accounting wrapper: bitcoinjs-lib's stock cache getter tests __FEE / // __FEE_RATE for truthiness, so a primed 0 (zero fee, or any fee under @@ -148,6 +154,9 @@ describe('applyBufferutilsPatch', function () { assert.ok(psbt.getFeeRate() > 0); }); }); +}); + +describe('applyBufferutilsPatch', function () { // Twin guard. This file is a fork of xchain-sdk/src/utils/apply_bufferutils_patch.js // and has drifted apart three times. Convergence runs SDK-ward, so pin the From 6b0d4d2851b318bb3285804b5a6a0cc33e65990d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:26 -0700 Subject: [PATCH 078/148] test: split CORS origin suite into same-title blocks by behavior --- test/unit/cors_origin.test.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/unit/cors_origin.test.js b/test/unit/cors_origin.test.js index 343162b..592f2dc 100644 --- a/test/unit/cors_origin.test.js +++ b/test/unit/cors_origin.test.js @@ -89,6 +89,9 @@ describe('CORS_ORIGIN allowlist parsing', function () { assert.deepStrictEqual(parseCorsOrigin(`${IOS},,${WEB}`), [IOS, WEB]) }) }) +}) + +describe('CORS_ORIGIN allowlist parsing', function () { describe('what a caller actually receives', function () { @@ -116,6 +119,12 @@ describe('CORS_ORIGIN allowlist parsing', function () { assert.strictEqual(acao[IOS], WEB) assert.strictEqual(acao[HOSTILE], WEB) }) + }) +}) + +describe('CORS_ORIGIN allowlist parsing', function () { + + describe('what a caller actually receives', function () { // The allowlist form is strictly stronger: an unlisted origin is refused at // the SERVER, without a header, rather than relying on the browser. From 30c074deab6bd024b90dc8dfbad62c3fee3035fd Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:26 -0700 Subject: [PATCH 079/148] test: split crypto networks suite into same-title blocks by behavior --- test/unit/crypto_networks.test.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/unit/crypto_networks.test.js b/test/unit/crypto_networks.test.js index 9fd6045..ff447b0 100644 --- a/test/unit/crypto_networks.test.js +++ b/test/unit/crypto_networks.test.js @@ -40,6 +40,12 @@ describe('CryptoNetworks', () => { assert.strictEqual(result.dustThreshold, 546) }) }) + }) +}) + +describe('CryptoNetworks', () => { + + describe('.getBitcoinJsNetwork()', () => { describe('Dogecoin networks', () => { const dogeNetworks = ['dogecoin-mainnet', 'dogecoin-testnet', 'dogecoin-regtest'] @@ -71,6 +77,12 @@ describe('CryptoNetworks', () => { assert.strictEqual(reg.pubKeyHash, 0x6f) }) }) + }) +}) + +describe('CryptoNetworks', () => { + + describe('.getBitcoinJsNetwork()', () => { describe('Litecoin networks', () => { const ltcNetworks = ['litecoin-mainnet', 'litecoin-testnet', 'litecoin-regtest'] @@ -118,6 +130,9 @@ describe('CryptoNetworks', () => { }) }) }) +}) + +describe('CryptoNetworks', () => { describe('.getFirstBlock()', () => { it('returns 950000 for bitcoin-mainnet', () => { From 535277c1eff6797b76a5b008f801cfaf68380082 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:26 -0700 Subject: [PATCH 080/148] test: split error sanitization suite into same-title blocks by behavior --- test/unit/error_sanitize.test.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/unit/error_sanitize.test.js b/test/unit/error_sanitize.test.js index fe86096..482046f 100644 --- a/test/unit/error_sanitize.test.js +++ b/test/unit/error_sanitize.test.js @@ -49,6 +49,10 @@ describe('errorSanitize.upstreamErrorMessage', () => { assert.strictEqual(upstreamErrorMessage(null, FALLBACK), FALLBACK) assert.strictEqual(upstreamErrorMessage({}, FALLBACK), FALLBACK) }) +}) + +describe('errorSanitize.upstreamErrorMessage', () => { + const FALLBACK = 'Transaction broadcast failed' it('isTransportError matches network errnos but not application errors', () => { assert.strictEqual(isTransportError({ code: 'ENOTFOUND' }), true) From ff051bdd7405e51bab0e2090a2c5298e02c85524 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:27 -0700 Subject: [PATCH 081/148] test: split EVM finality gate suite into same-title blocks by behavior --- test/unit/evm_adapter.test.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/test/unit/evm_adapter.test.js b/test/unit/evm_adapter.test.js index d4e7964..2064ea1 100644 --- a/test/unit/evm_adapter.test.js +++ b/test/unit/evm_adapter.test.js @@ -18,6 +18,8 @@ const evmFinality = require('../../src/adapters/evm_finality') const { ChainAdapter, AdapterNotImplementedError } = require('../../src/adapters/chain_adapter') const { EvmAdapter } = require('../../src/adapters/evm_adapter') +describe('EVM finality gate (evmFinality)', registerFinalityChoices) + describe('EVM finality gate (evmFinality)', () => { describe('.finalityGate()', () => { @@ -34,6 +36,9 @@ describe('EVM finality gate (evmFinality)', () => { assert.throws(() => evmFinality.finalityGate(undefined), /No EVM finality gate/) }) }) +}) + +describe('EVM finality gate (evmFinality)', () => { describe('L1 Ethereum', () => { it('gates on checkpoint finality, not raw depth', () => { @@ -51,6 +56,9 @@ describe('EVM finality gate (evmFinality)', () => { assert.strictEqual(evmFinality.requiresSettlementChainFinality('ETH'), false) }) }) +}) + +describe('EVM finality gate (evmFinality)', () => { describe('L2 rollups (THE L2 TRAP, spec §12)', () => { const l2s = ['BASE', 'ARBITRUM', 'OPTIMISM'] @@ -73,7 +81,9 @@ describe('EVM finality gate (evmFinality)', () => { }) }) }) +}) +function registerFinalityChoices() { it('recommends Base as the primary L2 target (spec §11)', () => { assert.strictEqual(evmFinality.PRIMARY_L2_TARGET, 'BASE') // The recommended target must itself be a defined L2 gate. @@ -87,7 +97,7 @@ describe('EVM finality gate (evmFinality)', () => { key + ' must stay unarmed until a coordinated coin-file + pin bump') } }) -}) +} describe('ChainAdapter base', () => { it('every surface method throws AdapterNotImplementedError with the method name', () => { From 2012aff036e68479494a7d484c75a8dc5a8d211e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:27 -0700 Subject: [PATCH 082/148] test: split health readiness suite into same-title blocks by behavior --- test/unit/health_serve_readiness.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/unit/health_serve_readiness.test.js b/test/unit/health_serve_readiness.test.js index e9a6c54..07c3c13 100644 --- a/test/unit/health_serve_readiness.test.js +++ b/test/unit/health_serve_readiness.test.js @@ -70,6 +70,9 @@ describe('health(): tracker_synced is serve-readiness (create_tx parity) @regres assert.strictEqual(h.tracker_synced, false); } finally { restore(); } }); +}); + +describe('health(): tracker_synced is serve-readiness (create_tx parity) @regression', function () { // The tracker publishes halted independently of synced, so a tracker // frozen on an unrecoverable reorg whose last committed height still shows an @@ -104,6 +107,9 @@ describe('health(): tracker_synced is serve-readiness (create_tx parity) @regres assert.strictEqual(h.tracker_lag, -100); } finally { restore(); } }); +}); + +describe('health(): tracker_synced is serve-readiness (create_tx parity) @regression', function () { // create_tx refuses UTXO_TRACKER_NOT_READY for the whole post-restart // window in which the mempool index is still rebuilding, so a probe blind to that From 2ea7bc7b28ab6ab4a8da354bffe0bdce8ca9e98b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:27 -0700 Subject: [PATCH 083/148] test: split large satoshi amounts suite into same-title blocks by behavior --- test/unit/large_satoshi_amounts.test.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/unit/large_satoshi_amounts.test.js b/test/unit/large_satoshi_amounts.test.js index 9253674..3f3cfd6 100644 --- a/test/unit/large_satoshi_amounts.test.js +++ b/test/unit/large_satoshi_amounts.test.js @@ -87,6 +87,9 @@ describe('large satoshi amounts (>2^53-1)', () => { assert.throws(() => parseSatoshiAmount(BIG_STR + '.5', 'v', { allowBig: true }), /non-negative integer/) }) }) +}) + +describe('large satoshi amounts (>2^53-1)', () => { describe('validateUtxoEntry / validateCustomOutputs', () => { it('accepts a >2^53-1 utxo value string, coercing to BigInt', () => { @@ -101,6 +104,9 @@ describe('large satoshi amounts (>2^53-1)', () => { assert.strictEqual(outs[0].value, 9007199254740993n) }) }) +}) + +describe('large satoshi amounts (>2^53-1)', () => { describe('createTransaction', () => { it('builds a PSBT paying a >2^53-1-sat custom output with exact change', async () => { @@ -141,6 +147,12 @@ describe('large satoshi amounts (>2^53-1)', () => { const expectedChange = 12000000000000000000n - 100000000n - 10000n assert.ok(values.includes(expectedChange), `change ${expectedChange} missing exactly; got ${values}`) }) + }) +}) + +describe('large satoshi amounts (>2^53-1)', () => { + + describe('createTransaction', () => { it('reports insufficient funds with JSON-safe (stringified) big amounts', async () => { const encoder = makeEncoder() @@ -161,6 +173,9 @@ describe('large satoshi amounts (>2^53-1)', () => { ) }) }) +}) + +describe('large satoshi amounts (>2^53-1)', () => { describe('patched 64-bit serializers', () => { it('round-trips safe-range values as Number and big values as BigInt', () => { From 251ceeee3713ce544a15f32f593d21571fde41d1 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:32 -0700 Subject: [PATCH 084/148] test: split maintenance window suites into same-title blocks and hoist the status setup --- test/unit/maintenance_window.test.js | 74 ++++++++++++++++++---------- 1 file changed, 47 insertions(+), 27 deletions(-) diff --git a/test/unit/maintenance_window.test.js b/test/unit/maintenance_window.test.js index 9bc1753..7399cb3 100644 --- a/test/unit/maintenance_window.test.js +++ b/test/unit/maintenance_window.test.js @@ -93,7 +93,11 @@ describe('maintenanceWindow: an operator-declared scheduled outage @regression', const later = JSON.stringify({ since: new Date(NOW + 3600 * 1000).toISOString(), until: in2h }); assert.strictEqual(parseMaintenanceWindow(later, NOW), null); }); + }); +}); +describe('maintenanceWindow: an operator-declared scheduled outage @regression', function () { + describe('parseMaintenanceWindow()', function () { it('resolves malformed, empty, non-object and oversized sentinels to no maintenance', function () { for (const bad of ['', 'not json', '[]', 'null', '"just a string"', '42']) { assert.strictEqual(parseMaintenanceWindow(bad, NOW), null, `expected null for ${JSON.stringify(bad)}`); @@ -131,7 +135,11 @@ describe('maintenanceWindow: an operator-declared scheduled outage @regression', assert.strictEqual(parseMaintenanceWindow(opened, NOW + 60000), null, 'invalid just after it opens'); assert.strictEqual(parseMaintenanceWindow(opened, NOW + thirtyDays - 3600 * 1000), null, 'still invalid an hour before expiry'); }); + }); +}); +describe('maintenanceWindow: an operator-declared scheduled outage @regression', function () { + describe('parseMaintenanceWindow()', function () { it('keeps a declared span of exactly the ceiling, and refuses one millisecond more', function () { const atCeiling = JSON.stringify({ since: NOW - (MAX_WINDOW_MS - 3600 * 1000), until: NOW + 3600 * 1000 }); assert.ok(parseMaintenanceWindow(atCeiling, NOW), 'the ceiling itself is still a window'); @@ -165,7 +173,9 @@ describe('maintenanceWindow: an operator-declared scheduled outage @regression', assert.strictEqual(w.since, null); }); }); +}); +describe('maintenanceWindow: an operator-declared scheduled outage @regression', function () { describe('readMaintenanceWindow()', function () { let dir; beforeEach(function () { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'xc-maint-')); }); @@ -208,7 +218,9 @@ describe('maintenanceWindow: an operator-declared scheduled outage @regression', assert.strictEqual(await readMaintenanceWindow(NOW, p), null); }); }); +}); +describe('maintenanceWindow: an operator-declared scheduled outage @regression', function () { describe('sentinelPath()', function () { // Default lives INSIDE the encoder container so xchain-node can write it // with a plain `docker exec tee` against an already-running encoder: no @@ -229,34 +241,37 @@ describe('maintenanceWindow: an operator-declared scheduled outage @regression', }); }); -describe('health()/GET /status carry the window without bending readiness @regression', function () { - const { jsonRpcController, encoder } = require('../../src/api'); - - let dir, sentinel, origEnv; - beforeEach(function () { - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'xc-maint-api-')); - sentinel = path.join(dir, 'window.json'); - origEnv = process.env.ENCODER_MAINTENANCE_FILE; - process.env.ENCODER_MAINTENANCE_FILE = sentinel; - }); - afterEach(function () { - if (origEnv === undefined) delete process.env.ENCODER_MAINTENANCE_FILE; - else process.env.ENCODER_MAINTENANCE_FILE = origEnv; - fs.rmSync(dir, { recursive: true, force: true }); - }); +const { jsonRpcController, encoder } = require('../../src/api'); + +let dir, sentinel, origEnv; +function setupApi() { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'xc-maint-api-')); + sentinel = path.join(dir, 'window.json'); + origEnv = process.env.ENCODER_MAINTENANCE_FILE; + process.env.ENCODER_MAINTENANCE_FILE = sentinel; +} +function cleanupApi() { + if (origEnv === undefined) delete process.env.ENCODER_MAINTENANCE_FILE; + else process.env.ENCODER_MAINTENANCE_FILE = origEnv; + fs.rmSync(dir, { recursive: true, force: true }); +} + +function stubSync(status) { + const orig = encoder.utxoTrackerConnector.getSyncStatus; + encoder.utxoTrackerConnector.getSyncStatus = async () => status; + return () => { encoder.utxoTrackerConnector.getSyncStatus = orig; }; +} +function declare(minutesAhead) { + fs.writeFileSync(sentinel, JSON.stringify({ + reason: 'utxo-tracker bootstrap publish', + since: new Date().toISOString(), + until: new Date(Date.now() + minutesAhead * 60000).toISOString() + })); +} - function stubSync(status) { - const orig = encoder.utxoTrackerConnector.getSyncStatus; - encoder.utxoTrackerConnector.getSyncStatus = async () => status; - return () => { encoder.utxoTrackerConnector.getSyncStatus = orig; }; - } - function declare(minutesAhead) { - fs.writeFileSync(sentinel, JSON.stringify({ - reason: 'utxo-tracker bootstrap publish', - since: new Date().toISOString(), - until: new Date(Date.now() + minutesAhead * 60000).toISOString() - })); - } +describe('health()/GET /status carry the window without bending readiness @regression', function () { + beforeEach(setupApi); + afterEach(cleanupApi); it('reports maintenance null when no window is declared', async function () { const restore = stubSync({ synced: true, lag: 0 }); @@ -281,6 +296,11 @@ describe('health()/GET /status carry the window without bending readiness @regre assert.strictEqual(h.maintenance.reason, 'utxo-tracker bootstrap publish'); } finally { restore(); } }); +}); + +describe('health()/GET /status carry the window without bending readiness @regression', function () { + beforeEach(setupApi); + afterEach(cleanupApi); it('a window does not make a lagging tracker read synced', async function () { const restore = stubSync({ synced: true, lag: encoder.maxUtxoTrackerLagBlocks + 5 }); From eac5686be123d510084b3ca5e9954d80ff3956a1 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:32 -0700 Subject: [PATCH 085/148] test: split OpenRPC coverage suite into same-title blocks by behavior --- test/unit/openrpc_coverage.test.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/unit/openrpc_coverage.test.js b/test/unit/openrpc_coverage.test.js index 809a9ac..5bf5181 100644 --- a/test/unit/openrpc_coverage.test.js +++ b/test/unit/openrpc_coverage.test.js @@ -23,8 +23,7 @@ const fs = require('fs'); const path = require('path'); const assert = require('assert'); -describe('openrpc.json method coverage', () => { - +{ const src = fs.readFileSync(path.join(__dirname, '../../src/api.js'), 'utf8'); const spec = JSON.parse(fs.readFileSync(path.join(__dirname, '../../docs/openrpc.json'), 'utf8')); @@ -32,6 +31,8 @@ describe('openrpc.json method coverage', () => { const block = src.slice(src.indexOf('jsonRpcController = {'), src.indexOf('jsonRouter(')); const controllerMethods = [...block.matchAll(/^\s{4}async\s+([a-z][a-z0-9_]*)\s*\(/gm)].map((m) => m[1]); +describe('openrpc.json method coverage', () => { + it('extracts a sane controller method list', () => { assert.ok(controllerMethods.includes('ping') && controllerMethods.includes('create_tx'), `extraction broken: ${controllerMethods.join(', ')}`); @@ -62,6 +63,7 @@ describe('openrpc.json method coverage', () => { assert.deepStrictEqual(spec, JSON.parse(JSON.stringify(built)), 'docs/openrpc.json is out of date or hand-edited; run: node docs/openrpc.build.js'); }); +}); // Param-level drift guard. The method check above never looked at params, which // is how create_tx came to validate and act on `attachPrevTx` while the published @@ -78,6 +80,8 @@ describe('openrpc.json method coverage', () => { [...validateAllBody.matchAll(/params\.([A-Za-z_][A-Za-z0-9_]*)/g)].map((m) => m[1]) )].sort(); +describe('openrpc.json method coverage', () => { + it('extracts a sane validateAll param list', () => { assert.notStrictEqual(fnStart, -1, 'validateAll not found in src/common/validator.js'); assert.ok(fnEnd > fnStart, 'validateAll body slice is empty; the return marker moved'); @@ -95,3 +99,4 @@ describe('openrpc.json method coverage', () => { + 'then regenerate with: node docs/openrpc.build.js'); }); }); +} From a4ba2586a91de4aca7e6e0c7b3fdad6e22db37a7 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:32 -0700 Subject: [PATCH 086/148] test: split dependency advisory suite into same-title blocks by behavior --- .../configuration/dependency_advisories.test.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/test/unit/security/configuration/dependency_advisories.test.js b/test/unit/security/configuration/dependency_advisories.test.js index 447df6c..1cfbb75 100644 --- a/test/unit/security/configuration/dependency_advisories.test.js +++ b/test/unit/security/configuration/dependency_advisories.test.js @@ -19,7 +19,7 @@ const axios = require('axios'); // silently resolve back into a known-vulnerable range. npm only re-resolves // a lock entry when that entry is absent, so an override alone is not enough // to prove the tree is clean: assert the resolved version too. -describe('Security: remediated dependency advisories @regression @tier4', function () { +{ // Located by walking up to the lockfile rather than by a fixed number of // '..' hops, so this file stays byte-identical across all the sibling // repos that carry it regardless of where each one files its tests. @@ -138,6 +138,7 @@ describe('Security: remediated dependency advisories @regression @tier4', functi || (pkg.devDependencies || {})[name]; } +describe('Security: remediated dependency advisories @regression @tier4', function () { advisories.forEach(function (adv) { const floor = adv.minSafe.join('.'); const present = lockEntries(adv.name).length > 0; @@ -165,11 +166,13 @@ describe('Security: remediated dependency advisories @regression @tier4', functi }); }); }); +}); - // The version pins above are necessary but not sufficient: a minimatch that - // cannot call the overridden brace-expansion installs quietly and only fails - // when something actually expands a brace, which in this tree is mocha's own - // file collector. Exercise the seam so the breakage surfaces here. +// The version pins above are necessary but not sufficient: a minimatch that +// cannot call the overridden brace-expansion installs quietly and only fails +// when something actually expands a brace, which in this tree is mocha's own +// file collector. Exercise the seam so the breakage surfaces here. +describe('Security: remediated dependency advisories @regression @tier4', function () { it('ADV-3: minimatch can still brace-expand through the overridden brace-expansion', function () { const { minimatch, braceExpand } = require('minimatch'); @@ -202,3 +205,4 @@ describe('Security: remediated dependency advisories @regression @tier4', functi `installed axios is ${axios.VERSION}, inside the vulnerable range (fixed in 1.18.0)`); }); }); +} From 983bc111ffaa3f595b8590346a3b4ac17039887d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:33 -0700 Subject: [PATCH 087/148] test: split suggested fee ceiling suite and scope its env cleanup hook --- test/unit/suggested_fee_ceiling.test.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/unit/suggested_fee_ceiling.test.js b/test/unit/suggested_fee_ceiling.test.js index 6ba4733..36ee2c9 100644 --- a/test/unit/suggested_fee_ceiling.test.js +++ b/test/unit/suggested_fee_ceiling.test.js @@ -29,9 +29,8 @@ const SATOSHI_UNIT = 100000000; const ceiling = (net) => XChainEncoder.suggestedFeeCeilingPerByte(net, SATOSHI_UNIT); const perVbyte = (btcPerByte) => btcPerByte == null ? null : Math.round(btcPerByte * SATOSHI_UNIT); -afterEach(function () { delete process.env.SUGGESTED_FEE_MAX_PER_VBYTE; }); - describe('suggested fee-rate ceiling @regression @tier1', function () { + afterEach(function () { delete process.env.SUGGESTED_FEE_MAX_PER_VBYTE; }); it('clamps test chains to the default ceiling', function () { for (const net of ['bitcoin-testnet', 'litecoin-testnet', 'dogecoin-testnet', 'bitcoin-regtest']) { @@ -76,6 +75,10 @@ describe('suggested fee-rate ceiling @regression @tier1', function () { assert.strictEqual(ceiling(net), null, net + ' must not be clamped'); } }); +}); + +describe('suggested fee-rate ceiling @regression @tier1', function () { + afterEach(function () { delete process.env.SUGGESTED_FEE_MAX_PER_VBYTE; }); it('treats an unknown or empty network as mainnet, never as a test chain', function () { // Fail toward the unclamped side: wrongly clamping a real fee market From 941c3cd17127ab57982cafd0bafa4b47cb9be124 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:33 -0700 Subject: [PATCH 088/148] test: split transaction size estimator suites into same-title blocks by behavior --- test/unit/tx_size_estimator.test.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/unit/tx_size_estimator.test.js b/test/unit/tx_size_estimator.test.js index 8806226..46e2a49 100644 --- a/test/unit/tx_size_estimator.test.js +++ b/test/unit/tx_size_estimator.test.js @@ -35,7 +35,9 @@ describe('TxSizeEstimator', () => { assert.strictEqual(TxSizeEstimator.estimateOpReturnOutput(data), 12) }) }) +}) +describe('TxSizeEstimator', () => { describe('.estimateP2shOutput()', () => { it('always returns 32', () => { assert.strictEqual(TxSizeEstimator.estimateP2shOutput(), 32) @@ -47,7 +49,9 @@ describe('TxSizeEstimator', () => { assert.strictEqual(TxSizeEstimator.estimateP2wshOutput(), 43) }) }) +}) +describe('TxSizeEstimator', () => { // The P2SH/P2WSH funding tx has to pay the reveal's miner fee for // every reveal-side customOutput, so it needs those outputs' byte cost. describe('.estimateOutputSizeForAddress(address, network)', () => { @@ -84,6 +88,16 @@ describe('TxSizeEstimator', () => { TxSizeEstimator.estimateOutputSizeForAddress(address, NET), TxSizeEstimator.estimateP2wshOutput()) }) + }) +}) + +describe('TxSizeEstimator', () => { + describe('.estimateOutputSizeForAddress(address, network)', () => { + const NET = bitcoin.networks.regtest + const pk = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex' + ) it('falls back to the largest standard output (43) for an unparseable address', () => { assert.strictEqual( @@ -105,7 +119,9 @@ describe('TxSizeEstimator', () => { TxSizeEstimator.estimateOutputSizeForAddress(mainnetAddress, NET), 43) }) }) +}) +describe('TxSizeEstimator', () => { describe('.estimateMultisignOutput()', () => { it('returns 8 + 1 + the compiled 1-of-3 bare-multisig script length (114)', () => { // Derive the expected size from a real compiled p2ms script rather than a @@ -122,7 +138,9 @@ describe('TxSizeEstimator', () => { assert.strictEqual(TxSizeEstimator.estimateMultisignOutput(), expected) }) }) +}) +describe('TxSizeEstimator', () => { describe('.estimateP2shInputWithRedeem(redeemData)', () => { // Estimate breakdown: // 40 outpoint+sequence + scriptSig-varint + scriptSig @@ -171,7 +189,9 @@ describe('TxSizeEstimator', () => { assert.strictEqual(TxSizeEstimator.estimateP2shInputWithRedeem(redeem), 40 + 3 + 253) }) }) +}) +describe('TxSizeEstimator', () => { describe('.estimateInputSize(utxo)', () => { describe('SegWit inputs (witnessUtxo)', () => { @@ -226,7 +246,11 @@ describe('TxSizeEstimator', () => { assert.strictEqual(TxSizeEstimator.estimateInputSize(utxo), 350) }) }) + }) +}) +describe('TxSizeEstimator', () => { + describe('.estimateInputSize(utxo)', () => { describe('Legacy inputs (nonWitnessUtxo)', () => { it('returns 180 for P2PKH nonWitnessUtxo', () => { const tx = new bitcoin.Transaction() @@ -266,7 +290,11 @@ describe('TxSizeEstimator', () => { assert.strictEqual(TxSizeEstimator.estimateInputSize(utxo), 289) }) }) + }) +}) +describe('TxSizeEstimator', () => { + describe('.estimateInputSize(utxo)', () => { describe('No witnessUtxo or nonWitnessUtxo', () => { it('returns 350 (conservative fallback)', () => { const utxo = { From 46e8d307191d40bfaa407e77669decead4675598 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:33 -0700 Subject: [PATCH 089/148] test: split automatic encoding suites into same-title blocks by behavior --- .../unit/xchain_encoder_auto_encoding.test.js | 78 ++++++++++++------- 1 file changed, 48 insertions(+), 30 deletions(-) diff --git a/test/unit/xchain_encoder_auto_encoding.test.js b/test/unit/xchain_encoder_auto_encoding.test.js index 55b506d..4ecfc92 100644 --- a/test/unit/xchain_encoder_auto_encoding.test.js +++ b/test/unit/xchain_encoder_auto_encoding.test.js @@ -114,7 +114,9 @@ describe('size-aware encoding selection (§6)', function () { assert.strictEqual(doge.selectEncoding(60, PUBKEY_HEX, opts), 'OP_RETURN') }) }) +}) +describe('size-aware encoding selection (§6)', function () { describe('through createTransaction', function () { it('AUTO returns the commit/reveal pair when it selects TAPROOT', async function () { const e = makeEncoder() @@ -157,7 +159,11 @@ describe('size-aware encoding selection (§6)', function () { (err) => err instanceof RangeError && new RegExp(`exceeds maximum ${validator.ENVELOPE_MAX_PAYLOAD}`).test(err.message)) }) + }) +}) +describe('size-aware encoding selection (§6)', function () { + describe('through createTransaction', function () { it('leaves the legacy no-encoding path exactly as shipped', async function () { // The whole point of the opt-in. A caller who passes no encoding // gets OP_RETURN or P2SH, never TAPROOT, and never a reveal PSBT to @@ -183,7 +189,9 @@ describe('size-aware encoding selection (§6)', function () { assert.strictEqual(large.revealPsbt, undefined) }) }) +}) +describe('size-aware encoding selection (§6)', function () { describe('validator', function () { function base(extra) { return Object.assign({ @@ -253,38 +261,11 @@ describe('compression ON by default (§5.2/§7)', function () { null, null, null, true, null, null, null, false, false) assert.strictEqual(result.compression, undefined) }) +}) - describe('the default pass rides raw where an explicit request throws', function () { - const GATED = 'FILE|0|secret.enc|application/octet-stream|Secret||MYTOKEN|1|' + 'a'.repeat(64) - const cases = [ - ['a non-FILE action', 'SEND|0|TOK|1|mzBc4XEFSdzCDcTxAgf6EZXgsZWpztRhef|m', 'not-a-file-action'], - ['a token-gated FILE', GATED, 'gated-file'], - ['an action that already declares a codec', compression.withCompressionField(FILE_ACTION, '1'), 'codec-already-declared'] - ] - - for (const [label, action, reason] of cases) { - it(`${label}: the default pass builds and reports "${reason}"`, async function () { - const e = makeEncoder() - const network = e.network - const result = await e.createTransaction( - [segwitUtxo(network)], callerAddress(network), null, action, - compressibleText(3000), null, false, 'P2WSH', callerAddress(network)) - assert.strictEqual(result.compression.compressed, false) - assert.strictEqual(result.compression.reason, reason) - }) +describe('compression ON by default (§5.2/§7)', function () { - it(`${label}: an explicit request still fails closed`, async function () { - const e = makeEncoder() - const network = e.network - await assert.rejects( - e.createTransaction( - [segwitUtxo(network)], callerAddress(network), null, action, - compressibleText(3000), null, false, 'P2WSH', callerAddress(network), - null, null, null, true, null, null, null, false, true), - /Compression requested/) - }) - } - }) + afterEach(function () { delete process.env.XCHAIN_COMPRESSION_DEFAULT }) it('compression can make a payload fit a lane it would otherwise overflow', async function () { // A real consequence of the flip, worth stating rather than discovering: @@ -321,3 +302,40 @@ describe('compression ON by default (§5.2/§7)', function () { assert.strictEqual(result.compression.compressed, false) }) }) + +describe('compression ON by default (§5.2/§7)', function () { + + afterEach(function () { delete process.env.XCHAIN_COMPRESSION_DEFAULT }) + + describe('the default pass rides raw where an explicit request throws', function () { + const GATED = 'FILE|0|secret.enc|application/octet-stream|Secret||MYTOKEN|1|' + 'a'.repeat(64) + const cases = [ + ['a non-FILE action', 'SEND|0|TOK|1|mzBc4XEFSdzCDcTxAgf6EZXgsZWpztRhef|m', 'not-a-file-action'], + ['a token-gated FILE', GATED, 'gated-file'], + ['an action that already declares a codec', compression.withCompressionField(FILE_ACTION, '1'), 'codec-already-declared'] + ] + + for (const [label, action, reason] of cases) { + it(`${label}: the default pass builds and reports "${reason}"`, async function () { + const e = makeEncoder() + const network = e.network + const result = await e.createTransaction( + [segwitUtxo(network)], callerAddress(network), null, action, + compressibleText(3000), null, false, 'P2WSH', callerAddress(network)) + assert.strictEqual(result.compression.compressed, false) + assert.strictEqual(result.compression.reason, reason) + }) + + it(`${label}: an explicit request still fails closed`, async function () { + const e = makeEncoder() + const network = e.network + await assert.rejects( + e.createTransaction( + [segwitUtxo(network)], callerAddress(network), null, action, + compressibleText(3000), null, false, 'P2WSH', callerAddress(network), + null, null, null, true, null, null, null, false, true), + /Compression requested/) + }) + } + }) +}) From 36cef070e701f83d611f244de3bf9861fdbd4b31 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:33 -0700 Subject: [PATCH 090/148] test: split data to pubkey suite into same-title blocks by behavior --- test/unit/xchain_encoder_data_to_pubkey.test.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/unit/xchain_encoder_data_to_pubkey.test.js b/test/unit/xchain_encoder_data_to_pubkey.test.js index dc5e64a..780899d 100644 --- a/test/unit/xchain_encoder_data_to_pubkey.test.js +++ b/test/unit/xchain_encoder_data_to_pubkey.test.js @@ -54,6 +54,14 @@ describe('XChainEncoder.dataToPubkey()', () => { assert.strictEqual(padding[i], 0x00, `padding byte at index ${i} is not 0x00`) } }) +}) + +describe('XChainEncoder.dataToPubkey()', () => { + let encoder + + beforeEach(() => { + encoder = makeEncoder() + }) it('no padding when data is exactly 32 bytes', async () => { const data = Buffer.alloc(32, 0xCC) From 55b1cfd9200adb941c0138ef117c38cfcc9b48e0 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:40 -0700 Subject: [PATCH 091/148] test: split exact-input suite into same-title blocks by behavior --- test/unit/xchain_encoder_exact_inputs.test.js | 108 +++++++++--------- 1 file changed, 57 insertions(+), 51 deletions(-) diff --git a/test/unit/xchain_encoder_exact_inputs.test.js b/test/unit/xchain_encoder_exact_inputs.test.js index aa1505c..02c22c9 100644 --- a/test/unit/xchain_encoder_exact_inputs.test.js +++ b/test/unit/xchain_encoder_exact_inputs.test.js @@ -158,58 +158,9 @@ describe('XChainEncoder create_tx options.exactInputs', () => { assert.strictEqual(input.sequence, 0xfffffffd) } }) +}) - describe('reservations, and how exact-input mode interacts with them', () => { - // Every selected outpoint is reserved, so caller-supplied sets - // included. Exact-input mode cannot take the ordinary escape hatch of - // SKIPPING a reserved outpoint: it promised the caller every named outpoint - // is spent, and a CPFP child that quietly drops one descends from less than - // the stuck chain and still does not mine. So it claims the whole set or - // refuses the build, and it never reorders to dodge a hold. - - it('reserves every named outpoint, so a concurrent build cannot take one', async () => { - const encoder = makeEncoder() - const { psbt } = await createTx(encoder, stuckChainUtxos(), - { options: { exactInputs: true } }) - assert.strictEqual(psbt.txInputs.length, 3) - for (const k of [`${TXID_BIG}:0`, `${TXID_MID}:1`, `${TXID_TINY}:2`]) { - assert.ok(encoder.outpointReservations.has(k), `${k} must be reserved`) - } - }) - - it('refuses the whole build when another build holds a named outpoint', async () => { - const encoder = makeEncoder() - encoder.outpointReservations.set(`${TXID_MID}:1`, Date.now() + 5 * 60 * 1000) - await assert.rejects( - () => createTx(encoder, stuckChainUtxos(), { options: { exactInputs: true } }), - (err) => { - assert.strictEqual(err.operational, true) - assert.strictEqual(err.xchainCode, 'INPUT_RESERVED') - assert.deepStrictEqual(err.details.reserved, [`${TXID_MID}:1`]) - assert.match(err.message, /exact-input mode cannot drop them/) - return true - }, - 'a held named outpoint must fail the build, never be silently dropped') - }) - - it('hands back its own claims when the build fails, so a retry is not blocked', async () => { - const encoder = makeEncoder() - encoder.outpointReservations.set(`${TXID_TINY}:2`, Date.now() + 5 * 60 * 1000) - await assert.rejects(() => createTx(encoder, stuckChainUtxos(), - { options: { exactInputs: true } })) - assert.strictEqual(encoder.outpointReservations.size, 1, - 'only the foreign hold should remain; this call must release nothing of its own into the map') - }) - - it('a second identical rescue is refused rather than rebuilt, as on every other path', async () => { - const encoder = makeEncoder() - await createTx(encoder, stuckChainUtxos(), { options: { exactInputs: true } }) - await assert.rejects( - () => createTx(encoder, stuckChainUtxos(), { options: { exactInputs: true } }), - (err) => err.operational === true && err.xchainCode === 'INPUT_RESERVED') - }) - }) - +describe('XChainEncoder create_tx options.exactInputs', () => { it('refuses an empty or absent utxos array instead of falling back to the tracker', async () => { const encoder = makeEncoder() await assert.rejects( @@ -260,7 +211,62 @@ describe('XChainEncoder create_tx options.exactInputs', () => { `options ${JSON.stringify(options)} must not change selection`) } }) +}) + +describe('XChainEncoder create_tx options.exactInputs', () => { + describe('reservations, and how exact-input mode interacts with them', () => { + // Every selected outpoint is reserved, so caller-supplied sets + // included. Exact-input mode cannot take the ordinary escape hatch of + // SKIPPING a reserved outpoint: it promised the caller every named outpoint + // is spent, and a CPFP child that quietly drops one descends from less than + // the stuck chain and still does not mine. So it claims the whole set or + // refuses the build, and it never reorders to dodge a hold. + + it('reserves every named outpoint, so a concurrent build cannot take one', async () => { + const encoder = makeEncoder() + const { psbt } = await createTx(encoder, stuckChainUtxos(), + { options: { exactInputs: true } }) + assert.strictEqual(psbt.txInputs.length, 3) + for (const k of [`${TXID_BIG}:0`, `${TXID_MID}:1`, `${TXID_TINY}:2`]) { + assert.ok(encoder.outpointReservations.has(k), `${k} must be reserved`) + } + }) + + it('refuses the whole build when another build holds a named outpoint', async () => { + const encoder = makeEncoder() + encoder.outpointReservations.set(`${TXID_MID}:1`, Date.now() + 5 * 60 * 1000) + await assert.rejects( + () => createTx(encoder, stuckChainUtxos(), { options: { exactInputs: true } }), + (err) => { + assert.strictEqual(err.operational, true) + assert.strictEqual(err.xchainCode, 'INPUT_RESERVED') + assert.deepStrictEqual(err.details.reserved, [`${TXID_MID}:1`]) + assert.match(err.message, /exact-input mode cannot drop them/) + return true + }, + 'a held named outpoint must fail the build, never be silently dropped') + }) + + it('hands back its own claims when the build fails, so a retry is not blocked', async () => { + const encoder = makeEncoder() + encoder.outpointReservations.set(`${TXID_TINY}:2`, Date.now() + 5 * 60 * 1000) + await assert.rejects(() => createTx(encoder, stuckChainUtxos(), + { options: { exactInputs: true } })) + assert.strictEqual(encoder.outpointReservations.size, 1, + 'only the foreign hold should remain; this call must release nothing of its own into the map') + }) + + it('a second identical rescue is refused rather than rebuilt, as on every other path', async () => { + const encoder = makeEncoder() + await createTx(encoder, stuckChainUtxos(), { options: { exactInputs: true } }) + await assert.rejects( + () => createTx(encoder, stuckChainUtxos(), { options: { exactInputs: true } }), + (err) => err.operational === true && err.xchainCode === 'INPUT_RESERVED') + }) + }) +}) +describe('XChainEncoder create_tx options.exactInputs', () => { describe('validator', () => { it('accepts exactInputs as a real boolean', () => { const params = validator.validateAll({ From 7182a12bc377a419e2891d79b3d039c31fa6f622 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:40 -0700 Subject: [PATCH 092/148] test: split fee-rate cap suite into same-title blocks by behavior --- test/unit/xchain_encoder_fee_rate_cap.test.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/unit/xchain_encoder_fee_rate_cap.test.js b/test/unit/xchain_encoder_fee_rate_cap.test.js index 8d4ce2c..c38f704 100644 --- a/test/unit/xchain_encoder_fee_rate_cap.test.js +++ b/test/unit/xchain_encoder_fee_rate_cap.test.js @@ -122,7 +122,9 @@ describe('XChainEncoder fee-rate cap', () => { assert.ok(paidFee(result) >= 1000 && paidFee(result) <= 3000, `fee ${paidFee(result)} should be ~10 sat/byte (~1310 sats), not clamped or 1e8x inflated`) }) +}) +describe('XChainEncoder fee-rate cap', () => { it('clamps a hostile feePerKb via the relayfee anchor when estimatesmartfee is unavailable', async () => { const encoder = makeEncoder() // Node has no smart-fee estimate (fresh node / warming mempool / low @@ -170,7 +172,9 @@ describe('XChainEncoder fee-rate cap', () => { (err) => err instanceof RangeError && /fee-rate cap/.test(err.message) ) }) +}) +describe('XChainEncoder fee-rate cap', () => { it('burn backstop ceiling is anchored to the node rate, not a caller-inflated feePerKb (cap disabled)', async () => { // MAX_FEE_RATE_MULTIPLIER=0 disables the relative cap and no absolute // MAX_FEE_RATE_KB is set. Before the fix, the 100x burn backstop derived From 52e7a274fe7cb33c9b3c069b27d9d1ae6364b18d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:40 -0700 Subject: [PATCH 093/148] test: split obfuscate suite into same-title blocks by behavior --- test/unit/xchain_encoder_obfuscate.test.js | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/test/unit/xchain_encoder_obfuscate.test.js b/test/unit/xchain_encoder_obfuscate.test.js index e5c05c3..ef14987 100644 --- a/test/unit/xchain_encoder_obfuscate.test.js +++ b/test/unit/xchain_encoder_obfuscate.test.js @@ -26,11 +26,11 @@ function deobfuscate (data, key) { return Buffer.concat([decipher.update(data), decipher.final()]) } -describe('XChainEncoder.obfuscate()', () => { - let encoder - // A fake txid (64 hex chars) with all distinct first 32 chars - const TXID = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789' +let encoder +// A fake txid (64 hex chars) with all distinct first 32 chars +const TXID = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789' +describe('XChainEncoder.obfuscate()', () => { beforeEach(() => { encoder = makeEncoder() }) @@ -60,6 +60,12 @@ describe('XChainEncoder.obfuscate()', () => { const result2 = await encoder.obfuscate(data, TXID) assert.deepStrictEqual(result1, result2) }) +}) + +describe('XChainEncoder.obfuscate()', () => { + beforeEach(() => { + encoder = makeEncoder() + }) it('different keys produce different output', async () => { const data = Buffer.from('key sensitivity test') @@ -81,6 +87,12 @@ describe('XChainEncoder.obfuscate()', () => { const actual = await encoder.obfuscate(data, TXID) assert.deepStrictEqual(actual, expected) }) +}) + +describe('XChainEncoder.obfuscate()', () => { + beforeEach(() => { + encoder = makeEncoder() + }) it('handles 1-byte data', async () => { const data = Buffer.from([0x42]) From fdb8b58295af8fb91032a588ba8fb7e9e2657566 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:40 -0700 Subject: [PATCH 094/148] test: split reveal headroom suite into same-title blocks by behavior --- test/unit/xchain_encoder_p2sh_reveal_headroom.test.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/unit/xchain_encoder_p2sh_reveal_headroom.test.js b/test/unit/xchain_encoder_p2sh_reveal_headroom.test.js index 4358ca4..7bc82a6 100644 --- a/test/unit/xchain_encoder_p2sh_reveal_headroom.test.js +++ b/test/unit/xchain_encoder_p2sh_reveal_headroom.test.js @@ -185,7 +185,9 @@ describe('XChainEncoder P2SH reveal headroom', () => { assert.ok(sweep, 'the surplus must return to the caller address') assert.ok(sweep.value >= DOGE_DUST, `sweep ${sweep && sweep.value} must be at least dust ${DOGE_DUST}`) }) +}) +describe('XChainEncoder P2SH reveal headroom', () => { it('funds floor-dominant legs with exactly one extra output floor of headroom (tiny fee rate)', async () => { const encoder = makeEncoder(DOGE) // 1 koinu/byte: every size-based estimate is far below the output floor, so the @@ -236,7 +238,9 @@ describe('XChainEncoder P2SH reveal headroom', () => { assert.strictEqual(reveal.outs[0].value, 0) assert.ok(reveal.outs[0].script.toString('hex').startsWith('6a')) }) +}) +describe('XChainEncoder P2SH reveal headroom', () => { // The DOGE pin spends a P2PKH input, the only output type that chain holds. // Byte-identity pins for the paths that already work today. The expected // hexes were generated with THIS harness against the pre-fix (git HEAD) From 95bf4f6ac30b0a048b87caacddf7c4abf33237fd Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:40 -0700 Subject: [PATCH 095/148] test: split prepareData suite into same-title blocks by encoding --- test/unit/xchain_encoder_prepare_data.test.js | 48 +++++++++++++++++-- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/test/unit/xchain_encoder_prepare_data.test.js b/test/unit/xchain_encoder_prepare_data.test.js index 08af891..4556a6c 100644 --- a/test/unit/xchain_encoder_prepare_data.test.js +++ b/test/unit/xchain_encoder_prepare_data.test.js @@ -31,12 +31,14 @@ function makeEncoder () { ) } -describe('XChainEncoder.prepareData()', () => { - let encoder +let encoder - beforeEach(() => { - encoder = makeEncoder() - }) +function resetEncoder () { + encoder = makeEncoder() +} + +describe('XChainEncoder.prepareData()', () => { + beforeEach(resetEncoder) describe('auto-selection (encoding = null/undefined)', () => { it('selects OP_RETURN when data fits within 76 bytes (80 - 4 magic)', () => { @@ -63,6 +65,10 @@ describe('XChainEncoder.prepareData()', () => { assert.strictEqual(result.encoding, 'OP_RETURN') }) }) +}) + +describe('XChainEncoder.prepareData()', () => { + beforeEach(resetEncoder) describe('OP_RETURN encoding', () => { it('produces a single chunk for data that fits', () => { @@ -94,7 +100,13 @@ describe('XChainEncoder.prepareData()', () => { `chunk length ${chunk.length} exceeds ${OP_RETURN_SIZE}`) } }) + }) +}) + +describe('XChainEncoder.prepareData()', () => { + beforeEach(resetEncoder) + describe('OP_RETURN encoding', () => { it('accepts a payload exactly at the 76-byte single-output limit', () => { const data = Buffer.alloc(OP_RETURN_SIZE - MAGIC_LEN) // 76 bytes const result = encoder.prepareData(data, 'OP_RETURN', null) @@ -122,6 +134,10 @@ describe('XChainEncoder.prepareData()', () => { assert.strictEqual(result.encoding, 'OP_RETURN') }) }) +}) + +describe('XChainEncoder.prepareData()', () => { + beforeEach(resetEncoder) describe('P2SH encoding', () => { const chunkDataSize = P2SH_SIZE - 44 // 476 @@ -169,6 +185,10 @@ describe('XChainEncoder.prepareData()', () => { assert.deepStrictEqual(reassembled, data) }) }) +}) + +describe('XChainEncoder.prepareData()', () => { + beforeEach(resetEncoder) // The chunk-lane redeem script gates its reveal spend with HASH160(caller // pubkey). prepareData must derive that 20-byte hash from ANY identity form a @@ -217,6 +237,10 @@ describe('XChainEncoder.prepareData()', () => { /cannot resolve a 20-byte caller HASH160/) }) }) +}) + +describe('XChainEncoder.prepareData()', () => { + beforeEach(resetEncoder) describe('P2WSH encoding', () => { const chunkDataSize = PW2SH_SIZE - 44 // 476 @@ -247,6 +271,10 @@ describe('XChainEncoder.prepareData()', () => { assert.strictEqual(result.dataBufferArray.length, 2) }) }) +}) + +describe('XChainEncoder.prepareData()', () => { + beforeEach(resetEncoder) describe('MULTISIGN encoding', () => { // MULTISIGN_SIZE(69) - MAGIC_LEN(4) - 1 - 1 - 1 - 1 - 1 = 60 @@ -294,7 +322,13 @@ describe('XChainEncoder.prepareData()', () => { const result = encoder.prepareData(data, 'MULTISIGN', null) assert.ok(result) }) + }) +}) + +describe('XChainEncoder.prepareData()', () => { + beforeEach(resetEncoder) + describe('MULTISIGN encoding', () => { // Every MULTISIGN output splits its 64 data bytes across two 32-byte // pubkey halves. A short final chunk used to leave the second half empty // or near-empty, so dataToPubkey() produced an all-zero / low-entropy EC @@ -322,6 +356,10 @@ describe('XChainEncoder.prepareData()', () => { } }) }) +}) + +describe('XChainEncoder.prepareData()', () => { + beforeEach(resetEncoder) describe('invalid encoding', () => { it('throws TypeError for unrecognized encoding string', () => { From 498bd219126e5f0c0ef9137d079a0844f016ae2d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:40 -0700 Subject: [PATCH 096/148] test: split reservation release suite into same-title blocks by behavior --- test/unit/xchain_encoder_release_reservations.test.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/unit/xchain_encoder_release_reservations.test.js b/test/unit/xchain_encoder_release_reservations.test.js index c396578..d69f444 100644 --- a/test/unit/xchain_encoder_release_reservations.test.js +++ b/test/unit/xchain_encoder_release_reservations.test.js @@ -114,7 +114,9 @@ describe('XChainEncoder reservation release', () => { const again = await createTx(encoder, oneFundedUtxo()) assert.strictEqual(again.psbt.txInputs.length, 1) }) +}) +describe('XChainEncoder reservation release', () => { it('a foreign ticket id releases nothing and leaves the claims standing', async () => { const encoder = makeEncoder() await createTx(encoder, oneFundedUtxo()) @@ -156,7 +158,9 @@ describe('XChainEncoder reservation release', () => { assert.strictEqual(second.found, false) assert.deepStrictEqual(second.released, []) }) +}) +describe('XChainEncoder reservation release', () => { it('releases only the presented build\'s claims, not a concurrent build\'s', async () => { const encoder = makeEncoder() const utxos = [makeSegwitUtxo(TXID_ONE, 0, 5000000), makeSegwitUtxo(TXID_TWO, 1, 4000000)] From 3538e3228331bb698805612c6fcbe2a9bfdad913 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:40 -0700 Subject: [PATCH 097/148] test: split reveal funding binding suite into same-title blocks by behavior --- test/unit/xchain_encoder_reveal_funding_binding.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/unit/xchain_encoder_reveal_funding_binding.test.js b/test/unit/xchain_encoder_reveal_funding_binding.test.js index 82e43fa..7bde83d 100644 --- a/test/unit/xchain_encoder_reveal_funding_binding.test.js +++ b/test/unit/xchain_encoder_reveal_funding_binding.test.js @@ -149,7 +149,9 @@ describe('XChainEncoder reveal: p2shHash must be the id of p2shHex', () => { { name: 'TypeError', message: /does not match the txid of the supplied p2shHex/ } ) }) +}) +describe('XChainEncoder reveal: p2shHash must be the id of p2shHex', () => { it('still builds a P2WSH reveal when p2shHash is the funding tx id', async () => { const encoder = makeEncoder('bitcoin-regtest') const funding = await buildFunding(encoder, 'P2WSH', BTC_REGTEST_ADDR) From 03afcd464a31f47540b24aaa736edb2764925cc3 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:40 -0700 Subject: [PATCH 098/148] test: split soft dust floor suite into same-title blocks by behavior --- test/unit/xchain_encoder_soft_dust_floor.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/unit/xchain_encoder_soft_dust_floor.test.js b/test/unit/xchain_encoder_soft_dust_floor.test.js index e7753d9..d43caff 100644 --- a/test/unit/xchain_encoder_soft_dust_floor.test.js +++ b/test/unit/xchain_encoder_soft_dust_floor.test.js @@ -113,7 +113,9 @@ describe('XChainEncoder soft-dust floor on authored outputs', () => { assert.strictEqual(btc.outputFloor, 1000, 'BTC takes the operator floor above 546') }) }) +}) +describe('XChainEncoder soft-dust floor on authored outputs', () => { describe('P2SH funding legs at the fee rate that stalled testnet', () => { it('is a discriminating rate: the per-chunk fee share alone sits under the soft limit', () => { // Guards the test itself: at 0.0112 DOGE/kB a chunk-sized reveal input From 3236b9fa106fbffc41c5b4a7e867d2de36bbe473 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:40 -0700 Subject: [PATCH 099/148] test: split tracker freshness classifier suite into same-title blocks by behavior --- test/unit/xchain_encoder_tracker_freshness_classifier.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/unit/xchain_encoder_tracker_freshness_classifier.test.js b/test/unit/xchain_encoder_tracker_freshness_classifier.test.js index 66b3bd1..8e2c16c 100644 --- a/test/unit/xchain_encoder_tracker_freshness_classifier.test.js +++ b/test/unit/xchain_encoder_tracker_freshness_classifier.test.js @@ -94,7 +94,9 @@ describe('classifyTrackerFreshness(): the single tracker-freshness verdict', fun assert.strictEqual(classify({ lag: 7, synced: true }, 2).code, 'UTXO_TRACKER_STALE') assert.strictEqual(classify({ lag: 7, synced: true }, 10).code, null) }) +}) +describe('classifyTrackerFreshness(): the single tracker-freshness verdict', function () { it('serves at exactly the ceiling (lag == max is not "above")', function () { const v = classify({ lag: 2, synced: true }, 2) assert.strictEqual(v.code, null) From 5f80ca1d9da3e7cb78dc5f4549829c799daac4a6 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:29:41 -0700 Subject: [PATCH 100/148] test: split UTXO dedup suite into same-title blocks by behavior --- test/unit/xchain_encoder_utxo_dedup.test.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/unit/xchain_encoder_utxo_dedup.test.js b/test/unit/xchain_encoder_utxo_dedup.test.js index e7d870f..ec87b5f 100644 --- a/test/unit/xchain_encoder_utxo_dedup.test.js +++ b/test/unit/xchain_encoder_utxo_dedup.test.js @@ -100,7 +100,9 @@ describe('_buildTransaction outpoint dedup / mempool filter', function () { await build(encoder, address, utxos, true) assert.strictEqual(utxos.length, 3, 'same txid, different vout, is not a duplicate') }) +}) +describe('_buildTransaction outpoint dedup / mempool filter', function () { it('drops every mempool entry when unconfirmed is false', async function () { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) @@ -125,7 +127,9 @@ describe('_buildTransaction outpoint dedup / mempool filter', function () { await build(encoder, address, utxos, true) assert.deepStrictEqual(outpoints(utxos), [TXID_A + ':0', TXID_B + ':0']) }) +}) +describe('_buildTransaction outpoint dedup / mempool filter', function () { it('applies the mempool filter BEFORE dedup, so a confirmed twin survives', async function () { // The ordering is load-bearing. Dedup-first would let the unconfirmed entry // claim the outpoint's slot and then the mempool filter would delete it, From dc678814e3fb81def08610b079b7d0a1ccacc95f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:32:41 -0700 Subject: [PATCH 101/148] test: split P2SH and P2WSH sequence e2e suite into same-title blocks by behavior --- test/e2e/p2sh_p2wsh_sequence_e2e.test.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/e2e/p2sh_p2wsh_sequence_e2e.test.js b/test/e2e/p2sh_p2wsh_sequence_e2e.test.js index 6d4b7c0..a4ec8aa 100644 --- a/test/e2e/p2sh_p2wsh_sequence_e2e.test.js +++ b/test/e2e/p2sh_p2wsh_sequence_e2e.test.js @@ -105,6 +105,9 @@ describe('E2E-2: Two-Transaction P2SH/P2WSH Orchestration', () => { assert.strictEqual(decompiled[6], bitcoin.opcodes.OP_CHECKSIG) }) }) +}) + +describe('E2E-2: Two-Transaction P2SH/P2WSH Orchestration', () => { describe('E2E-2.2: P2SH tx2 OP_RETURN marker', () => { it('tx2 marker deobfuscates to XCHNp2sh', async () => { @@ -120,6 +123,9 @@ describe('E2E-2: Two-Transaction P2SH/P2WSH Orchestration', () => { assert.strictEqual(decrypted.toString('utf8'), 'XCHNp2sh') }) }) +}) + +describe('E2E-2: Two-Transaction P2SH/P2WSH Orchestration', () => { describe('E2E-2.3: P2SH data fidelity', () => { it('redeemScript contains original ACTION data (unobfuscated)', async () => { @@ -140,6 +146,9 @@ describe('E2E-2: Two-Transaction P2SH/P2WSH Orchestration', () => { assert.strictEqual(innerDecompiled[0].toString('utf8'), action.data) }) }) +}) + +describe('E2E-2: Two-Transaction P2SH/P2WSH Orchestration', () => { describe('E2E-2.4: P2WSH full sequence (FILE)', () => { it('tx1 creates P2WSH output, tx2 spends with witnessScript', async () => { @@ -171,6 +180,9 @@ describe('E2E-2: Two-Transaction P2SH/P2WSH Orchestration', () => { assert.strictEqual(decompiled[1], bitcoin.opcodes.OP_DROP) }) }) +}) + +describe('E2E-2: Two-Transaction P2SH/P2WSH Orchestration', () => { describe('E2E-2.5: P2WSH tx2 OP_RETURN marker', () => { it('tx2 marker deobfuscates to XCHNp2sh', async () => { @@ -188,6 +200,9 @@ describe('E2E-2: Two-Transaction P2SH/P2WSH Orchestration', () => { assert.strictEqual(decrypted.toString('utf8'), 'XCHNp2wsh') }) }) +}) + +describe('E2E-2: Two-Transaction P2SH/P2WSH Orchestration', () => { describe('E2E-2.6: P2WSH data fidelity', () => { it('witnessScript contains original ACTION data (unobfuscated)', async () => { @@ -216,6 +231,9 @@ describe('E2E-2: Two-Transaction P2SH/P2WSH Orchestration', () => { assert.strictEqual(innerDecompiled[0].toString('utf8'), action.data) }) }) +}) + +describe('E2E-2: Two-Transaction P2SH/P2WSH Orchestration', () => { describe('E2E-2.7: tx1→tx2 ID chaining', () => { it('tx2 references tx1 ID as input hash', async () => { @@ -227,6 +245,9 @@ describe('E2E-2: Two-Transaction P2SH/P2WSH Orchestration', () => { assert.strictEqual(tx2InputHash, tx1Id) }) }) +}) + +describe('E2E-2: Two-Transaction P2SH/P2WSH Orchestration', () => { describe('E2E-2.8: tx1→tx2 hex chaining', () => { it('tx2 nonWitnessUtxo matches tx1 raw hex', async () => { @@ -239,6 +260,9 @@ describe('E2E-2: Two-Transaction P2SH/P2WSH Orchestration', () => { assert.strictEqual(input.nonWitnessUtxo.toString('hex'), tx1Hex) }) }) +}) + +describe('E2E-2: Two-Transaction P2SH/P2WSH Orchestration', () => { describe('E2E-2.9: Multi-chunk P2SH', () => { it('large ACTION requiring multiple P2SH outputs all have valid structure', async () => { From ebb3b683b11cd8cba7d4884019a7d5115f0181a8 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:32:41 -0700 Subject: [PATCH 102/148] test: split round-trip e2e suite into same-title blocks by behavior --- test/e2e/round_trip_e2e.test.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/e2e/round_trip_e2e.test.js b/test/e2e/round_trip_e2e.test.js index 07a65eb..06e9eed 100644 --- a/test/e2e/round_trip_e2e.test.js +++ b/test/e2e/round_trip_e2e.test.js @@ -76,6 +76,9 @@ describe('E2E-9: Round-Trip Verification', () => { assert.strictEqual(decompiled[0].toString('utf8'), action.data) }) }) +}) + +describe('E2E-9: Round-Trip Verification', () => { describe('E2E-9.2: P2SH ISSUE round-trip (all 25+ fields)', () => { it('all ISSUE fields survive P2SH tx1→tx2 round-trip', async () => { @@ -125,6 +128,9 @@ describe('E2E-9: Round-Trip Verification', () => { assert.ok(recoveredAction.includes('RT memo')) }) }) +}) + +describe('E2E-9: Round-Trip Verification', () => { describe('E2E-9.3: MULTISIGN round-trip', () => { it('data encoded in fake pubkeys recovers original payload', async () => { @@ -149,6 +155,9 @@ describe('E2E-9: Round-Trip Verification', () => { assert.strictEqual(decompiled[0].toString('utf8'), MS_DATA) }) }) +}) + +describe('E2E-9: Round-Trip Verification', () => { describe('E2E-9.4: P2WSH FILE round-trip', () => { it('large file content survives P2WSH encoding', async () => { @@ -188,6 +197,9 @@ describe('E2E-9: Round-Trip Verification', () => { assert.strictEqual(innerDecompiled[0].toString('utf8'), action.data) }) }) +}) + +describe('E2E-9: Round-Trip Verification', () => { describe('E2E-9.5: Oversized OP_RETURN rejected', () => { it('forced OP_RETURN beyond a single output is rejected', async () => { @@ -212,6 +224,9 @@ describe('E2E-9: Round-Trip Verification', () => { ) }) }) +}) + +describe('E2E-9: Round-Trip Verification', () => { describe('E2E-9.6: BATCH round-trip', () => { it('semicolon-separated batch survives encoding', async () => { @@ -250,6 +265,9 @@ describe('E2E-9: Round-Trip Verification', () => { } }) }) +}) + +describe('E2E-9: Round-Trip Verification', () => { describe('E2E-9.7: Cross-chain decode equivalence', () => { it('same ACTION on BTC/DOGE/LTC produces identical decoded payloads', async () => { From 9f322dbd3801959ab75f910310f8bb83c4c7cf81 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:32:42 -0700 Subject: [PATCH 103/148] test: split prepareData fuzz suite into same-title blocks by carrier --- test/fuzz/preparedata.fuzz.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/fuzz/preparedata.fuzz.js b/test/fuzz/preparedata.fuzz.js index b032a64..cbd06a3 100644 --- a/test/fuzz/preparedata.fuzz.js +++ b/test/fuzz/preparedata.fuzz.js @@ -70,6 +70,11 @@ describe('Fuzz: prepareData()', function () { assert.strictEqual(failures.length, 0, `OP_RETURN oversize guard violated in ${failures.length}/${ITERATIONS}: ${JSON.stringify(failures.slice(0, 5))}`) }) +}) + +describe('Fuzz: prepareData()', function () { + this.timeout(120000) + const encoder = makeTestEncoder() it(`MULTISIGN chunks are full 64-byte magic-prefixed slots over ${ITERATIONS} payloads`, () => { const failures = [] From 4f5bcd25a058e0387dc7c05bf56ee431dbb43130 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:32:42 -0700 Subject: [PATCH 104/148] test: split action encoding suite into same-title blocks and share the action table --- test/integration/action_encoding.test.js | 69 +++++++++++++++--------- 1 file changed, 45 insertions(+), 24 deletions(-) diff --git a/test/integration/action_encoding.test.js b/test/integration/action_encoding.test.js index 57dc8df..1f429bb 100644 --- a/test/integration/action_encoding.test.js +++ b/test/integration/action_encoding.test.js @@ -70,6 +70,29 @@ async function encodeAndExtract (actionObj) { } } +const SMALL_ACTIONS = [ + { name: 'SEND', factory: () => actions.makeSend() }, + { name: 'SEND v1', factory: () => actions.makeMultiSendV1() }, + { name: 'ISSUE (minimal)', factory: () => actions.makeIssueMinimal() }, + { name: 'ISSUE v1', factory: () => actions.makeIssueEditDescription() }, + { name: 'MINT', factory: () => actions.makeMint() }, + { name: 'DESTROY', factory: () => actions.makeDestroy() }, + { name: 'CALLBACK', factory: () => actions.makeCallback() }, + { name: 'SLEEP', factory: () => actions.makeSleep() }, + { name: 'SWEEP', factory: () => actions.makeSweep() }, + { name: 'AIRDROP', factory: () => actions.makeAirdrop() }, + { name: 'DIVIDEND', factory: () => actions.makeDividend() }, + { name: 'ORDER', factory: () => actions.makeOrder() }, + { name: 'COINPAY', factory: () => actions.makeCoinpay() }, + { name: 'DISPENSER', factory: () => actions.makeDispenser() }, + { name: 'SWAP', factory: () => actions.makeSwap() }, + { name: 'BROADCAST', factory: () => actions.makeBroadcast() }, + { name: 'MESSAGE', factory: () => actions.makeMessage() }, + { name: 'ADDRESS', factory: () => actions.makeAddress() }, + { name: 'LINK', factory: () => actions.makeLink() }, + { name: 'LIST', factory: () => actions.makeList() }, +] + describe('Category A: ACTION Payload Encoding Fidelity', () => { describe('A-1: Minimal SEND (fits OP_RETURN)', () => { @@ -100,6 +123,9 @@ describe('Category A: ACTION Payload Encoding Fidelity', () => { assert.strictEqual(dataString, action.data) }) }) +}) + +describe('Category A: ACTION Payload Encoding Fidelity', () => { describe('A-4: Full ISSUE with all fields (P2SH)', () => { it('encodes as P2SH and preserves all 25+ fields', async () => { @@ -135,6 +161,9 @@ describe('Category A: ACTION Payload Encoding Fidelity', () => { assert.strictEqual(dataString, 'ISSUE|0|X') }) }) +}) + +describe('Category A: ACTION Payload Encoding Fidelity', () => { describe('A-6: BATCH with multiple commands', () => { it('semicolon-separated commands preserved', async () => { @@ -164,6 +193,9 @@ describe('Category A: ACTION Payload Encoding Fidelity', () => { assert.strictEqual(dataString, action.data) }) }) +}) + +describe('Category A: ACTION Payload Encoding Fidelity', () => { describe('A-11: TICK with special characters', () => { it('special chars in TICK name survive encoding', async () => { @@ -182,6 +214,9 @@ describe('Category A: ACTION Payload Encoding Fidelity', () => { assert.ok(dataString.includes('^1234')) }) }) +}) + +describe('Category A: ACTION Payload Encoding Fidelity', () => { describe('A-13: Maximum OP_RETURN boundary', () => { it('ACTION of exactly 72 bytes fits in single OP_RETURN', async () => { @@ -205,6 +240,9 @@ describe('Category A: ACTION Payload Encoding Fidelity', () => { assert.strictEqual(result.encoding, 'OP_RETURN') }) }) +}) + +describe('Category A: ACTION Payload Encoding Fidelity', () => { describe('A-14: One byte over OP_RETURN boundary', () => { it('ACTION exceeding 76 compiled bytes auto-selects P2SH', async () => { @@ -224,32 +262,12 @@ describe('Category A: ACTION Payload Encoding Fidelity', () => { assert.strictEqual(result.encoding, 'P2SH') }) }) +}) + +describe('Category A: ACTION Payload Encoding Fidelity', () => { describe('All ACTION types: basic OP_RETURN encoding', () => { - const smallActions = [ - { name: 'SEND', factory: () => actions.makeSend() }, - { name: 'SEND v1', factory: () => actions.makeMultiSendV1() }, - { name: 'ISSUE (minimal)', factory: () => actions.makeIssueMinimal() }, - { name: 'ISSUE v1', factory: () => actions.makeIssueEditDescription() }, - { name: 'MINT', factory: () => actions.makeMint() }, - { name: 'DESTROY', factory: () => actions.makeDestroy() }, - { name: 'CALLBACK', factory: () => actions.makeCallback() }, - { name: 'SLEEP', factory: () => actions.makeSleep() }, - { name: 'SWEEP', factory: () => actions.makeSweep() }, - { name: 'AIRDROP', factory: () => actions.makeAirdrop() }, - { name: 'DIVIDEND', factory: () => actions.makeDividend() }, - { name: 'ORDER', factory: () => actions.makeOrder() }, - { name: 'COINPAY', factory: () => actions.makeCoinpay() }, - { name: 'DISPENSER', factory: () => actions.makeDispenser() }, - { name: 'SWAP', factory: () => actions.makeSwap() }, - { name: 'BROADCAST', factory: () => actions.makeBroadcast() }, - { name: 'MESSAGE', factory: () => actions.makeMessage() }, - { name: 'ADDRESS', factory: () => actions.makeAddress() }, - { name: 'LINK', factory: () => actions.makeLink() }, - { name: 'LIST', factory: () => actions.makeList() }, - ] - - for (const { name, factory } of smallActions) { + for (const { name, factory } of SMALL_ACTIONS) { it(`${name} action string survives encoding round-trip`, async () => { const action = factory() @@ -279,6 +297,9 @@ describe('Category A: ACTION Payload Encoding Fidelity', () => { }) } }) +}) + +describe('Category A: ACTION Payload Encoding Fidelity', () => { describe('data + rawData dual parameter', () => { it('both data and rawData are preserved in encoding', async () => { From c4974ed4f5ffd27ea3ec00b53d59f15c3c8fde8e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:32:43 -0700 Subject: [PATCH 105/148] test: split custom output suite into same-title blocks and share the funding helpers --- test/integration/custom_outputs.test.js | 78 +++++++++++++++---------- 1 file changed, 47 insertions(+), 31 deletions(-) diff --git a/test/integration/custom_outputs.test.js b/test/integration/custom_outputs.test.js index 5657665..aa14f82 100644 --- a/test/integration/custom_outputs.test.js +++ b/test/integration/custom_outputs.test.js @@ -28,6 +28,36 @@ const { const actions = require('./helpers/actionFactory') const NETWORK = 'dogecoin-regtest' +// P2SH/P2WSH is a two-transaction flow. The funding (phase-1) tx creates the +// P2SH data outputs; the reveal (phase-2) tx spends them and is the tx the +// indexer reads as the action. customOutputs are therefore EMITTED on the +// reveal only, with their total FOLDED into the first funding output so the +// reveal can pay them. Asserting the custom output on the funding tx +// contradicts that contract, and an assertion of that shape can only pass +// against an encoder that lacks the fold. +const CUSTOM_VALUE = 100000 + +// Rebuilds the funding tx from the PSBT without a private key: the encoder +// only reads p2shHex's outputs by index, and the unsigned serialization +// already yields a stable txid for the reveal's input. +function unsignedTxFrom (psbt) { + const tx = new bitcoin.Transaction() + for (const input of psbt.txInputs) tx.addInput(input.hash, input.index, input.sequence) + for (const output of psbt.txOutputs) tx.addOutput(output.script, output.value) + return tx +} + +async function buildFunding (encoder, address, customOutputs) { + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) + const action = actions.makeIssueFull('BIGTOKEN') + // Each probe respends the one fixture input on the same encoder. + encoder.clearReservations() + return await encoder.createTransaction( + [utxo], address, customOutputs && customOutputs.map(o => ({ ...o })), + action.data, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) +} describe('Category E: Custom Outputs (COINPAY Integration)', () => { @@ -59,6 +89,9 @@ describe('Category E: Custom Outputs (COINPAY Integration)', () => { assert.strictEqual(customValues.length, 2) }) }) +}) + +describe('Category E: Custom Outputs (COINPAY Integration)', () => { describe('E-2: Custom outputs affect change calculation', () => { it('custom output value is deducted from change', async () => { @@ -94,6 +127,9 @@ describe('Category E: Custom Outputs (COINPAY Integration)', () => { 'change should decrease by exactly the custom output value') }) }) +}) + +describe('Category E: Custom Outputs (COINPAY Integration)', () => { describe('E-3: Non-array customOutputs ignored', () => { it('object instead of array is silently skipped', async () => { @@ -127,39 +163,11 @@ describe('Category E: Custom Outputs (COINPAY Integration)', () => { assert.strictEqual(result.psbt.txOutputs.length, 2) }) }) +}) - describe('E-4: Custom outputs + P2SH ACTION payload', () => { - // P2SH/P2WSH is a two-transaction flow. The funding (phase-1) tx creates the - // P2SH data outputs; the reveal (phase-2) tx spends them and is the tx the - // indexer reads as the action. customOutputs are therefore EMITTED on the - // reveal only, with their total FOLDED into the first funding output so the - // reveal can pay them. Asserting the custom output on the funding tx (which - // this test used to do) contradicts that contract and only ever passed - // before the fold existed. - const CUSTOM_VALUE = 100000 - - // Rebuilds the funding tx from the PSBT without a private key: the encoder - // only reads p2shHex's outputs by index, and the unsigned serialization - // already yields a stable txid for the reveal's input. - function unsignedTxFrom (psbt) { - const tx = new bitcoin.Transaction() - for (const input of psbt.txInputs) tx.addInput(input.hash, input.index, input.sequence) - for (const output of psbt.txOutputs) tx.addOutput(output.script, output.value) - return tx - } - - async function buildFunding (encoder, address, customOutputs) { - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeIssueFull('BIGTOKEN') - // Each probe respends the one fixture input on the same encoder. - encoder.clearReservations() - return await encoder.createTransaction( - [utxo], address, customOutputs && customOutputs.map(o => ({ ...o })), - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - } +describe('Category E: Custom Outputs (COINPAY Integration)', () => { + describe('E-4: Custom outputs + P2SH ACTION payload', () => { it('funding tx carries the P2SH data outputs and folds the custom-output value in', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) @@ -182,6 +190,11 @@ describe('Category E: Custom Outputs (COINPAY Integration)', () => { assert.ok(fundedDelta >= CUSTOM_VALUE, `first funding output should carry >= ${CUSTOM_VALUE} extra, carried ${fundedDelta}`) }) + }) +}) + +describe('Category E: Custom Outputs (COINPAY Integration)', () => { + describe('E-4: Custom outputs + P2SH ACTION payload', () => { it('reveal tx emits the custom output alongside the ACTION data', async () => { const encoder = makeEncoder(NETWORK) @@ -212,6 +225,9 @@ describe('Category E: Custom Outputs (COINPAY Integration)', () => { assert.strictEqual(reveal.psbt.txOutputs.filter(o => o.value === CUSTOM_VALUE).length, 1) }) }) +}) + +describe('Category E: Custom Outputs (COINPAY Integration)', () => { describe('Empty customOutputs array', () => { it('empty array produces no extra outputs', async () => { From a9b9621a33250e9a5774b9f05fd947ca950e2944 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:32:43 -0700 Subject: [PATCH 106/148] test: split encoding types suite into same-title blocks and share the multisign data --- test/integration/encoding_types.test.js | 31 +++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/test/integration/encoding_types.test.js b/test/integration/encoding_types.test.js index 4ee0afe..a643faa 100644 --- a/test/integration/encoding_types.test.js +++ b/test/integration/encoding_types.test.js @@ -36,6 +36,7 @@ const { const actions = require('./helpers/actionFactory') const NETWORK = 'dogecoin-regtest' +const MS_DATA = 'A'.repeat(59) // compiles to ~60 bytes → 64-byte chunk after XCHN describe('Category B: Encoding Type Integration', () => { @@ -79,6 +80,9 @@ describe('Category B: Encoding Type Integration', () => { assert.strictEqual(payload.magic, MAGIC_WORD) }) }) +}) + +describe('Category B: Encoding Type Integration', () => { describe('B-2: P2SH tx1 (funding output)', () => { it('creates P2SH output with value >= dustAmount', async () => { @@ -130,6 +134,9 @@ describe('Category B: Encoding Type Integration', () => { assert.ok(changeOutput.value > 0, 'should have a change output') }) }) +}) + +describe('Category B: Encoding Type Integration', () => { describe('B-3: P2SH tx2 (spending input with redeemScript)', () => { it('creates tx2 with P2SH input and OP_RETURN marker', async () => { @@ -165,6 +172,11 @@ describe('Category B: Encoding Type Integration', () => { assert.ok(markerOutput, 'tx2 should have OP_RETURN marker') assert.strictEqual(markerOutput.script[0], bitcoin.opcodes.OP_RETURN) }) + }) +}) + +describe('Category B: Encoding Type Integration', () => { + describe('B-3: P2SH tx2 (spending input with redeemScript)', () => { it('tx2 P2SH input has redeemScript containing ACTION data', async () => { const encoder = makeEncoder(NETWORK) @@ -199,6 +211,9 @@ describe('Category B: Encoding Type Integration', () => { assert.strictEqual(decompiled[3], bitcoin.opcodes.OP_HASH160) }) }) +}) + +describe('Category B: Encoding Type Integration', () => { describe('B-4: P2WSH tx1 (funding output)', () => { it('creates P2WSH output when explicitly requested', async () => { @@ -229,6 +244,9 @@ describe('Category B: Encoding Type Integration', () => { assert.ok(p2wshOutputs.length >= 1, 'should have at least one P2WSH output') }) }) +}) + +describe('Category B: Encoding Type Integration', () => { describe('B-5: P2WSH tx2 (witness input)', () => { it('creates tx2 with witnessScript containing ACTION data', async () => { @@ -272,12 +290,13 @@ describe('Category B: Encoding Type Integration', () => { assert.ok(markerOutput, 'tx2 should have OP_RETURN marker') }) }) +}) + +describe('Category B: Encoding Type Integration', () => { describe('B-6: MULTISIGN output structure', () => { // MULTISIGN requires data that produces valid EC points after obfuscation. // Use TXID_MULTISIGN (brute-forced) with carefully sized data. - const MS_DATA = 'A'.repeat(59) // compiles to ~60 bytes → 64-byte chunk after XCHN - it('creates 1-of-3 multisig output with correct structure', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) @@ -310,6 +329,11 @@ describe('Category B: Encoding Type Integration', () => { assert.strictEqual(decompiled[4], bitcoin.opcodes.OP_3) // n = 3 assert.strictEqual(decompiled[5], bitcoin.opcodes.OP_CHECKMULTISIG) }) + }) +}) + +describe('Category B: Encoding Type Integration', () => { + describe('B-6: MULTISIGN output structure', () => { it('third pubkey is the real compressed public key', async () => { const encoder = makeEncoder(NETWORK) @@ -330,6 +354,9 @@ describe('Category B: Encoding Type Integration', () => { assert.deepStrictEqual(decompiled[3], PUBKEY_BUF) }) }) +}) + +describe('Category B: Encoding Type Integration', () => { describe('B-7: Forced encoding override', () => { it('uses P2SH when explicitly requested despite data fitting OP_RETURN', async () => { From 75c2a229b1d3ec109439b4df285bfc17c1f3f126 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:32:44 -0700 Subject: [PATCH 107/148] test: split error handling suite into same-title blocks by behavior --- test/integration/error_handling.test.js | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/integration/error_handling.test.js b/test/integration/error_handling.test.js index e2a6cd7..31e6a5e 100644 --- a/test/integration/error_handling.test.js +++ b/test/integration/error_handling.test.js @@ -75,6 +75,9 @@ describe('Category H: Error Handling at Integration Boundaries', () => { assert.ok(result.psbt) }) }) +}) + +describe('Category H: Error Handling at Integration Boundaries', () => { describe('H-2: BlockchainConnector.getTransactionHex() failure', () => { it('propagates error when fetching raw tx for legacy UTXO fails', async () => { @@ -106,6 +109,9 @@ describe('Category H: Error Handling at Integration Boundaries', () => { ) }) }) +}) + +describe('Category H: Error Handling at Integration Boundaries', () => { describe('H-3: UtxoTracker unreachable', () => { it('propagates error when tracker is down and utxos=null', async () => { @@ -127,6 +133,9 @@ describe('Category H: Error Handling at Integration Boundaries', () => { ) }) }) +}) + +describe('Category H: Error Handling at Integration Boundaries', () => { describe('H-4: Invalid network name', () => { it('throws TypeError during construction for invalid network', () => { @@ -138,6 +147,9 @@ describe('Category H: Error Handling at Integration Boundaries', () => { ) }) }) +}) + +describe('Category H: Error Handling at Integration Boundaries', () => { describe('H-5: MULTISIGN without compressedPubKey', () => { it('throws when compressedPubKey is null', async () => { @@ -155,6 +167,9 @@ describe('Category H: Error Handling at Integration Boundaries', () => { ) }) }) +}) + +describe('Category H: Error Handling at Integration Boundaries', () => { describe('H-6: P2SH without valid pubkey address', () => { it('throws when pubkey is not a valid base58 address', async () => { @@ -171,6 +186,9 @@ describe('Category H: Error Handling at Integration Boundaries', () => { ) }) }) +}) + +describe('Category H: Error Handling at Integration Boundaries', () => { describe('Return value shape', () => { it('returns { psbt, encoding } where psbt is a Psbt instance', async () => { @@ -190,6 +208,9 @@ describe('Category H: Error Handling at Integration Boundaries', () => { assert.ok(['OP_RETURN', 'P2SH', 'P2WSH', 'MULTISIGN'].includes(result.encoding)) }) }) +}) + +describe('Category H: Error Handling at Integration Boundaries', () => { describe('Custom dust parameter', () => { it('overrides dust for MULTISIGN output value but not fee floor', async () => { @@ -211,6 +232,11 @@ describe('Category H: Error Handling at Integration Boundaries', () => { const msOutput = result.psbt.txOutputs.find(o => o.value === customDust) assert.ok(msOutput, 'MULTISIGN output should use custom dust value') }) + }) +}) + +describe('Category H: Error Handling at Integration Boundaries', () => { + describe('Custom dust parameter', () => { it('fee floor still uses network dustAmount, not custom dust', async () => { const encoder = makeEncoder(NETWORK) From 57c4ac8926f64b911b4dff5d59030854eac206f3 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:32:44 -0700 Subject: [PATCH 108/148] test: split large satoshi tracker suite into same-title blocks by behavior --- test/integration/large_satoshi_tracker.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/integration/large_satoshi_tracker.test.js b/test/integration/large_satoshi_tracker.test.js index e8fab84..72b96a3 100644 --- a/test/integration/large_satoshi_tracker.test.js +++ b/test/integration/large_satoshi_tracker.test.js @@ -65,6 +65,9 @@ describe('Category D: tracker-fetched UTXO above 2^53-1 satoshis', () => { // last satoshi shows up here as an off-by-one change value. assert.strictEqual(BigInt(change[0].value), BigInt(BIG_SATS) - 10000n) }) +}) + +describe('Category D: tracker-fetched UTXO above 2^53-1 satoshis', () => { it('reaches the freshness gate, which a sync-less envelope failed open past', async () => { // Proof the envelope is actually consulted: the same record behind a view @@ -90,6 +93,9 @@ describe('Category D: tracker-fetched UTXO above 2^53-1 satoshis', () => { /stale/i ) }) +}) + +describe('Category D: tracker-fetched UTXO above 2^53-1 satoshis', () => { it('serves the producer shape, not a simplified double', async () => { // Guard on the factory itself: if a record ever loses the exact-decimal From 75a1404874432564d13ce19a18ca55c5b69c8eec Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:32:45 -0700 Subject: [PATCH 109/148] test: split multi-chain integration suite into same-title blocks and share the multisign helpers --- test/integration/multi_chain.test.js | 73 ++++++++++++++++------------ 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/test/integration/multi_chain.test.js b/test/integration/multi_chain.test.js index c87cbae..3634677 100644 --- a/test/integration/multi_chain.test.js +++ b/test/integration/multi_chain.test.js @@ -35,6 +35,36 @@ const CHAINS = [ { name: 'dogecoin-regtest', dustThreshold: 100000 }, { name: 'litecoin-regtest', dustThreshold: 5460 }, ] +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 +// 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) +} + +async function buildMultisign (networkName) { + const encoder = makeEncoder(networkName) + const address = getTestAddress(networkName) + const utxo = makeUtxo(networkName, TXID_MULTISIGN, 0, 100000000) + const result = await encoder.createTransaction( + [utxo], address, null, + MS_DATA, null, 10000, false, 'MULTISIGN', address, + null, null, PUBKEY_BUF.toString('hex'), true, 0.00001 + ) + assert.strictEqual(result.encoding, 'MULTISIGN') + // Identify the data output by SHAPE (bare multisig ends in OP_CHECKMULTISIG), + // never by a value that the dust policy is free to move. + const output = result.psbt.txOutputs.find(o => + o.script[o.script.length - 1] === bitcoin.opcodes.OP_CHECKMULTISIG) + assert.ok(output, `${networkName} should have a MULTISIGN output`) + return { encoder, output } +} describe('Category F: Multi-Chain Network Configs', () => { @@ -87,39 +117,11 @@ describe('Category F: Multi-Chain Network Configs', () => { }) }) } +}) - describe('F-4: Litecoin higher dust threshold', () => { - 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 - // chain threshold wins. This test used to look the multisig output up by - // `value === encoder.dustAmount`, which silently stopped matching on Bitcoin - // once that max() landed, so it asserted nothing on either chain. - function bareMultisigDust (scriptLength) { - return Math.ceil((8 + 1 + scriptLength + 148) * 3) - } - - async function buildMultisign (networkName) { - const encoder = makeEncoder(networkName) - const address = getTestAddress(networkName) - const utxo = makeUtxo(networkName, TXID_MULTISIGN, 0, 100000000) - const result = await encoder.createTransaction( - [utxo], address, null, - MS_DATA, null, 10000, false, 'MULTISIGN', address, - null, null, PUBKEY_BUF.toString('hex'), true, 0.00001 - ) - assert.strictEqual(result.encoding, 'MULTISIGN') - // Identify the data output by SHAPE (bare multisig ends in OP_CHECKMULTISIG), - // never by a value that the dust policy is free to move. - const output = result.psbt.txOutputs.find(o => - o.script[o.script.length - 1] === bitcoin.opcodes.OP_CHECKMULTISIG) - assert.ok(output, `${networkName} should have a MULTISIGN output`) - return { encoder, output } - } +describe('Category F: Multi-Chain Network Configs', () => { + describe('F-4: Litecoin higher dust threshold', () => { 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) @@ -144,6 +146,9 @@ describe('Category F: Multi-Chain Network Configs', () => { 'Litecoin dust should be higher than Bitcoin') }) }) +}) + +describe('Category F: Multi-Chain Network Configs', () => { describe('F-5: Dogecoin address in P2SH redeem script', () => { it('P2SH output address uses Dogecoin network params', async () => { @@ -176,6 +181,9 @@ describe('Category F: Multi-Chain Network Configs', () => { assert.strictEqual(decompiled[2], bitcoin.opcodes.OP_EQUAL) }) }) +}) + +describe('Category F: Multi-Chain Network Configs', () => { describe('CryptoNetworks completeness', () => { const ALL_NETWORKS = [ @@ -195,6 +203,9 @@ describe('Category F: Multi-Chain Network Configs', () => { }) } }) +}) + +describe('Category F: Multi-Chain Network Configs', () => { describe('Fee floor uses chain-specific dust threshold', () => { it('Litecoin fee floor is 5460, not 546', async () => { From 65f24694b17b01ed91986f1a756a2e62dfe0393b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 14 Sep 2026 22:32:45 -0700 Subject: [PATCH 110/148] test: split obfuscation round-trip suite into same-title blocks by behavior --- test/integration/obfuscation_roundtrip.test.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/integration/obfuscation_roundtrip.test.js b/test/integration/obfuscation_roundtrip.test.js index 52ec3c0..dcd8d37 100644 --- a/test/integration/obfuscation_roundtrip.test.js +++ b/test/integration/obfuscation_roundtrip.test.js @@ -86,6 +86,9 @@ describe('Category C: Obfuscation Round-Trip', () => { ) }) }) +}) + +describe('Category C: Obfuscation Round-Trip', () => { describe('C-2: MULTISIGN obfuscation round-trip', () => { const MS_DATA = 'A'.repeat(59) @@ -110,6 +113,9 @@ describe('Category C: Obfuscation Round-Trip', () => { assert.strictEqual(decompiled[0].toString('utf8'), MS_DATA) }) }) +}) + +describe('Category C: Obfuscation Round-Trip', () => { describe('C-3: P2SH marker obfuscation', () => { it('tx2 OP_RETURN marker deobfuscates to XCHNp2sh', async () => { @@ -149,6 +155,9 @@ describe('Category C: Obfuscation Round-Trip', () => { assert.strictEqual(markerStr, 'XCHNp2sh') }) }) +}) + +describe('Category C: Obfuscation Round-Trip', () => { describe('C-4: TXID sensitivity', () => { it('same ACTION with different TXIDs produces different obfuscated output', async () => { @@ -194,6 +203,9 @@ describe('Category C: Obfuscation Round-Trip', () => { assert.strictEqual(decompA[0].toString('utf8'), decompB[0].toString('utf8')) }) }) +}) + +describe('Category C: Obfuscation Round-Trip', () => { describe('C-5: UTXO sorting preserves obfuscation key derivation', () => { it('obfuscation uses largest UTXO txid regardless of input order', async () => { From b91c44f5ccfe1cfc406dce53c6ae3be3f37f1863 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 03:29:42 -0700 Subject: [PATCH 111/148] test(observability): cite the hub canonical suite at its test/unit/observability/ path Comment only: the hub test layout moved the suite this port was taken from. --- test/unit/observability.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/observability.test.js b/test/unit/observability.test.js index 616bbaa..623f7f6 100644 --- a/test/unit/observability.test.js +++ b/test/unit/observability.test.js @@ -15,7 +15,7 @@ // text, default-off wiring (no route, no timer, no socket without env), and a // log shim that redacts credentials and never throws at a dead collector. // -// Ported from the canonical suite at xchain-hub/test/unit/observability.test.js. +// Ported from the canonical suite at xchain-hub/test/unit/observability/observability.test.js. // src/observability/ here is a verbatim vendored copy, vendored and verified by // xchain-hub/bin/sync-observability.sh. Parity is gated in the HUB, not here: // the hub's pre-push gate (bin/ci-full.sh) and the drift-guards job of its From fadcfc414e6233c4f62cbdf24cecc23d81018abe Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 07:39:14 -0700 Subject: [PATCH 112/148] test: split the blockchain connector suite into numbered parts by behaviour --- test/unit/blockchain_connector.test.js | 885 ------------------ .../01_get_network_info.test.js | 136 +++ .../02_is_regtest.test.js | 121 +++ .../03_get_transaction_hex.test.js | 122 +++ .../04_send_raw_transaction.test.js | 154 +++ ...d_raw_transaction_maxfeerate_retry.test.js | 141 +++ .../06_get_fee_per_kilobyte.test.js | 395 ++++++++ ...07_rpc_credential_log_sanitization.test.js | 75 ++ 8 files changed, 1144 insertions(+), 885 deletions(-) create mode 100644 test/unit/blockchain_connector.test/01_get_network_info.test.js create mode 100644 test/unit/blockchain_connector.test/02_is_regtest.test.js create mode 100644 test/unit/blockchain_connector.test/03_get_transaction_hex.test.js create mode 100644 test/unit/blockchain_connector.test/04_send_raw_transaction.test.js create mode 100644 test/unit/blockchain_connector.test/05_send_raw_transaction_maxfeerate_retry.test.js create mode 100644 test/unit/blockchain_connector.test/06_get_fee_per_kilobyte.test.js create mode 100644 test/unit/blockchain_connector.test/07_rpc_credential_log_sanitization.test.js diff --git a/test/unit/blockchain_connector.test.js b/test/unit/blockchain_connector.test.js index 6fd598f..0b68392 100644 --- a/test/unit/blockchain_connector.test.js +++ b/test/unit/blockchain_connector.test.js @@ -9,34 +9,7 @@ // contact legal@dankest.llc. const assert = require('assert') -const axios = require('axios') const BlockchainConnector = require('../../src/build/blockchain_connector') -const util = require('util'); -const { getLogger } = require('../../src/observability'); -const logger = getLogger(); - -function makeConnector () { - return new BlockchainConnector('127.0.0.1', 18332, 'rpcuser', 'rpcpass') -} - -// Capture and restore axios.post between tests -let originalPost - -beforeEach(() => { - originalPost = axios.post -}) - -afterEach(() => { - axios.post = originalPost -}) - -function stubAxiosPost (response) { - axios.post = async () => response -} - -function stubAxiosPostThrow (err) { - axios.post = async () => { throw err } -} describe('BlockchainConnector constructor', () => { it('builds the correct URL from host and port', () => { @@ -50,861 +23,3 @@ describe('BlockchainConnector constructor', () => { assert.strictEqual(c.rpcPassword, 'secret') }) }) - -describe('BlockchainConnector.getNetworkInfo()', () => { - it('sends the correct JSON-RPC method', async () => { - let capturedPayload - axios.post = async (url, data) => { - capturedPayload = data - return { data: { result: { version: 220000, subversion: '/Satoshi:22.0.0/' } } } - } - - const c = makeConnector() - await c.getNetworkInfo() - assert.strictEqual(capturedPayload.method, 'getnetworkinfo') - assert.strictEqual(capturedPayload.jsonrpc, '2.0') - assert.strictEqual(capturedPayload.id, 1) - }) - - it('passes auth credentials to axios', async () => { - let capturedOptions - axios.post = async (url, data, options) => { - capturedOptions = options - return { data: { result: { version: 1 } } } - } - - const c = makeConnector() - await c.getNetworkInfo() - assert.strictEqual(capturedOptions.auth.username, 'rpcuser') - assert.strictEqual(capturedOptions.auth.password, 'rpcpass') - }) - - it('returns the result object on success', async () => { - stubAxiosPost({ data: { result: { version: 220000, subversion: '/Satoshi:22.0.0/' } } }) - const c = makeConnector() - const info = await c.getNetworkInfo() - assert.strictEqual(info.version, 220000) - assert.strictEqual(info.subversion, '/Satoshi:22.0.0/') - }) - - it('throws when result is missing from response', async () => { - stubAxiosPost({ data: { result: null } }) - const c = makeConnector() - await assert.rejects( - () => c.getNetworkInfo(), - /Error in network request/ - ) - }) - - it('wraps axios transport errors', async () => { - stubAxiosPostThrow(new Error('ECONNREFUSED')) - const c = makeConnector() - await assert.rejects( - () => c.getNetworkInfo(), - /Error in network request.*ECONNREFUSED/ - ) - }) - - // A warming node answers -28 "Loading block index..."; LTC/DOGE carry that in - // an HTTP 500 body, BTC v28 in an HTTP 200 one. Both must keep the RPC code and - // message: a bare status line leaves warmup indistinguishable from a network fault. - it('keeps the node RPC code and message from an HTTP-500 error body', async () => { - const err = new Error('Request failed with status code 500') - err.response = { status: 500, data: { error: { code: -28, message: 'Loading block index...' } } } - err.config = { auth: { username: 'rpcuser', password: 'FAKEPASS_must_never_be_logged_9c2d' } } - stubAxiosPostThrow(err) - const c = makeConnector() - await assert.rejects(() => c.getNetworkInfo(), (e) => { - assert.ok(/^Error in network request/.test(e.message), e.message) - assert.ok(e.message.includes('-28'), e.message) - assert.ok(e.message.includes('Loading block index'), e.message) - assert.ok(!e.message.includes('FAKEPASS'), 'the RPC password must never reach the message') - return true - }) - }) - - it('keeps the node RPC code and message from an HTTP-200 error body', async () => { - stubAxiosPost({ data: { result: null, error: { code: -28, message: 'Loading block index...' } } }) - const c = makeConnector() - await assert.rejects(() => c.getNetworkInfo(), (e) => { - assert.ok(/^Error in network request/.test(e.message), e.message) - assert.ok(e.message.includes('-28'), e.message) - assert.ok(e.message.includes('Loading block index'), e.message) - return true - }) - }) - - it('sets a timeout on the request', async () => { - let capturedOptions - axios.post = async (url, data, options) => { - capturedOptions = options - return { data: { result: { version: 1 } } } - } - const c = makeConnector() - await c.getNetworkInfo() - assert.ok(typeof capturedOptions.timeout === 'number' && capturedOptions.timeout > 0, - 'timeout must be a positive number') - }) -}) - -describe('BlockchainConnector.isRegtest()', () => { - it('sends getblockchaininfo method', async () => { - let capturedPayload - axios.post = async (url, data) => { - capturedPayload = data - return { data: { result: { chain: 'regtest' } } } - } - const c = makeConnector() - await c.isRegtest() - assert.strictEqual(capturedPayload.method, 'getblockchaininfo') - }) - - it('returns true when chain is "regtest"', async () => { - stubAxiosPost({ data: { result: { chain: 'regtest' } } }) - const c = makeConnector() - assert.strictEqual(await c.isRegtest(), true) - }) - - it('returns false when chain is "main"', async () => { - stubAxiosPost({ data: { result: { chain: 'main' } } }) - const c = makeConnector() - assert.strictEqual(await c.isRegtest(), false) - }) - - it('returns false when chain is "test"', async () => { - stubAxiosPost({ data: { result: { chain: 'test' } } }) - const c = makeConnector() - assert.strictEqual(await c.isRegtest(), false) - }) - - it('throws when result is missing', async () => { - stubAxiosPost({ data: { result: null } }) - const c = makeConnector() - await assert.rejects( - () => c.isRegtest(), - /Error in network request/ - ) - }) - - it('throws when result has no chain property', async () => { - stubAxiosPost({ data: { result: {} } }) - const c = makeConnector() - await assert.rejects( - () => c.isRegtest(), - /Error in network request/ - ) - }) - - it('wraps transport errors', async () => { - stubAxiosPostThrow(new Error('timeout of 30000ms exceeded')) - const c = makeConnector() - await assert.rejects( - () => c.isRegtest(), - /Error in network request.*timeout/ - ) - }) - - it('keeps the node RPC code and message from an HTTP-500 error body', async () => { - const err = new Error('Request failed with status code 500') - err.response = { status: 500, data: { error: { code: -28, message: 'Loading block index...' } } } - stubAxiosPostThrow(err) - const c = makeConnector() - await assert.rejects(() => c.chainName(), (e) => { - assert.ok(/^Error in network request/.test(e.message), e.message) - assert.ok(e.message.includes('-28'), e.message) - assert.ok(e.message.includes('Loading block index'), e.message) - return true - }) - }) - - it('keeps the node RPC code and message from an HTTP-200 error body', async () => { - stubAxiosPost({ data: { result: null, error: { code: -28, message: 'Loading block index...' } } }) - const c = makeConnector() - await assert.rejects(() => c.chainName(), (e) => { - assert.ok(/^Error in network request/.test(e.message), e.message) - assert.ok(e.message.includes('-28'), e.message) - assert.ok(e.message.includes('Loading block index'), e.message) - return true - }) - }) -}) - -describe('BlockchainConnector.getTransactionHex()', () => { - const TXID = 'a'.repeat(64) - const HEX = '0100000001' + '0'.repeat(100) - - it('sends getrawtransaction with correct txid and hexFormat=true', async () => { - let capturedPayload - axios.post = async (url, data) => { - capturedPayload = data - return { data: { result: { hex: HEX } } } - } - const c = makeConnector() - await c.getTransactionHex(TXID) - assert.strictEqual(capturedPayload.method, 'getrawtransaction') - assert.deepStrictEqual(capturedPayload.params, [TXID, true]) - }) - - it('passes hexFormat=false when requested', async () => { - let capturedPayload - axios.post = async (url, data) => { - capturedPayload = data - return { data: { result: { hex: HEX } } } - } - const c = makeConnector() - await c.getTransactionHex(TXID, false) - assert.deepStrictEqual(capturedPayload.params, [TXID, false]) - }) - - it('returns the hex string on success', async () => { - stubAxiosPost({ data: { result: { hex: HEX } } }) - const c = makeConnector() - const result = await c.getTransactionHex(TXID) - assert.strictEqual(result, HEX) - }) - - it('throws "not found" message when error.code is -5', async () => { - stubAxiosPost({ data: { error: { code: -5, message: 'No such mempool or blockchain transaction' } } }) - const c = makeConnector() - await assert.rejects( - () => c.getTransactionHex(TXID), - /not found.*txindex/ - ) - }) - - it('includes the txid in the "not found" error message', async () => { - stubAxiosPost({ data: { error: { code: -5, message: 'tx not found' } } }) - const c = makeConnector() - await assert.rejects( - () => c.getTransactionHex(TXID), - new RegExp(TXID.slice(0, 8)) - ) - }) - - it('throws generic error when result is missing and error is not -5', async () => { - stubAxiosPost({ data: { result: null, error: null } }) - const c = makeConnector() - await assert.rejects( - () => c.getTransactionHex(TXID), - /Error getting transaction hex/ - ) - }) - - it('rethrows transport errors directly', async () => { - stubAxiosPostThrow(new Error('Network failure')) - const c = makeConnector() - await assert.rejects( - () => c.getTransactionHex(TXID), - /Network failure/ - ) - }) - - it('sends auth credentials', async () => { - let capturedOptions - axios.post = async (url, data, options) => { - capturedOptions = options - return { data: { result: { hex: HEX } } } - } - const c = makeConnector() - await c.getTransactionHex(TXID) - assert.strictEqual(capturedOptions.auth.username, 'rpcuser') - assert.strictEqual(capturedOptions.auth.password, 'rpcpass') - }) -}) - -describe('BlockchainConnector.sendRawTransaction()', () => { - const TX_HEX = '0100000001' + '0'.repeat(60) + 'ffffffff' - const TXID_RESULT = 'b'.repeat(64) - - it('sends sendrawtransaction with the tx hex as param', async () => { - let capturedPayload - axios.post = async (url, data) => { - capturedPayload = data - return { data: { result: TXID_RESULT } } - } - const c = makeConnector() - await c.sendRawTransaction(TX_HEX) - assert.strictEqual(capturedPayload.method, 'sendrawtransaction') - assert.deepStrictEqual(capturedPayload.params, [TX_HEX]) - }) - - it('returns the txid on success', async () => { - stubAxiosPost({ data: { result: TXID_RESULT } }) - const c = makeConnector() - const txid = await c.sendRawTransaction(TX_HEX) - assert.strictEqual(txid, TXID_RESULT) - }) - - it('throws when JSON-RPC response contains an error field', async () => { - stubAxiosPost({ data: { error: { message: 'Transaction already in block chain' } } }) - const c = makeConnector() - await assert.rejects( - () => c.sendRawTransaction(TX_HEX), - /Transaction already in block chain/ - ) - }) - - it('uses error.message from JSON-RPC error when present', async () => { - stubAxiosPost({ data: { error: { message: 'bad-txns-inputs-missingorspent', code: -25 } } }) - const c = makeConnector() - await assert.rejects( - () => c.sendRawTransaction(TX_HEX), - /bad-txns-inputs-missingorspent/ - ) - }) - - it('falls back to JSON.stringify when error has no message', async () => { - stubAxiosPost({ data: { error: { code: -99 } } }) - const c = makeConnector() - await assert.rejects( - () => c.sendRawTransaction(TX_HEX), - /-99/ - ) - }) - - it('throws generic error when result is missing and no error', async () => { - stubAxiosPost({ data: { result: null } }) - const c = makeConnector() - await assert.rejects( - () => c.sendRawTransaction(TX_HEX), - /Error broadcasting transaction/ - ) - }) - - it('surfaces bitcoind HTTP-500 error body when axios throws', async () => { - // bitcoind returns HTTP 500 for rejected transactions with a JSON-RPC error body - const rpcErr = new Error('Request failed with status code 500') - rpcErr.response = { - data: { error: { code: -26, message: 'dust' } } - } - stubAxiosPostThrow(rpcErr) - const c = makeConnector() - await assert.rejects( - () => c.sendRawTransaction(TX_HEX), - /dust/ - ) - }) - - it('rethrows transport errors that have no response body', async () => { - const netErr = new Error('socket hang up') - stubAxiosPostThrow(netErr) - const c = makeConnector() - await assert.rejects( - () => c.sendRawTransaction(TX_HEX), - /socket hang up/ - ) - }) - - it('uses the message from error.response.data.error when code is -26', async () => { - const rpcErr = new Error('HTTP 500') - rpcErr.response = { - data: { error: { code: -26, message: 'non-mandatory-script-verify-flag' } } - } - stubAxiosPostThrow(rpcErr) - const c = makeConnector() - await assert.rejects( - () => c.sendRawTransaction(TX_HEX), - /non-mandatory-script-verify-flag/ - ) - }) - - it('JSON.stringify fallback in HTTP-500 error body when error.message is absent', async () => { - // Exercises the || JSON.stringify(body.error) branch on line 165: - // body.error is truthy but body.error.message is absent/falsy. - const rpcErr = new Error('HTTP 500') - rpcErr.response = { - data: { error: { code: -26 } } // no message field - } - stubAxiosPostThrow(rpcErr) - const c = makeConnector() - await assert.rejects( - () => c.sendRawTransaction(TX_HEX), - /-26/ // JSON.stringify includes the code - ) - }) -}) - -// Mirrors xchain-e2e-test/src/BlockchainConnector.js broadcastTx. -describe('BlockchainConnector.sendRawTransaction() maxfeerate retry', () => { - const TX_HEX = '0100000001' + '0'.repeat(60) + 'ffffffff' - const TXID_RESULT = 'c'.repeat(64) - const CAP_MSG = 'Fee exceeds maximum configured by user (e.g. -maxtxfee, maxfeerate)' - - // Stub a node: first sendrawtransaction rejects with the fee cap, a retry - // carrying params [hex, 0] succeeds; getblockchaininfo reports `chain`. - function stubCapNode (chain, capturedSends) { - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') return { data: { result: { chain } } } - if (data.method === 'sendrawtransaction') { - capturedSends.push(data.params) - if (data.params.length === 1) { - const err = new Error('Request failed with status code 500') - err.response = { data: { error: { code: -25, message: CAP_MSG } } } - throw err - } - return { data: { result: TXID_RESULT } } - } - return { data: { result: {} } } - } - } - - it('retries once with maxfeerate=0 on the fee-cap error when chain is regtest', async () => { - const sends = [] - stubCapNode('regtest', sends) - const c = makeConnector() - const txid = await c.sendRawTransaction(TX_HEX) - assert.strictEqual(txid, TXID_RESULT) - assert.strictEqual(sends.length, 2) - assert.deepStrictEqual(sends[0], [TX_HEX]) - assert.deepStrictEqual(sends[1], [TX_HEX, 0]) - }) - - it('does NOT retry on the fee-cap error when chain is not regtest', async () => { - const sends = [] - stubCapNode('main', sends) - const c = makeConnector() - await assert.rejects(() => c.sendRawTransaction(TX_HEX), /Fee exceeds maximum/) - assert.strictEqual(sends.length, 1, 'no maxfeerate retry off regtest') - }) - - it('does NOT retry on regtest for non-fee-cap errors', async () => { - const sends = [] - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'regtest' } } } - if (data.method === 'sendrawtransaction') { - capturedPush(sends, data.params) - const err = new Error('HTTP 500') - err.response = { data: { error: { code: -26, message: 'bad-txns-inputs-missingorspent' } } } - throw err - } - return { data: { result: {} } } - } - function capturedPush (arr, v) { arr.push(v) } - const c = makeConnector() - await assert.rejects(() => c.sendRawTransaction(TX_HEX), /bad-txns-inputs-missingorspent/) - assert.strictEqual(sends.length, 1) - }) - - it('propagates the original fee-cap error when the chain check itself fails', async () => { - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') throw new Error('Connection refused') - const err = new Error('HTTP 500') - err.response = { data: { error: { code: -25, message: CAP_MSG } } } - throw err - } - const c = makeConnector() - await assert.rejects(() => c.sendRawTransaction(TX_HEX), /Fee exceeds maximum/) - }) - - it('propagates the retry failure if the maxfeerate=0 retry is also rejected', async () => { - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'regtest' } } } - const err = new Error('HTTP 500') - err.response = { - data: { - error: data.params && data.params.length === 2 - ? { code: -26, message: 'insufficient fee, rejecting replacement' } - : { code: -25, message: CAP_MSG } - } - } - throw err - } - const c = makeConnector() - await assert.rejects(() => c.sendRawTransaction(TX_HEX), /insufficient fee/) - }) - - it('matches the bare "maxfeerate" wording variant', async () => { - const sends = [] - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'regtest' } } } - if (data.method === 'sendrawtransaction') { - sends.push(data.params) - if (data.params.length === 1) { - return { data: { error: { code: -25, message: 'max feerate exceeded: see maxfeerate' } } } - } - return { data: { result: TXID_RESULT } } - } - return { data: { result: {} } } - } - const c = makeConnector() - const txid = await c.sendRawTransaction(TX_HEX) - assert.strictEqual(txid, TXID_RESULT) - assert.strictEqual(sends.length, 2) - }) -}) - -describe('BlockchainConnector.getFeePerKilobyte()', () => { - // getFeePerKilobyte now consults isRegtest() FIRST (getblockchaininfo): on regtest - // it returns the relayfee floor and never calls estimatesmartfee; only non-regtest - // nodes reach the estimatesmartfee path. These tests therefore stub the chain. - it('sends estimatesmartfee with the blocksNumber param (non-regtest)', async () => { - let capturedFeePayload - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'main' } } } - if (data.method === 'estimatesmartfee') { capturedFeePayload = data; return { data: { result: { feerate: 0.00005 } } } } - return { data: { result: {} } } - } - const c = makeConnector() - await c.getFeePerKilobyte(6) - assert.strictEqual(capturedFeePayload.method, 'estimatesmartfee') - assert.deepStrictEqual(capturedFeePayload.params, [6]) - }) - - it('returns feerate when present (non-regtest)', async () => { - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'main' } } } - return { data: { result: { feerate: 0.00012345 } } } - } - const c = makeConnector() - const feerate = await c.getFeePerKilobyte(3) - assert.strictEqual(feerate, 0.00012345) - }) - - it('uses the relayfee floor on regtest and never consults estimatesmartfee', async () => { - // Regression for the deep-regtest fee-inflation bug: on regtest, even when - // estimatesmartfee WOULD return a (large) value, the connector must ignore it - // and use the node's relayfee floor. We assert estimatesmartfee is never sent. - let smartFeeCalled = false - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'regtest' } } } - if (data.method === 'getnetworkinfo') return { data: { result: { relayfee: 0.00001000 } } } - if (data.method === 'estimatesmartfee') { smartFeeCalled = true; return { data: { result: { feerate: 0.1386 } } } } - return { data: { result: {} } } - } - const c = makeConnector() - const feerate = await c.getFeePerKilobyte(1) - assert.strictEqual(feerate, 0.00001000) - assert.strictEqual(smartFeeCalled, false, 'estimatesmartfee must not be consulted on regtest') - }) - - it('falls back to the regtest default when relayfee is unavailable', async () => { - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'regtest' } } } - if (data.method === 'getnetworkinfo') return { data: { result: {} } } - return { data: { result: {} } } - } - const c = makeConnector() - const feerate = await c.getFeePerKilobyte(1) - assert.strictEqual(feerate, 0.00001000) - }) - - it('throws when feerate is missing and chain is not regtest', async () => { - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'main' } } } - return { data: { result: {} } } - } - const c = makeConnector() - await assert.rejects( - () => c.getFeePerKilobyte(1), - /Error getting smart fee from node/ - ) - }) - - // A public testnet can be fully synced with an empty mempool and still have no - // fee history, so estimatesmartfee answers feerate:-1 (DOGE testnet does exactly - // this). Non-mainnet chains fall back to 10x the node's relayfee floor (the bare - // floor is rejected by Dogecoin 1.14's free-tx priority gate as "66: insufficient - // priority"); mainnet must keep failing, because there a missing estimate means - // the node is unhealthy. - it('falls back to 10x the relayfee floor on testnet when estimatesmartfee has no data', async () => { - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'test' } } } - if (data.method === 'estimatesmartfee') return { data: { result: { feerate: -1, blocks: 25 } } } - if (data.method === 'getnetworkinfo') return { data: { result: { relayfee: 0.001 } } } - return { data: { result: {} } } - } - const c = makeConnector() - const feerate = await c.getFeePerKilobyte(6) - assert.ok(Math.abs(feerate - 0.01) < 1e-12, 'expected 0.01 DOGE/kB, got ' + feerate) - }) - - // The documented 10x rate is not always minable: on Dogecoin testnet, measured - // over 400 blocks, transactions land at 0.03 and around 1.0 DOGE per kB while - // 0.0102 sat unmined. Only the operator running a given chain can measure what - // it takes there, so the multiple is deployment-tunable. - it('honours FEE_NO_ESTIMATE_RELAY_MULTIPLIER for the no-estimate fallback', async () => { - process.env.FEE_NO_ESTIMATE_RELAY_MULTIPLIER = '100' - try { - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'test' } } } - if (data.method === 'estimatesmartfee') return { data: { result: { feerate: -1 } } } - if (data.method === 'getnetworkinfo') return { data: { result: { relayfee: 0.001 } } } - return { data: { result: {} } } - } - const c = makeConnector() - const feerate = await c.getFeePerKilobyte(6) - assert.ok(Math.abs(feerate - 0.1) < 1e-12, 'expected 0.1 DOGE/kB at 100x, got ' + feerate) - } finally { - delete process.env.FEE_NO_ESTIMATE_RELAY_MULTIPLIER - } - }) - - it('ignores a non-positive or unparseable multiplier and keeps the default', async () => { - for (const bad of ['0', '-5', 'abc']) { - process.env.FEE_NO_ESTIMATE_RELAY_MULTIPLIER = bad - try { - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'test' } } } - if (data.method === 'estimatesmartfee') return { data: { result: { feerate: -1 } } } - if (data.method === 'getnetworkinfo') return { data: { result: { relayfee: 0.001 } } } - return { data: { result: {} } } - } - const c = makeConnector() - const feerate = await c.getFeePerKilobyte(6) - assert.ok(Math.abs(feerate - 0.01) < 1e-12, bad + ' must fall back to 10x, got ' + feerate) - } finally { - delete process.env.FEE_NO_ESTIMATE_RELAY_MULTIPLIER - } - } - }) - - it('still prefers the estimate over the relayfee floor on testnet when one exists', async () => { - let networkInfoCalled = false - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'test' } } } - if (data.method === 'estimatesmartfee') return { data: { result: { feerate: 0.005 } } } - if (data.method === 'getnetworkinfo') { networkInfoCalled = true; return { data: { result: { relayfee: 0.001 } } } } - return { data: { result: {} } } - } - const c = makeConnector() - const feerate = await c.getFeePerKilobyte(6) - assert.strictEqual(feerate, 0.005) - assert.strictEqual(networkInfoCalled, false, 'relayfee must not be consulted when an estimate exists') - }) - - it('throws on testnet when neither an estimate nor a relayfee is available', async () => { - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'test' } } } - if (data.method === 'estimatesmartfee') return { data: { result: { feerate: -1 } } } - if (data.method === 'getnetworkinfo') return { data: { result: {} } } - return { data: { result: {} } } - } - const c = makeConnector() - await assert.rejects(() => c.getFeePerKilobyte(1), /Error getting smart fee from node/) - }) - - it('does NOT fall back to the relayfee floor on mainnet', async () => { - let networkInfoCalled = false - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'main' } } } - if (data.method === 'estimatesmartfee') return { data: { result: { feerate: -1 } } } - if (data.method === 'getnetworkinfo') { networkInfoCalled = true; return { data: { result: { relayfee: 0.00001 } } } } - return { data: { result: {} } } - } - const c = makeConnector() - await assert.rejects(() => c.getFeePerKilobyte(1), /Error getting smart fee from node/) - assert.strictEqual(networkInfoCalled, false, 'mainnet must never substitute the relay floor for a missing estimate') - }) - - it('rethrows transport errors', async () => { - stubAxiosPostThrow(new Error('Connection refused')) - const c = makeConnector() - await assert.rejects( - () => c.getFeePerKilobyte(1), - /Connection refused/ - ) - }) - - // A node reports "no fee estimate" in two shapes, and only one of them is a - // success body carrying feerate:-1. LTC/DOGE answer HTTP 500 with a JSON-RPC - // error body, so axios throws before the try-block fallback is reached; the - // testnet relay-multiplier fallback has to run on that shape too, or a public - // testnet node in an ordinary state fails every fee-bearing build. - describe('no-estimate reported as an RPC error rather than feerate:-1', () => { - it('falls back to 10x the relayfee floor on testnet when estimatesmartfee throws', async () => { - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'test' } } } - if (data.method === 'getnetworkinfo') return { data: { result: { relayfee: 0.001 } } } - if (data.method === 'estimatesmartfee') { - const err = new Error('Request failed with status code 500') - err.response = { status: 500, data: { error: { code: -1, message: 'Insufficient data' } } } - throw err - } - return { data: { result: {} } } - } - const c = makeConnector() - const feerate = await c.getFeePerKilobyte(6) - assert.ok(Math.abs(feerate - 0.01) < 1e-12, 'expected 10x the 0.001 relay floor, got ' + feerate) - }) - - it('still rejects on testnet when the node reports no relayfee either', async () => { - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'test' } } } - if (data.method === 'getnetworkinfo') return { data: { result: {} } } - if (data.method === 'estimatesmartfee') throw new Error('Request failed with status code 500') - return { data: { result: {} } } - } - const c = makeConnector() - await assert.rejects(() => c.getFeePerKilobyte(6), /status code 500/) - }) - - it('does NOT invent a rate on mainnet when estimatesmartfee throws', async () => { - let networkInfoCalled = false - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'main' } } } - if (data.method === 'getnetworkinfo') { networkInfoCalled = true; return { data: { result: { relayfee: 0.001 } } } } - if (data.method === 'estimatesmartfee') throw new Error('Request failed with status code 500') - return { data: { result: {} } } - } - const c = makeConnector() - await assert.rejects(() => c.getFeePerKilobyte(6), /status code 500/) - assert.strictEqual(networkInfoCalled, false, 'mainnet must never substitute the relay floor') - }) - - it('keeps a node-down failure a failure', async () => { - stubAxiosPostThrow(new Error('ECONNREFUSED')) - const c = makeConnector() - await assert.rejects(() => c.getFeePerKilobyte(6), /ECONNREFUSED/) - }) - }) - - it('throws when result is entirely missing (non-regtest)', async () => { - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'main' } } } - return { data: { result: null } } - } - const c = makeConnector() - await assert.rejects( - () => c.getFeePerKilobyte(1), - /Error getting smart fee from node/ - ) - }) - - // estimatesmartfee was taken verbatim on testnet/mainnet with no - // upper bound. A misbehaving/compromised/misconfigured node feeding a - // spiked estimate on this money-affecting path fed straight into what the - // caller was charged, and into the caller-facing caps' own anchor. - describe('sanity ceiling on the raw non-regtest estimate', () => { - let originalNetwork, originalCeiling, warnSpy, errorSpy - - beforeEach(() => { - originalNetwork = process.env.NETWORK - originalCeiling = process.env.FEE_ESTIMATE_SANITY_CEILING - delete process.env.NETWORK - delete process.env.FEE_ESTIMATE_SANITY_CEILING - errorSpy = [] - // Spy at the logger boundary, not console.error: this diagnostic now - // routes through observability/index.js's logger, whose _lazyLogger - // falls through to console.error only until some earlier-run test file - // has installed a real shipper in the shared _logger singleton, after - // which it no longer touches console at all. - warnSpy = logger.error - logger.error = (...args) => { errorSpy.push(args.join(' ')); } - }) - - afterEach(() => { - if (originalNetwork === undefined) delete process.env.NETWORK; else process.env.NETWORK = originalNetwork - if (originalCeiling === undefined) delete process.env.FEE_ESTIMATE_SANITY_CEILING; else process.env.FEE_ESTIMATE_SANITY_CEILING = originalCeiling - logger.error = warnSpy - }) - - it('clamps a spiked mainnet estimate to the coin-default ceiling and logs loudly', async () => { - process.env.NETWORK = 'bitcoin-mainnet' - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'main' } } } - if (data.method === 'estimatesmartfee') return { data: { result: { feerate: 5 } } } // 5 BTC/kB: absurd - return { data: { result: {} } } - } - const c = makeConnector() - const feerate = await c.getFeePerKilobyte(1) - assert.strictEqual(feerate, 0.01, 'expected clamp to the bitcoin default ceiling 0.01 BTC/kB') - 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 () => { - 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 - 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') - }) - - it('passes through an estimate under the ceiling unchanged', async () => { - process.env.NETWORK = 'bitcoin-mainnet' - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'main' } } } - if (data.method === 'estimatesmartfee') return { data: { result: { feerate: 0.0005 } } } - return { data: { result: {} } } - } - const c = makeConnector() - const feerate = await c.getFeePerKilobyte(1) - assert.strictEqual(feerate, 0.0005) - assert.strictEqual(errorSpy.length, 0, 'must not log when under the ceiling') - }) - - it('honours FEE_ESTIMATE_SANITY_CEILING as an override', async () => { - process.env.NETWORK = 'bitcoin-mainnet' - process.env.FEE_ESTIMATE_SANITY_CEILING = '0.0001' - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'main' } } } - if (data.method === 'estimatesmartfee') return { data: { result: { feerate: 0.0005 } } } - return { data: { result: {} } } - } - const c = makeConnector() - const feerate = await c.getFeePerKilobyte(1) - assert.strictEqual(feerate, 0.0001, 'expected clamp to the overridden ceiling') - }) - - it('ignores a non-positive or unparseable override and keeps the coin default', async () => { - process.env.NETWORK = 'litecoin-mainnet' - process.env.FEE_ESTIMATE_SANITY_CEILING = '-1' - axios.post = async (url, data) => { - if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'main' } } } - if (data.method === 'estimatesmartfee') return { data: { result: { feerate: 5 } } } - return { data: { result: {} } } - } - const c = makeConnector() - const feerate = await c.getFeePerKilobyte(1) - assert.strictEqual(feerate, 0.01, 'expected fallback to the litecoin default ceiling') - }) - }) -}) - -// Behavioral lock: every RPC call passes auth:{username,password} to axios, and -// axios attaches that config to the thrown error. Logging or re-throwing the raw -// error serializes the node RPC password into the encoder logs. Drive a failing -// getTransactionHex and assert the password never reaches console.error and is -// scrubbed from the re-thrown error. FAKE_RPC_PASSWORD is a test sentinel. -describe('BlockchainConnector RPC-credential log sanitization', () => { - it('does not leak the RPC password when an axios call fails', async () => { - const FAKE_RPC_PASSWORD = 'FAKEPASS_must_never_be_logged_9c3f' - - const err = new Error('Request failed with status code 401') - err.code = 'ERR_BAD_REQUEST' - err.config = { - auth: { username: 'rpcuser', password: FAKE_RPC_PASSWORD }, - headers: { Authorization: 'Basic ' + Buffer.from('rpcuser:' + FAKE_RPC_PASSWORD).toString('base64') } - } - err.request = { _header: 'Authorization: Basic ' + Buffer.from('rpcuser:' + FAKE_RPC_PASSWORD).toString('base64') } - err.response = { status: 401, data: 'unauthorized', config: err.config } - - const c = new BlockchainConnector('127.0.0.1', 18332, 'rpcuser', FAKE_RPC_PASSWORD) - stubAxiosPostThrow(err) - - const originalError = console.error - const logs = [] - console.error = (...args) => { - logs.push(args.map(a => (typeof a === 'string' ? a : util.inspect(a, { depth: 8 }))).join(' ')) - } - - let thrown - try { - await c.getTransactionHex('deadbeef') - } catch (e) { - thrown = e - } finally { - console.error = originalError - } - - const combined = logs.join('\n') - assert.ok(thrown, 'the failing RPC should propagate an error') - assert.ok(!combined.includes(FAKE_RPC_PASSWORD), - 'the RPC password must never appear in connector error logs (got: ' + combined + ')') - assert.strictEqual(thrown.config && thrown.config.auth, undefined, - 'the re-thrown error must have its config.auth scrubbed') - }) -}) diff --git a/test/unit/blockchain_connector.test/01_get_network_info.test.js b/test/unit/blockchain_connector.test/01_get_network_info.test.js new file mode 100644 index 0000000..b5b4eff --- /dev/null +++ b/test/unit/blockchain_connector.test/01_get_network_info.test.js @@ -0,0 +1,136 @@ +// 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') + +function makeConnector () { + return new BlockchainConnector('127.0.0.1', 18332, 'rpcuser', 'rpcpass') +} + +function stubAxiosPost (response) { + axios.post = async () => response +} + +function stubAxiosPostThrow (err) { + axios.post = async () => { throw err } +} + +// Capture and restore axios.post between tests +function registerAxiosHooks () { + let originalPost + beforeEach(() => { + originalPost = axios.post + }) + afterEach(() => { + axios.post = originalPost + }) +} + +describe('BlockchainConnector.getNetworkInfo()', () => { + registerAxiosHooks() + it('sends the correct JSON-RPC method', async () => { + let capturedPayload + axios.post = async (url, data) => { + capturedPayload = data + return { data: { result: { version: 220000, subversion: '/Satoshi:22.0.0/' } } } + } + + const c = makeConnector() + await c.getNetworkInfo() + assert.strictEqual(capturedPayload.method, 'getnetworkinfo') + assert.strictEqual(capturedPayload.jsonrpc, '2.0') + assert.strictEqual(capturedPayload.id, 1) + }) + + it('passes auth credentials to axios', async () => { + let capturedOptions + axios.post = async (url, data, options) => { + capturedOptions = options + return { data: { result: { version: 1 } } } + } + + const c = makeConnector() + await c.getNetworkInfo() + assert.strictEqual(capturedOptions.auth.username, 'rpcuser') + assert.strictEqual(capturedOptions.auth.password, 'rpcpass') + }) + + it('returns the result object on success', async () => { + stubAxiosPost({ data: { result: { version: 220000, subversion: '/Satoshi:22.0.0/' } } }) + const c = makeConnector() + const info = await c.getNetworkInfo() + assert.strictEqual(info.version, 220000) + assert.strictEqual(info.subversion, '/Satoshi:22.0.0/') + }) + + it('throws when result is missing from response', async () => { + stubAxiosPost({ data: { result: null } }) + const c = makeConnector() + await assert.rejects( + () => c.getNetworkInfo(), + /Error in network request/ + ) + }) +}) + +describe('BlockchainConnector.getNetworkInfo()', () => { + registerAxiosHooks() + it('wraps axios transport errors', async () => { + stubAxiosPostThrow(new Error('ECONNREFUSED')) + const c = makeConnector() + await assert.rejects( + () => c.getNetworkInfo(), + /Error in network request.*ECONNREFUSED/ + ) + }) + + // A warming node answers -28 "Loading block index..."; LTC/DOGE carry that in + // an HTTP 500 body, BTC v28 in an HTTP 200 one. Both must keep the RPC code and + // message: a bare status line leaves warmup indistinguishable from a network fault. + it('keeps the node RPC code and message from an HTTP-500 error body', async () => { + const err = new Error('Request failed with status code 500') + err.response = { status: 500, data: { error: { code: -28, message: 'Loading block index...' } } } + err.config = { auth: { username: 'rpcuser', password: 'FAKEPASS_must_never_be_logged_9c2d' } } + stubAxiosPostThrow(err) + const c = makeConnector() + await assert.rejects(() => c.getNetworkInfo(), (e) => { + assert.ok(/^Error in network request/.test(e.message), e.message) + assert.ok(e.message.includes('-28'), e.message) + assert.ok(e.message.includes('Loading block index'), e.message) + assert.ok(!e.message.includes('FAKEPASS'), 'the RPC password must never reach the message') + return true + }) + }) + + it('keeps the node RPC code and message from an HTTP-200 error body', async () => { + stubAxiosPost({ data: { result: null, error: { code: -28, message: 'Loading block index...' } } }) + const c = makeConnector() + await assert.rejects(() => c.getNetworkInfo(), (e) => { + assert.ok(/^Error in network request/.test(e.message), e.message) + assert.ok(e.message.includes('-28'), e.message) + assert.ok(e.message.includes('Loading block index'), e.message) + return true + }) + }) + + it('sets a timeout on the request', async () => { + let capturedOptions + axios.post = async (url, data, options) => { + capturedOptions = options + return { data: { result: { version: 1 } } } + } + const c = makeConnector() + await c.getNetworkInfo() + assert.ok(typeof capturedOptions.timeout === 'number' && capturedOptions.timeout > 0, + 'timeout must be a positive number') + }) +}) diff --git a/test/unit/blockchain_connector.test/02_is_regtest.test.js b/test/unit/blockchain_connector.test/02_is_regtest.test.js new file mode 100644 index 0000000..ce64d86 --- /dev/null +++ b/test/unit/blockchain_connector.test/02_is_regtest.test.js @@ -0,0 +1,121 @@ +// 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') + +function makeConnector () { + return new BlockchainConnector('127.0.0.1', 18332, 'rpcuser', 'rpcpass') +} + +function stubAxiosPost (response) { + axios.post = async () => response +} + +function stubAxiosPostThrow (err) { + axios.post = async () => { throw err } +} + +function registerAxiosHooks () { + let originalPost + beforeEach(() => { + originalPost = axios.post + }) + afterEach(() => { + axios.post = originalPost + }) +} + +describe('BlockchainConnector.isRegtest()', () => { + registerAxiosHooks() + it('sends getblockchaininfo method', async () => { + let capturedPayload + axios.post = async (url, data) => { + capturedPayload = data + return { data: { result: { chain: 'regtest' } } } + } + const c = makeConnector() + await c.isRegtest() + assert.strictEqual(capturedPayload.method, 'getblockchaininfo') + }) + + it('returns true when chain is "regtest"', async () => { + stubAxiosPost({ data: { result: { chain: 'regtest' } } }) + const c = makeConnector() + assert.strictEqual(await c.isRegtest(), true) + }) + + it('returns false when chain is "main"', async () => { + stubAxiosPost({ data: { result: { chain: 'main' } } }) + const c = makeConnector() + assert.strictEqual(await c.isRegtest(), false) + }) + + it('returns false when chain is "test"', async () => { + stubAxiosPost({ data: { result: { chain: 'test' } } }) + const c = makeConnector() + assert.strictEqual(await c.isRegtest(), false) + }) + + it('throws when result is missing', async () => { + stubAxiosPost({ data: { result: null } }) + const c = makeConnector() + await assert.rejects( + () => c.isRegtest(), + /Error in network request/ + ) + }) + + it('throws when result has no chain property', async () => { + stubAxiosPost({ data: { result: {} } }) + const c = makeConnector() + await assert.rejects( + () => c.isRegtest(), + /Error in network request/ + ) + }) +}) + +describe('BlockchainConnector.isRegtest()', () => { + registerAxiosHooks() + it('wraps transport errors', async () => { + stubAxiosPostThrow(new Error('timeout of 30000ms exceeded')) + const c = makeConnector() + await assert.rejects( + () => c.isRegtest(), + /Error in network request.*timeout/ + ) + }) + + it('keeps the node RPC code and message from an HTTP-500 error body', async () => { + const err = new Error('Request failed with status code 500') + err.response = { status: 500, data: { error: { code: -28, message: 'Loading block index...' } } } + stubAxiosPostThrow(err) + const c = makeConnector() + await assert.rejects(() => c.chainName(), (e) => { + assert.ok(/^Error in network request/.test(e.message), e.message) + assert.ok(e.message.includes('-28'), e.message) + assert.ok(e.message.includes('Loading block index'), e.message) + return true + }) + }) + + it('keeps the node RPC code and message from an HTTP-200 error body', async () => { + stubAxiosPost({ data: { result: null, error: { code: -28, message: 'Loading block index...' } } }) + const c = makeConnector() + await assert.rejects(() => c.chainName(), (e) => { + assert.ok(/^Error in network request/.test(e.message), e.message) + assert.ok(e.message.includes('-28'), e.message) + assert.ok(e.message.includes('Loading block index'), e.message) + return true + }) + }) +}) diff --git a/test/unit/blockchain_connector.test/03_get_transaction_hex.test.js b/test/unit/blockchain_connector.test/03_get_transaction_hex.test.js new file mode 100644 index 0000000..186f690 --- /dev/null +++ b/test/unit/blockchain_connector.test/03_get_transaction_hex.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. + +const assert = require('assert') +const axios = require('axios') +const BlockchainConnector = require('../../../src/build/blockchain_connector') + +function makeConnector () { + return new BlockchainConnector('127.0.0.1', 18332, 'rpcuser', 'rpcpass') +} + +function stubAxiosPost (response) { + axios.post = async () => response +} + +function stubAxiosPostThrow (err) { + axios.post = async () => { throw err } +} + +function registerAxiosHooks () { + let originalPost + beforeEach(() => { + originalPost = axios.post + }) + afterEach(() => { + axios.post = originalPost + }) +} + +const TXID = 'a'.repeat(64) +const HEX = '0100000001' + '0'.repeat(100) + +describe('BlockchainConnector.getTransactionHex()', () => { + registerAxiosHooks() + it('sends getrawtransaction with correct txid and hexFormat=true', async () => { + let capturedPayload + axios.post = async (url, data) => { + capturedPayload = data + return { data: { result: { hex: HEX } } } + } + const c = makeConnector() + await c.getTransactionHex(TXID) + assert.strictEqual(capturedPayload.method, 'getrawtransaction') + assert.deepStrictEqual(capturedPayload.params, [TXID, true]) + }) + + it('passes hexFormat=false when requested', async () => { + let capturedPayload + axios.post = async (url, data) => { + capturedPayload = data + return { data: { result: { hex: HEX } } } + } + const c = makeConnector() + await c.getTransactionHex(TXID, false) + assert.deepStrictEqual(capturedPayload.params, [TXID, false]) + }) + + it('returns the hex string on success', async () => { + stubAxiosPost({ data: { result: { hex: HEX } } }) + const c = makeConnector() + const result = await c.getTransactionHex(TXID) + assert.strictEqual(result, HEX) + }) + + it('throws "not found" message when error.code is -5', async () => { + stubAxiosPost({ data: { error: { code: -5, message: 'No such mempool or blockchain transaction' } } }) + const c = makeConnector() + await assert.rejects( + () => c.getTransactionHex(TXID), + /not found.*txindex/ + ) + }) + + it('includes the txid in the "not found" error message', async () => { + stubAxiosPost({ data: { error: { code: -5, message: 'tx not found' } } }) + const c = makeConnector() + await assert.rejects( + () => c.getTransactionHex(TXID), + new RegExp(TXID.slice(0, 8)) + ) + }) +}) + +describe('BlockchainConnector.getTransactionHex()', () => { + registerAxiosHooks() + it('throws generic error when result is missing and error is not -5', async () => { + stubAxiosPost({ data: { result: null, error: null } }) + const c = makeConnector() + await assert.rejects( + () => c.getTransactionHex(TXID), + /Error getting transaction hex/ + ) + }) + + it('rethrows transport errors directly', async () => { + stubAxiosPostThrow(new Error('Network failure')) + const c = makeConnector() + await assert.rejects( + () => c.getTransactionHex(TXID), + /Network failure/ + ) + }) + + it('sends auth credentials', async () => { + let capturedOptions + axios.post = async (url, data, options) => { + capturedOptions = options + return { data: { result: { hex: HEX } } } + } + const c = makeConnector() + await c.getTransactionHex(TXID) + assert.strictEqual(capturedOptions.auth.username, 'rpcuser') + assert.strictEqual(capturedOptions.auth.password, 'rpcpass') + }) +}) diff --git a/test/unit/blockchain_connector.test/04_send_raw_transaction.test.js b/test/unit/blockchain_connector.test/04_send_raw_transaction.test.js new file mode 100644 index 0000000..c3998a0 --- /dev/null +++ b/test/unit/blockchain_connector.test/04_send_raw_transaction.test.js @@ -0,0 +1,154 @@ +// 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') + +function makeConnector () { + return new BlockchainConnector('127.0.0.1', 18332, 'rpcuser', 'rpcpass') +} + +function stubAxiosPost (response) { + axios.post = async () => response +} + +function stubAxiosPostThrow (err) { + axios.post = async () => { throw err } +} + +function registerAxiosHooks () { + let originalPost + beforeEach(() => { + originalPost = axios.post + }) + afterEach(() => { + axios.post = originalPost + }) +} + +const TX_HEX = '0100000001' + '0'.repeat(60) + 'ffffffff' +const TXID_RESULT = 'b'.repeat(64) + +describe('BlockchainConnector.sendRawTransaction()', () => { + registerAxiosHooks() + it('sends sendrawtransaction with the tx hex as param', async () => { + let capturedPayload + axios.post = async (url, data) => { + capturedPayload = data + return { data: { result: TXID_RESULT } } + } + const c = makeConnector() + await c.sendRawTransaction(TX_HEX) + assert.strictEqual(capturedPayload.method, 'sendrawtransaction') + assert.deepStrictEqual(capturedPayload.params, [TX_HEX]) + }) + + it('returns the txid on success', async () => { + stubAxiosPost({ data: { result: TXID_RESULT } }) + const c = makeConnector() + const txid = await c.sendRawTransaction(TX_HEX) + assert.strictEqual(txid, TXID_RESULT) + }) + + it('throws when JSON-RPC response contains an error field', async () => { + stubAxiosPost({ data: { error: { message: 'Transaction already in block chain' } } }) + const c = makeConnector() + await assert.rejects( + () => c.sendRawTransaction(TX_HEX), + /Transaction already in block chain/ + ) + }) +}) + +describe('BlockchainConnector.sendRawTransaction()', () => { + registerAxiosHooks() + it('uses error.message from JSON-RPC error when present', async () => { + stubAxiosPost({ data: { error: { message: 'bad-txns-inputs-missingorspent', code: -25 } } }) + const c = makeConnector() + await assert.rejects( + () => c.sendRawTransaction(TX_HEX), + /bad-txns-inputs-missingorspent/ + ) + }) + + it('falls back to JSON.stringify when error has no message', async () => { + stubAxiosPost({ data: { error: { code: -99 } } }) + const c = makeConnector() + await assert.rejects( + () => c.sendRawTransaction(TX_HEX), + /-99/ + ) + }) + + it('throws generic error when result is missing and no error', async () => { + stubAxiosPost({ data: { result: null } }) + const c = makeConnector() + await assert.rejects( + () => c.sendRawTransaction(TX_HEX), + /Error broadcasting transaction/ + ) + }) + + it('surfaces bitcoind HTTP-500 error body when axios throws', async () => { + // bitcoind returns HTTP 500 for rejected transactions with a JSON-RPC error body + const rpcErr = new Error('Request failed with status code 500') + rpcErr.response = { + data: { error: { code: -26, message: 'dust' } } + } + stubAxiosPostThrow(rpcErr) + const c = makeConnector() + await assert.rejects( + () => c.sendRawTransaction(TX_HEX), + /dust/ + ) + }) +}) + +describe('BlockchainConnector.sendRawTransaction()', () => { + registerAxiosHooks() + it('rethrows transport errors that have no response body', async () => { + const netErr = new Error('socket hang up') + stubAxiosPostThrow(netErr) + const c = makeConnector() + await assert.rejects( + () => c.sendRawTransaction(TX_HEX), + /socket hang up/ + ) + }) + + it('uses the message from error.response.data.error when code is -26', async () => { + const rpcErr = new Error('HTTP 500') + rpcErr.response = { + data: { error: { code: -26, message: 'non-mandatory-script-verify-flag' } } + } + stubAxiosPostThrow(rpcErr) + const c = makeConnector() + await assert.rejects( + () => c.sendRawTransaction(TX_HEX), + /non-mandatory-script-verify-flag/ + ) + }) + + it('JSON.stringify fallback in HTTP-500 error body when error.message is absent', async () => { + // Exercises the || JSON.stringify(body.error) branch on line 165: + // body.error is truthy but body.error.message is absent/falsy. + const rpcErr = new Error('HTTP 500') + rpcErr.response = { + data: { error: { code: -26 } } // no message field + } + stubAxiosPostThrow(rpcErr) + const c = makeConnector() + await assert.rejects( + () => c.sendRawTransaction(TX_HEX), + /-26/ // JSON.stringify includes the code + ) + }) +}) diff --git a/test/unit/blockchain_connector.test/05_send_raw_transaction_maxfeerate_retry.test.js b/test/unit/blockchain_connector.test/05_send_raw_transaction_maxfeerate_retry.test.js new file mode 100644 index 0000000..f621656 --- /dev/null +++ b/test/unit/blockchain_connector.test/05_send_raw_transaction_maxfeerate_retry.test.js @@ -0,0 +1,141 @@ +// 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') + +function makeConnector () { + return new BlockchainConnector('127.0.0.1', 18332, 'rpcuser', 'rpcpass') +} + +function registerAxiosHooks () { + let originalPost + beforeEach(() => { + originalPost = axios.post + }) + afterEach(() => { + axios.post = originalPost + }) +} + +const TX_HEX = '0100000001' + '0'.repeat(60) + 'ffffffff' +const TXID_RESULT = 'c'.repeat(64) +const CAP_MSG = 'Fee exceeds maximum configured by user (e.g. -maxtxfee, maxfeerate)' + +// Stub a node: first sendrawtransaction rejects with the fee cap, a retry +// carrying params [hex, 0] succeeds; getblockchaininfo reports `chain`. +function stubCapNode (chain, capturedSends) { + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain } } } + if (data.method === 'sendrawtransaction') { + capturedSends.push(data.params) + if (data.params.length === 1) { + const err = new Error('Request failed with status code 500') + err.response = { data: { error: { code: -25, message: CAP_MSG } } } + throw err + } + return { data: { result: TXID_RESULT } } + } + return { data: { result: {} } } + } +} + +// Mirrors xchain-e2e-test/src/BlockchainConnector.js broadcastTx. + +describe('BlockchainConnector.sendRawTransaction() maxfeerate retry', () => { + registerAxiosHooks() + it('retries once with maxfeerate=0 on the fee-cap error when chain is regtest', async () => { + const sends = [] + stubCapNode('regtest', sends) + const c = makeConnector() + const txid = await c.sendRawTransaction(TX_HEX) + assert.strictEqual(txid, TXID_RESULT) + assert.strictEqual(sends.length, 2) + assert.deepStrictEqual(sends[0], [TX_HEX]) + assert.deepStrictEqual(sends[1], [TX_HEX, 0]) + }) + + it('does NOT retry on the fee-cap error when chain is not regtest', async () => { + const sends = [] + stubCapNode('main', sends) + const c = makeConnector() + await assert.rejects(() => c.sendRawTransaction(TX_HEX), /Fee exceeds maximum/) + assert.strictEqual(sends.length, 1, 'no maxfeerate retry off regtest') + }) + + it('does NOT retry on regtest for non-fee-cap errors', async () => { + const sends = [] + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'regtest' } } } + if (data.method === 'sendrawtransaction') { + capturedPush(sends, data.params) + const err = new Error('HTTP 500') + err.response = { data: { error: { code: -26, message: 'bad-txns-inputs-missingorspent' } } } + throw err + } + return { data: { result: {} } } + } + function capturedPush (arr, v) { arr.push(v) } + const c = makeConnector() + await assert.rejects(() => c.sendRawTransaction(TX_HEX), /bad-txns-inputs-missingorspent/) + assert.strictEqual(sends.length, 1) + }) +}) + +describe('BlockchainConnector.sendRawTransaction() maxfeerate retry', () => { + registerAxiosHooks() + it('propagates the original fee-cap error when the chain check itself fails', async () => { + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') throw new Error('Connection refused') + const err = new Error('HTTP 500') + err.response = { data: { error: { code: -25, message: CAP_MSG } } } + throw err + } + const c = makeConnector() + await assert.rejects(() => c.sendRawTransaction(TX_HEX), /Fee exceeds maximum/) + }) + + it('propagates the retry failure if the maxfeerate=0 retry is also rejected', async () => { + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'regtest' } } } + const err = new Error('HTTP 500') + err.response = { + data: { + error: data.params && data.params.length === 2 + ? { code: -26, message: 'insufficient fee, rejecting replacement' } + : { code: -25, message: CAP_MSG } + } + } + throw err + } + const c = makeConnector() + await assert.rejects(() => c.sendRawTransaction(TX_HEX), /insufficient fee/) + }) + + it('matches the bare "maxfeerate" wording variant', async () => { + const sends = [] + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'regtest' } } } + if (data.method === 'sendrawtransaction') { + sends.push(data.params) + if (data.params.length === 1) { + return { data: { error: { code: -25, message: 'max feerate exceeded: see maxfeerate' } } } + } + return { data: { result: TXID_RESULT } } + } + return { data: { result: {} } } + } + const c = makeConnector() + const txid = await c.sendRawTransaction(TX_HEX) + assert.strictEqual(txid, TXID_RESULT) + assert.strictEqual(sends.length, 2) + }) +}) 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 new file mode 100644 index 0000000..a545b8a --- /dev/null +++ b/test/unit/blockchain_connector.test/06_get_fee_per_kilobyte.test.js @@ -0,0 +1,395 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const assert = require('assert') +const axios = require('axios') +const BlockchainConnector = require('../../../src/build/blockchain_connector') +const { getLogger } = require('../../../src/observability'); +const logger = getLogger(); + +function makeConnector () { + return new BlockchainConnector('127.0.0.1', 18332, 'rpcuser', 'rpcpass') +} + +function stubAxiosPostThrow (err) { + axios.post = async () => { throw err } +} + +function registerAxiosHooks () { + let originalPost + beforeEach(() => { + originalPost = axios.post + }) + afterEach(() => { + axios.post = originalPost + }) +} + +let originalNetwork, originalCeiling, warnSpy, errorSpy + +function registerSanityHooks () { + beforeEach(() => { + originalNetwork = process.env.NETWORK + originalCeiling = process.env.FEE_ESTIMATE_SANITY_CEILING + delete process.env.NETWORK + delete process.env.FEE_ESTIMATE_SANITY_CEILING + errorSpy = [] + // Spy at the logger boundary, not console.error: this diagnostic now + // routes through observability/index.js's logger, whose _lazyLogger + // falls through to console.error only until some earlier-run test file + // has installed a real shipper in the shared _logger singleton, after + // which it no longer touches console at all. + warnSpy = logger.error + logger.error = (...args) => { errorSpy.push(args.join(' ')); } + }) + + afterEach(() => { + if (originalNetwork === undefined) delete process.env.NETWORK; else process.env.NETWORK = originalNetwork + if (originalCeiling === undefined) delete process.env.FEE_ESTIMATE_SANITY_CEILING; else process.env.FEE_ESTIMATE_SANITY_CEILING = originalCeiling + logger.error = warnSpy + }) +} + +describe('BlockchainConnector.getFeePerKilobyte()', () => { + registerAxiosHooks() + // getFeePerKilobyte now consults isRegtest() FIRST (getblockchaininfo): on regtest + // it returns the relayfee floor and never calls estimatesmartfee; only non-regtest + // nodes reach the estimatesmartfee path. These tests therefore stub the chain. + it('sends estimatesmartfee with the blocksNumber param (non-regtest)', async () => { + let capturedFeePayload + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'main' } } } + if (data.method === 'estimatesmartfee') { capturedFeePayload = data; return { data: { result: { feerate: 0.00005 } } } } + return { data: { result: {} } } + } + const c = makeConnector() + await c.getFeePerKilobyte(6) + assert.strictEqual(capturedFeePayload.method, 'estimatesmartfee') + assert.deepStrictEqual(capturedFeePayload.params, [6]) + }) + + it('returns feerate when present (non-regtest)', async () => { + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'main' } } } + return { data: { result: { feerate: 0.00012345 } } } + } + const c = makeConnector() + const feerate = await c.getFeePerKilobyte(3) + assert.strictEqual(feerate, 0.00012345) + }) + + it('uses the relayfee floor on regtest and never consults estimatesmartfee', async () => { + // Regression for the deep-regtest fee-inflation bug: on regtest, even when + // estimatesmartfee WOULD return a (large) value, the connector must ignore it + // and use the node's relayfee floor. We assert estimatesmartfee is never sent. + let smartFeeCalled = false + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'regtest' } } } + if (data.method === 'getnetworkinfo') return { data: { result: { relayfee: 0.00001000 } } } + if (data.method === 'estimatesmartfee') { smartFeeCalled = true; return { data: { result: { feerate: 0.1386 } } } } + return { data: { result: {} } } + } + const c = makeConnector() + const feerate = await c.getFeePerKilobyte(1) + assert.strictEqual(feerate, 0.00001000) + assert.strictEqual(smartFeeCalled, false, 'estimatesmartfee must not be consulted on regtest') + }) + + it('falls back to the regtest default when relayfee is unavailable', async () => { + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'regtest' } } } + if (data.method === 'getnetworkinfo') return { data: { result: {} } } + return { data: { result: {} } } + } + const c = makeConnector() + const feerate = await c.getFeePerKilobyte(1) + assert.strictEqual(feerate, 0.00001000) + }) +}) + +describe('BlockchainConnector.getFeePerKilobyte()', () => { + registerAxiosHooks() + it('throws when feerate is missing and chain is not regtest', async () => { + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'main' } } } + return { data: { result: {} } } + } + const c = makeConnector() + await assert.rejects( + () => c.getFeePerKilobyte(1), + /Error getting smart fee from node/ + ) + }) + + // A public testnet can be fully synced with an empty mempool and still have no + // fee history, so estimatesmartfee answers feerate:-1 (DOGE testnet does exactly + // this). Non-mainnet chains fall back to 10x the node's relayfee floor (the bare + // floor is rejected by Dogecoin 1.14's free-tx priority gate as "66: insufficient + // priority"); mainnet must keep failing, because there a missing estimate means + // the node is unhealthy. + it('falls back to 10x the relayfee floor on testnet when estimatesmartfee has no data', async () => { + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'test' } } } + if (data.method === 'estimatesmartfee') return { data: { result: { feerate: -1, blocks: 25 } } } + if (data.method === 'getnetworkinfo') return { data: { result: { relayfee: 0.001 } } } + return { data: { result: {} } } + } + const c = makeConnector() + const feerate = await c.getFeePerKilobyte(6) + assert.ok(Math.abs(feerate - 0.01) < 1e-12, 'expected 0.01 DOGE/kB, got ' + feerate) + }) + + // The documented 10x rate is not always minable: on Dogecoin testnet, measured + // over 400 blocks, transactions land at 0.03 and around 1.0 DOGE per kB while + // 0.0102 sat unmined. Only the operator running a given chain can measure what + // it takes there, so the multiple is deployment-tunable. + it('honours FEE_NO_ESTIMATE_RELAY_MULTIPLIER for the no-estimate fallback', async () => { + process.env.FEE_NO_ESTIMATE_RELAY_MULTIPLIER = '100' + try { + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'test' } } } + if (data.method === 'estimatesmartfee') return { data: { result: { feerate: -1 } } } + if (data.method === 'getnetworkinfo') return { data: { result: { relayfee: 0.001 } } } + return { data: { result: {} } } + } + const c = makeConnector() + const feerate = await c.getFeePerKilobyte(6) + assert.ok(Math.abs(feerate - 0.1) < 1e-12, 'expected 0.1 DOGE/kB at 100x, got ' + feerate) + } finally { + delete process.env.FEE_NO_ESTIMATE_RELAY_MULTIPLIER + } + }) +}) + +describe('BlockchainConnector.getFeePerKilobyte()', () => { + registerAxiosHooks() + it('ignores a non-positive or unparseable multiplier and keeps the default', async () => { + for (const bad of ['0', '-5', 'abc']) { + process.env.FEE_NO_ESTIMATE_RELAY_MULTIPLIER = bad + try { + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'test' } } } + if (data.method === 'estimatesmartfee') return { data: { result: { feerate: -1 } } } + if (data.method === 'getnetworkinfo') return { data: { result: { relayfee: 0.001 } } } + return { data: { result: {} } } + } + const c = makeConnector() + const feerate = await c.getFeePerKilobyte(6) + assert.ok(Math.abs(feerate - 0.01) < 1e-12, bad + ' must fall back to 10x, got ' + feerate) + } finally { + delete process.env.FEE_NO_ESTIMATE_RELAY_MULTIPLIER + } + } + }) + + it('still prefers the estimate over the relayfee floor on testnet when one exists', async () => { + let networkInfoCalled = false + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'test' } } } + if (data.method === 'estimatesmartfee') return { data: { result: { feerate: 0.005 } } } + if (data.method === 'getnetworkinfo') { networkInfoCalled = true; return { data: { result: { relayfee: 0.001 } } } } + return { data: { result: {} } } + } + const c = makeConnector() + const feerate = await c.getFeePerKilobyte(6) + assert.strictEqual(feerate, 0.005) + assert.strictEqual(networkInfoCalled, false, 'relayfee must not be consulted when an estimate exists') + }) + + it('throws on testnet when neither an estimate nor a relayfee is available', async () => { + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'test' } } } + if (data.method === 'estimatesmartfee') return { data: { result: { feerate: -1 } } } + if (data.method === 'getnetworkinfo') return { data: { result: {} } } + return { data: { result: {} } } + } + const c = makeConnector() + await assert.rejects(() => c.getFeePerKilobyte(1), /Error getting smart fee from node/) + }) +}) + +describe('BlockchainConnector.getFeePerKilobyte()', () => { + registerAxiosHooks() + it('does NOT fall back to the relayfee floor on mainnet', async () => { + let networkInfoCalled = false + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'main' } } } + if (data.method === 'estimatesmartfee') return { data: { result: { feerate: -1 } } } + if (data.method === 'getnetworkinfo') { networkInfoCalled = true; return { data: { result: { relayfee: 0.00001 } } } } + return { data: { result: {} } } + } + const c = makeConnector() + await assert.rejects(() => c.getFeePerKilobyte(1), /Error getting smart fee from node/) + assert.strictEqual(networkInfoCalled, false, 'mainnet must never substitute the relay floor for a missing estimate') + }) + + it('rethrows transport errors', async () => { + stubAxiosPostThrow(new Error('Connection refused')) + const c = makeConnector() + await assert.rejects( + () => c.getFeePerKilobyte(1), + /Connection refused/ + ) + }) + + it('throws when result is entirely missing (non-regtest)', async () => { + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'main' } } } + return { data: { result: null } } + } + const c = makeConnector() + await assert.rejects( + () => c.getFeePerKilobyte(1), + /Error getting smart fee from node/ + ) + }) +}) + +describe('BlockchainConnector.getFeePerKilobyte()', () => { + registerAxiosHooks() + // A node reports "no fee estimate" in two shapes, and only one of them is a + // success body carrying feerate:-1. LTC/DOGE answer HTTP 500 with a JSON-RPC + // error body, so axios throws before the try-block fallback is reached; the + // testnet relay-multiplier fallback has to run on that shape too, or a public + // testnet node in an ordinary state fails every fee-bearing build. + describe('no-estimate reported as an RPC error rather than feerate:-1', () => { + it('falls back to 10x the relayfee floor on testnet when estimatesmartfee throws', async () => { + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'test' } } } + if (data.method === 'getnetworkinfo') return { data: { result: { relayfee: 0.001 } } } + if (data.method === 'estimatesmartfee') { + const err = new Error('Request failed with status code 500') + err.response = { status: 500, data: { error: { code: -1, message: 'Insufficient data' } } } + throw err + } + return { data: { result: {} } } + } + const c = makeConnector() + const feerate = await c.getFeePerKilobyte(6) + assert.ok(Math.abs(feerate - 0.01) < 1e-12, 'expected 10x the 0.001 relay floor, got ' + feerate) + }) + + it('still rejects on testnet when the node reports no relayfee either', async () => { + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'test' } } } + if (data.method === 'getnetworkinfo') return { data: { result: {} } } + if (data.method === 'estimatesmartfee') throw new Error('Request failed with status code 500') + return { data: { result: {} } } + } + const c = makeConnector() + await assert.rejects(() => c.getFeePerKilobyte(6), /status code 500/) + }) + + it('does NOT invent a rate on mainnet when estimatesmartfee throws', async () => { + let networkInfoCalled = false + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'main' } } } + if (data.method === 'getnetworkinfo') { networkInfoCalled = true; return { data: { result: { relayfee: 0.001 } } } } + if (data.method === 'estimatesmartfee') throw new Error('Request failed with status code 500') + return { data: { result: {} } } + } + const c = makeConnector() + await assert.rejects(() => c.getFeePerKilobyte(6), /status code 500/) + assert.strictEqual(networkInfoCalled, false, 'mainnet must never substitute the relay floor') + }) + + it('keeps a node-down failure a failure', async () => { + stubAxiosPostThrow(new Error('ECONNREFUSED')) + const c = makeConnector() + await assert.rejects(() => c.getFeePerKilobyte(6), /ECONNREFUSED/) + }) + }) +}) + +// estimatesmartfee was taken verbatim on testnet/mainnet with no +// upper bound. A misbehaving/compromised/misconfigured node feeding a +// spiked estimate on this money-affecting path fed straight into what the +// caller was charged, and into the caller-facing caps' own anchor. +describe('BlockchainConnector.getFeePerKilobyte()', () => { + registerAxiosHooks() + describe('sanity ceiling on the raw non-regtest estimate', () => { + registerSanityHooks() + it('clamps a spiked mainnet estimate to the coin-default ceiling and logs loudly', async () => { + process.env.NETWORK = 'bitcoin-mainnet' + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'main' } } } + if (data.method === 'estimatesmartfee') return { data: { result: { feerate: 5 } } } // 5 BTC/kB: absurd + return { data: { result: {} } } + } + const c = makeConnector() + const feerate = await c.getFeePerKilobyte(1) + assert.strictEqual(feerate, 0.01, 'expected clamp to the bitcoin default ceiling 0.01 BTC/kB') + 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 () => { + 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 + 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') + }) + }) +}) + +describe('BlockchainConnector.getFeePerKilobyte()', () => { + registerAxiosHooks() + describe('sanity ceiling on the raw non-regtest estimate', () => { + registerSanityHooks() + it('passes through an estimate under the ceiling unchanged', async () => { + process.env.NETWORK = 'bitcoin-mainnet' + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'main' } } } + if (data.method === 'estimatesmartfee') return { data: { result: { feerate: 0.0005 } } } + return { data: { result: {} } } + } + const c = makeConnector() + const feerate = await c.getFeePerKilobyte(1) + assert.strictEqual(feerate, 0.0005) + assert.strictEqual(errorSpy.length, 0, 'must not log when under the ceiling') + }) + + it('honours FEE_ESTIMATE_SANITY_CEILING as an override', async () => { + process.env.NETWORK = 'bitcoin-mainnet' + process.env.FEE_ESTIMATE_SANITY_CEILING = '0.0001' + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'main' } } } + if (data.method === 'estimatesmartfee') return { data: { result: { feerate: 0.0005 } } } + return { data: { result: {} } } + } + const c = makeConnector() + const feerate = await c.getFeePerKilobyte(1) + assert.strictEqual(feerate, 0.0001, 'expected clamp to the overridden ceiling') + }) + }) +}) + +describe('BlockchainConnector.getFeePerKilobyte()', () => { + registerAxiosHooks() + describe('sanity ceiling on the raw non-regtest estimate', () => { + registerSanityHooks() + it('ignores a non-positive or unparseable override and keeps the coin default', async () => { + process.env.NETWORK = 'litecoin-mainnet' + process.env.FEE_ESTIMATE_SANITY_CEILING = '-1' + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'main' } } } + if (data.method === 'estimatesmartfee') return { data: { result: { feerate: 5 } } } + return { data: { result: {} } } + } + const c = makeConnector() + const feerate = await c.getFeePerKilobyte(1) + assert.strictEqual(feerate, 0.01, 'expected fallback to the litecoin default ceiling') + }) + }) +}) diff --git a/test/unit/blockchain_connector.test/07_rpc_credential_log_sanitization.test.js b/test/unit/blockchain_connector.test/07_rpc_credential_log_sanitization.test.js new file mode 100644 index 0000000..a46ca96 --- /dev/null +++ b/test/unit/blockchain_connector.test/07_rpc_credential_log_sanitization.test.js @@ -0,0 +1,75 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const assert = require('assert') +const axios = require('axios') +const BlockchainConnector = require('../../../src/build/blockchain_connector') +const util = require('util'); + +function stubAxiosPostThrow (err) { + axios.post = async () => { throw err } +} + +function registerAxiosHooks () { + let originalPost + beforeEach(() => { + originalPost = axios.post + }) + afterEach(() => { + axios.post = originalPost + }) +} + +// Behavioral lock: every RPC call passes auth:{username,password} to axios, and +// axios attaches that config to the thrown error. Logging or re-throwing the raw +// error serializes the node RPC password into the encoder logs. Drive a failing +// getTransactionHex and assert the password never reaches console.error and is +// scrubbed from the re-thrown error. FAKE_RPC_PASSWORD is a test sentinel. + +describe('BlockchainConnector RPC-credential log sanitization', () => { + registerAxiosHooks() + it('does not leak the RPC password when an axios call fails', async () => { + const FAKE_RPC_PASSWORD = 'FAKEPASS_must_never_be_logged_9c3f' + + const err = new Error('Request failed with status code 401') + err.code = 'ERR_BAD_REQUEST' + err.config = { + auth: { username: 'rpcuser', password: FAKE_RPC_PASSWORD }, + headers: { Authorization: 'Basic ' + Buffer.from('rpcuser:' + FAKE_RPC_PASSWORD).toString('base64') } + } + err.request = { _header: 'Authorization: Basic ' + Buffer.from('rpcuser:' + FAKE_RPC_PASSWORD).toString('base64') } + err.response = { status: 401, data: 'unauthorized', config: err.config } + + const c = new BlockchainConnector('127.0.0.1', 18332, 'rpcuser', FAKE_RPC_PASSWORD) + stubAxiosPostThrow(err) + + const originalError = console.error + const logs = [] + console.error = (...args) => { + logs.push(args.map(a => (typeof a === 'string' ? a : util.inspect(a, { depth: 8 }))).join(' ')) + } + + let thrown + try { + await c.getTransactionHex('deadbeef') + } catch (e) { + thrown = e + } finally { + console.error = originalError + } + + const combined = logs.join('\n') + assert.ok(thrown, 'the failing RPC should propagate an error') + assert.ok(!combined.includes(FAKE_RPC_PASSWORD), + 'the RPC password must never appear in connector error logs (got: ' + combined + ')') + assert.strictEqual(thrown.config && thrown.config.auth, undefined, + 'the re-thrown error must have its config.auth scrubbed') + }) +}) From fda0f5de321375d77c6219d557d555ee8490c11a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 07:40:15 -0700 Subject: [PATCH 113/148] test: split the FILE payload compression suite into numbered parts by behaviour --- test/unit/compression.test.js | 388 +----------------- .../01_action_string_helpers.test.js | 67 +++ .../02_create_transaction_integration.test.js | 179 ++++++++ ...arries_the_bytes_that_were_written.test.js | 144 +++++++ .../compression.test/04_validator.test.js | 56 +++ .../05_constants_conformance.test.js | 108 +++++ .../unit/compression.test/helpers/fixtures.js | 90 ++++ 7 files changed, 658 insertions(+), 374 deletions(-) create mode 100644 test/unit/compression.test/01_action_string_helpers.test.js create mode 100644 test/unit/compression.test/02_create_transaction_integration.test.js create mode 100644 test/unit/compression.test/03_the_compression_report_carries_the_bytes_that_were_written.test.js create mode 100644 test/unit/compression.test/04_validator.test.js create mode 100644 test/unit/compression.test/05_constants_conformance.test.js create mode 100644 test/unit/compression.test/helpers/fixtures.js diff --git a/test/unit/compression.test.js b/test/unit/compression.test.js index df8803f..93f50a2 100644 --- a/test/unit/compression.test.js +++ b/test/unit/compression.test.js @@ -33,60 +33,15 @@ const assert = require('assert') const crypto = require('crypto') const zlib = require('zlib') -const path = require('path') -const fs = require('fs') -const bitcoin = require('bitcoinjs-lib') -const ecc = require('tiny-secp256k1') -const { ECPairFactory } = require('ecpair') -const XChainEncoder = require('../../src/XChainEncoder') const compression = require('../../src/build/compression') -const validator = require('../../src/common/validator') - -bitcoin.initEccLib(ecc) -const ECPair = ECPairFactory(ecc) - -const KEY = ECPair.fromPrivateKey(Buffer.alloc(32, 7)) -const PUBKEY = Buffer.from(KEY.publicKey) -const TXID_A = 'a'.repeat(64) - -const PUBLIC_FILE = 'FILE|0|report.txt|text/plain|Report|memo' -const GATED_FILE = 'FILE|0|secret.enc|application/octet-stream|Secret||MYTOKEN|1|' + 'a'.repeat(64) - -// Realistically compressible: JSON-ish records compress ~4-5:1, which is a -// real-world FILE profile AND comfortably inside the 150:1 guard. Naive -// repeated text is a bad fixture here: it compresses ~210:1 and would trip the -// emit-time ratio guard, so it exercises a different branch than intended. -function compressibleBytes(n = 40000) { - let out = '' - for (let i = 0; out.length < n; i++) - out += JSON.stringify({ id: i, name: 'item-' + i, value: (i * 7919) % 100000, ok: i % 3 === 0 }) + '\n' - return Buffer.from(out.slice(0, n), 'utf8') -} - -function makeEncoder(networkName = 'bitcoin-regtest') { - const encoder = new XChainEncoder(networkName, '127.0.0.1', '8333', 'rpc', 'rpc', '', '') - encoder.connector = { - getFeePerKilobyte: async () => 0.00001, - getTransactionHex: async () => { throw new Error('unit test: no node') } - } - encoder.utxoTrackerConnector = { - getUtxosFromAddress: async () => { throw new Error('unit test: no tracker') } - } - return encoder -} - -function segwitUtxo(network, value = 10000000) { - const p2wpkh = bitcoin.payments.p2wpkh({ pubkey: PUBKEY, network }) - return { txid: TXID_A, vout: 0, value, confirmations: 6, scriptPubKey: p2wpkh.output.toString('hex') } -} - -function callerAddress(network) { - return bitcoin.payments.p2wpkh({ pubkey: PUBKEY, network }).address -} +const { + PUBLIC_FILE, + GATED_FILE, + compressibleBytes +} = require('./compression.test/helpers/fixtures') describe('encoder FILE payload compression (spec Part B)', function () { - describe('compressPayloadForAction', function () { it('compresses a public FILE payload and appends the COMPRESSION field', async function () { @@ -131,6 +86,11 @@ describe('encoder FILE payload compression (spec Part B)', function () { assert.strictEqual(r.reason, 'no-payload') assert.strictEqual(r.data, PUBLIC_FILE) }) + }) +}) + +describe('encoder FILE payload compression (spec Part B)', function () { + describe('compressPayloadForAction', function () { // --- the refusals --- @@ -173,7 +133,11 @@ describe('encoder FILE payload compression (spec Part B)', function () { /exceeds the 1024-byte compression input cap/ ) }) + }) +}) +describe('encoder FILE payload compression (spec Part B)', function () { + describe('compressPayloadForAction', function () { it('a gated FILE passes its own COMPRESSION field through untouched', function () { // The client set it; the encoder must neither add nor remove it. const clientSet = GATED_FILE + '|100|1' @@ -181,328 +145,4 @@ describe('encoder FILE payload compression (spec Part B)', function () { assert.strictEqual(compression.isGatedFileAction(clientSet), true) }) }) - - describe('action-string helpers', function () { - it('detects FILE v0 and gating correctly', function () { - assert.strictEqual(compression.isFileV0Action(PUBLIC_FILE), true) - assert.strictEqual(compression.isFileV0Action('SEND|0|X|1'), false) - assert.strictEqual(compression.isFileV0Action('FILE|1|a|b'), false) - assert.strictEqual(compression.isGatedFileAction(GATED_FILE), true) - assert.strictEqual(compression.isGatedFileAction(PUBLIC_FILE), false) - }) - - it('setting the field empty leaves the action byte-identical', function () { - assert.strictEqual(compression.withCompressionField(PUBLIC_FILE, ''), PUBLIC_FILE) - }) - - it('pads intermediate optional fields to reach index 10', function () { - const out = compression.withCompressionField('FILE|0|a.txt|text/plain', '1') - assert.strictEqual(out.split('|').length, 11) - assert.strictEqual(out.split('|')[10], '1') - }) - - it('preserves the gating fields when a client sets the marker', function () { - const out = compression.withCompressionField(GATED_FILE + '|100', '1') - const parts = out.split('|') - assert.strictEqual(parts[6], 'MYTOKEN') - assert.strictEqual(parts[7], '1') - assert.strictEqual(parts[9], '100') - assert.strictEqual(parts[10], '1') - }) - }) - - describe('createTransaction integration', function () { - - // The default is ON (spec §5.2/§7). The assertion also had to - // change in KIND: the previous version inspected the input constant, - // which proved nothing about what the encoder built and would have - // passed whichever way the default went. It now reads the encoder's own - // report of what it did. - it('compresses by DEFAULT, with no compress flag passed at all', async function () { - const encoder = makeEncoder() - const network = encoder.network - const payload = compressibleBytes(2000).toString('binary') - - const result = await encoder.createTransaction( - [segwitUtxo(network)], callerAddress(network), null, PUBLIC_FILE, payload, - null, false, 'P2WSH', callerAddress(network)) - - assert.ok(result.compression, 'the build reports what compression did') - assert.strictEqual(result.compression.compressed, true, 'compressed without being asked') - assert.ok(result.compression.storedLength < result.compression.rawLength, - 'stored fewer bytes than the caller handed in') - }) - - it('honours an explicit opt-out', async function () { - const encoder = makeEncoder() - const network = encoder.network - const payload = compressibleBytes(2000).toString('binary') - - const result = await encoder.createTransaction( - [segwitUtxo(network)], callerAddress(network), null, PUBLIC_FILE, payload, - null, false, 'P2WSH', callerAddress(network), null, null, null, - true, null, null, null, false, false) - - assert.strictEqual(result.compression, undefined, - 'compression never ran, so there is nothing to report') - }) - - it('compresses when opted in, and the estimator prices the COMPRESSED bytes', async function () { - const encoder = makeEncoder() - const network = encoder.network - const raw = compressibleBytes(30000) - - // Same payload, once raw and once compressed, through the same lane. - const uncompressed = await encoder.createTransaction( - [segwitUtxo(network)], callerAddress(network), null, - 'FILE|0|big.txt|text/plain|Big|', raw.toString('binary'), - null, false, 'TAPROOT', callerAddress(network), null, null, PUBKEY.toString('hex'), - true, null, null, null, false, false) - - // Same input on purpose: release the first build's reservation so the - // second is a rebuild for comparison, not a refused double-spend. - encoder.clearReservations() - const compressed = await encoder.createTransaction( - [segwitUtxo(network)], callerAddress(network), null, - 'FILE|0|big.txt|text/plain|Big|', raw.toString('binary'), - null, false, 'TAPROOT', callerAddress(network), null, null, PUBKEY.toString('hex'), - true, null, null, null, false, true) - - // The envelope carries the payload in its witness; a compressed - // payload must produce a materially smaller reveal. - const rawScript = Buffer.from(uncompressed.carrierScripts[0], 'hex') - const compScript = Buffer.from(compressed.carrierScripts[0], 'hex') - assert.ok(compScript.length < rawScript.length / 2, - `compressed envelope (${compScript.length}) should be far smaller than raw (${rawScript.length})`) - - // The fee the estimator prefunded tracks the compressed reveal, not - // the caller's original payload size. - assert.ok(compressed.envelope.revealFee < uncompressed.envelope.revealFee, - 'the quoted reveal fee reflects the bytes actually written') - }) - - it('the compressed transaction carries the marker AND reconstructs byte-exactly', async function () { - const encoder = makeEncoder() - const network = encoder.network - const raw = compressibleBytes(20000) - - const result = await encoder.createTransaction( - [segwitUtxo(network)], callerAddress(network), null, - 'FILE|0|doc.txt|text/plain|Doc|', raw.toString('binary'), - null, false, 'TAPROOT', callerAddress(network), null, null, PUBKEY.toString('hex'), - true, null, null, null, false, true) - - // Pull the payload back out of the envelope script and invert it, - // exactly as a reader would. - const script = Buffer.from(result.carrierScripts[0], 'hex') - const decompiled = bitcoin.script.decompile(script) - const endifIndex = decompiled.lastIndexOf(bitcoin.opcodes.OP_ENDIF) - const payload = Buffer.concat(decompiled.slice(4, endifIndex)) - const pushes = bitcoin.script.decompile(payload) - - const actionString = pushes[0].toString('utf8') - assert.strictEqual(compression.compressionFieldOf(actionString), '1', - 'the on-chain action string declares deflate-raw') - assert.ok(zlib.inflateRawSync(pushes[1]).equals(raw), - 'the on-chain bytes inflate back to the caller\'s original payload') - }) - - it('refuses at build time when a gated FILE asks for encoder-side compression', async function () { - const encoder = makeEncoder() - const network = encoder.network - await assert.rejects( - () => encoder.createTransaction( - [segwitUtxo(network)], callerAddress(network), null, GATED_FILE, - compressibleBytes(2000).toString('binary'), - null, false, 'P2WSH', callerAddress(network), null, null, null, - true, null, null, null, false, true), - /token-gated FILE/ - ) - }) - - it('an incompressible payload opted in still builds, just uncompressed', async function () { - const encoder = makeEncoder() - const network = encoder.network - const media = crypto.randomBytes(3000).toString('binary') - const result = await encoder.createTransaction( - [segwitUtxo(network)], callerAddress(network), null, - 'FILE|0|photo.jpg|image/jpeg|Photo|', media, - null, false, 'P2WSH', callerAddress(network), null, null, null, - true, null, null, null, false, true) - assert.ok(result.psbt, 'still produces a transaction') - }) - }) - - // Compression rewrites the action string and replaces the payload - // in place, and a boolean plus two lengths does not let anyone rebuild the - // result: the caller's confirm check described a string the PSBT does not - // carry (so a compressible FILE upload refused itself as tampered), and the - // phase-2 reveal was re-derived from the caller's uncompressed payload, - // compiling a carrier that hashes to nothing the commit created. The report - // now carries the WRITTEN bytes, both halves, so the rebuild is a copy - // rather than a re-derivation. - describe('the compression report carries the bytes that were written', function () { - - it('reports the written action string and the stored payload', async function () { - const encoder = makeEncoder() - const network = encoder.network - const raw = compressibleBytes(4000) - - const result = await encoder.createTransaction( - [segwitUtxo(network)], callerAddress(network), null, PUBLIC_FILE, - raw.toString('binary'), - null, false, 'P2WSH', callerAddress(network)) - - assert.strictEqual(result.compression.compressed, true) - assert.strictEqual(compression.compressionFieldOf(result.compression.data), '1', - 'the reported string is the one carrying the marker') - assert.strictEqual(result.compression.data, - compression.withCompressionField(PUBLIC_FILE, '1'), - 'and it is the caller\'s own string with nothing but COMPRESSION set') - // The stored payload, in the same Latin-1 form every rawData - // parameter takes, and it really is the caller's bytes deflated. - const stored = Buffer.from(result.compression.rawData, 'binary') - assert.strictEqual(stored.length, result.compression.storedLength) - assert.ok(zlib.inflateRawSync(stored).equals(raw)) - }) - - it('says nothing extra when compression did not fire', async function () { - const encoder = makeEncoder() - const network = encoder.network - const result = await encoder.createTransaction( - [segwitUtxo(network)], callerAddress(network), null, - 'FILE|0|photo.jpg|image/jpeg|Photo|', crypto.randomBytes(3000).toString('binary'), - null, false, 'P2WSH', callerAddress(network)) - assert.strictEqual(result.compression.compressed, false) - assert.strictEqual(result.compression.data, undefined, - 'an untouched action string is already the caller\'s own') - assert.strictEqual(result.compression.rawData, undefined) - }) - - it('a reveal REBUILT FROM THE REPORT reproduces the commit carrier byte for byte', async function () { - // The decisive property: phase 2 chunks script.compile([data, rawData]) - // and must land on the same carriers phase 1 committed to, or the - // reveal cannot spend the commit and the funds are stranded. - const encoder = makeEncoder() - const network = encoder.network - const raw = compressibleBytes(4000).toString('binary') - - const commit = await encoder.createTransaction( - [segwitUtxo(network)], callerAddress(network), null, PUBLIC_FILE, raw, - null, false, 'P2WSH', callerAddress(network)) - assert.ok(commit.carrierScripts.length > 1, 'a multi-carrier payload, as a real FILE is') - - // Rebuild from what the encoder said it wrote, compression off: this - // is exactly what the SDK now hands spendP2sh. - encoder.clearReservations() - const rebuilt = await encoder.createTransaction( - [segwitUtxo(network)], callerAddress(network), null, - commit.compression.data, commit.compression.rawData, - null, false, 'P2WSH', callerAddress(network), null, null, null, - true, null, null, null, false, false) - assert.deepStrictEqual(rebuilt.carrierScripts, commit.carrierScripts, - 'the reveal reproduces the commit\'s chunks exactly') - }) - - it('the marker WITHOUT the stored bytes builds a different carrier (why both halves ride)', async function () { - // The withdrawn wallet-side remedy, measured: re-derive the marker - // locally and keep the caller's uncompressed payload, and the reveal - // compiles carriers the commit never created. This is the stranding. - const encoder = makeEncoder() - const network = encoder.network - const raw = compressibleBytes(4000).toString('binary') - - const commit = await encoder.createTransaction( - [segwitUtxo(network)], callerAddress(network), null, PUBLIC_FILE, raw, - null, false, 'P2WSH', callerAddress(network)) - - encoder.clearReservations() - const rederived = await encoder.createTransaction( - [segwitUtxo(network)], callerAddress(network), null, - commit.compression.data, raw, - null, false, 'P2WSH', callerAddress(network), null, null, null, - true, null, null, null, false, false) - assert.notDeepStrictEqual(rederived.carrierScripts, commit.carrierScripts, - 'a reveal built this way could never spend the commit') - }) - }) - - describe('validator', function () { - it('accepts compress as an optional boolean; absent means "use the deployment default"', function () { - const base = { - utxos: [{ txid: TXID_A, vout: 0, value: 100000, scriptPubKey: '0014' + 'bb'.repeat(20) }], - pubkey: 'mzBc4XEFSdzCDcTxAgf6EZXgsZWpztRhef', - data: PUBLIC_FILE, - change: 'mzBc4XEFSdzCDcTxAgf6EZXgsZWpztRhef' - } - // Absent stays absent through the validator: the tri-state is - // resolved in the encoder, which is the only place that knows the - // deployment default. - assert.strictEqual(validator.validateAll({ ...base }).compress, undefined, 'absent stays absent'); - assert.strictEqual(validator.validateAll({ ...base, compress: true }).compress, true); - assert.strictEqual(validator.validateAll({ ...base, compress: false }).compress, false); - assert.throws(() => validator.validateAll({ ...base, compress: 'yes' }), /compress/); - }) - }) - - describe('constants conformance', function () { - const DOCS = process.env.XCHAIN_DOCUMENTATION_DIR || - path.join(__dirname, '..', '..', '..', 'xchain-documentation') - const DOCS_CONSTANTS = path.join(DOCS, 'protocol', 'constants.js') - - it('the local values are the pinned ones', function () { - assert.strictEqual(validator.COMPRESSION_CODE_DEFLATE_RAW, '1') - assert.strictEqual(validator.COMPRESSION_MAX_RATIO, 150) - assert.strictEqual(validator.COMPRESSION_MAX_INPUT_BYTES, 16 * 1024 * 1024) - }) - - it('they equal the canonical declaration (skips without the docs sibling)', function () { - if (!fs.existsSync(DOCS_CONSTANTS)) { - if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') - throw new Error('xchain-documentation sibling not found but XCHAIN_REQUIRE_SIBLINGS=1') - this.skip() - } - const docs = require(DOCS_CONSTANTS) - assert.strictEqual(docs.COMPRESSION_CODE_DEFLATE_RAW, validator.COMPRESSION_CODE_DEFLATE_RAW) - assert.strictEqual(docs.COMPRESSION_MAX_RATIO, validator.COMPRESSION_MAX_RATIO) - assert.strictEqual(docs.COMPRESSION_MAX_INPUT_BYTES, validator.COMPRESSION_MAX_INPUT_BYTES) - }) - - // The encoder and the SDK each vendor this logic (independent - // containers, no shared node_modules). They must agree on every - // decision or a payload the SDK marked compressed could be emitted - // raw, or vice versa. - it('agrees with the SDK helper on every decision (skips without the sdk sibling)', async function () { - const SDK = process.env.XCHAIN_SDK_DIR || path.join(__dirname, '..', '..', '..', 'xchain-sdk') - const SDK_COMPRESSION = path.join(SDK, 'src', 'protocol', 'compression.js') - if (!fs.existsSync(SDK_COMPRESSION)) { - if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') - throw new Error('xchain-sdk sibling not found but XCHAIN_REQUIRE_SIBLINGS=1') - this.skip() - } - const SdkCompression = require(SDK_COMPRESSION) - const sdk = new SdkCompression() - - const payloads = [ - compressibleBytes(5000), - crypto.randomBytes(5000), - Buffer.alloc(200000, 0x20), - Buffer.alloc(0) - ] - for (const payload of payloads) { - const sdkResult = await sdk.compressIfSmaller(payload) - const encoderResult = await compression.compressPayloadForAction(PUBLIC_FILE, payload) - assert.strictEqual(encoderResult.compressed, sdkResult.compressed, - `keep/discard decision diverged for a ${payload.length}-byte payload`) - if (sdkResult.compressed) - assert.ok(encoderResult.rawData.equals(sdkResult.bytes), 'compressed bytes diverged') - } - - // And they agree on where the field lives. - assert.strictEqual(SdkCompression.COMPRESSION_FIELD_INDEX, compression.COMPRESSION_FIELD_INDEX) - assert.strictEqual(SdkCompression.GATE_TICKER_FIELD_INDEX, compression.GATE_TICKER_FIELD_INDEX) - assert.strictEqual(sdk.withCompressionField(PUBLIC_FILE, '1'), - compression.withCompressionField(PUBLIC_FILE, '1')) - }) - }) }) diff --git a/test/unit/compression.test/01_action_string_helpers.test.js b/test/unit/compression.test/01_action_string_helpers.test.js new file mode 100644 index 0000000..e8dc26a --- /dev/null +++ b/test/unit/compression.test/01_action_string_helpers.test.js @@ -0,0 +1,67 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Emit-side FILE payload compression (spec Part B), encoder side. + * + * The load-bearing assertions here are the REFUSALS. Compression is opt-in and + * mutates the published ACTION string, so every way it could publish + * permanently unreadable bytes has to fail closed BEFORE broadcast: + * - a non-FILE action has nowhere to record the marker; + * - a GATED FILE's COMPRESSION field means inflate-after-decrypt and belongs + * to the client that did compress-then-encrypt, never to the encoder; + * - a caller-declared codec is never silently re-compressed; + * - hyper-compressible payloads are emitted raw, because a compliant reader + * would refuse to inflate them (emit-time mirror of the serve guard). + * + * Plus the two properties that make the feature safe to ship dark: + * - the default (ON, per spec §5.2) and the explicit opt-out; + * - the estimator prices the COMPRESSED bytes, not the caller's original. + */ + +'use strict' + +const assert = require('assert') + +const compression = require('../../../src/build/compression') +const { PUBLIC_FILE, GATED_FILE } = require('./helpers/fixtures') + +describe('encoder FILE payload compression (spec Part B)', function () { + describe('action-string helpers', function () { + it('detects FILE v0 and gating correctly', function () { + assert.strictEqual(compression.isFileV0Action(PUBLIC_FILE), true) + assert.strictEqual(compression.isFileV0Action('SEND|0|X|1'), false) + assert.strictEqual(compression.isFileV0Action('FILE|1|a|b'), false) + assert.strictEqual(compression.isGatedFileAction(GATED_FILE), true) + assert.strictEqual(compression.isGatedFileAction(PUBLIC_FILE), false) + }) + + it('setting the field empty leaves the action byte-identical', function () { + assert.strictEqual(compression.withCompressionField(PUBLIC_FILE, ''), PUBLIC_FILE) + }) + + it('pads intermediate optional fields to reach index 10', function () { + const out = compression.withCompressionField('FILE|0|a.txt|text/plain', '1') + assert.strictEqual(out.split('|').length, 11) + assert.strictEqual(out.split('|')[10], '1') + }) + + it('preserves the gating fields when a client sets the marker', function () { + const out = compression.withCompressionField(GATED_FILE + '|100', '1') + const parts = out.split('|') + assert.strictEqual(parts[6], 'MYTOKEN') + assert.strictEqual(parts[7], '1') + assert.strictEqual(parts[9], '100') + assert.strictEqual(parts[10], '1') + }) + }) +}) diff --git a/test/unit/compression.test/02_create_transaction_integration.test.js b/test/unit/compression.test/02_create_transaction_integration.test.js new file mode 100644 index 0000000..f7107a3 --- /dev/null +++ b/test/unit/compression.test/02_create_transaction_integration.test.js @@ -0,0 +1,179 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * Emit-side FILE payload compression (spec Part B), encoder side. + * + * The load-bearing assertions here are the REFUSALS. Compression is opt-in and + * mutates the published ACTION string, so every way it could publish + * permanently unreadable bytes has to fail closed BEFORE broadcast: + * - a non-FILE action has nowhere to record the marker; + * - a GATED FILE's COMPRESSION field means inflate-after-decrypt and belongs + * to the client that did compress-then-encrypt, never to the encoder; + * - a caller-declared codec is never silently re-compressed; + * - hyper-compressible payloads are emitted raw, because a compliant reader + * would refuse to inflate them (emit-time mirror of the serve guard). + * + * Plus the two properties that make the feature safe to ship dark: + * - the default (ON, per spec §5.2) and the explicit opt-out; + * - the estimator prices the COMPRESSED bytes, not the caller's original. + */ + +'use strict' + +const assert = require('assert') +const crypto = require('crypto') +const zlib = require('zlib') +const bitcoin = require('bitcoinjs-lib') + +const compression = require('../../../src/build/compression') +const { + PUBKEY, + PUBLIC_FILE, + GATED_FILE, + compressibleBytes, + makeEncoder, + segwitUtxo, + callerAddress +} = require('./helpers/fixtures') + +describe('encoder FILE payload compression (spec Part B)', function () { + describe('createTransaction integration', function () { + + // The default is ON (spec §5.2/§7). The assertion also had to + // change in KIND: the previous version inspected the input constant, + // which proved nothing about what the encoder built and would have + // passed whichever way the default went. It now reads the encoder's own + // report of what it did. + it('compresses by DEFAULT, with no compress flag passed at all', async function () { + const encoder = makeEncoder() + const network = encoder.network + const payload = compressibleBytes(2000).toString('binary') + + const result = await encoder.createTransaction( + [segwitUtxo(network)], callerAddress(network), null, PUBLIC_FILE, payload, + null, false, 'P2WSH', callerAddress(network)) + + assert.ok(result.compression, 'the build reports what compression did') + assert.strictEqual(result.compression.compressed, true, 'compressed without being asked') + assert.ok(result.compression.storedLength < result.compression.rawLength, + 'stored fewer bytes than the caller handed in') + }) + + it('honours an explicit opt-out', async function () { + const encoder = makeEncoder() + const network = encoder.network + const payload = compressibleBytes(2000).toString('binary') + + const result = await encoder.createTransaction( + [segwitUtxo(network)], callerAddress(network), null, PUBLIC_FILE, payload, + null, false, 'P2WSH', callerAddress(network), null, null, null, + true, null, null, null, false, false) + + assert.strictEqual(result.compression, undefined, + 'compression never ran, so there is nothing to report') + }) + }) +}) + +describe('encoder FILE payload compression (spec Part B)', function () { + describe('createTransaction integration', function () { + it('compresses when opted in, and the estimator prices the COMPRESSED bytes', async function () { + const encoder = makeEncoder() + const network = encoder.network + const raw = compressibleBytes(30000) + + // Same payload, once raw and once compressed, through the same lane. + const uncompressed = await encoder.createTransaction( + [segwitUtxo(network)], callerAddress(network), null, + 'FILE|0|big.txt|text/plain|Big|', raw.toString('binary'), + null, false, 'TAPROOT', callerAddress(network), null, null, PUBKEY.toString('hex'), + true, null, null, null, false, false) + + // Same input on purpose: release the first build's reservation so the + // second is a rebuild for comparison, not a refused double-spend. + encoder.clearReservations() + const compressed = await encoder.createTransaction( + [segwitUtxo(network)], callerAddress(network), null, + 'FILE|0|big.txt|text/plain|Big|', raw.toString('binary'), + null, false, 'TAPROOT', callerAddress(network), null, null, PUBKEY.toString('hex'), + true, null, null, null, false, true) + + // The envelope carries the payload in its witness; a compressed + // payload must produce a materially smaller reveal. + const rawScript = Buffer.from(uncompressed.carrierScripts[0], 'hex') + const compScript = Buffer.from(compressed.carrierScripts[0], 'hex') + assert.ok(compScript.length < rawScript.length / 2, + `compressed envelope (${compScript.length}) should be far smaller than raw (${rawScript.length})`) + + // The fee the estimator prefunded tracks the compressed reveal, not + // the caller's original payload size. + assert.ok(compressed.envelope.revealFee < uncompressed.envelope.revealFee, + 'the quoted reveal fee reflects the bytes actually written') + }) + }) +}) + +describe('encoder FILE payload compression (spec Part B)', function () { + describe('createTransaction integration', function () { + it('the compressed transaction carries the marker AND reconstructs byte-exactly', async function () { + const encoder = makeEncoder() + const network = encoder.network + const raw = compressibleBytes(20000) + + const result = await encoder.createTransaction( + [segwitUtxo(network)], callerAddress(network), null, + 'FILE|0|doc.txt|text/plain|Doc|', raw.toString('binary'), + null, false, 'TAPROOT', callerAddress(network), null, null, PUBKEY.toString('hex'), + true, null, null, null, false, true) + + // Pull the payload back out of the envelope script and invert it, + // exactly as a reader would. + const script = Buffer.from(result.carrierScripts[0], 'hex') + const decompiled = bitcoin.script.decompile(script) + const endifIndex = decompiled.lastIndexOf(bitcoin.opcodes.OP_ENDIF) + const payload = Buffer.concat(decompiled.slice(4, endifIndex)) + const pushes = bitcoin.script.decompile(payload) + + const actionString = pushes[0].toString('utf8') + assert.strictEqual(compression.compressionFieldOf(actionString), '1', + 'the on-chain action string declares deflate-raw') + assert.ok(zlib.inflateRawSync(pushes[1]).equals(raw), + 'the on-chain bytes inflate back to the caller\'s original payload') + }) + + it('refuses at build time when a gated FILE asks for encoder-side compression', async function () { + const encoder = makeEncoder() + const network = encoder.network + await assert.rejects( + () => encoder.createTransaction( + [segwitUtxo(network)], callerAddress(network), null, GATED_FILE, + compressibleBytes(2000).toString('binary'), + null, false, 'P2WSH', callerAddress(network), null, null, null, + true, null, null, null, false, true), + /token-gated FILE/ + ) + }) + + it('an incompressible payload opted in still builds, just uncompressed', async function () { + const encoder = makeEncoder() + const network = encoder.network + const media = crypto.randomBytes(3000).toString('binary') + const result = await encoder.createTransaction( + [segwitUtxo(network)], callerAddress(network), null, + 'FILE|0|photo.jpg|image/jpeg|Photo|', media, + null, false, 'P2WSH', callerAddress(network), null, null, null, + true, null, null, null, false, true) + assert.ok(result.psbt, 'still produces a transaction') + }) + }) +}) diff --git a/test/unit/compression.test/03_the_compression_report_carries_the_bytes_that_were_written.test.js b/test/unit/compression.test/03_the_compression_report_carries_the_bytes_that_were_written.test.js new file mode 100644 index 0000000..a12f0c8 --- /dev/null +++ b/test/unit/compression.test/03_the_compression_report_carries_the_bytes_that_were_written.test.js @@ -0,0 +1,144 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * Emit-side FILE payload compression (spec Part B), encoder side. + * + * The load-bearing assertions here are the REFUSALS. Compression is opt-in and + * mutates the published ACTION string, so every way it could publish + * permanently unreadable bytes has to fail closed BEFORE broadcast: + * - a non-FILE action has nowhere to record the marker; + * - a GATED FILE's COMPRESSION field means inflate-after-decrypt and belongs + * to the client that did compress-then-encrypt, never to the encoder; + * - a caller-declared codec is never silently re-compressed; + * - hyper-compressible payloads are emitted raw, because a compliant reader + * would refuse to inflate them (emit-time mirror of the serve guard). + * + * Plus the two properties that make the feature safe to ship dark: + * - the default (ON, per spec §5.2) and the explicit opt-out; + * - the estimator prices the COMPRESSED bytes, not the caller's original. + */ + +'use strict' + +const assert = require('assert') +const crypto = require('crypto') +const zlib = require('zlib') + +const compression = require('../../../src/build/compression') +const { + PUBLIC_FILE, + compressibleBytes, + makeEncoder, + segwitUtxo, + callerAddress +} = require('./helpers/fixtures') + +describe('encoder FILE payload compression (spec Part B)', function () { + // Compression rewrites the action string and replaces the payload + // in place, and a boolean plus two lengths does not let anyone rebuild the + // result: the caller's confirm check described a string the PSBT does not + // carry (so a compressible FILE upload refused itself as tampered), and the + // phase-2 reveal was re-derived from the caller's uncompressed payload, + // compiling a carrier that hashes to nothing the commit created. The report + // now carries the WRITTEN bytes, both halves, so the rebuild is a copy + // rather than a re-derivation. + describe('the compression report carries the bytes that were written', function () { + + it('reports the written action string and the stored payload', async function () { + const encoder = makeEncoder() + const network = encoder.network + const raw = compressibleBytes(4000) + + const result = await encoder.createTransaction( + [segwitUtxo(network)], callerAddress(network), null, PUBLIC_FILE, + raw.toString('binary'), + null, false, 'P2WSH', callerAddress(network)) + + assert.strictEqual(result.compression.compressed, true) + assert.strictEqual(compression.compressionFieldOf(result.compression.data), '1', + 'the reported string is the one carrying the marker') + assert.strictEqual(result.compression.data, + compression.withCompressionField(PUBLIC_FILE, '1'), + 'and it is the caller\'s own string with nothing but COMPRESSION set') + // The stored payload, in the same Latin-1 form every rawData + // parameter takes, and it really is the caller's bytes deflated. + const stored = Buffer.from(result.compression.rawData, 'binary') + assert.strictEqual(stored.length, result.compression.storedLength) + assert.ok(zlib.inflateRawSync(stored).equals(raw)) + }) + + it('says nothing extra when compression did not fire', async function () { + const encoder = makeEncoder() + const network = encoder.network + const result = await encoder.createTransaction( + [segwitUtxo(network)], callerAddress(network), null, + 'FILE|0|photo.jpg|image/jpeg|Photo|', crypto.randomBytes(3000).toString('binary'), + null, false, 'P2WSH', callerAddress(network)) + assert.strictEqual(result.compression.compressed, false) + assert.strictEqual(result.compression.data, undefined, + 'an untouched action string is already the caller\'s own') + assert.strictEqual(result.compression.rawData, undefined) + }) + }) +}) + +describe('encoder FILE payload compression (spec Part B)', function () { + describe('the compression report carries the bytes that were written', function () { + it('a reveal REBUILT FROM THE REPORT reproduces the commit carrier byte for byte', async function () { + // The decisive property: phase 2 chunks script.compile([data, rawData]) + // and must land on the same carriers phase 1 committed to, or the + // reveal cannot spend the commit and the funds are stranded. + const encoder = makeEncoder() + const network = encoder.network + const raw = compressibleBytes(4000).toString('binary') + + const commit = await encoder.createTransaction( + [segwitUtxo(network)], callerAddress(network), null, PUBLIC_FILE, raw, + null, false, 'P2WSH', callerAddress(network)) + assert.ok(commit.carrierScripts.length > 1, 'a multi-carrier payload, as a real FILE is') + + // Rebuild from what the encoder said it wrote, compression off: this + // is exactly what the SDK now hands spendP2sh. + encoder.clearReservations() + const rebuilt = await encoder.createTransaction( + [segwitUtxo(network)], callerAddress(network), null, + commit.compression.data, commit.compression.rawData, + null, false, 'P2WSH', callerAddress(network), null, null, null, + true, null, null, null, false, false) + assert.deepStrictEqual(rebuilt.carrierScripts, commit.carrierScripts, + 'the reveal reproduces the commit\'s chunks exactly') + }) + + it('the marker WITHOUT the stored bytes builds a different carrier (why both halves ride)', async function () { + // The withdrawn wallet-side remedy, measured: re-derive the marker + // locally and keep the caller's uncompressed payload, and the reveal + // compiles carriers the commit never created. This is the stranding. + const encoder = makeEncoder() + const network = encoder.network + const raw = compressibleBytes(4000).toString('binary') + + const commit = await encoder.createTransaction( + [segwitUtxo(network)], callerAddress(network), null, PUBLIC_FILE, raw, + null, false, 'P2WSH', callerAddress(network)) + + encoder.clearReservations() + const rederived = await encoder.createTransaction( + [segwitUtxo(network)], callerAddress(network), null, + commit.compression.data, raw, + null, false, 'P2WSH', callerAddress(network), null, null, null, + true, null, null, null, false, false) + assert.notDeepStrictEqual(rederived.carrierScripts, commit.carrierScripts, + 'a reveal built this way could never spend the commit') + }) + }) +}) diff --git a/test/unit/compression.test/04_validator.test.js b/test/unit/compression.test/04_validator.test.js new file mode 100644 index 0000000..524ca67 --- /dev/null +++ b/test/unit/compression.test/04_validator.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. + * + ********************************************************************** + * Emit-side FILE payload compression (spec Part B), encoder side. + * + * The load-bearing assertions here are the REFUSALS. Compression is opt-in and + * mutates the published ACTION string, so every way it could publish + * permanently unreadable bytes has to fail closed BEFORE broadcast: + * - a non-FILE action has nowhere to record the marker; + * - a GATED FILE's COMPRESSION field means inflate-after-decrypt and belongs + * to the client that did compress-then-encrypt, never to the encoder; + * - a caller-declared codec is never silently re-compressed; + * - hyper-compressible payloads are emitted raw, because a compliant reader + * would refuse to inflate them (emit-time mirror of the serve guard). + * + * Plus the two properties that make the feature safe to ship dark: + * - the default (ON, per spec §5.2) and the explicit opt-out; + * - the estimator prices the COMPRESSED bytes, not the caller's original. + */ + +'use strict' + +const assert = require('assert') + +const validator = require('../../../src/common/validator') +const { TXID_A, PUBLIC_FILE } = require('./helpers/fixtures') + +describe('encoder FILE payload compression (spec Part B)', function () { + describe('validator', function () { + it('accepts compress as an optional boolean; absent means "use the deployment default"', function () { + const base = { + utxos: [{ txid: TXID_A, vout: 0, value: 100000, scriptPubKey: '0014' + 'bb'.repeat(20) }], + pubkey: 'mzBc4XEFSdzCDcTxAgf6EZXgsZWpztRhef', + data: PUBLIC_FILE, + change: 'mzBc4XEFSdzCDcTxAgf6EZXgsZWpztRhef' + } + // Absent stays absent through the validator: the tri-state is + // resolved in the encoder, which is the only place that knows the + // deployment default. + assert.strictEqual(validator.validateAll({ ...base }).compress, undefined, 'absent stays absent'); + assert.strictEqual(validator.validateAll({ ...base, compress: true }).compress, true); + assert.strictEqual(validator.validateAll({ ...base, compress: false }).compress, false); + assert.throws(() => validator.validateAll({ ...base, compress: 'yes' }), /compress/); + }) + }) +}) diff --git a/test/unit/compression.test/05_constants_conformance.test.js b/test/unit/compression.test/05_constants_conformance.test.js new file mode 100644 index 0000000..8f0015f --- /dev/null +++ b/test/unit/compression.test/05_constants_conformance.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. + * + ********************************************************************** + * Emit-side FILE payload compression (spec Part B), encoder side. + * + * The load-bearing assertions here are the REFUSALS. Compression is opt-in and + * mutates the published ACTION string, so every way it could publish + * permanently unreadable bytes has to fail closed BEFORE broadcast: + * - a non-FILE action has nowhere to record the marker; + * - a GATED FILE's COMPRESSION field means inflate-after-decrypt and belongs + * to the client that did compress-then-encrypt, never to the encoder; + * - a caller-declared codec is never silently re-compressed; + * - hyper-compressible payloads are emitted raw, because a compliant reader + * would refuse to inflate them (emit-time mirror of the serve guard). + * + * Plus the two properties that make the feature safe to ship dark: + * - the default (ON, per spec §5.2) and the explicit opt-out; + * - the estimator prices the COMPRESSED bytes, not the caller's original. + */ + +'use strict' + +const assert = require('assert') +const crypto = require('crypto') +const path = require('path') +const fs = require('fs') + +const compression = require('../../../src/build/compression') +const validator = require('../../../src/common/validator') +const { PUBLIC_FILE, compressibleBytes } = require('./helpers/fixtures') + +describe('encoder FILE payload compression (spec Part B)', function () { + describe('constants conformance', function () { + it('the local values are the pinned ones', function () { + assert.strictEqual(validator.COMPRESSION_CODE_DEFLATE_RAW, '1') + assert.strictEqual(validator.COMPRESSION_MAX_RATIO, 150) + assert.strictEqual(validator.COMPRESSION_MAX_INPUT_BYTES, 16 * 1024 * 1024) + }) + }) +}) + +describe('encoder FILE payload compression (spec Part B)', function () { + describe('constants conformance', function () { + const DOCS = process.env.XCHAIN_DOCUMENTATION_DIR || + path.join(__dirname, '..', '..', '..', '..', 'xchain-documentation') + const DOCS_CONSTANTS = path.join(DOCS, 'protocol', 'constants.js') + + it('they equal the canonical declaration (skips without the docs sibling)', function () { + if (!fs.existsSync(DOCS_CONSTANTS)) { + if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') + throw new Error('xchain-documentation sibling not found but XCHAIN_REQUIRE_SIBLINGS=1') + this.skip() + } + const docs = require(DOCS_CONSTANTS) + assert.strictEqual(docs.COMPRESSION_CODE_DEFLATE_RAW, validator.COMPRESSION_CODE_DEFLATE_RAW) + assert.strictEqual(docs.COMPRESSION_MAX_RATIO, validator.COMPRESSION_MAX_RATIO) + assert.strictEqual(docs.COMPRESSION_MAX_INPUT_BYTES, validator.COMPRESSION_MAX_INPUT_BYTES) + }) + + // The encoder and the SDK each vendor this logic (independent + // containers, no shared node_modules). They must agree on every + // decision or a payload the SDK marked compressed could be emitted + // raw, or vice versa. + it('agrees with the SDK helper on every decision (skips without the sdk sibling)', async function () { + const SDK = process.env.XCHAIN_SDK_DIR || + path.join(__dirname, '..', '..', '..', '..', 'xchain-sdk') + const SDK_COMPRESSION = path.join(SDK, 'src', 'protocol', 'compression.js') + if (!fs.existsSync(SDK_COMPRESSION)) { + if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') + throw new Error('xchain-sdk sibling not found but XCHAIN_REQUIRE_SIBLINGS=1') + this.skip() + } + const SdkCompression = require(SDK_COMPRESSION) + const sdk = new SdkCompression() + + const payloads = [ + compressibleBytes(5000), + crypto.randomBytes(5000), + Buffer.alloc(200000, 0x20), + Buffer.alloc(0) + ] + for (const payload of payloads) { + const sdkResult = await sdk.compressIfSmaller(payload) + const encoderResult = await compression.compressPayloadForAction(PUBLIC_FILE, payload) + assert.strictEqual(encoderResult.compressed, sdkResult.compressed, + `keep/discard decision diverged for a ${payload.length}-byte payload`) + if (sdkResult.compressed) + assert.ok(encoderResult.rawData.equals(sdkResult.bytes), 'compressed bytes diverged') + } + + // And they agree on where the field lives. + assert.strictEqual(SdkCompression.COMPRESSION_FIELD_INDEX, compression.COMPRESSION_FIELD_INDEX) + assert.strictEqual(SdkCompression.GATE_TICKER_FIELD_INDEX, compression.GATE_TICKER_FIELD_INDEX) + assert.strictEqual(sdk.withCompressionField(PUBLIC_FILE, '1'), + compression.withCompressionField(PUBLIC_FILE, '1')) + }) + }) +}) diff --git a/test/unit/compression.test/helpers/fixtures.js b/test/unit/compression.test/helpers/fixtures.js new file mode 100644 index 0000000..d74cfbf --- /dev/null +++ b/test/unit/compression.test/helpers/fixtures.js @@ -0,0 +1,90 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * Emit-side FILE payload compression (spec Part B), encoder side. + * + * The load-bearing assertions here are the REFUSALS. Compression is opt-in and + * mutates the published ACTION string, so every way it could publish + * permanently unreadable bytes has to fail closed BEFORE broadcast: + * - a non-FILE action has nowhere to record the marker; + * - a GATED FILE's COMPRESSION field means inflate-after-decrypt and belongs + * to the client that did compress-then-encrypt, never to the encoder; + * - a caller-declared codec is never silently re-compressed; + * - hyper-compressible payloads are emitted raw, because a compliant reader + * would refuse to inflate them (emit-time mirror of the serve guard). + * + * Plus the two properties that make the feature safe to ship dark: + * - the default (ON, per spec §5.2) and the explicit opt-out; + * - the estimator prices the COMPRESSED bytes, not the caller's original. + */ + +'use strict' + +const bitcoin = require('bitcoinjs-lib') +const ecc = require('tiny-secp256k1') +const { ECPairFactory } = require('ecpair') + +const XChainEncoder = require('../../../../src/XChainEncoder') + +bitcoin.initEccLib(ecc) +const ECPair = ECPairFactory(ecc) + +const KEY = ECPair.fromPrivateKey(Buffer.alloc(32, 7)) +const PUBKEY = Buffer.from(KEY.publicKey) +const TXID_A = 'a'.repeat(64) + +const PUBLIC_FILE = 'FILE|0|report.txt|text/plain|Report|memo' +const GATED_FILE = 'FILE|0|secret.enc|application/octet-stream|Secret||MYTOKEN|1|' + 'a'.repeat(64) + +// Realistically compressible: JSON-ish records compress ~4-5:1, which is a +// real-world FILE profile AND comfortably inside the 150:1 guard. Naive +// repeated text is a bad fixture here: it compresses ~210:1 and would trip the +// emit-time ratio guard, so it exercises a different branch than intended. +function compressibleBytes(n = 40000) { + let out = '' + for (let i = 0; out.length < n; i++) + out += JSON.stringify({ id: i, name: 'item-' + i, value: (i * 7919) % 100000, ok: i % 3 === 0 }) + '\n' + return Buffer.from(out.slice(0, n), 'utf8') +} + +function makeEncoder(networkName = 'bitcoin-regtest') { + const encoder = new XChainEncoder(networkName, '127.0.0.1', '8333', 'rpc', 'rpc', '', '') + encoder.connector = { + getFeePerKilobyte: async () => 0.00001, + getTransactionHex: async () => { throw new Error('unit test: no node') } + } + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => { throw new Error('unit test: no tracker') } + } + return encoder +} + +function segwitUtxo(network, value = 10000000) { + const p2wpkh = bitcoin.payments.p2wpkh({ pubkey: PUBKEY, network }) + return { txid: TXID_A, vout: 0, value, confirmations: 6, scriptPubKey: p2wpkh.output.toString('hex') } +} + +function callerAddress(network) { + return bitcoin.payments.p2wpkh({ pubkey: PUBKEY, network }).address +} + +module.exports = { + PUBKEY, + TXID_A, + PUBLIC_FILE, + GATED_FILE, + compressibleBytes, + makeEncoder, + segwitUtxo, + callerAddress +} From 451ec146f59de8a56143043b2e8b85f6536fafd8 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 07:40:32 -0700 Subject: [PATCH 114/148] test: split the package fee sizing suite into numbered parts by behaviour --- test/unit/package_fee_sizing.test.js | 645 +----------------- .../01_package_fee_uplift_satoshis.test.js | 86 +++ ...n_encoder_package_aware_fee_sizing.test.js | 261 +++++++ ...3_two_phase_reveal_package_prefund.test.js | 231 +++++++ .../04_p2sh_two_phase_package_prefund.test.js | 153 +++++ 5 files changed, 764 insertions(+), 612 deletions(-) create mode 100644 test/unit/package_fee_sizing.test/01_package_fee_uplift_satoshis.test.js create mode 100644 test/unit/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js create mode 100644 test/unit/package_fee_sizing.test/03_two_phase_reveal_package_prefund.test.js create mode 100644 test/unit/package_fee_sizing.test/04_p2sh_two_phase_package_prefund.test.js diff --git a/test/unit/package_fee_sizing.test.js b/test/unit/package_fee_sizing.test.js index 6bd6a26..9f90f6f 100644 --- a/test/unit/package_fee_sizing.test.js +++ b/test/unit/package_fee_sizing.test.js @@ -20,13 +20,10 @@ const assert = require('assert') const axios = require('axios') -const bitcoin = require('bitcoinjs-lib') + const BlockchainConnector = require('../../src/build/blockchain_connector') -const XChainEncoder = require('../../src/XChainEncoder') -const util = require('util'); -const ecc = require('tiny-secp256k1'); -const SATOSHI_UNIT = 100000000 +const util = require('util'); const TXID_PARENT_A = 'a'.repeat(64) const TXID_PARENT_B = 'b'.repeat(64) @@ -34,8 +31,6 @@ const TXID_SHARED = 'c'.repeat(64) const TXID_GRANDPA = 'd'.repeat(64) let originalPost -beforeEach(() => { originalPost = axios.post }) -afterEach(() => { axios.post = originalPost }) function makeConnector () { return new BlockchainConnector('127.0.0.1', 18332, 'rpcuser', 'rpcpass') @@ -69,6 +64,9 @@ function stubMempool (mempool, onCall) { describe('BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1', () => { + beforeEach(() => { originalPost = axios.post }) + afterEach(() => { axios.post = originalPost }) + it('sums an unconfirmed parent and everything above it', async () => { stubMempool({ [TXID_PARENT_A]: { entry: entry(400, 0.001), ancestors: [TXID_GRANDPA] }, @@ -110,6 +108,13 @@ describe('BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 assert.strictEqual(pkg.size, 400, 'the confirmed parent contributes nothing') }) +}) + +describe('BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1', () => { + + beforeEach(() => { originalPost = axios.post }) + afterEach(() => { axios.post = originalPost }) + it('returns an empty package when every input is already confirmed', async () => { stubMempool({}) const pkg = await makeConnector().getUnconfirmedAncestorPackage([TXID_PARENT_A]) @@ -150,6 +155,13 @@ describe('BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 assert.strictEqual(await makeConnector().getUnconfirmedAncestorPackage([TXID_PARENT_A]), null) }) +}) + +describe('BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1', () => { + + beforeEach(() => { originalPost = axios.post }) + afterEach(() => { axios.post = originalPost }) + it('returns null when an entry carries no readable size or fee', async () => { // Undercounting an ancestor is worse than not sizing the package at all: it // produces a confident uplift that still leaves the package under target. @@ -190,6 +202,13 @@ describe('BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 assert.deepStrictEqual(await makeConnector().getUnconfirmedAncestorPackage([TXID_PARENT_B]), { size: 250, fees: 0 }) }) +}) + +describe('BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1', () => { + + beforeEach(() => { originalPost = axios.post }) + afterEach(() => { axios.post = originalPost }) + it('returns null when the size field is a boolean rather than a number', async () => { // Number(true) is 1, which used to be accepted as a one-byte ancestor. stubMempool({ @@ -232,6 +251,13 @@ describe('BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 assert.ok(Math.abs(pkg.fees - 0.0014) < 1e-12, 'got ' + pkg.fees) }) +}) + +describe('BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1', () => { + + beforeEach(() => { originalPost = axios.post }) + afterEach(() => { axios.post = originalPost }) + it('returns null when the second call fails for a reason other than absence', async () => { axios.post = async (url, data) => { if (data.method === 'getmempoolentry') return { data: { result: entry(400, 0.001) } } @@ -259,610 +285,5 @@ describe('BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 } assert.ok(!logs.join('\n').includes(FAKE_RPC_PASSWORD), 'the RPC password must never be logged') }) -}) - -describe('packageFeeUpliftSatoshis() @regression @tier1', () => { - const uplift = (o) => XChainEncoder.packageFeeUpliftSatoshis(Object.assign({ satoshiUnit: SATOSHI_UNIT }, o)) - - it('reproduces the live DOGE shortfall', () => { - // 2000 bytes of ancestors paying 0.00313 DOGE/kB, a 1000-byte child, a - // 0.01 DOGE/kB target: the package needs 0.03 DOGE and the ancestors bring - // 0.00626, so the child owes 0.02374 DOGE (2,374,000 koinu). - const owed = uplift({ - currentFee: 0, - txSize: 1000, - ancestorSize: 2000, - ancestorFees: 0.00626, - targetFeePerBytes: 0.01 / 1000 - }) - assert.strictEqual(owed, 2374000) - const packageRate = (626000 + owed) / 3000 * 1000 / SATOSHI_UNIT - assert.ok(packageRate >= 0.01, 'the uplifted package must clear the inclusion floor, got ' + packageRate) - }) - - it('subtracts what the transaction already pays', () => { - const owed = uplift({ - currentFee: 1000000, - txSize: 1000, - ancestorSize: 2000, - ancestorFees: 0.00626, - targetFeePerBytes: 0.01 / 1000 - }) - assert.strictEqual(owed, 1374000) - }) - - it('returns 0 when the package already clears the target', () => { - assert.strictEqual(uplift({ - currentFee: 500000, - txSize: 250, - ancestorSize: 500, - ancestorFees: 0.05, - targetFeePerBytes: 0.01 / 1000 - }), 0, 'a rich ancestor must never LOWER this fee') - }) - - it('takes the ancestor fee in base units when one is given, ahead of the coin-unit figure', () => { - // The two-phase prefund knows the commit's fee exactly, as an integer. A - // coin-unit round trip would reintroduce the float error the ceiling shaves. - const owed = uplift({ - currentFee: 0, - txSize: 1000, - ancestorSize: 2000, - ancestorFees: 1, // a wildly different coin-unit figure - ancestorFeeSatoshis: 626000, - targetFeePerBytes: 0.01 / 1000 - }) - assert.strictEqual(owed, 2374000, 'the base-unit figure wins') - }) - - it('returns 0 for an empty package or a missing target', () => { - const base = { currentFee: 0, txSize: 250, ancestorSize: 0, ancestorFees: 0, targetFeePerBytes: 0.00001 } - assert.strictEqual(uplift(base), 0) - assert.strictEqual(uplift(Object.assign({}, base, { ancestorSize: 1000, targetFeePerBytes: 0 })), 0) - assert.strictEqual(uplift(Object.assign({}, base, { ancestorSize: 1000, txSize: 0 })), 0) - assert.strictEqual(uplift(Object.assign({}, base, { ancestorSize: NaN })), 0) - }) -}) - -describe('XChainEncoder package-aware fee sizing @regression @tier1', () => { - const pubkeyBuf = Buffer.from('0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', 'hex') - const DOGE_REGTEST = require('../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') - const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST }).address - const INPUT_VALUE = 100000000 // 1 DOGE in koinu - const NODE_RATE_PER_KB = 0.01 // Dogecoin's block-inclusion floor - const TARGET_PER_BYTE = NODE_RATE_PER_KB * SATOSHI_UNIT / 1000 // 1000 koinu/byte - - afterEach(() => { delete process.env.MAX_CPFP_UPLIFT_SAT }) - - // P2PKH, the only output type a chain without segwit holds, so these fixtures - // describe a UTXO the chain under test could actually produce. - const P2PKH_SCRIPT = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: bitcoin.networks.regtest }).output - - function makeUtxo (txid, confirmations) { - return { - txid, - vout: 0, - value: INPUT_VALUE, - confirmations, - scriptPubKey: P2PKH_SCRIPT.toString('hex') - } - } - - // The whole previous transaction, which a legacy input carries as nonWitnessUtxo. - function prevTxHex () { - const tx = new bitcoin.Transaction() - tx.addInput(Buffer.alloc(32, 0x11), 0) - tx.addOutput(P2PKH_SCRIPT, INPUT_VALUE) - return tx.toHex() - } - - function makeEncoder (ancestorPackage) { - const encoder = new XChainEncoder('dogecoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '') - encoder.connector = { - getFeePerKilobyte: async () => NODE_RATE_PER_KB, - getTransactionHex: async () => prevTxHex(), - // The suggested-rate ceiling on a test chain reads the node's relay floor; - // without it the build would clamp to the 20-per-vByte Bitcoin-scale default - // and never price a DOGE package at all. - getNetworkInfo: async () => ({ relayfee: 0.001 }), - getUnconfirmedAncestorPackage: async (txids) => { - encoder.connector.askedFor = txids - return typeof ancestorPackage === 'function' ? ancestorPackage(txids) : ancestorPackage - } - } - // The chain dust floor sits above the fees these probes produce; the floor has - // its own suite, so lower it here to keep the sizing behaviour observable. - encoder.dustAmount = 546 - return encoder - } - - async function create (encoder, utxos) { - // Every probe here respends the same fixture input on one encoder to - // compare fees, so release the previous build's reservation first. - encoder.clearReservations() - return encoder.createTransaction( - utxos, TEST_ADDRESS, null, 'test', null, null, false, null, TEST_ADDRESS, - null, null, null, true, null - ) - } - - // inputs − all outputs = the fee actually embedded in the PSBT - function paidFee (result) { - const outputs = result.psbt.txOutputs.reduce((sum, o) => sum + o.value, 0) - return INPUT_VALUE - outputs - } - - // The node rate is exactly 1000 koinu/byte, so the unpackaged fee IS the - // estimated size in bytes × 1000. Recovering the size this way keeps the - // assertions below independent of the estimator's exact byte count. - async function baselineFee (encoder) { - return paidFee(await create(encoder, [makeUtxo(TXID_PARENT_A, 6)])) - } - - it('leaves the fee untouched when every input is confirmed, and never asks the node', async () => { - const encoder = makeEncoder({ size: 5000, fees: 0.001 }) - const fee = paidFee(await create(encoder, [makeUtxo(TXID_PARENT_A, 6)])) - assert.strictEqual(encoder.connector.askedFor, undefined, 'no ancestor lookup for a confirmed selection') - assert.ok(fee > 0 && fee % 1000 === 0, 'the fee is the per-transaction fee, got ' + fee) - }) - - it('lifts the fee so a cheap ancestor package clears the target rate', async () => { - // The live shape: 2000 bytes of ancestors paying 0.00313 DOGE/kB against a - // 0.01 DOGE/kB target. - const ancestorSize = 2000 - const ancestorFees = 0.00626 - const encoder = makeEncoder({ size: ancestorSize, fees: ancestorFees }) - const baseline = await baselineFee(encoder) - const txSize = baseline / 1000 - - const fee = paidFee(await create(encoder, [makeUtxo(TXID_PARENT_A, 0)])) - assert.deepStrictEqual(encoder.connector.askedFor, [TXID_PARENT_A]) - assert.ok(fee > baseline, `fee ${fee} must exceed the per-transaction fee ${baseline}`) - - const packageRate = (ancestorFees * SATOSHI_UNIT + fee) / (ancestorSize + txSize) - assert.ok(packageRate >= TARGET_PER_BYTE, - `package rate ${packageRate} koinu/byte must reach the ${TARGET_PER_BYTE} target`) - // And no further than it needs to go. - assert.ok(packageRate < TARGET_PER_BYTE * 1.01, 'the uplift must not overpay, got ' + packageRate) - }) - - it('does not lower the fee when the ancestors already pay well above the target', async () => { - const encoder = makeEncoder({ size: 2000, fees: 1 }) // 1 DOGE over 2000 bytes - const baseline = await baselineFee(encoder) - const fee = paidFee(await create(encoder, [makeUtxo(TXID_PARENT_A, 0)])) - assert.strictEqual(fee, baseline, 'a rich package must leave this fee exactly as it was') - }) - - it('counts a shared ancestor once by handing the node every unconfirmed input txid', async () => { - const encoder = makeEncoder({ size: 2000, fees: 0.00626 }) - // Two unconfirmed inputs, one confirmed: only the unconfirmed pair is asked - // about, and the connector is what dedupes the ancestors they share. - const utxos = [makeUtxo(TXID_PARENT_A, 0), makeUtxo(TXID_PARENT_B, 0), makeUtxo(TXID_SHARED, 6)] - utxos[1].vout = 1 - utxos[2].vout = 2 - // A tiny value on the later inputs so selection takes the big one first and - // still walks the rest of the set. - utxos[1].value = 1 - utxos[2].value = 1 - await create(encoder, utxos) - assert.deepStrictEqual(encoder.connector.askedFor, [TXID_PARENT_A], - 'selection stops once the inputs cover outputs plus fee, so only what it took is asked about') - }) - it('falls back to the per-transaction fee when the ancestor lookup fails', async () => { - const encoder = makeEncoder(null) // the connector could not price the package - const baseline = await baselineFee(encoder) - const fee = paidFee(await create(encoder, [makeUtxo(TXID_PARENT_A, 0)])) - assert.strictEqual(fee, baseline) - }) - - it('never throws when the ancestor lookup itself throws', async () => { - const encoder = makeEncoder(() => { throw new Error('node RPC exploded') }) - const baseline = await baselineFee(encoder) - const fee = paidFee(await create(encoder, [makeUtxo(TXID_PARENT_A, 0)])) - assert.strictEqual(fee, baseline, 'a thrown lookup degrades to the per-transaction fee') - }) - - it('degrades when the connector has no package method at all', async () => { - const encoder = makeEncoder({ size: 2000, fees: 0 }) - delete encoder.connector.getUnconfirmedAncestorPackage - const fee = paidFee(await create(encoder, [makeUtxo(TXID_PARENT_A, 0)])) - assert.ok(fee > 0 && fee % 1000 === 0, 'the fee is the per-transaction fee, got ' + fee) - }) - - it('clamps the uplift at MAX_CPFP_UPLIFT_SAT and warns that the package stays under target', async () => { - const ancestorSize = 2000 - const ancestorFees = 0.00626 - const encoder = makeEncoder({ size: ancestorSize, fees: ancestorFees }) - const baseline = await baselineFee(encoder) - const txSize = baseline / 1000 - - process.env.MAX_CPFP_UPLIFT_SAT = '100000' - const warnings = [] - const originalWarn = console.warn - console.warn = (...args) => warnings.push(args.join(' ')) - let fee - try { - fee = paidFee(await create(encoder, [makeUtxo(TXID_PARENT_A, 0)])) - } finally { - console.warn = originalWarn - } - - assert.strictEqual(fee, baseline + 100000, 'the uplift is bounded at exactly the configured maximum') - const packageRate = (ancestorFees * SATOSHI_UNIT + fee) / (ancestorSize + txSize) - assert.ok(packageRate < TARGET_PER_BYTE, 'this is the clamped, still-under-target case') - assert.ok(warnings.some(w => /Package fee uplift clamped/.test(w) && /MAX_CPFP_UPLIFT_SAT/.test(w)), - 'the operator must be told the package will stay under target: ' + warnings.join(' | ')) - }) - - it('MAX_CPFP_UPLIFT_SAT=0 turns package sizing off entirely', async () => { - const encoder = makeEncoder({ size: 2000, fees: 0 }) - const baseline = await baselineFee(encoder) - process.env.MAX_CPFP_UPLIFT_SAT = '0' - const fee = paidFee(await create(encoder, [makeUtxo(TXID_PARENT_A, 0)])) - assert.strictEqual(fee, baseline) - assert.strictEqual(encoder.connector.askedFor, undefined, 'a disabled uplift costs no RPC round trip') - }) - - it('clamps the uplift to the fee-rate cap rather than blowing through it', async () => { - // MAX_FEE_RATE_KB of 2x the node rate leaves only 1x the per-transaction fee - // of headroom, far less than a 2000-byte cheap package asks for. - const encoder = new XChainEncoder('dogecoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '', - NODE_RATE_PER_KB * SATOSHI_UNIT * 2) - encoder.connector = { - getFeePerKilobyte: async () => NODE_RATE_PER_KB, - // The suggested-rate ceiling on a test chain reads the node's relay floor; - // without it the build would clamp to the 20-per-vByte Bitcoin-scale default - // and never price a DOGE package at all. - getNetworkInfo: async () => ({ relayfee: 0.001 }), - getTransactionHex: async () => prevTxHex(), - getUnconfirmedAncestorPackage: async () => ({ size: 2000, fees: 0 }) - } - encoder.dustAmount = 546 - const baseline = paidFee(await create(encoder, [makeUtxo(TXID_PARENT_A, 6)])) - const fee = paidFee(await create(encoder, [makeUtxo(TXID_PARENT_A, 0)])) - assert.ok(fee > baseline, 'the uplift still applies up to the cap') - // The cap ceiling rounds a float rate up, so allow the one base unit that - // costs; the point is that it stops there and not at the package rate. - assert.ok(fee >= baseline * 2 && fee <= baseline * 2 + 1, - `fee ${fee} must stop at the capped rate (~${baseline * 2}) for this size`) - }) - - it('never spends more than the inputs hold', async () => { - // A 2000-byte free package wants ~2,000,000 koinu of uplift; this input - // cannot cover it, and the build must still produce a signable PSBT rather - // than fail with INSUFFICIENT_FUNDS. - const encoder = makeEncoder({ size: 2000, fees: 0 }) - const utxo = makeUtxo(TXID_PARENT_A, 0) - utxo.value = 400000 - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, 'test', null, null, false, null, TEST_ADDRESS, - null, null, null, true, null - ) - const outputs = result.psbt.txOutputs.reduce((sum, o) => sum + o.value, 0) - assert.ok(outputs <= 400000, 'the transaction may never pay out more than it takes in') - }) -}) - -// A reveal spends nothing but the commit's own outputs, so the pair is -// always one mempool package and a miner weighs them together. The reveal's fee -// is money the commit set aside and the reveal has no second input to raise it -// from, so a commit that lands under the target rate drags the package under it -// and nothing downstream can fix that: the pair sits unmined behind a confirmed -// commit. These pin that the commit prefunds the reveal at the PACKAGE rate, and -// that the P2SH reveal keeps that money as fee instead of sweeping it home. -describe('two-phase reveal package prefund @regression @tier1', () => { - const { ECPairFactory } = require('ecpair') - bitcoin.initEccLib(ecc) - const KEY = ECPairFactory(ecc).fromPrivateKey(Buffer.alloc(32, 7)) - const PUBKEY_HEX = Buffer.from(KEY.publicKey).toString('hex') - - const BTC_REGTEST = require('../../src/build/crypto_networks').getBitcoinJsNetwork('bitcoin-regtest') - const BTC_RATE_KB = 0.0001 // 10 sat/byte - const BTC_TARGET_PER_BYTE = BTC_RATE_KB * SATOSHI_UNIT / 1000 - const COMMIT_INPUT_VALUE = 100000000 - // Far under the target rate for a commit of any size: the congestion-clamped - // commit the ledger entry describes, reproduced as an explicit caller fee. - const UNDER_TARGET_COMMIT_FEE = 700 - const ENVELOPE_PAYLOAD = 'x'.repeat(20000) - - afterEach(() => { delete process.env.MAX_CPFP_UPLIFT_SAT }) - - function envelopeEncoder (ancestorPackage) { - const encoder = new XChainEncoder('bitcoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '') - encoder.connector = { - getFeePerKilobyte: async () => BTC_RATE_KB, - // Without a relayfee the suggested-rate ceiling clamps to its default and - // the probe rate below would never be priced at all. - getNetworkInfo: async () => ({ relayfee: 0.00001 }), - getTransactionHex: async () => { throw new Error('unit test: no node') } - } - if (ancestorPackage !== undefined) { - encoder.connector.getUnconfirmedAncestorPackage = async () => - (typeof ancestorPackage === 'function' ? ancestorPackage() : ancestorPackage) - } - return encoder - } - - function callerAddress () { - return bitcoin.payments.p2wpkh({ pubkey: Buffer.from(KEY.publicKey), network: BTC_REGTEST }).address - } - - function segwitUtxo (value, confirmations) { - const p2wpkh = bitcoin.payments.p2wpkh({ pubkey: Buffer.from(KEY.publicKey), network: BTC_REGTEST }) - return { txid: TXID_PARENT_A, vout: 0, value, confirmations, scriptPubKey: p2wpkh.output.toString('hex') } - } - - async function buildEnvelope (encoder, { commitFee = null, inputValue = COMMIT_INPUT_VALUE, confirmations = 6 } = {}) { - encoder.clearReservations() - const addr = callerAddress() - return encoder.createTransaction( - [segwitUtxo(inputValue, confirmations)], addr, null, 'FILE|0|package-prefund', - ENVELOPE_PAYLOAD, commitFee, false, 'TAPROOT', addr, null, null, PUBKEY_HEX) - } - - function unsignedTx (psbt) { - return bitcoin.Transaction.fromBuffer(psbt.data.globalMap.unsignedTx.toBuffer()) - } - - // Sign both halves so the package is measured on the bytes a miner actually - // sees, not on the builder's own estimate of them. - function signedPair (result) { - const signer = { - publicKey: Buffer.from(KEY.publicKey), - sign: (h) => Buffer.from(KEY.sign(h)), - signSchnorr: (h) => Buffer.from(ecc.signSchnorr(h, KEY.privateKey)) - } - const commitPsbt = bitcoin.Psbt.fromHex(result.psbt.toHex()) - commitPsbt.signAllInputs(signer) - commitPsbt.finalizeAllInputs() - const commitTx = commitPsbt.extractTransaction() - - const revealPsbt = result.revealPsbt - revealPsbt.signInput(0, signer) - revealPsbt.finalizeAllInputs() - const revealTx = revealPsbt.extractTransaction() - return { commitTx, revealTx } - } - - it('lifts the prefund so a commit paying under the target rate still clears it as a package', async () => { - const result = await buildEnvelope(envelopeEncoder(), { commitFee: UNDER_TARGET_COMMIT_FEE }) - const { commitTx, revealTx } = signedPair(result) - - const commitFee = COMMIT_INPUT_VALUE - commitTx.outs.reduce((s, o) => s + o.value, 0) - assert.strictEqual(commitFee, UNDER_TARGET_COMMIT_FEE, 'the commit still pays exactly what the caller asked') - assert.ok(commitFee / commitTx.virtualSize() < BTC_TARGET_PER_BYTE, - 'this probe is only meaningful while the commit itself is under target') - - const revealFee = commitTx.outs[result.envelope.commitVout].value - - revealTx.outs.reduce((s, o) => s + o.value, 0) - const packageRate = (commitFee + revealFee) / (commitTx.virtualSize() + revealTx.virtualSize()) - assert.ok(packageRate >= BTC_TARGET_PER_BYTE, - `commit+reveal package pays ${packageRate} sat/byte, under the ${BTC_TARGET_PER_BYTE} target`) - }) - - it('leaves the reveal its change output rather than paying the uplift out of it', async () => { - const encoder = envelopeEncoder() - const result = await buildEnvelope(encoder, { commitFee: UNDER_TARGET_COMMIT_FEE }) - // commitValue = revealFee + dust by construction; both move together or the - // uplift silently returns to the caller as change instead of reaching miners. - assert.strictEqual(result.envelope.commitValue, result.envelope.revealFee + encoder.dustAmount) - assert.strictEqual(Number(result.revealPsbt.txOutputs[0].value), encoder.dustAmount, - 'the reveal still leaves exactly one dust output back to the caller') - const commitTx = unsignedTx(result.psbt) - assert.strictEqual(commitTx.outs[result.envelope.commitVout].value, result.envelope.commitValue, - 'the commit output on the wire carries the raised value') - }) - - it('adds nothing when the commit already pays the target rate on its own bytes', async () => { - // Same build, node-derived fee: the commit pays the target, so the package - // already clears it and the prefund must not grow by a single satoshi. - const withPackaging = await buildEnvelope(envelopeEncoder()) - process.env.MAX_CPFP_UPLIFT_SAT = '0' - const withoutPackaging = await buildEnvelope(envelopeEncoder()) - assert.strictEqual(withPackaging.envelope.revealFee, withoutPackaging.envelope.revealFee, - 'a commit at target must not buy the reveal a bigger prefund') - }) - - it('carries ancestors the commit\'s own capped uplift could not pay for', async () => { - // A commit whose CPFP uplift is capped at the node rate cannot pay for the - // free ancestors it spends: the rate cap measures a fee against the commit's - // OWN bytes, so there is no room in it for anyone else's. The reveal is the - // only half of the package left that can still be raised, and its prefund is - // bought with change rather than with commit fee, so the cap does not bind it. - const capped = (ancestorPackage) => { - const encoder = new XChainEncoder('bitcoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '', - BTC_RATE_KB * SATOSHI_UNIT) - encoder.connector = { - getFeePerKilobyte: async () => BTC_RATE_KB, - getNetworkInfo: async () => ({ relayfee: 0.00001 }), - getTransactionHex: async () => { throw new Error('unit test: no node') }, - getUnconfirmedAncestorPackage: async () => ancestorPackage - } - return encoder - } - const originalWarn = console.warn - console.warn = () => {} - let withAncestors, withoutAncestors - try { - withAncestors = await buildEnvelope(capped({ size: 2000, fees: 0 }), { confirmations: 0 }) - withoutAncestors = await buildEnvelope(capped({ size: 0, fees: 0 }), { confirmations: 0 }) - } finally { - console.warn = originalWarn - } - assert.ok(withAncestors.envelope.revealFee > withoutAncestors.envelope.revealFee, - `unpaid ancestors must raise the prefund (${withAncestors.envelope.revealFee} vs ${withoutAncestors.envelope.revealFee})`) - // 2000 free ancestor bytes at the 10 sat/byte target is what is owed, give or - // take the single base unit the rate's float representation costs. - const owed = withAncestors.envelope.revealFee - withoutAncestors.envelope.revealFee - assert.ok(Math.abs(owed - 2000 * BTC_TARGET_PER_BYTE) <= 1, - `owed ${owed} against ${2000 * BTC_TARGET_PER_BYTE} for the unpaid ancestor bytes`) - }) - - it('MAX_CPFP_UPLIFT_SAT=0 turns the prefund off entirely', async () => { - const baseline = await buildEnvelope(envelopeEncoder(), { commitFee: UNDER_TARGET_COMMIT_FEE }) - process.env.MAX_CPFP_UPLIFT_SAT = '0' - const disabled = await buildEnvelope(envelopeEncoder(), { commitFee: UNDER_TARGET_COMMIT_FEE }) - assert.ok(baseline.envelope.revealFee > disabled.envelope.revealFee, - 'the probe must actually be lifting something when sizing is on') - }) - - it('clamps at MAX_CPFP_UPLIFT_SAT and warns that the package stays under target', async () => { - process.env.MAX_CPFP_UPLIFT_SAT = '0' - const disabled = await buildEnvelope(envelopeEncoder(), { commitFee: UNDER_TARGET_COMMIT_FEE }) - - process.env.MAX_CPFP_UPLIFT_SAT = '11' - const warnings = [] - const originalWarn = console.warn - console.warn = (...args) => warnings.push(args.join(' ')) - let clamped - try { - clamped = await buildEnvelope(envelopeEncoder(), { commitFee: UNDER_TARGET_COMMIT_FEE }) - } finally { - console.warn = originalWarn - } - assert.strictEqual(clamped.envelope.revealFee, disabled.envelope.revealFee + 11, - 'the prefund stops at exactly the configured bound') - assert.ok(warnings.some(w => /Reveal package prefund clamped/.test(w) && /MAX_CPFP_UPLIFT_SAT/.test(w)), - 'the operator must be told the package will stay under target: ' + warnings.join(' | ')) - }) - - it('never prefunds more than the commit inputs hold', async () => { - // The input barely covers the commit's own outputs and fee. The build must - // still produce a signable pair rather than fail with INSUFFICIENT_FUNDS. - const encoder = envelopeEncoder({ size: 500000, fees: 0 }) - const result = await buildEnvelope(encoder, { commitFee: UNDER_TARGET_COMMIT_FEE, inputValue: 53000, confirmations: 0 }) - const commitTx = unsignedTx(result.psbt) - const outputs = commitTx.outs.reduce((s, o) => s + o.value, 0) - assert.ok(outputs + UNDER_TARGET_COMMIT_FEE <= 53000, - `commit pays out ${outputs} + ${UNDER_TARGET_COMMIT_FEE} fee against a ${53000} input`) - assert.strictEqual(result.envelope.commitValue, commitTx.outs[result.envelope.commitVout].value) - }) -}) - -// The P2SH lane splits the two phases across two calls, so the commit funds the -// package rate into its first leg and the reveal has to KEEP that money as fee -// instead of sweeping it back to the caller. -describe('P2SH two-phase package prefund @regression @tier1', () => { - const DOGE = 'dogecoin-regtest' - const DOGE_REGTEST_NET = require('../../src/build/crypto_networks').getBitcoinJsNetwork(DOGE) - const RATE_KB = 1000000 // 1000 koinu/byte, the venue rate - const TARGET_PER_BYTE = RATE_KB / 1000 - const pubkeyBuf = Buffer.from('0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', 'hex') - const CALLER = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST_NET }).address - // Four inputs this size: selection takes two, which is enough to leave a - // change output at or above the 0.01 DOGE relay floor. That matters - a change - // that folds into the fee instead of being emitted quietly lifts the commit - // back over the target rate and the probe stops probing anything. - const FUNDING_INPUT = 2000000 - const PAYLOAD = 'x'.repeat(400) - // The dust floor, and far under the target rate for a two-input funding tx: - // the congestion-clamped commit the ledger entry describes. - const UNDER_TARGET_COMMIT_FEE = 100000 - - function prevTxHex () { - const tx = new bitcoin.Transaction() - tx.addInput(Buffer.alloc(32, 0x11), 0) - for (let i = 0; i < 4; i++) { - tx.addOutput(bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: bitcoin.networks.regtest }).output, FUNDING_INPUT) - } - return tx.toHex() - } - - function makeEncoder (commitPackage) { - const encoder = new XChainEncoder(DOGE, '127.0.0.1', '8333', 'rpc', 'rpc', '', '') - encoder.connector = { - getFeePerKilobyte: async () => 0.01, - getTransactionHex: async () => prevTxHex() - } - if (commitPackage !== undefined) { - encoder.connector.getUnconfirmedAncestorPackage = async () => - (typeof commitPackage === 'function' ? commitPackage() : commitPackage) - } - return encoder - } - - function legacyUtxos () { - const p2pkh = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: bitcoin.networks.regtest }) - return [0, 1, 2, 3].map(vout => ({ - txid: TXID_PARENT_A, vout, value: FUNDING_INPUT, confirmations: 6, scriptPubKey: p2pkh.output.toString('hex') - })) - } - - async function buildFunding (encoder, commitFee) { - encoder.clearReservations() - const res = await encoder.createTransaction( - legacyUtxos(), CALLER, null, PAYLOAD, null, commitFee, false, 'P2SH', CALLER, - null, null, null, true, RATE_KB) - return res.psbt.__CACHE.__TX - } - - async function buildReveal (encoder, fundingTx) { - const res = await encoder.createTransaction( - [], CALLER, null, PAYLOAD, null, null, false, 'P2SH', CALLER, - fundingTx.getId(), fundingTx.toHex(), null, true, RATE_KB) - return res.psbt.__CACHE.__TX - } - - const legTotal = (tx) => tx.outs - .filter(o => o.script.toString('hex').startsWith('a914')) - .reduce((s, o) => s + o.value, 0) - - it('funds the package rate into the leg when the commit pays under target', async () => { - const atTarget = await buildFunding(makeEncoder(), null) - const underTarget = await buildFunding(makeEncoder(), UNDER_TARGET_COMMIT_FEE) - assert.ok(legTotal(underTarget) > legTotal(atTarget), - `an under-target commit must over-fund its leg (${legTotal(underTarget)} vs ${legTotal(atTarget)})`) - }) - - it('the reveal keeps the package money as fee instead of sweeping it back', async () => { - const funding = await buildFunding(makeEncoder(), UNDER_TARGET_COMMIT_FEE) - const commitSize = funding.virtualSize() - // What the commit ACTUALLY pays: a sub-floor change would have folded into - // the fee and quietly lifted it back over target. - const commitFee = funding.ins.length * FUNDING_INPUT - funding.outs.reduce((s, o) => s + o.value, 0) - assert.ok(commitFee / commitSize < TARGET_PER_BYTE, - `this probe is only meaningful while the commit is under target (${commitFee / commitSize})`) - - // The node reports the commit exactly as it was broadcast: its real size and - // the under-target fee the caller chose. - const packaged = await buildReveal( - makeEncoder({ size: commitSize, fees: commitFee / SATOSHI_UNIT }), funding) - // Control: the same reveal built against a node that cannot price the commit. - const unpackaged = await buildReveal(makeEncoder(), funding) - - const legs = legTotal(funding) - const packagedFee = legs - packaged.outs.reduce((s, o) => s + o.value, 0) - const unpackagedFee = legs - unpackaged.outs.reduce((s, o) => s + o.value, 0) - assert.ok(packagedFee > unpackagedFee, - `the reveal must keep the package money (${packagedFee} vs ${unpackagedFee})`) - - // The reveal's own priced size, recovered from the control: at exactly - // 1000 koinu/byte an unpackaged fee IS the size in bytes. - const revealSize = unpackagedFee / TARGET_PER_BYTE - const packageRate = (commitFee + packagedFee) / (commitSize + revealSize) - assert.ok(packageRate >= TARGET_PER_BYTE, - `commit+reveal package pays ${packageRate} koinu/byte, under the ${TARGET_PER_BYTE} target`) - - // And it is still not a full burn: the SDK refuses to sign an outputless reveal. - const swept = packaged.outs.reduce((s, o) => s + o.value, 0) - assert.ok(swept > 0, 'the reveal must still leave the caller an output') - }) - - it('a confirmed commit asks the reveal for nothing', async () => { - const funding = await buildFunding(makeEncoder(), UNDER_TARGET_COMMIT_FEE) - // An empty package is what the connector reports once the commit confirms; a - // confirmed parent is nobody's ancestor for fee purposes any more. - const confirmed = await buildReveal(makeEncoder({ size: 0, fees: 0 }), funding) - const unpackaged = await buildReveal(makeEncoder(), funding) - assert.strictEqual(confirmed.toHex(), unpackaged.toHex(), - 'a confirmed commit must leave the reveal byte-identical') - }) - - it('degrades to the per-transaction fee when the commit lookup throws', async () => { - const funding = await buildFunding(makeEncoder(), UNDER_TARGET_COMMIT_FEE) - const thrown = await buildReveal(makeEncoder(() => { throw new Error('node RPC exploded') }), funding) - const unpackaged = await buildReveal(makeEncoder(), funding) - assert.strictEqual(thrown.toHex(), unpackaged.toHex()) - }) }) diff --git a/test/unit/package_fee_sizing.test/01_package_fee_uplift_satoshis.test.js b/test/unit/package_fee_sizing.test/01_package_fee_uplift_satoshis.test.js new file mode 100644 index 0000000..cd85c11 --- /dev/null +++ b/test/unit/package_fee_sizing.test/01_package_fee_uplift_satoshis.test.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. + +const assert = require('assert') +const XChainEncoder = require('../../../src/XChainEncoder') + +const SATOSHI_UNIT = 100000000 + +describe('packageFeeUpliftSatoshis() @regression @tier1', () => { + + const uplift = (o) => XChainEncoder.packageFeeUpliftSatoshis(Object.assign({ satoshiUnit: SATOSHI_UNIT }, o)) + + it('reproduces the live DOGE shortfall', () => { + // 2000 bytes of ancestors paying 0.00313 DOGE/kB, a 1000-byte child, a + // 0.01 DOGE/kB target: the package needs 0.03 DOGE and the ancestors bring + // 0.00626, so the child owes 0.02374 DOGE (2,374,000 koinu). + const owed = uplift({ + currentFee: 0, + txSize: 1000, + ancestorSize: 2000, + ancestorFees: 0.00626, + targetFeePerBytes: 0.01 / 1000 + }) + assert.strictEqual(owed, 2374000) + const packageRate = (626000 + owed) / 3000 * 1000 / SATOSHI_UNIT + assert.ok(packageRate >= 0.01, 'the uplifted package must clear the inclusion floor, got ' + packageRate) + }) + + it('subtracts what the transaction already pays', () => { + const owed = uplift({ + currentFee: 1000000, + txSize: 1000, + ancestorSize: 2000, + ancestorFees: 0.00626, + targetFeePerBytes: 0.01 / 1000 + }) + assert.strictEqual(owed, 1374000) + }) + + it('returns 0 when the package already clears the target', () => { + assert.strictEqual(uplift({ + currentFee: 500000, + txSize: 250, + ancestorSize: 500, + ancestorFees: 0.05, + targetFeePerBytes: 0.01 / 1000 + }), 0, 'a rich ancestor must never LOWER this fee') + }) + +}) + +describe('packageFeeUpliftSatoshis() @regression @tier1', () => { + + const uplift = (o) => XChainEncoder.packageFeeUpliftSatoshis(Object.assign({ satoshiUnit: SATOSHI_UNIT }, o)) + + it('takes the ancestor fee in base units when one is given, ahead of the coin-unit figure', () => { + // The two-phase prefund knows the commit's fee exactly, as an integer. A + // coin-unit round trip would reintroduce the float error the ceiling shaves. + const owed = uplift({ + currentFee: 0, + txSize: 1000, + ancestorSize: 2000, + ancestorFees: 1, // a wildly different coin-unit figure + ancestorFeeSatoshis: 626000, + targetFeePerBytes: 0.01 / 1000 + }) + assert.strictEqual(owed, 2374000, 'the base-unit figure wins') + }) + + it('returns 0 for an empty package or a missing target', () => { + const base = { currentFee: 0, txSize: 250, ancestorSize: 0, ancestorFees: 0, targetFeePerBytes: 0.00001 } + assert.strictEqual(uplift(base), 0) + assert.strictEqual(uplift(Object.assign({}, base, { ancestorSize: 1000, targetFeePerBytes: 0 })), 0) + assert.strictEqual(uplift(Object.assign({}, base, { ancestorSize: 1000, txSize: 0 })), 0) + assert.strictEqual(uplift(Object.assign({}, base, { ancestorSize: NaN })), 0) + }) + +}) + diff --git a/test/unit/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js b/test/unit/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js new file mode 100644 index 0000000..db8cb33 --- /dev/null +++ b/test/unit/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js @@ -0,0 +1,261 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') + +const SATOSHI_UNIT = 100000000 + +const TXID_PARENT_A = 'a'.repeat(64) +const TXID_PARENT_B = 'b'.repeat(64) +const TXID_SHARED = 'c'.repeat(64) + +const pubkeyBuf = Buffer.from('0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', 'hex') +const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST }).address +const INPUT_VALUE = 100000000 // 1 DOGE in koinu +const NODE_RATE_PER_KB = 0.01 // Dogecoin's block-inclusion floor +const TARGET_PER_BYTE = NODE_RATE_PER_KB * SATOSHI_UNIT / 1000 // 1000 koinu/byte + + +// P2PKH, the only output type a chain without segwit holds, so these fixtures +// describe a UTXO the chain under test could actually produce. +const P2PKH_SCRIPT = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: bitcoin.networks.regtest }).output + +function makeUtxo (txid, confirmations) { + return { + txid, + vout: 0, + value: INPUT_VALUE, + confirmations, + scriptPubKey: P2PKH_SCRIPT.toString('hex') + } +} + +// The whole previous transaction, which a legacy input carries as nonWitnessUtxo. +function prevTxHex () { + const tx = new bitcoin.Transaction() + tx.addInput(Buffer.alloc(32, 0x11), 0) + tx.addOutput(P2PKH_SCRIPT, INPUT_VALUE) + return tx.toHex() +} + +function makeEncoder (ancestorPackage) { + const encoder = new XChainEncoder('dogecoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '') + encoder.connector = { + getFeePerKilobyte: async () => NODE_RATE_PER_KB, + getTransactionHex: async () => prevTxHex(), + // The suggested-rate ceiling on a test chain reads the node's relay floor; + // without it the build would clamp to the 20-per-vByte Bitcoin-scale default + // and never price a DOGE package at all. + getNetworkInfo: async () => ({ relayfee: 0.001 }), + getUnconfirmedAncestorPackage: async (txids) => { + encoder.connector.askedFor = txids + return typeof ancestorPackage === 'function' ? ancestorPackage(txids) : ancestorPackage + } + } + // The chain dust floor sits above the fees these probes produce; the floor has + // its own suite, so lower it here to keep the sizing behaviour observable. + encoder.dustAmount = 546 + return encoder +} + +async function create (encoder, utxos) { + // Every probe here respends the same fixture input on one encoder to + // compare fees, so release the previous build's reservation first. + encoder.clearReservations() + return encoder.createTransaction( + utxos, TEST_ADDRESS, null, 'test', null, null, false, null, TEST_ADDRESS, + null, null, null, true, null + ) +} + +// inputs − all outputs = the fee actually embedded in the PSBT +function paidFee (result) { + const outputs = result.psbt.txOutputs.reduce((sum, o) => sum + o.value, 0) + return INPUT_VALUE - outputs +} + +// The node rate is exactly 1000 koinu/byte, so the unpackaged fee IS the +// estimated size in bytes × 1000. Recovering the size this way keeps the +// assertions below independent of the estimator's exact byte count. +async function baselineFee (encoder) { + return paidFee(await create(encoder, [makeUtxo(TXID_PARENT_A, 6)])) +} + +describe('XChainEncoder package-aware fee sizing @regression @tier1', () => { + + afterEach(() => { delete process.env.MAX_CPFP_UPLIFT_SAT }) + + it('leaves the fee untouched when every input is confirmed, and never asks the node', async () => { + const encoder = makeEncoder({ size: 5000, fees: 0.001 }) + const fee = paidFee(await create(encoder, [makeUtxo(TXID_PARENT_A, 6)])) + assert.strictEqual(encoder.connector.askedFor, undefined, 'no ancestor lookup for a confirmed selection') + assert.ok(fee > 0 && fee % 1000 === 0, 'the fee is the per-transaction fee, got ' + fee) + }) + + it('lifts the fee so a cheap ancestor package clears the target rate', async () => { + // The live shape: 2000 bytes of ancestors paying 0.00313 DOGE/kB against a + // 0.01 DOGE/kB target. + const ancestorSize = 2000 + const ancestorFees = 0.00626 + const encoder = makeEncoder({ size: ancestorSize, fees: ancestorFees }) + const baseline = await baselineFee(encoder) + const txSize = baseline / 1000 + + const fee = paidFee(await create(encoder, [makeUtxo(TXID_PARENT_A, 0)])) + assert.deepStrictEqual(encoder.connector.askedFor, [TXID_PARENT_A]) + assert.ok(fee > baseline, `fee ${fee} must exceed the per-transaction fee ${baseline}`) + + const packageRate = (ancestorFees * SATOSHI_UNIT + fee) / (ancestorSize + txSize) + assert.ok(packageRate >= TARGET_PER_BYTE, + `package rate ${packageRate} koinu/byte must reach the ${TARGET_PER_BYTE} target`) + // And no further than it needs to go. + assert.ok(packageRate < TARGET_PER_BYTE * 1.01, 'the uplift must not overpay, got ' + packageRate) + }) + + it('does not lower the fee when the ancestors already pay well above the target', async () => { + const encoder = makeEncoder({ size: 2000, fees: 1 }) // 1 DOGE over 2000 bytes + const baseline = await baselineFee(encoder) + const fee = paidFee(await create(encoder, [makeUtxo(TXID_PARENT_A, 0)])) + assert.strictEqual(fee, baseline, 'a rich package must leave this fee exactly as it was') + }) + + it('counts a shared ancestor once by handing the node every unconfirmed input txid', async () => { + const encoder = makeEncoder({ size: 2000, fees: 0.00626 }) + // Two unconfirmed inputs, one confirmed: only the unconfirmed pair is asked + // about, and the connector is what dedupes the ancestors they share. + const utxos = [makeUtxo(TXID_PARENT_A, 0), makeUtxo(TXID_PARENT_B, 0), makeUtxo(TXID_SHARED, 6)] + utxos[1].vout = 1 + utxos[2].vout = 2 + // A tiny value on the later inputs so selection takes the big one first and + // still walks the rest of the set. + utxos[1].value = 1 + utxos[2].value = 1 + await create(encoder, utxos) + assert.deepStrictEqual(encoder.connector.askedFor, [TXID_PARENT_A], + 'selection stops once the inputs cover outputs plus fee, so only what it took is asked about') + }) + +}) + +describe('XChainEncoder package-aware fee sizing @regression @tier1', () => { + + afterEach(() => { delete process.env.MAX_CPFP_UPLIFT_SAT }) + + it('falls back to the per-transaction fee when the ancestor lookup fails', async () => { + const encoder = makeEncoder(null) // the connector could not price the package + const baseline = await baselineFee(encoder) + const fee = paidFee(await create(encoder, [makeUtxo(TXID_PARENT_A, 0)])) + assert.strictEqual(fee, baseline) + }) + + it('never throws when the ancestor lookup itself throws', async () => { + const encoder = makeEncoder(() => { throw new Error('node RPC exploded') }) + const baseline = await baselineFee(encoder) + const fee = paidFee(await create(encoder, [makeUtxo(TXID_PARENT_A, 0)])) + assert.strictEqual(fee, baseline, 'a thrown lookup degrades to the per-transaction fee') + }) + + it('degrades when the connector has no package method at all', async () => { + const encoder = makeEncoder({ size: 2000, fees: 0 }) + delete encoder.connector.getUnconfirmedAncestorPackage + const fee = paidFee(await create(encoder, [makeUtxo(TXID_PARENT_A, 0)])) + assert.ok(fee > 0 && fee % 1000 === 0, 'the fee is the per-transaction fee, got ' + fee) + }) + +}) + +describe('XChainEncoder package-aware fee sizing @regression @tier1', () => { + + afterEach(() => { delete process.env.MAX_CPFP_UPLIFT_SAT }) + + it('clamps the uplift at MAX_CPFP_UPLIFT_SAT and warns that the package stays under target', async () => { + const ancestorSize = 2000 + const ancestorFees = 0.00626 + const encoder = makeEncoder({ size: ancestorSize, fees: ancestorFees }) + const baseline = await baselineFee(encoder) + const txSize = baseline / 1000 + + process.env.MAX_CPFP_UPLIFT_SAT = '100000' + const warnings = [] + const originalWarn = console.warn + console.warn = (...args) => warnings.push(args.join(' ')) + let fee + try { + fee = paidFee(await create(encoder, [makeUtxo(TXID_PARENT_A, 0)])) + } finally { + console.warn = originalWarn + } + + assert.strictEqual(fee, baseline + 100000, 'the uplift is bounded at exactly the configured maximum') + const packageRate = (ancestorFees * SATOSHI_UNIT + fee) / (ancestorSize + txSize) + assert.ok(packageRate < TARGET_PER_BYTE, 'this is the clamped, still-under-target case') + assert.ok(warnings.some(w => /Package fee uplift clamped/.test(w) && /MAX_CPFP_UPLIFT_SAT/.test(w)), + 'the operator must be told the package will stay under target: ' + warnings.join(' | ')) + }) + + it('MAX_CPFP_UPLIFT_SAT=0 turns package sizing off entirely', async () => { + const encoder = makeEncoder({ size: 2000, fees: 0 }) + const baseline = await baselineFee(encoder) + process.env.MAX_CPFP_UPLIFT_SAT = '0' + const fee = paidFee(await create(encoder, [makeUtxo(TXID_PARENT_A, 0)])) + assert.strictEqual(fee, baseline) + assert.strictEqual(encoder.connector.askedFor, undefined, 'a disabled uplift costs no RPC round trip') + }) + +}) + +describe('XChainEncoder package-aware fee sizing @regression @tier1', () => { + + afterEach(() => { delete process.env.MAX_CPFP_UPLIFT_SAT }) + + it('clamps the uplift to the fee-rate cap rather than blowing through it', async () => { + // MAX_FEE_RATE_KB of 2x the node rate leaves only 1x the per-transaction fee + // of headroom, far less than a 2000-byte cheap package asks for. + const encoder = new XChainEncoder('dogecoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '', + NODE_RATE_PER_KB * SATOSHI_UNIT * 2) + encoder.connector = { + getFeePerKilobyte: async () => NODE_RATE_PER_KB, + // The suggested-rate ceiling on a test chain reads the node's relay floor; + // without it the build would clamp to the 20-per-vByte Bitcoin-scale default + // and never price a DOGE package at all. + getNetworkInfo: async () => ({ relayfee: 0.001 }), + getTransactionHex: async () => prevTxHex(), + getUnconfirmedAncestorPackage: async () => ({ size: 2000, fees: 0 }) + } + encoder.dustAmount = 546 + const baseline = paidFee(await create(encoder, [makeUtxo(TXID_PARENT_A, 6)])) + const fee = paidFee(await create(encoder, [makeUtxo(TXID_PARENT_A, 0)])) + assert.ok(fee > baseline, 'the uplift still applies up to the cap') + // The cap ceiling rounds a float rate up, so allow the one base unit that + // costs; the point is that it stops there and not at the package rate. + assert.ok(fee >= baseline * 2 && fee <= baseline * 2 + 1, + `fee ${fee} must stop at the capped rate (~${baseline * 2}) for this size`) + }) + + it('never spends more than the inputs hold', async () => { + // A 2000-byte free package wants ~2,000,000 koinu of uplift; this input + // cannot cover it, and the build must still produce a signable PSBT rather + // than fail with INSUFFICIENT_FUNDS. + const encoder = makeEncoder({ size: 2000, fees: 0 }) + const utxo = makeUtxo(TXID_PARENT_A, 0) + utxo.value = 400000 + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, 'test', null, null, false, null, TEST_ADDRESS, + null, null, null, true, null + ) + const outputs = result.psbt.txOutputs.reduce((sum, o) => sum + o.value, 0) + assert.ok(outputs <= 400000, 'the transaction may never pay out more than it takes in') + }) + +}) + diff --git a/test/unit/package_fee_sizing.test/03_two_phase_reveal_package_prefund.test.js b/test/unit/package_fee_sizing.test/03_two_phase_reveal_package_prefund.test.js new file mode 100644 index 0000000..98a3c74 --- /dev/null +++ b/test/unit/package_fee_sizing.test/03_two_phase_reveal_package_prefund.test.js @@ -0,0 +1,231 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const ecc = require('tiny-secp256k1'); + +const SATOSHI_UNIT = 100000000 + +const TXID_PARENT_A = 'a'.repeat(64) + +// A reveal spends nothing but the commit's own outputs, so the pair is +// always one mempool package and a miner weighs them together. The reveal's fee +// is money the commit set aside and the reveal has no second input to raise it +// from, so a commit that lands under the target rate drags the package under it +// and nothing downstream can fix that: the pair sits unmined behind a confirmed +// commit. These pin that the commit prefunds the reveal at the PACKAGE rate, and +// that the P2SH reveal keeps that money as fee instead of sweeping it home. + +const { ECPairFactory } = require('ecpair') +bitcoin.initEccLib(ecc) +const KEY = ECPairFactory(ecc).fromPrivateKey(Buffer.alloc(32, 7)) +const PUBKEY_HEX = Buffer.from(KEY.publicKey).toString('hex') + +const BTC_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('bitcoin-regtest') +const BTC_RATE_KB = 0.0001 // 10 sat/byte +const BTC_TARGET_PER_BYTE = BTC_RATE_KB * SATOSHI_UNIT / 1000 +const COMMIT_INPUT_VALUE = 100000000 +// Far under the target rate for a commit of any size: the congestion-clamped +// commit the ledger entry describes, reproduced as an explicit caller fee. +const UNDER_TARGET_COMMIT_FEE = 700 +const ENVELOPE_PAYLOAD = 'x'.repeat(20000) + + +function envelopeEncoder (ancestorPackage) { + const encoder = new XChainEncoder('bitcoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '') + encoder.connector = { + getFeePerKilobyte: async () => BTC_RATE_KB, + // Without a relayfee the suggested-rate ceiling clamps to its default and + // the probe rate below would never be priced at all. + getNetworkInfo: async () => ({ relayfee: 0.00001 }), + getTransactionHex: async () => { throw new Error('unit test: no node') } + } + if (ancestorPackage !== undefined) { + encoder.connector.getUnconfirmedAncestorPackage = async () => + (typeof ancestorPackage === 'function' ? ancestorPackage() : ancestorPackage) + } + return encoder +} + +function callerAddress () { + return bitcoin.payments.p2wpkh({ pubkey: Buffer.from(KEY.publicKey), network: BTC_REGTEST }).address +} + +function segwitUtxo (value, confirmations) { + const p2wpkh = bitcoin.payments.p2wpkh({ pubkey: Buffer.from(KEY.publicKey), network: BTC_REGTEST }) + return { txid: TXID_PARENT_A, vout: 0, value, confirmations, scriptPubKey: p2wpkh.output.toString('hex') } +} + +async function buildEnvelope (encoder, { commitFee = null, inputValue = COMMIT_INPUT_VALUE, confirmations = 6 } = {}) { + encoder.clearReservations() + const addr = callerAddress() + return encoder.createTransaction( + [segwitUtxo(inputValue, confirmations)], addr, null, 'FILE|0|package-prefund', + ENVELOPE_PAYLOAD, commitFee, false, 'TAPROOT', addr, null, null, PUBKEY_HEX) +} + +function unsignedTx (psbt) { + return bitcoin.Transaction.fromBuffer(psbt.data.globalMap.unsignedTx.toBuffer()) +} + +// Sign both halves so the package is measured on the bytes a miner actually +// sees, not on the builder's own estimate of them. +function signedPair (result) { + const signer = { + publicKey: Buffer.from(KEY.publicKey), + sign: (h) => Buffer.from(KEY.sign(h)), + signSchnorr: (h) => Buffer.from(ecc.signSchnorr(h, KEY.privateKey)) + } + const commitPsbt = bitcoin.Psbt.fromHex(result.psbt.toHex()) + commitPsbt.signAllInputs(signer) + commitPsbt.finalizeAllInputs() + const commitTx = commitPsbt.extractTransaction() + + const revealPsbt = result.revealPsbt + revealPsbt.signInput(0, signer) + revealPsbt.finalizeAllInputs() + const revealTx = revealPsbt.extractTransaction() + return { commitTx, revealTx } +} + +describe('two-phase reveal package prefund @regression @tier1', () => { + + afterEach(() => { delete process.env.MAX_CPFP_UPLIFT_SAT }) + + it('lifts the prefund so a commit paying under the target rate still clears it as a package', async () => { + const result = await buildEnvelope(envelopeEncoder(), { commitFee: UNDER_TARGET_COMMIT_FEE }) + const { commitTx, revealTx } = signedPair(result) + + const commitFee = COMMIT_INPUT_VALUE - commitTx.outs.reduce((s, o) => s + o.value, 0) + assert.strictEqual(commitFee, UNDER_TARGET_COMMIT_FEE, 'the commit still pays exactly what the caller asked') + assert.ok(commitFee / commitTx.virtualSize() < BTC_TARGET_PER_BYTE, + 'this probe is only meaningful while the commit itself is under target') + + const revealFee = commitTx.outs[result.envelope.commitVout].value - + revealTx.outs.reduce((s, o) => s + o.value, 0) + const packageRate = (commitFee + revealFee) / (commitTx.virtualSize() + revealTx.virtualSize()) + assert.ok(packageRate >= BTC_TARGET_PER_BYTE, + `commit+reveal package pays ${packageRate} sat/byte, under the ${BTC_TARGET_PER_BYTE} target`) + }) + + it('leaves the reveal its change output rather than paying the uplift out of it', async () => { + const encoder = envelopeEncoder() + const result = await buildEnvelope(encoder, { commitFee: UNDER_TARGET_COMMIT_FEE }) + // commitValue = revealFee + dust by construction; both move together or the + // uplift silently returns to the caller as change instead of reaching miners. + assert.strictEqual(result.envelope.commitValue, result.envelope.revealFee + encoder.dustAmount) + assert.strictEqual(Number(result.revealPsbt.txOutputs[0].value), encoder.dustAmount, + 'the reveal still leaves exactly one dust output back to the caller') + const commitTx = unsignedTx(result.psbt) + assert.strictEqual(commitTx.outs[result.envelope.commitVout].value, result.envelope.commitValue, + 'the commit output on the wire carries the raised value') + }) + + it('adds nothing when the commit already pays the target rate on its own bytes', async () => { + // Same build, node-derived fee: the commit pays the target, so the package + // already clears it and the prefund must not grow by a single satoshi. + const withPackaging = await buildEnvelope(envelopeEncoder()) + process.env.MAX_CPFP_UPLIFT_SAT = '0' + const withoutPackaging = await buildEnvelope(envelopeEncoder()) + assert.strictEqual(withPackaging.envelope.revealFee, withoutPackaging.envelope.revealFee, + 'a commit at target must not buy the reveal a bigger prefund') + }) + +}) + +describe('two-phase reveal package prefund @regression @tier1', () => { + + afterEach(() => { delete process.env.MAX_CPFP_UPLIFT_SAT }) + + it('carries ancestors the commit\'s own capped uplift could not pay for', async () => { + // A commit whose CPFP uplift is capped at the node rate cannot pay for the + // free ancestors it spends: the rate cap measures a fee against the commit's + // OWN bytes, so there is no room in it for anyone else's. The reveal is the + // only half of the package left that can still be raised, and its prefund is + // bought with change rather than with commit fee, so the cap does not bind it. + const capped = (ancestorPackage) => { + const encoder = new XChainEncoder('bitcoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '', + BTC_RATE_KB * SATOSHI_UNIT) + encoder.connector = { + getFeePerKilobyte: async () => BTC_RATE_KB, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), + getTransactionHex: async () => { throw new Error('unit test: no node') }, + getUnconfirmedAncestorPackage: async () => ancestorPackage + } + return encoder + } + const originalWarn = console.warn + console.warn = () => {} + let withAncestors, withoutAncestors + try { + withAncestors = await buildEnvelope(capped({ size: 2000, fees: 0 }), { confirmations: 0 }) + withoutAncestors = await buildEnvelope(capped({ size: 0, fees: 0 }), { confirmations: 0 }) + } finally { + console.warn = originalWarn + } + assert.ok(withAncestors.envelope.revealFee > withoutAncestors.envelope.revealFee, + `unpaid ancestors must raise the prefund (${withAncestors.envelope.revealFee} vs ${withoutAncestors.envelope.revealFee})`) + // 2000 free ancestor bytes at the 10 sat/byte target is what is owed, give or + // take the single base unit the rate's float representation costs. + const owed = withAncestors.envelope.revealFee - withoutAncestors.envelope.revealFee + assert.ok(Math.abs(owed - 2000 * BTC_TARGET_PER_BYTE) <= 1, + `owed ${owed} against ${2000 * BTC_TARGET_PER_BYTE} for the unpaid ancestor bytes`) + }) + + it('MAX_CPFP_UPLIFT_SAT=0 turns the prefund off entirely', async () => { + const baseline = await buildEnvelope(envelopeEncoder(), { commitFee: UNDER_TARGET_COMMIT_FEE }) + process.env.MAX_CPFP_UPLIFT_SAT = '0' + const disabled = await buildEnvelope(envelopeEncoder(), { commitFee: UNDER_TARGET_COMMIT_FEE }) + assert.ok(baseline.envelope.revealFee > disabled.envelope.revealFee, + 'the probe must actually be lifting something when sizing is on') + }) + +}) + +describe('two-phase reveal package prefund @regression @tier1', () => { + + afterEach(() => { delete process.env.MAX_CPFP_UPLIFT_SAT }) + + it('clamps at MAX_CPFP_UPLIFT_SAT and warns that the package stays under target', async () => { + process.env.MAX_CPFP_UPLIFT_SAT = '0' + const disabled = await buildEnvelope(envelopeEncoder(), { commitFee: UNDER_TARGET_COMMIT_FEE }) + + process.env.MAX_CPFP_UPLIFT_SAT = '11' + const warnings = [] + const originalWarn = console.warn + console.warn = (...args) => warnings.push(args.join(' ')) + let clamped + try { + clamped = await buildEnvelope(envelopeEncoder(), { commitFee: UNDER_TARGET_COMMIT_FEE }) + } finally { + console.warn = originalWarn + } + assert.strictEqual(clamped.envelope.revealFee, disabled.envelope.revealFee + 11, + 'the prefund stops at exactly the configured bound') + assert.ok(warnings.some(w => /Reveal package prefund clamped/.test(w) && /MAX_CPFP_UPLIFT_SAT/.test(w)), + 'the operator must be told the package will stay under target: ' + warnings.join(' | ')) + }) + + it('never prefunds more than the commit inputs hold', async () => { + // The input barely covers the commit's own outputs and fee. The build must + // still produce a signable pair rather than fail with INSUFFICIENT_FUNDS. + const encoder = envelopeEncoder({ size: 500000, fees: 0 }) + const result = await buildEnvelope(encoder, { commitFee: UNDER_TARGET_COMMIT_FEE, inputValue: 53000, confirmations: 0 }) + const commitTx = unsignedTx(result.psbt) + const outputs = commitTx.outs.reduce((s, o) => s + o.value, 0) + assert.ok(outputs + UNDER_TARGET_COMMIT_FEE <= 53000, + `commit pays out ${outputs} + ${UNDER_TARGET_COMMIT_FEE} fee against a ${53000} input`) + assert.strictEqual(result.envelope.commitValue, commitTx.outs[result.envelope.commitVout].value) + }) + +}) + diff --git a/test/unit/package_fee_sizing.test/04_p2sh_two_phase_package_prefund.test.js b/test/unit/package_fee_sizing.test/04_p2sh_two_phase_package_prefund.test.js new file mode 100644 index 0000000..5427686 --- /dev/null +++ b/test/unit/package_fee_sizing.test/04_p2sh_two_phase_package_prefund.test.js @@ -0,0 +1,153 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') + +const SATOSHI_UNIT = 100000000 + +const TXID_PARENT_A = 'a'.repeat(64) + +// The P2SH lane splits the two phases across two calls, so the commit funds the +// package rate into its first leg and the reveal has to KEEP that money as fee +// instead of sweeping it back to the caller. + +const DOGE = 'dogecoin-regtest' +const DOGE_REGTEST_NET = require('../../../src/build/crypto_networks').getBitcoinJsNetwork(DOGE) +const RATE_KB = 1000000 // 1000 koinu/byte, the venue rate +const TARGET_PER_BYTE = RATE_KB / 1000 +const pubkeyBuf = Buffer.from('0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', 'hex') +const CALLER = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST_NET }).address +// Four inputs this size: selection takes two, which is enough to leave a +// change output at or above the 0.01 DOGE relay floor. That matters - a change +// that folds into the fee instead of being emitted quietly lifts the commit +// back over the target rate and the probe stops probing anything. +const FUNDING_INPUT = 2000000 +const PAYLOAD = 'x'.repeat(400) +// The dust floor, and far under the target rate for a two-input funding tx: +// the congestion-clamped commit the ledger entry describes. +const UNDER_TARGET_COMMIT_FEE = 100000 + +function prevTxHex () { + const tx = new bitcoin.Transaction() + tx.addInput(Buffer.alloc(32, 0x11), 0) + for (let i = 0; i < 4; i++) { + tx.addOutput(bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: bitcoin.networks.regtest }).output, FUNDING_INPUT) + } + return tx.toHex() +} + +function makeEncoder (commitPackage) { + const encoder = new XChainEncoder(DOGE, '127.0.0.1', '8333', 'rpc', 'rpc', '', '') + encoder.connector = { + getFeePerKilobyte: async () => 0.01, + getTransactionHex: async () => prevTxHex() + } + if (commitPackage !== undefined) { + encoder.connector.getUnconfirmedAncestorPackage = async () => + (typeof commitPackage === 'function' ? commitPackage() : commitPackage) + } + return encoder +} + +function legacyUtxos () { + const p2pkh = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: bitcoin.networks.regtest }) + return [0, 1, 2, 3].map(vout => ({ + txid: TXID_PARENT_A, vout, value: FUNDING_INPUT, confirmations: 6, scriptPubKey: p2pkh.output.toString('hex') + })) +} + +async function buildFunding (encoder, commitFee) { + encoder.clearReservations() + const res = await encoder.createTransaction( + legacyUtxos(), CALLER, null, PAYLOAD, null, commitFee, false, 'P2SH', CALLER, + null, null, null, true, RATE_KB) + return res.psbt.__CACHE.__TX +} + +async function buildReveal (encoder, fundingTx) { + const res = await encoder.createTransaction( + [], CALLER, null, PAYLOAD, null, null, false, 'P2SH', CALLER, + fundingTx.getId(), fundingTx.toHex(), null, true, RATE_KB) + return res.psbt.__CACHE.__TX +} + +const legTotal = (tx) => tx.outs + .filter(o => o.script.toString('hex').startsWith('a914')) + .reduce((s, o) => s + o.value, 0) + + +describe('P2SH two-phase package prefund @regression @tier1', () => { + + it('funds the package rate into the leg when the commit pays under target', async () => { + const atTarget = await buildFunding(makeEncoder(), null) + const underTarget = await buildFunding(makeEncoder(), UNDER_TARGET_COMMIT_FEE) + assert.ok(legTotal(underTarget) > legTotal(atTarget), + `an under-target commit must over-fund its leg (${legTotal(underTarget)} vs ${legTotal(atTarget)})`) + }) + + it('the reveal keeps the package money as fee instead of sweeping it back', async () => { + const funding = await buildFunding(makeEncoder(), UNDER_TARGET_COMMIT_FEE) + const commitSize = funding.virtualSize() + // What the commit ACTUALLY pays: a sub-floor change would have folded into + // the fee and quietly lifted it back over target. + const commitFee = funding.ins.length * FUNDING_INPUT - funding.outs.reduce((s, o) => s + o.value, 0) + assert.ok(commitFee / commitSize < TARGET_PER_BYTE, + `this probe is only meaningful while the commit is under target (${commitFee / commitSize})`) + + // The node reports the commit exactly as it was broadcast: its real size and + // the under-target fee the caller chose. + const packaged = await buildReveal( + makeEncoder({ size: commitSize, fees: commitFee / SATOSHI_UNIT }), funding) + // Control: the same reveal built against a node that cannot price the commit. + const unpackaged = await buildReveal(makeEncoder(), funding) + + const legs = legTotal(funding) + const packagedFee = legs - packaged.outs.reduce((s, o) => s + o.value, 0) + const unpackagedFee = legs - unpackaged.outs.reduce((s, o) => s + o.value, 0) + assert.ok(packagedFee > unpackagedFee, + `the reveal must keep the package money (${packagedFee} vs ${unpackagedFee})`) + + // The reveal's own priced size, recovered from the control: at exactly + // 1000 koinu/byte an unpackaged fee IS the size in bytes. + const revealSize = unpackagedFee / TARGET_PER_BYTE + const packageRate = (commitFee + packagedFee) / (commitSize + revealSize) + assert.ok(packageRate >= TARGET_PER_BYTE, + `commit+reveal package pays ${packageRate} koinu/byte, under the ${TARGET_PER_BYTE} target`) + + // And it is still not a full burn: the SDK refuses to sign an outputless reveal. + const swept = packaged.outs.reduce((s, o) => s + o.value, 0) + assert.ok(swept > 0, 'the reveal must still leave the caller an output') + }) + +}) + +describe('P2SH two-phase package prefund @regression @tier1', () => { + + it('a confirmed commit asks the reveal for nothing', async () => { + const funding = await buildFunding(makeEncoder(), UNDER_TARGET_COMMIT_FEE) + // An empty package is what the connector reports once the commit confirms; a + // confirmed parent is nobody's ancestor for fee purposes any more. + const confirmed = await buildReveal(makeEncoder({ size: 0, fees: 0 }), funding) + const unpackaged = await buildReveal(makeEncoder(), funding) + assert.strictEqual(confirmed.toHex(), unpackaged.toHex(), + 'a confirmed commit must leave the reveal byte-identical') + }) + + it('degrades to the per-transaction fee when the commit lookup throws', async () => { + const funding = await buildFunding(makeEncoder(), UNDER_TARGET_COMMIT_FEE) + const thrown = await buildReveal(makeEncoder(() => { throw new Error('node RPC exploded') }), funding) + const unpackaged = await buildReveal(makeEncoder(), funding) + assert.strictEqual(thrown.toHex(), unpackaged.toHex()) + }) + +}) + From 5da1692dff89801c921815a2e42952e0c30e2fd1 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 07:40:55 -0700 Subject: [PATCH 115/148] test: split the single instance guard suite into numbered parts by behaviour --- test/unit/single_instance_guard.test.js | 460 +----------------- .../01_acquire_instance_lock.test.js | 397 +++++++++++++++ .../02_is_pid_alive.test.js | 11 + .../03_release_lock_on_signals.test.js | 148 ++++++ 4 files changed, 557 insertions(+), 459 deletions(-) create mode 100644 test/unit/single_instance_guard.test/01_acquire_instance_lock.test.js create mode 100644 test/unit/single_instance_guard.test/02_is_pid_alive.test.js create mode 100644 test/unit/single_instance_guard.test/03_release_lock_on_signals.test.js diff --git a/test/unit/single_instance_guard.test.js b/test/unit/single_instance_guard.test.js index 703ad5e..6a531dc 100644 --- a/test/unit/single_instance_guard.test.js +++ b/test/unit/single_instance_guard.test.js @@ -9,11 +9,8 @@ */ const assert = require('assert') -const fs = require('fs') -const os = require('os') -const path = require('path') -const { assertSingleInstance, acquireInstanceLock, releaseLockOnSignals, isPidAlive } = require('../../src/server/single_instance_guard') +const { assertSingleInstance } = require('../../src/server/single_instance_guard') describe('singleInstanceGuard', function () { @@ -59,459 +56,4 @@ describe('singleInstanceGuard', function () { }) }) - // The lock records { pid, cmd } so a later boot can tell the process that - // took it from whatever holds that pid number now. A bare integer - // is still accepted, because a lock can outlive an upgrade. - function heldPid(file) { - const text = fs.readFileSync(file, 'utf8') - // A bare pid is itself valid JSON, so check for an object before reading - // .pid off it, the same trap the reader in the source has to avoid. - try { - const parsed = JSON.parse(text) - if (parsed && typeof parsed === 'object') return parseInt(parsed.pid, 10) - } catch (e) { /* not JSON */ } - return parseInt(text, 10) - } - function heldCmd(file) { - try { return JSON.parse(fs.readFileSync(file, 'utf8')).cmd } catch (e) { return null } - } - - describe('acquireInstanceLock', function () { - let dir - beforeEach(function () { - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'enc-lock-test-')) - }) - afterEach(function () { - fs.rmSync(dir, { recursive: true, force: true }) - }) - - it('takes the lock, writes our pid, and release removes the file', function () { - const file = path.join(dir, 'a.lock') - const release = acquireInstanceLock(file) - assert.strictEqual(heldPid(file), process.pid) - release() - assert.strictEqual(fs.existsSync(file), false) - release() // idempotent - }) - - it('refuses when a live other process holds the lock', function () { - const file = path.join(dir, 'b.lock') - // PID 1 is alive and is never our own pid, which is all this case - // needs; the holder's IDENTITY is injected because pid 1 is init, not - // an encoder. Before identity was checked this test passed on macOS - // and would have failed on Linux for a reason it never meant to - // assert: no /proc off Linux means "cannot tell" and the guard - // refuses, while on Linux /proc/1/cmdline reads `systemd` and the - // guard now correctly rules it out. Pinning the premise keeps this - // about refusing a REAL holder on every platform. - fs.writeFileSync(file, '1') - assert.throws( - () => acquireInstanceLock(file, {}, { describePid: () => 'node /XChainEncoder/src/api.js' }), - /Another xchain-encoder instance \(pid 1\)/ - ) - // Lock left intact for the real holder. - assert.strictEqual(fs.readFileSync(file, 'utf8'), '1') - }) - - // Same list, second refusal: a same-host conflict is the other way an - // operator meets the constraint, so it names the per-process stores too. - it('names the in-process stores in the lock-conflict refusal', function () { - const file = path.join(dir, 'b-stores.lock') - fs.writeFileSync(file, '1') - let message = '' - try { - acquireInstanceLock(file, {}, { describePid: () => 'node /XChainEncoder/src/api.js' }) - } catch (err) { message = err.message } - for (const store of [/outpoint-reservation/, /recent-build/]) { - assert.match(message, store, 'lock-conflict refusal must name ' + store) - } - }) - - // The platform difference above is worth asserting rather than merely - // designing around, because it is the whole reason the legacy path needs - // an identity test at all. - it('on Linux, rules out init as a lock holder through the real /proc read', function () { - if (process.platform !== 'linux') return this.skip() - const file = path.join(dir, 'b-linux.lock') - fs.writeFileSync(file, '1') // legacy lock naming pid 1 - const release = acquireInstanceLock(file) // real describePid: reads /proc/1/cmdline - assert.strictEqual(heldPid(file), process.pid, 'init is alive but is not an encoder') - release() - }) - - it('off Linux, refuses on the same lock because identity is unknowable', function () { - if (process.platform === 'linux') return this.skip() - const file = path.join(dir, 'b-nonlinux.lock') - fs.writeFileSync(file, '1') - assert.throws(() => acquireInstanceLock(file), /Another xchain-encoder instance \(pid 1\)/) - }) - - it('breaks a stale lock held by a dead pid', function () { - const file = path.join(dir, 'c.lock') - let deadPid = 99999 - while (isPidAlive(deadPid)) deadPid-- - fs.writeFileSync(file, String(deadPid)) - const release = acquireInstanceLock(file) - assert.strictEqual(heldPid(file), process.pid) - release() - }) - - it('breaks a lock with garbage contents', function () { - const file = path.join(dir, 'd.lock') - fs.writeFileSync(file, 'not-a-pid') - const release = acquireInstanceLock(file) - assert.strictEqual(heldPid(file), process.pid) - release() - }) - - it('re-takes a lock recorded under our own pid (crash-restart same pid file)', function () { - const file = path.join(dir, 'e.lock') - fs.writeFileSync(file, String(process.pid)) - const release = acquireInstanceLock(file) - assert.strictEqual(heldPid(file), process.pid) - release() - }) - - // A bare pid is not an identity: the lockfile lives in the - // container's writable layer and survives a restart, and the next boot's - // process tree hands that number to a different live process. Measured on - // the regtest encoder: the lock held node's pid, that number had become - // npm's `sh` wrapper, and the guard refused to boot against npm's own - // shell until the file was removed by hand. - it('[REGRESSION] breaks a lock whose live pid has been REUSED by another process', function () { - const file = path.join(dir, 'reuse.lock') - fs.writeFileSync(file, JSON.stringify({ pid: 1, cmd: 'node /XChainEncoder/src/api.js' })) - const release = acquireInstanceLock(file, {}, { - isPidAlive: () => true, // pid 1 is alive, as it was on the venue - describePid: () => 'sh', // but it is npm's shell now, not the encoder - }) - assert.strictEqual(heldPid(file), process.pid, 'the stale lock is broken and re-taken') - release() - }) - - it('still refuses when the live pid IS the process that took the lock', function () { - // The guard must not become permissive: a genuine second encoder has a - // matching command line and has to be rejected. - const file = path.join(dir, 'real.lock') - const cmd = 'node /XChainEncoder/src/api.js' - fs.writeFileSync(file, JSON.stringify({ pid: 1, cmd })) - assert.throws( - () => acquireInstanceLock(file, {}, { isPidAlive: () => true, describePid: () => cmd }), - /Another xchain-encoder instance \(pid 1\)/ - ) - assert.strictEqual(heldPid(file), 1, 'the real holder keeps its lock') - }) - - it('refuses when identity cannot be established, which is the safe side', function () { - // Off Linux there is no /proc, so describePid returns null and the old - // conservative pid-liveness rule has to stand. - const file = path.join(dir, 'unknown.lock') - fs.writeFileSync(file, JSON.stringify({ pid: 1, cmd: 'node /XChainEncoder/src/api.js' })) - assert.throws( - () => acquireInstanceLock(file, {}, { isPidAlive: () => true, describePid: () => null }), - /Another xchain-encoder instance \(pid 1\)/ - ) - }) - - // A lock written before this change carries no identity. Refusing on it - // outright would reproduce the wedge on the FIRST restart after the - // upgrade, which is exactly when an old bare-pid file is lying around, so - // the holder is ruled out by what it is actually running instead. - // The command lines below are the REAL ones, read out of the running - // regtest encoder container: - // pid 1 npm run api - // pid 18 sh -c node ./src/api.js - // pid 19 node ./src/api.js - // A stale lock most often lands on 18 or 1, and both of those mention - // api.js, so ruling the holder out by substring would have kept the - // container wedged on precisely the case this defends. - const CONTAINER_TREE = { - npm: 'npm run api', - shim: 'sh -c node ./src/api.js', - node: 'node ./src/api.js', - } - - it('[REGRESSION] breaks a legacy bare-pid lock whose holder is npm\'s shell wrapper', function () { - const file = path.join(dir, 'legacy-reused.lock') - fs.writeFileSync(file, '1') - const release = acquireInstanceLock(file, {}, { - isPidAlive: () => true, describePid: () => CONTAINER_TREE.shim, - }) - assert.strictEqual(heldPid(file), process.pid, 'sh -c is not an encoder, however much it mentions one') - release() - }) - - it('[REGRESSION] breaks a legacy bare-pid lock whose holder is the npm parent', function () { - const file = path.join(dir, 'legacy-npm.lock') - fs.writeFileSync(file, '1') - const release = acquireInstanceLock(file, {}, { - isPidAlive: () => true, describePid: () => CONTAINER_TREE.npm, - }) - assert.strictEqual(heldPid(file), process.pid) - release() - }) - - it('breaks a legacy bare-pid lock whose holder is unrelated', function () { - const file = path.join(dir, 'legacy-sh.lock') - fs.writeFileSync(file, '1') - const release = acquireInstanceLock(file, {}, { - isPidAlive: () => true, describePid: () => 'sh', - }) - assert.strictEqual(heldPid(file), process.pid) - release() - }) - - it('refuses on a legacy bare-pid lock whose holder IS running an encoder', function () { - const file = path.join(dir, 'legacy-real.lock') - fs.writeFileSync(file, '1') - assert.throws( - () => acquireInstanceLock(file, {}, { - isPidAlive: () => true, describePid: () => CONTAINER_TREE.node, - }), - /Another xchain-encoder instance \(pid 1\)/ - ) - assert.strictEqual(heldPid(file), 1, 'the real holder keeps its lock') - }) - - it('refuses a real second encoder started under an absolute node path', function () { - const file = path.join(dir, 'legacy-abs.lock') - fs.writeFileSync(file, '1') - assert.throws( - () => acquireInstanceLock(file, {}, { - isPidAlive: () => true, describePid: () => '/usr/local/bin/node /XChainEncoder/src/api.js', - }), - /Another xchain-encoder instance \(pid 1\)/ - ) - }) - - it('refuses on a legacy lock when the holder cannot be described at all', function () { - const file = path.join(dir, 'legacy-unknown.lock') - fs.writeFileSync(file, '1') - assert.throws( - () => acquireInstanceLock(file, {}, { isPidAlive: () => true, describePid: () => null }), - /Another xchain-encoder instance \(pid 1\)/ - ) - }) - - it('records its own identity in the lock, which is what makes reuse detectable', function () { - const file = path.join(dir, 'identity.lock') - const release = acquireInstanceLock(file, {}, { selfDescription: () => 'node /XChainEncoder/src/api.js' }) - assert.strictEqual(heldCmd(file), 'node /XChainEncoder/src/api.js') - release() - }) - - it('honors ENCODER_INSTANCE_LOCK_FILE from env when no path arg given', function () { - const file = path.join(dir, 'env.lock') - const release = acquireInstanceLock(null, { ENCODER_INSTANCE_LOCK_FILE: file }) - assert.strictEqual(fs.existsSync(file), true) - release() - }) - - // Publication is a single link() of a fully written record: finished, or not - // at all. A zero-byte file observable at the lock path reads as pid NaN, which - // fails the Number.isInteger test and so skips the ENTIRE liveness and identity - // block, falling straight through to unlink-and-take: two processes then hold - // the lock, and the first release deletes the other's record. - it('never leaves a half-written record observable at the lock path', function () { - const file = path.join(dir, 'atomic.lock') - const observed = [] - // selfDescription runs INSIDE publication, which is exactly the window - // the old protocol left open, so it doubles as the observation hook. - const release = acquireInstanceLock(file, {}, { - selfDescription: () => { - observed.push(fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null) - return 'node /XChainEncoder/src/api.js' - } - }) - assert.ok(observed.length > 0, 'the observation hook must have run inside publication') - for (const sample of observed) { - if (sample === null) continue // the name does not exist yet: fine - const parsed = JSON.parse(sample) // throws on the zero-byte file - assert.ok(Number.isInteger(parsed.pid), 'a published record must name a pid') - } - release() - }) - - it('leaves no .tmp file behind after an acquire/release cycle', function () { - const file = path.join(dir, 'tmp.lock') - const release = acquireInstanceLock(file) - assert.deepStrictEqual(fs.readdirSync(dir).filter((n) => n.endsWith('.tmp')), []) - release() - assert.deepStrictEqual(fs.readdirSync(dir).filter((n) => n.endsWith('.tmp')), []) - }) - - it('waits out an illegible lock instead of breaking it on sight', function () { - const file = path.join(dir, 'initializing.lock') - // A zero-byte lock is an owner mid-publication on the no-hardlink - // fallback path, not a stale lock, so it must not be unlinked on sight. - // The ELAPSED TIME is the measurement: the bounded re-check has to - // expire before the break, and breaking on sight returns in no time. - fs.writeFileSync(file, '') - const started = Date.now() - const release = acquireInstanceLock(file, {}, { isPidAlive: () => true }) - const elapsed = Date.now() - started - assert.ok(elapsed >= 50, - 'an illegible lock must be re-read before it is judged stale, took ' + elapsed + 'ms') - assert.strictEqual(heldPid(file), process.pid, 'and it is still recoverable afterwards') - release() - }) - - it('release does not delete a lock that has changed hands', function () { - const file = path.join(dir, 'ownership.lock') - const release = acquireInstanceLock(file) - // A successor's record at the same path: a pid-reuse break leaves the - // ruled-stale process alive and still holding a release closure. - const successor = JSON.stringify({ pid: 999999, cmd: 'node other', token: 'f'.repeat(32) }) - fs.writeFileSync(file, successor) - release() - assert.strictEqual(fs.existsSync(file), true, - 'unlinking by path alone is how one owner\'s exit freed another owner\'s lock') - assert.strictEqual(fs.readFileSync(file, 'utf8'), successor) - }) - - it('records an ownership token, which is what release verifies against', function () { - const file = path.join(dir, 'token.lock') - const release = acquireInstanceLock(file) - const record = JSON.parse(fs.readFileSync(file, 'utf8')) - assert.match(record.token, /^[0-9a-f]{32}$/) - release() - assert.strictEqual(fs.existsSync(file), false) - }) - }) - - describe('isPidAlive', function () { - it('reports our own pid alive', function () { - assert.strictEqual(isPidAlive(process.pid), true) - }) - }) - - // The lock is released on the way out through a SIGNAL, because that is how - // this process actually dies: `docker stop` and `docker restart` - // send SIGTERM, and 'exit' does not fire for that. A fake process stands in - // so the assertions do not depend on signalling the test runner. - describe('releaseLockOnSignals', function () { - // Records what the handler did instead of doing it. `alive` models - // whether the re-raise actually killed us: false is the ordinary case, - // true is PID 1, where the kernel drops signals that have no handler. - function fakeProcess({ diesOnReraise = true } = {}) { - const proc = { - pid: 4242, - listeners: {}, - killed: [], - exited: [], - alive: true, - on(sig, fn) { (this.listeners[sig] = this.listeners[sig] || []).push(fn) }, - off(sig, fn) { - this.listeners[sig] = (this.listeners[sig] || []).filter(f => f !== fn) - }, - kill(pid, sig) { - this.killed.push([pid, sig]) - if (diesOnReraise) this.alive = false - }, - exit(code) { this.exited.push(code) }, - raise(sig) { for (const fn of (this.listeners[sig] || []).slice()) fn() } - } - return proc - } - - // The handler arms a RERAISE_GRACE_MS fallback timer. A fixed sleep - // races it: under load the implementation timer can fire after the - // test's own timer, so wait on the OUTCOME instead of on wall clock. - const settleUntil = async (predicate, what) => { - const deadline = Date.now() + 5000 - while (Date.now() < deadline) { - if (predicate()) return - await new Promise(resolve => setTimeout(resolve, 5)) - } - throw new Error('timed out waiting for ' + what) - } - - it('registers a handler for both signals a stop can send', function () { - const proc = fakeProcess() - releaseLockOnSignals(() => {}, proc) - assert.strictEqual(proc.listeners.SIGTERM.length, 1) - assert.strictEqual(proc.listeners.SIGINT.length, 1) - }) - - it('releases the lock on SIGTERM, which is what `docker stop` sends', function () { - const proc = fakeProcess() - let released = 0 - releaseLockOnSignals(() => { released++ }, proc) - proc.raise('SIGTERM') - assert.strictEqual(released, 1) - }) - - it('releases the lock on SIGINT too', function () { - const proc = fakeProcess() - let released = 0 - releaseLockOnSignals(() => { released++ }, proc) - proc.raise('SIGINT') - assert.strictEqual(released, 1) - }) - - it('re-raises the same signal so the death stays a signalled death', function () { - const proc = fakeProcess() - releaseLockOnSignals(() => {}, proc) - proc.raise('SIGTERM') - assert.deepStrictEqual(proc.killed, [[4242, 'SIGTERM']]) - }) - - it('deregisters itself before re-raising, or the re-raise re-enters the handler', function () { - const proc = fakeProcess() - releaseLockOnSignals(() => {}, proc) - proc.raise('SIGTERM') - assert.strictEqual(proc.listeners.SIGTERM.length, 0) - }) - - it('leaves OTHER listeners for the same signal registered', function () { - const proc = fakeProcess() - const gracefulShutdown = () => {} - releaseLockOnSignals(() => {}, proc) - proc.on('SIGTERM', gracefulShutdown) - proc.raise('SIGTERM') - assert.deepStrictEqual(proc.listeners.SIGTERM, [gracefulShutdown]) - }) - - it('does not force an exit when the re-raise did kill the process', function () { - const proc = fakeProcess({ diesOnReraise: true }) - releaseLockOnSignals(() => {}, proc) - proc.raise('SIGTERM') - // raise() runs the handler synchronously, so the re-raise is already - // recorded and there is nothing to wait for; the fallback timer fires - // into nothing. What must not happen is a SECOND, different exit path - // being taken while the signalled death is already in flight. - assert.deepStrictEqual(proc.killed, [[4242, 'SIGTERM']]) - }) - - // 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. - it('forces exit 143 when a re-raised SIGTERM is ignored (PID 1)', async function () { - const proc = fakeProcess({ diesOnReraise: false }) - releaseLockOnSignals(() => {}, proc) - proc.raise('SIGTERM') - await settleUntil(() => proc.exited.length > 0, 'the PID-1 fallback exit') - assert.deepStrictEqual(proc.exited, [143]) - }) - - it('forces exit 130 when a re-raised SIGINT is ignored (PID 1)', async function () { - const proc = fakeProcess({ diesOnReraise: false }) - releaseLockOnSignals(() => {}, proc) - proc.raise('SIGINT') - await settleUntil(() => proc.exited.length > 0, 'the PID-1 fallback exit') - assert.deepStrictEqual(proc.exited, [130]) - }) - - it('defaults to the real process when none is injected', function () { - const before = process.listenerCount('SIGTERM') - releaseLockOnSignals(() => {}) - assert.strictEqual(process.listenerCount('SIGTERM'), before + 1) - // Leave the runner's signal disposition as we found it. - const added = process.listeners('SIGTERM').pop() - process.off('SIGTERM', added) - process.off('SIGINT', process.listeners('SIGINT').pop()) - }) - }) }) diff --git a/test/unit/single_instance_guard.test/01_acquire_instance_lock.test.js b/test/unit/single_instance_guard.test/01_acquire_instance_lock.test.js new file mode 100644 index 0000000..c680324 --- /dev/null +++ b/test/unit/single_instance_guard.test/01_acquire_instance_lock.test.js @@ -0,0 +1,397 @@ +const assert = require('assert') +const fs = require('fs') +const os = require('os') +const path = require('path') + +const { acquireInstanceLock, isPidAlive } = require('../../../src/server/single_instance_guard') + +// The lock records { pid, cmd } so a later boot can tell the process that +// took it from whatever holds that pid number now. A bare integer +// is still accepted, because a lock can outlive an upgrade. +function heldPid(file) { + const text = fs.readFileSync(file, 'utf8') + // A bare pid is itself valid JSON, so check for an object before reading + // .pid off it, the same trap the reader in the source has to avoid. + try { + const parsed = JSON.parse(text) + if (parsed && typeof parsed === 'object') return parseInt(parsed.pid, 10) + } catch (e) { /* not JSON */ } + return parseInt(text, 10) +} + +function heldCmd(file) { + try { return JSON.parse(fs.readFileSync(file, 'utf8')).cmd } catch (e) { return null } +} + +let dir + +function createLockDirectory() { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'enc-lock-test-')) +} + +function removeLockDirectory() { + fs.rmSync(dir, { recursive: true, force: true }) +} + +// A lock written before this change carries no identity. Refusing on it +// outright would reproduce the wedge on the FIRST restart after the +// upgrade, which is exactly when an old bare-pid file is lying around, so +// the holder is ruled out by what it is actually running instead. +// The command lines below are the REAL ones, read out of the running +// regtest encoder container: +// pid 1 npm run api +// pid 18 sh -c node ./src/api.js +// pid 19 node ./src/api.js +// A stale lock most often lands on 18 or 1, and both of those mention +// api.js, so ruling the holder out by substring would have kept the +// container wedged on precisely the case this defends. +const CONTAINER_TREE = { + npm: 'npm run api', + shim: 'sh -c node ./src/api.js', + node: 'node ./src/api.js', +} + +describe('singleInstanceGuard', function () { + describe('acquireInstanceLock', function () { + beforeEach(createLockDirectory) + afterEach(removeLockDirectory) + + it('takes the lock, writes our pid, and release removes the file', function () { + const file = path.join(dir, 'a.lock') + const release = acquireInstanceLock(file) + assert.strictEqual(heldPid(file), process.pid) + release() + assert.strictEqual(fs.existsSync(file), false) + release() // idempotent + }) + + it('refuses when a live other process holds the lock', function () { + const file = path.join(dir, 'b.lock') + // PID 1 is alive and is never our own pid, which is all this case + // needs; the holder's IDENTITY is injected because pid 1 is init, not + // an encoder. Before identity was checked this test passed on macOS + // and would have failed on Linux for a reason it never meant to + // assert: no /proc off Linux means "cannot tell" and the guard + // refuses, while on Linux /proc/1/cmdline reads `systemd` and the + // guard now correctly rules it out. Pinning the premise keeps this + // about refusing a REAL holder on every platform. + fs.writeFileSync(file, '1') + assert.throws( + () => acquireInstanceLock(file, {}, { describePid: () => 'node /XChainEncoder/src/api.js' }), + /Another xchain-encoder instance \(pid 1\)/ + ) + // Lock left intact for the real holder. + assert.strictEqual(fs.readFileSync(file, 'utf8'), '1') + }) + }) +}) + +describe('singleInstanceGuard', function () { + describe('acquireInstanceLock', function () { + beforeEach(createLockDirectory) + afterEach(removeLockDirectory) + + // Same list, second refusal: a same-host conflict is the other way an + // operator meets the constraint, so it names the per-process stores too. + it('names the in-process stores in the lock-conflict refusal', function () { + const file = path.join(dir, 'b-stores.lock') + fs.writeFileSync(file, '1') + let message = '' + try { + acquireInstanceLock(file, {}, { describePid: () => 'node /XChainEncoder/src/api.js' }) + } catch (err) { message = err.message } + for (const store of [/outpoint-reservation/, /recent-build/]) { + assert.match(message, store, 'lock-conflict refusal must name ' + store) + } + }) + + // The platform difference above is worth asserting rather than merely + // designing around, because it is the whole reason the legacy path needs + // an identity test at all. + it('on Linux, rules out init as a lock holder through the real /proc read', function () { + if (process.platform !== 'linux') return this.skip() + const file = path.join(dir, 'b-linux.lock') + fs.writeFileSync(file, '1') // legacy lock naming pid 1 + const release = acquireInstanceLock(file) // real describePid: reads /proc/1/cmdline + assert.strictEqual(heldPid(file), process.pid, 'init is alive but is not an encoder') + release() + }) + + it('off Linux, refuses on the same lock because identity is unknowable', function () { + if (process.platform === 'linux') return this.skip() + const file = path.join(dir, 'b-nonlinux.lock') + fs.writeFileSync(file, '1') + assert.throws(() => acquireInstanceLock(file), /Another xchain-encoder instance \(pid 1\)/) + }) + }) +}) + +describe('singleInstanceGuard', function () { + describe('acquireInstanceLock', function () { + beforeEach(createLockDirectory) + afterEach(removeLockDirectory) + + it('breaks a stale lock held by a dead pid', function () { + const file = path.join(dir, 'c.lock') + let deadPid = 99999 + while (isPidAlive(deadPid)) deadPid-- + fs.writeFileSync(file, String(deadPid)) + const release = acquireInstanceLock(file) + assert.strictEqual(heldPid(file), process.pid) + release() + }) + + it('breaks a lock with garbage contents', function () { + const file = path.join(dir, 'd.lock') + fs.writeFileSync(file, 'not-a-pid') + const release = acquireInstanceLock(file) + assert.strictEqual(heldPid(file), process.pid) + release() + }) + + it('re-takes a lock recorded under our own pid (crash-restart same pid file)', function () { + const file = path.join(dir, 'e.lock') + fs.writeFileSync(file, String(process.pid)) + const release = acquireInstanceLock(file) + assert.strictEqual(heldPid(file), process.pid) + release() + }) + }) +}) + +describe('singleInstanceGuard', function () { + describe('acquireInstanceLock', function () { + beforeEach(createLockDirectory) + afterEach(removeLockDirectory) + + // A bare pid is not an identity: the lockfile lives in the + // container's writable layer and survives a restart, and the next boot's + // process tree hands that number to a different live process. Measured on + // the regtest encoder: the lock held node's pid, that number had become + // npm's `sh` wrapper, and the guard refused to boot against npm's own + // shell until the file was removed by hand. + it('[REGRESSION] breaks a lock whose live pid has been REUSED by another process', function () { + const file = path.join(dir, 'reuse.lock') + fs.writeFileSync(file, JSON.stringify({ pid: 1, cmd: 'node /XChainEncoder/src/api.js' })) + const release = acquireInstanceLock(file, {}, { + isPidAlive: () => true, // pid 1 is alive, as it was on the venue + describePid: () => 'sh', // but it is npm's shell now, not the encoder + }) + assert.strictEqual(heldPid(file), process.pid, 'the stale lock is broken and re-taken') + release() + }) + + it('still refuses when the live pid IS the process that took the lock', function () { + // The guard must not become permissive: a genuine second encoder has a + // matching command line and has to be rejected. + const file = path.join(dir, 'real.lock') + const cmd = 'node /XChainEncoder/src/api.js' + fs.writeFileSync(file, JSON.stringify({ pid: 1, cmd })) + assert.throws( + () => acquireInstanceLock(file, {}, { isPidAlive: () => true, describePid: () => cmd }), + /Another xchain-encoder instance \(pid 1\)/ + ) + assert.strictEqual(heldPid(file), 1, 'the real holder keeps its lock') + }) + + it('refuses when identity cannot be established, which is the safe side', function () { + // Off Linux there is no /proc, so describePid returns null and the old + // conservative pid-liveness rule has to stand. + const file = path.join(dir, 'unknown.lock') + fs.writeFileSync(file, JSON.stringify({ pid: 1, cmd: 'node /XChainEncoder/src/api.js' })) + assert.throws( + () => acquireInstanceLock(file, {}, { isPidAlive: () => true, describePid: () => null }), + /Another xchain-encoder instance \(pid 1\)/ + ) + }) + }) +}) + +describe('singleInstanceGuard', function () { + describe('acquireInstanceLock', function () { + beforeEach(createLockDirectory) + afterEach(removeLockDirectory) + + it('[REGRESSION] breaks a legacy bare-pid lock whose holder is npm\'s shell wrapper', function () { + const file = path.join(dir, 'legacy-reused.lock') + fs.writeFileSync(file, '1') + const release = acquireInstanceLock(file, {}, { + isPidAlive: () => true, describePid: () => CONTAINER_TREE.shim, + }) + assert.strictEqual(heldPid(file), process.pid, 'sh -c is not an encoder, however much it mentions one') + release() + }) + + it('[REGRESSION] breaks a legacy bare-pid lock whose holder is the npm parent', function () { + const file = path.join(dir, 'legacy-npm.lock') + fs.writeFileSync(file, '1') + const release = acquireInstanceLock(file, {}, { + isPidAlive: () => true, describePid: () => CONTAINER_TREE.npm, + }) + assert.strictEqual(heldPid(file), process.pid) + release() + }) + + it('breaks a legacy bare-pid lock whose holder is unrelated', function () { + const file = path.join(dir, 'legacy-sh.lock') + fs.writeFileSync(file, '1') + const release = acquireInstanceLock(file, {}, { + isPidAlive: () => true, describePid: () => 'sh', + }) + assert.strictEqual(heldPid(file), process.pid) + release() + }) + }) +}) + +describe('singleInstanceGuard', function () { + describe('acquireInstanceLock', function () { + beforeEach(createLockDirectory) + afterEach(removeLockDirectory) + + it('refuses on a legacy bare-pid lock whose holder IS running an encoder', function () { + const file = path.join(dir, 'legacy-real.lock') + fs.writeFileSync(file, '1') + assert.throws( + () => acquireInstanceLock(file, {}, { + isPidAlive: () => true, describePid: () => CONTAINER_TREE.node, + }), + /Another xchain-encoder instance \(pid 1\)/ + ) + assert.strictEqual(heldPid(file), 1, 'the real holder keeps its lock') + }) + + it('refuses a real second encoder started under an absolute node path', function () { + const file = path.join(dir, 'legacy-abs.lock') + fs.writeFileSync(file, '1') + assert.throws( + () => acquireInstanceLock(file, {}, { + isPidAlive: () => true, describePid: () => '/usr/local/bin/node /XChainEncoder/src/api.js', + }), + /Another xchain-encoder instance \(pid 1\)/ + ) + }) + + it('refuses on a legacy lock when the holder cannot be described at all', function () { + const file = path.join(dir, 'legacy-unknown.lock') + fs.writeFileSync(file, '1') + assert.throws( + () => acquireInstanceLock(file, {}, { isPidAlive: () => true, describePid: () => null }), + /Another xchain-encoder instance \(pid 1\)/ + ) + }) + }) +}) + +describe('singleInstanceGuard', function () { + describe('acquireInstanceLock', function () { + beforeEach(createLockDirectory) + afterEach(removeLockDirectory) + + it('records its own identity in the lock, which is what makes reuse detectable', function () { + const file = path.join(dir, 'identity.lock') + const release = acquireInstanceLock(file, {}, { selfDescription: () => 'node /XChainEncoder/src/api.js' }) + assert.strictEqual(heldCmd(file), 'node /XChainEncoder/src/api.js') + release() + }) + + it('honors ENCODER_INSTANCE_LOCK_FILE from env when no path arg given', function () { + const file = path.join(dir, 'env.lock') + const release = acquireInstanceLock(null, { ENCODER_INSTANCE_LOCK_FILE: file }) + assert.strictEqual(fs.existsSync(file), true) + release() + }) + }) +}) + +describe('singleInstanceGuard', function () { + describe('acquireInstanceLock', function () { + beforeEach(createLockDirectory) + afterEach(removeLockDirectory) + + // Publication is a single link() of a fully written record: finished, or not + // at all. A zero-byte file observable at the lock path reads as pid NaN, which + // fails the Number.isInteger test and so skips the ENTIRE liveness and identity + // block, falling straight through to unlink-and-take: two processes then hold + // the lock, and the first release deletes the other's record. + it('never leaves a half-written record observable at the lock path', function () { + const file = path.join(dir, 'atomic.lock') + const observed = [] + // selfDescription runs INSIDE publication, which is exactly the window + // the old protocol left open, so it doubles as the observation hook. + const release = acquireInstanceLock(file, {}, { + selfDescription: () => { + observed.push(fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null) + return 'node /XChainEncoder/src/api.js' + } + }) + assert.ok(observed.length > 0, 'the observation hook must have run inside publication') + for (const sample of observed) { + if (sample === null) continue // the name does not exist yet: fine + const parsed = JSON.parse(sample) // throws on the zero-byte file + assert.ok(Number.isInteger(parsed.pid), 'a published record must name a pid') + } + release() + }) + }) +}) + +describe('singleInstanceGuard', function () { + describe('acquireInstanceLock', function () { + beforeEach(createLockDirectory) + afterEach(removeLockDirectory) + + it('leaves no .tmp file behind after an acquire/release cycle', function () { + const file = path.join(dir, 'tmp.lock') + const release = acquireInstanceLock(file) + assert.deepStrictEqual(fs.readdirSync(dir).filter((n) => n.endsWith('.tmp')), []) + release() + assert.deepStrictEqual(fs.readdirSync(dir).filter((n) => n.endsWith('.tmp')), []) + }) + + it('waits out an illegible lock instead of breaking it on sight', function () { + const file = path.join(dir, 'initializing.lock') + // A zero-byte lock is an owner mid-publication on the no-hardlink + // fallback path, not a stale lock, so it must not be unlinked on sight. + // The ELAPSED TIME is the measurement: the bounded re-check has to + // expire before the break, and breaking on sight returns in no time. + fs.writeFileSync(file, '') + const started = Date.now() + const release = acquireInstanceLock(file, {}, { isPidAlive: () => true }) + const elapsed = Date.now() - started + assert.ok(elapsed >= 50, + 'an illegible lock must be re-read before it is judged stale, took ' + elapsed + 'ms') + assert.strictEqual(heldPid(file), process.pid, 'and it is still recoverable afterwards') + release() + }) + }) +}) + +describe('singleInstanceGuard', function () { + describe('acquireInstanceLock', function () { + beforeEach(createLockDirectory) + afterEach(removeLockDirectory) + + it('release does not delete a lock that has changed hands', function () { + const file = path.join(dir, 'ownership.lock') + const release = acquireInstanceLock(file) + // A successor's record at the same path: a pid-reuse break leaves the + // ruled-stale process alive and still holding a release closure. + const successor = JSON.stringify({ pid: 999999, cmd: 'node other', token: 'f'.repeat(32) }) + fs.writeFileSync(file, successor) + release() + assert.strictEqual(fs.existsSync(file), true, + 'unlinking by path alone is how one owner\'s exit freed another owner\'s lock') + assert.strictEqual(fs.readFileSync(file, 'utf8'), successor) + }) + + it('records an ownership token, which is what release verifies against', function () { + const file = path.join(dir, 'token.lock') + const release = acquireInstanceLock(file) + const record = JSON.parse(fs.readFileSync(file, 'utf8')) + assert.match(record.token, /^[0-9a-f]{32}$/) + release() + assert.strictEqual(fs.existsSync(file), false) + }) + }) +}) diff --git a/test/unit/single_instance_guard.test/02_is_pid_alive.test.js b/test/unit/single_instance_guard.test/02_is_pid_alive.test.js new file mode 100644 index 0000000..d5ec83d --- /dev/null +++ b/test/unit/single_instance_guard.test/02_is_pid_alive.test.js @@ -0,0 +1,11 @@ +const assert = require('assert') + +const { isPidAlive } = require('../../../src/server/single_instance_guard') + +describe('singleInstanceGuard', function () { + describe('isPidAlive', function () { + it('reports our own pid alive', function () { + assert.strictEqual(isPidAlive(process.pid), true) + }) + }) +}) diff --git a/test/unit/single_instance_guard.test/03_release_lock_on_signals.test.js b/test/unit/single_instance_guard.test/03_release_lock_on_signals.test.js new file mode 100644 index 0000000..87e2a0b --- /dev/null +++ b/test/unit/single_instance_guard.test/03_release_lock_on_signals.test.js @@ -0,0 +1,148 @@ +const assert = require('assert') + +const { releaseLockOnSignals } = require('../../../src/server/single_instance_guard') + +// The lock is released on the way out through a SIGNAL, because that is how +// this process actually dies: `docker stop` and `docker restart` +// send SIGTERM, and 'exit' does not fire for that. A fake process stands in +// so the assertions do not depend on signalling the test runner. + +// Records what the handler did instead of doing it. `alive` models +// whether the re-raise actually killed us: false is the ordinary case, +// true is PID 1, where the kernel drops signals that have no handler. +function fakeProcess({ diesOnReraise = true } = {}) { + const proc = { + pid: 4242, + listeners: {}, + killed: [], + exited: [], + alive: true, + on(sig, fn) { (this.listeners[sig] = this.listeners[sig] || []).push(fn) }, + off(sig, fn) { + this.listeners[sig] = (this.listeners[sig] || []).filter(f => f !== fn) + }, + kill(pid, sig) { + this.killed.push([pid, sig]) + if (diesOnReraise) this.alive = false + }, + exit(code) { this.exited.push(code) }, + raise(sig) { for (const fn of (this.listeners[sig] || []).slice()) fn() } + } + return proc +} + +// The handler arms a RERAISE_GRACE_MS fallback timer. A fixed sleep +// races it: under load the implementation timer can fire after the +// test's own timer, so wait on the OUTCOME instead of on wall clock. +const settleUntil = async (predicate, what) => { + const deadline = Date.now() + 5000 + while (Date.now() < deadline) { + if (predicate()) return + await new Promise(resolve => setTimeout(resolve, 5)) + } + throw new Error('timed out waiting for ' + what) +} + +describe('singleInstanceGuard', function () { + describe('releaseLockOnSignals', function () { + it('registers a handler for both signals a stop can send', function () { + const proc = fakeProcess() + releaseLockOnSignals(() => {}, proc) + assert.strictEqual(proc.listeners.SIGTERM.length, 1) + assert.strictEqual(proc.listeners.SIGINT.length, 1) + }) + + it('releases the lock on SIGTERM, which is what `docker stop` sends', function () { + const proc = fakeProcess() + let released = 0 + releaseLockOnSignals(() => { released++ }, proc) + proc.raise('SIGTERM') + assert.strictEqual(released, 1) + }) + + it('releases the lock on SIGINT too', function () { + const proc = fakeProcess() + let released = 0 + releaseLockOnSignals(() => { released++ }, proc) + proc.raise('SIGINT') + assert.strictEqual(released, 1) + }) + + it('re-raises the same signal so the death stays a signalled death', function () { + const proc = fakeProcess() + releaseLockOnSignals(() => {}, proc) + proc.raise('SIGTERM') + assert.deepStrictEqual(proc.killed, [[4242, 'SIGTERM']]) + }) + }) +}) + +describe('singleInstanceGuard', function () { + describe('releaseLockOnSignals', function () { + it('deregisters itself before re-raising, or the re-raise re-enters the handler', function () { + const proc = fakeProcess() + releaseLockOnSignals(() => {}, proc) + proc.raise('SIGTERM') + assert.strictEqual(proc.listeners.SIGTERM.length, 0) + }) + + it('leaves OTHER listeners for the same signal registered', function () { + const proc = fakeProcess() + const gracefulShutdown = () => {} + releaseLockOnSignals(() => {}, proc) + proc.on('SIGTERM', gracefulShutdown) + proc.raise('SIGTERM') + assert.deepStrictEqual(proc.listeners.SIGTERM, [gracefulShutdown]) + }) + + it('does not force an exit when the re-raise did kill the process', function () { + const proc = fakeProcess({ diesOnReraise: true }) + releaseLockOnSignals(() => {}, proc) + proc.raise('SIGTERM') + // raise() runs the handler synchronously, so the re-raise is already + // recorded and there is nothing to wait for; the fallback timer fires + // into nothing. What must not happen is a SECOND, different exit path + // being taken while the signalled death is already in flight. + assert.deepStrictEqual(proc.killed, [[4242, 'SIGTERM']]) + }) + }) +}) + +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. + it('forces exit 143 when a re-raised SIGTERM is ignored (PID 1)', async function () { + const proc = fakeProcess({ diesOnReraise: false }) + releaseLockOnSignals(() => {}, proc) + proc.raise('SIGTERM') + await settleUntil(() => proc.exited.length > 0, 'the PID-1 fallback exit') + assert.deepStrictEqual(proc.exited, [143]) + }) + + it('forces exit 130 when a re-raised SIGINT is ignored (PID 1)', async function () { + const proc = fakeProcess({ diesOnReraise: false }) + releaseLockOnSignals(() => {}, proc) + proc.raise('SIGINT') + await settleUntil(() => proc.exited.length > 0, 'the PID-1 fallback exit') + assert.deepStrictEqual(proc.exited, [130]) + }) + }) +}) + +describe('singleInstanceGuard', function () { + describe('releaseLockOnSignals', function () { + it('defaults to the real process when none is injected', function () { + const before = process.listenerCount('SIGTERM') + releaseLockOnSignals(() => {}) + assert.strictEqual(process.listenerCount('SIGTERM'), before + 1) + // Leave the runner's signal disposition as we found it. + const added = process.listeners('SIGTERM').pop() + process.off('SIGTERM', added) + process.off('SIGINT', process.listeners('SIGINT').pop()) + }) + }) +}) From 657190fb8d04ef1d03e48c561cb1262c9ef25802 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 07:40:55 -0700 Subject: [PATCH 116/148] test: split the utxo tracker client suite into numbered parts by behaviour --- test/unit/utxo_tracker.test.js | 556 ------------------ .../01_get_sync_status.test.js | 104 ++++ .../02_get_utxos_from_address.test.js | 286 +++++++++ ..._get_utxos_from_address_validation.test.js | 217 +++++++ .../unit/utxo_tracker.test/helpers/support.js | 79 +++ 5 files changed, 686 insertions(+), 556 deletions(-) create mode 100644 test/unit/utxo_tracker.test/01_get_sync_status.test.js create mode 100644 test/unit/utxo_tracker.test/02_get_utxos_from_address.test.js create mode 100644 test/unit/utxo_tracker.test/03_get_utxos_from_address_validation.test.js create mode 100644 test/unit/utxo_tracker.test/helpers/support.js diff --git a/test/unit/utxo_tracker.test.js b/test/unit/utxo_tracker.test.js index 52cf745..68fb8a9 100644 --- a/test/unit/utxo_tracker.test.js +++ b/test/unit/utxo_tracker.test.js @@ -9,66 +9,8 @@ // contact legal@dankest.llc. const assert = require('assert') -const axios = require('axios') const UtxoTracker = require('../../src/build/utxo_tracker') -function makeTracker () { - return new UtxoTracker('127.0.0.1', 18420) -} - -let originalPost - -beforeEach(() => { - originalPost = axios.post -}) - -afterEach(() => { - axios.post = originalPost -}) - -function stubAxiosPost (response) { - axios.post = async () => response -} - -function stubAxiosPostThrow (err) { - axios.post = async () => { throw err } -} - -// A minimal valid UTXO fixture -function makeUtxo (overrides) { - return Object.assign({ - txid: 'a'.repeat(64), - vout: 0, - value: 50000000, - scriptPubKey: '76a914' + 'ab'.repeat(20) + '88ac', - confirmations: 6 - }, overrides) -} - -// A stub that returns synced status first, then UTXOs -function stubSyncedThenUtxos (utxos) { - let callCount = 0 - axios.post = async (url, data) => { - callCount++ - if (callCount === 1) { - // getSyncStatus call - return { - data: { - result: { - committed_height: 100, - tracker_height: 100, - node_height: 100, - lag: 0, - synced: true - } - } - } - } - // get_utxos call - return { data: { result: { utxos } } } - } -} - describe('UtxoTracker constructor', () => { it('builds the correct URL from host and port', () => { const t = new UtxoTracker('myhost', 12345) @@ -80,501 +22,3 @@ describe('UtxoTracker constructor', () => { assert.strictEqual(t.port, 9999) }) }) - -describe('UtxoTracker.getSyncStatus()', () => { - it('sends get_sync_status JSON-RPC method', async () => { - let capturedPayload - axios.post = async (url, data) => { - capturedPayload = data - return { data: { result: { lag: 0, synced: true } } } - } - const t = makeTracker() - await t.getSyncStatus() - assert.strictEqual(capturedPayload.method, 'get_sync_status') - assert.strictEqual(capturedPayload.jsonrpc, '2.0') - }) - - it('sends params as an empty object', async () => { - let capturedPayload - axios.post = async (url, data) => { - capturedPayload = data - return { data: { result: { lag: 0, synced: true } } } - } - const t = makeTracker() - await t.getSyncStatus() - assert.deepStrictEqual(capturedPayload.params, {}) - }) - - it('returns the result object on success', async () => { - const expectedResult = { committed_height: 500, tracker_height: 500, node_height: 500, lag: 0, synced: true } - stubAxiosPost({ data: { result: expectedResult } }) - const t = makeTracker() - const status = await t.getSyncStatus() - assert.deepStrictEqual(status, expectedResult) - }) - - it('throws when result is null', async () => { - stubAxiosPost({ data: { result: null } }) - const t = makeTracker() - await assert.rejects( - () => t.getSyncStatus(), - /Error getting sync status: empty result/ - ) - }) - - it('throws when result is a non-object (string)', async () => { - stubAxiosPost({ data: { result: 'bad' } }) - const t = makeTracker() - await assert.rejects( - () => t.getSyncStatus(), - /Error getting sync status: empty result/ - ) - }) - - it('throws when result is missing entirely', async () => { - stubAxiosPost({ data: {} }) - const t = makeTracker() - await assert.rejects( - () => t.getSyncStatus(), - /Error getting sync status: empty result/ - ) - }) - - it('propagates transport errors', async () => { - stubAxiosPostThrow(new Error('ECONNREFUSED')) - const t = makeTracker() - await assert.rejects( - () => t.getSyncStatus(), - /ECONNREFUSED/ - ) - }) - - it('sets a timeout on the request', async () => { - let capturedOptions - axios.post = async (url, data, options) => { - capturedOptions = options - return { data: { result: { lag: 0, synced: true } } } - } - const t = makeTracker() - await t.getSyncStatus() - assert.ok(typeof capturedOptions.timeout === 'number' && capturedOptions.timeout > 0) - }) -}) - -describe('UtxoTracker.getUtxosFromAddress()', () => { - const ADDRESS = 'mhqpGkU1tUYKFrmtFDXEcBiMqzaZTbEPxX' - - it('calls getSyncStatus before fetching UTXOs', async () => { - let syncCalled = false - let utxosCalled = false - let callCount = 0 - axios.post = async (url, data) => { - callCount++ - if (callCount === 1) { - syncCalled = true - return { data: { result: { lag: 0, synced: true } } } - } - utxosCalled = true - return { data: { result: { utxos: [makeUtxo()] } } } - } - const t = makeTracker() - await t.getUtxosFromAddress(ADDRESS) - assert.strictEqual(syncCalled, true, 'getSyncStatus should be called first') - assert.strictEqual(utxosCalled, true, 'get_utxos should be called second') - }) - - it('sends get_utxos with the address param', async () => { - let capturedPayload - let callCount = 0 - axios.post = async (url, data) => { - callCount++ - if (callCount === 1) return { data: { result: { lag: 0, synced: true } } } - capturedPayload = data - return { data: { result: { utxos: [makeUtxo()] } } } - } - const t = makeTracker() - await t.getUtxosFromAddress(ADDRESS) - assert.strictEqual(capturedPayload.method, 'get_utxos') - assert.strictEqual(capturedPayload.params.address, ADDRESS) - assert.strictEqual(typeof capturedPayload.params.limit, 'number', 'params must include a numeric limit for pagination') - }) - - it('returns the result object on success', async () => { - const utxo = makeUtxo() - stubSyncedThenUtxos([utxo]) - const t = makeTracker() - const result = await t.getUtxosFromAddress(ADDRESS) - assert.ok(Array.isArray(result.utxos)) - assert.strictEqual(result.utxos.length, 1) - assert.strictEqual(result.utxos[0].txid, utxo.txid) - }) - - it('defaults confirmations to 0 when null', async () => { - const utxo = makeUtxo({ confirmations: null }) - stubSyncedThenUtxos([utxo]) - const t = makeTracker() - const result = await t.getUtxosFromAddress(ADDRESS) - assert.strictEqual(result.utxos[0].confirmations, 0) - }) - - it('defaults confirmations to 0 when undefined', async () => { - const utxo = makeUtxo() - delete utxo.confirmations - stubSyncedThenUtxos([utxo]) - const t = makeTracker() - const result = await t.getUtxosFromAddress(ADDRESS) - assert.strictEqual(result.utxos[0].confirmations, 0) - }) - - it('preserves existing confirmations when > 0', async () => { - const utxo = makeUtxo({ confirmations: 10 }) - stubSyncedThenUtxos([utxo]) - const t = makeTracker() - const result = await t.getUtxosFromAddress(ADDRESS) - assert.strictEqual(result.utxos[0].confirmations, 10) - }) - - it('coerces numeric-string confirmations and rejects non-integer values', async () => { - stubSyncedThenUtxos([makeUtxo({ confirmations: '7' })]) - let t = makeTracker() - let result = await t.getUtxosFromAddress(ADDRESS) - assert.strictEqual(result.utxos[0].confirmations, 7) - - for (const bad of [1.5, -1, 'abc', {}]) { - stubSyncedThenUtxos([makeUtxo({ confirmations: bad })]) - t = makeTracker() - await assert.rejects( - () => t.getUtxosFromAddress(ADDRESS), - /confirmations must be a non-negative integer/ - ) - } - }) - - it('throws when tracker is not synced (synced=false)', async () => { - let callCount = 0 - axios.post = async () => { - callCount++ - if (callCount === 1) { - return { data: { result: { lag: 10, synced: false } } } - } - return { data: { result: { utxos: [] } } } - } - const t = makeTracker() - await assert.rejects( - () => t.getUtxosFromAddress(ADDRESS), - /lagging by 10 blocks/ - ) - }) - - // halted is published independently of synced, so a tracker frozen on - // an unrecoverable reorg whose frozen height still shows an acceptable lag passed - // this preflight and its rolled-back UTXOs reached input selection. - it('throws when the tracker is halted, even at lag 0 with synced=true', async () => { - let callCount = 0 - axios.post = async () => { - callCount++ - if (callCount === 1) { - return { data: { result: { lag: 0, synced: true, halted: true, halt_reason: 'rolled back past the recovery window' } } } - } - return { data: { result: { utxos: [] } } } - } - const t = makeTracker() - await assert.rejects( - () => t.getUtxosFromAddress(ADDRESS), - /halted \(rolled back past the recovery window\)/ - ) - }) - - // A negative lag means the node reset or reindexed below our committed - // tip, so the outputs we would select live in blocks it no longer recognizes. The - // encoder checks it itself because it fails open on trackers lacking the fix. - it('throws when the tracker is ahead of the node (negative lag)', async () => { - let callCount = 0 - axios.post = async () => { - callCount++ - if (callCount === 1) { - return { data: { result: { lag: -100, synced: true } } } - } - return { data: { result: { utxos: [] } } } - } - const t = makeTracker() - await assert.rejects( - () => t.getUtxosFromAddress(ADDRESS), - /100 blocks ahead of the node/ - ) - }) - - // Block sync flips true before the first mempool rebuild finishes, so at - // lag 0 with synced:true the tracker can still hand back a confirmed output already - // spent in the node's mempool, which its empty index cannot filter. - it('throws when the tracker has not reconverged its mempool, even at lag 0', async () => { - let callCount = 0 - axios.post = async () => { - callCount++ - if (callCount === 1) { - return { data: { result: { lag: 0, synced: true, mempool_ready: false } } } - } - return { data: { result: { utxos: [] } } } - } - const t = makeTracker() - await assert.rejects( - () => t.getUtxosFromAddress(ADDRESS), - /has not reconverged its mempool/ - ) - }) - - // Refusal-cause attribution. get_sync_status derives mempool_ready as - // `synced && isMempoolReconverged()`, so a tracker that is merely lagging publishes - // mempool_ready:false as well and BOTH gates are eligible. The operator must be told - // the block lag, which is actionable and self-clearing, not sent hunting a mempool - // rebuild that is not the fault. Pins the ordering, so moving the readiness gate back - // above the sync gate reddens here. - it('names the block lag, not mempool reconvergence, when a lagging tracker de-asserts both', async () => { - let callCount = 0 - axios.post = async () => { - callCount++ - if (callCount === 1) { - return { data: { result: { lag: 50, synced: false, mempool_ready: false } } } - } - return { data: { result: { utxos: [] } } } - } - const t = makeTracker() - await assert.rejects( - () => t.getUtxosFromAddress(ADDRESS), - /lagging by 50 blocks/ - ) - }) - - // Fail-open parity with create_tx: a tracker predating the field omits it entirely - // and must still be fetchable, or every un-upgraded tracker in the fleet goes dark. - it('fetches normally from a tracker that omits mempool_ready', async () => { - let callCount = 0 - axios.post = async () => { - callCount++ - if (callCount === 1) { - return { data: { result: { lag: 0, synced: true } } } - } - return { data: { result: { utxos: [] } } } - } - const t = makeTracker() - assert.deepStrictEqual(await t.getUtxosFromAddress(ADDRESS), { utxos: [] }) - }) - - it('throws when lag is null (tracker has not indexed any blocks)', async () => { - let callCount = 0 - axios.post = async () => { - callCount++ - if (callCount === 1) { - return { data: { result: { lag: null, synced: false } } } - } - return { data: { result: { utxos: [] } } } - } - const t = makeTracker() - await assert.rejects( - () => t.getUtxosFromAddress(ADDRESS), - /has not indexed any blocks/ - ) - }) - - it('throws when lag is undefined', async () => { - let callCount = 0 - axios.post = async () => { - callCount++ - if (callCount === 1) { - return { data: { result: { synced: false } } } - } - return { data: { result: { utxos: [] } } } - } - const t = makeTracker() - await assert.rejects( - () => t.getUtxosFromAddress(ADDRESS), - /has not indexed any blocks/ - ) - }) - - it('throws when getSyncStatus transport fails', async () => { - stubAxiosPostThrow(new Error('tracker offline')) - const t = makeTracker() - await assert.rejects( - () => t.getUtxosFromAddress(ADDRESS), - /tracker offline/ - ) - }) - - it('throws when UTXO result is missing', async () => { - let callCount = 0 - axios.post = async () => { - callCount++ - if (callCount === 1) return { data: { result: { lag: 0, synced: true } } } - return { data: { result: null } } - } - const t = makeTracker() - await assert.rejects( - () => t.getUtxosFromAddress(ADDRESS), - /Error getting utxos: empty result/ - ) - }) - - it('throws when UTXO result is not an object', async () => { - let callCount = 0 - axios.post = async () => { - callCount++ - if (callCount === 1) return { data: { result: { lag: 0, synced: true } } } - return { data: { result: 'bad' } } - } - const t = makeTracker() - await assert.rejects( - () => t.getUtxosFromAddress(ADDRESS), - /Error getting utxos: empty result/ - ) - }) - - it('throws TypeError when utxos is not an array', async () => { - let callCount = 0 - axios.post = async () => { - callCount++ - if (callCount === 1) return { data: { result: { lag: 0, synced: true } } } - return { data: { result: { utxos: 'not-an-array' } } } - } - const t = makeTracker() - await assert.rejects( - () => t.getUtxosFromAddress(ADDRESS), - { name: 'TypeError', message: /UTXO tracker result missing utxos array/ } - ) - }) - - it('throws TypeError for malformed UTXO (missing txid)', async () => { - const badUtxo = makeUtxo() - delete badUtxo.txid - stubSyncedThenUtxos([badUtxo]) - const t = makeTracker() - await assert.rejects( - () => t.getUtxosFromAddress(ADDRESS), - { name: 'TypeError', message: /malformed utxo at index 0/ } - ) - }) - - it('throws TypeError for malformed UTXO (txid not a string)', async () => { - const badUtxo = makeUtxo({ txid: 12345 }) - stubSyncedThenUtxos([badUtxo]) - const t = makeTracker() - await assert.rejects( - () => t.getUtxosFromAddress(ADDRESS), - { name: 'TypeError', message: /malformed utxo at index 0/ } - ) - }) - - it('throws TypeError when txid is not a 64-char hex string (too short)', async () => { - const badUtxo = makeUtxo({ txid: 'abc123' }) - stubSyncedThenUtxos([badUtxo]) - const t = makeTracker() - await assert.rejects( - () => t.getUtxosFromAddress(ADDRESS), - { name: 'TypeError', message: /64-character hex string/ } - ) - }) - - it('throws TypeError when txid is 64 chars but not hex', async () => { - const badUtxo = makeUtxo({ txid: 'z'.repeat(64) }) - stubSyncedThenUtxos([badUtxo]) - const t = makeTracker() - await assert.rejects( - () => t.getUtxosFromAddress(ADDRESS), - { name: 'TypeError', message: /64-character hex string/ } - ) - }) - - it('throws TypeError for malformed UTXO (missing vout)', async () => { - const badUtxo = makeUtxo() - delete badUtxo.vout - stubSyncedThenUtxos([badUtxo]) - const t = makeTracker() - await assert.rejects( - () => t.getUtxosFromAddress(ADDRESS), - { name: 'TypeError', message: /malformed utxo at index 0/ } - ) - }) - - it('throws TypeError for malformed UTXO (missing value)', async () => { - const badUtxo = makeUtxo() - delete badUtxo.value - stubSyncedThenUtxos([badUtxo]) - const t = makeTracker() - await assert.rejects( - () => t.getUtxosFromAddress(ADDRESS), - { name: 'TypeError', message: /malformed utxo at index 0/ } - ) - }) - - it('throws TypeError when scriptPubKey is missing', async () => { - const badUtxo = makeUtxo() - delete badUtxo.scriptPubKey - stubSyncedThenUtxos([badUtxo]) - const t = makeTracker() - await assert.rejects( - () => t.getUtxosFromAddress(ADDRESS), - { name: 'TypeError', message: /scriptPubKey must be a non-empty string/ } - ) - }) - - it('throws TypeError when scriptPubKey is an empty string', async () => { - const badUtxo = makeUtxo({ scriptPubKey: '' }) - stubSyncedThenUtxos([badUtxo]) - const t = makeTracker() - await assert.rejects( - () => t.getUtxosFromAddress(ADDRESS), - { name: 'TypeError', message: /scriptPubKey must be a non-empty string/ } - ) - }) - - it('throws TypeError when a UTXO element is null', async () => { - stubSyncedThenUtxos([null]) - const t = makeTracker() - await assert.rejects( - () => t.getUtxosFromAddress(ADDRESS), - { name: 'TypeError', message: /malformed utxo at index 0/ } - ) - }) - - it('accepts an empty utxos array', async () => { - stubSyncedThenUtxos([]) - const t = makeTracker() - const result = await t.getUtxosFromAddress(ADDRESS) - assert.deepStrictEqual(result.utxos, []) - }) - - it('handles multiple valid UTXOs correctly', async () => { - const utxo1 = makeUtxo({ txid: 'a'.repeat(64), vout: 0, value: 10000 }) - const utxo2 = makeUtxo({ txid: 'b'.repeat(64), vout: 1, value: 20000 }) - stubSyncedThenUtxos([utxo1, utxo2]) - const t = makeTracker() - const result = await t.getUtxosFromAddress(ADDRESS) - assert.strictEqual(result.utxos.length, 2) - assert.strictEqual(result.utxos[1].txid, 'b'.repeat(64)) - }) - - it('reports the correct index in malformed UTXO error for second item', async () => { - const good = makeUtxo({ txid: 'a'.repeat(64) }) - const bad = makeUtxo({ txid: 'short' }) - stubSyncedThenUtxos([good, bad]) - const t = makeTracker() - await assert.rejects( - () => t.getUtxosFromAddress(ADDRESS), - /index 1/ - ) - }) - - it('rethrows get_utxos transport errors', async () => { - let callCount = 0 - axios.post = async () => { - callCount++ - if (callCount === 1) return { data: { result: { lag: 0, synced: true } } } - throw new Error('Connection reset by peer') - } - const t = makeTracker() - await assert.rejects( - () => t.getUtxosFromAddress(ADDRESS), - /Connection reset by peer/ - ) - }) -}) diff --git a/test/unit/utxo_tracker.test/01_get_sync_status.test.js b/test/unit/utxo_tracker.test/01_get_sync_status.test.js new file mode 100644 index 0000000..00ab136 --- /dev/null +++ b/test/unit/utxo_tracker.test/01_get_sync_status.test.js @@ -0,0 +1,104 @@ +// 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, + installAxiosHooks, + makeTracker, + stubAxiosPost, + stubAxiosPostThrow +} = require('./helpers/support') + +describe('UtxoTracker.getSyncStatus()', () => { + installAxiosHooks() + + it('sends get_sync_status JSON-RPC method', async () => { + let capturedPayload + axios.post = async (url, data) => { + capturedPayload = data + return { data: { result: { lag: 0, synced: true } } } + } + const t = makeTracker() + await t.getSyncStatus() + assert.strictEqual(capturedPayload.method, 'get_sync_status') + assert.strictEqual(capturedPayload.jsonrpc, '2.0') + }) + + it('sends params as an empty object', async () => { + let capturedPayload + axios.post = async (url, data) => { + capturedPayload = data + return { data: { result: { lag: 0, synced: true } } } + } + const t = makeTracker() + await t.getSyncStatus() + assert.deepStrictEqual(capturedPayload.params, {}) + }) + + it('returns the result object on success', async () => { + const expectedResult = { committed_height: 500, tracker_height: 500, node_height: 500, lag: 0, synced: true } + stubAxiosPost({ data: { result: expectedResult } }) + const t = makeTracker() + const status = await t.getSyncStatus() + assert.deepStrictEqual(status, expectedResult) + }) +}) + +describe('UtxoTracker.getSyncStatus()', () => { + installAxiosHooks() + + it('throws when result is null', async () => { + stubAxiosPost({ data: { result: null } }) + const t = makeTracker() + await assert.rejects( + () => t.getSyncStatus(), + /Error getting sync status: empty result/ + ) + }) + + it('throws when result is a non-object (string)', async () => { + stubAxiosPost({ data: { result: 'bad' } }) + const t = makeTracker() + await assert.rejects( + () => t.getSyncStatus(), + /Error getting sync status: empty result/ + ) + }) + + it('throws when result is missing entirely', async () => { + stubAxiosPost({ data: {} }) + const t = makeTracker() + await assert.rejects( + () => t.getSyncStatus(), + /Error getting sync status: empty result/ + ) + }) + + it('propagates transport errors', async () => { + stubAxiosPostThrow(new Error('ECONNREFUSED')) + const t = makeTracker() + await assert.rejects( + () => t.getSyncStatus(), + /ECONNREFUSED/ + ) + }) + + it('sets a timeout on the request', async () => { + let capturedOptions + axios.post = async (url, data, options) => { + capturedOptions = options + return { data: { result: { lag: 0, synced: true } } } + } + const t = makeTracker() + await t.getSyncStatus() + assert.ok(typeof capturedOptions.timeout === 'number' && capturedOptions.timeout > 0) + }) +}) diff --git a/test/unit/utxo_tracker.test/02_get_utxos_from_address.test.js b/test/unit/utxo_tracker.test/02_get_utxos_from_address.test.js new file mode 100644 index 0000000..1430b26 --- /dev/null +++ b/test/unit/utxo_tracker.test/02_get_utxos_from_address.test.js @@ -0,0 +1,286 @@ +// 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, + installAxiosHooks, + makeTracker, + makeUtxo, + stubAxiosPostThrow, + stubSyncedThenUtxos +} = require('./helpers/support') + +const ADDRESS = 'mhqpGkU1tUYKFrmtFDXEcBiMqzaZTbEPxX' + +describe('UtxoTracker.getUtxosFromAddress()', () => { + installAxiosHooks() + + it('calls getSyncStatus before fetching UTXOs', async () => { + let syncCalled = false + let utxosCalled = false + let callCount = 0 + axios.post = async (url, data) => { + callCount++ + if (callCount === 1) { + syncCalled = true + return { data: { result: { lag: 0, synced: true } } } + } + utxosCalled = true + return { data: { result: { utxos: [makeUtxo()] } } } + } + const t = makeTracker() + await t.getUtxosFromAddress(ADDRESS) + assert.strictEqual(syncCalled, true, 'getSyncStatus should be called first') + assert.strictEqual(utxosCalled, true, 'get_utxos should be called second') + }) + + it('sends get_utxos with the address param', async () => { + let capturedPayload + let callCount = 0 + axios.post = async (url, data) => { + callCount++ + if (callCount === 1) return { data: { result: { lag: 0, synced: true } } } + capturedPayload = data + return { data: { result: { utxos: [makeUtxo()] } } } + } + const t = makeTracker() + await t.getUtxosFromAddress(ADDRESS) + assert.strictEqual(capturedPayload.method, 'get_utxos') + assert.strictEqual(capturedPayload.params.address, ADDRESS) + assert.strictEqual(typeof capturedPayload.params.limit, 'number', 'params must include a numeric limit for pagination') + }) +}) + +describe('UtxoTracker.getUtxosFromAddress()', () => { + installAxiosHooks() + + it('returns the result object on success', async () => { + const utxo = makeUtxo() + stubSyncedThenUtxos([utxo]) + const t = makeTracker() + const result = await t.getUtxosFromAddress(ADDRESS) + assert.ok(Array.isArray(result.utxos)) + assert.strictEqual(result.utxos.length, 1) + assert.strictEqual(result.utxos[0].txid, utxo.txid) + }) + + it('defaults confirmations to 0 when null', async () => { + const utxo = makeUtxo({ confirmations: null }) + stubSyncedThenUtxos([utxo]) + const t = makeTracker() + const result = await t.getUtxosFromAddress(ADDRESS) + assert.strictEqual(result.utxos[0].confirmations, 0) + }) + + it('defaults confirmations to 0 when undefined', async () => { + const utxo = makeUtxo() + delete utxo.confirmations + stubSyncedThenUtxos([utxo]) + const t = makeTracker() + const result = await t.getUtxosFromAddress(ADDRESS) + assert.strictEqual(result.utxos[0].confirmations, 0) + }) + + it('preserves existing confirmations when > 0', async () => { + const utxo = makeUtxo({ confirmations: 10 }) + stubSyncedThenUtxos([utxo]) + const t = makeTracker() + const result = await t.getUtxosFromAddress(ADDRESS) + assert.strictEqual(result.utxos[0].confirmations, 10) + }) +}) + +describe('UtxoTracker.getUtxosFromAddress()', () => { + installAxiosHooks() + + it('coerces numeric-string confirmations and rejects non-integer values', async () => { + stubSyncedThenUtxos([makeUtxo({ confirmations: '7' })]) + let t = makeTracker() + let result = await t.getUtxosFromAddress(ADDRESS) + assert.strictEqual(result.utxos[0].confirmations, 7) + + for (const bad of [1.5, -1, 'abc', {}]) { + stubSyncedThenUtxos([makeUtxo({ confirmations: bad })]) + t = makeTracker() + await assert.rejects( + () => t.getUtxosFromAddress(ADDRESS), + /confirmations must be a non-negative integer/ + ) + } + }) + + it('throws when tracker is not synced (synced=false)', async () => { + let callCount = 0 + axios.post = async () => { + callCount++ + if (callCount === 1) { + return { data: { result: { lag: 10, synced: false } } } + } + return { data: { result: { utxos: [] } } } + } + const t = makeTracker() + await assert.rejects( + () => t.getUtxosFromAddress(ADDRESS), + /lagging by 10 blocks/ + ) + }) +}) + +describe('UtxoTracker.getUtxosFromAddress()', () => { + installAxiosHooks() + + // halted is published independently of synced, so a tracker frozen on + // an unrecoverable reorg whose frozen height still shows an acceptable lag passed + // this preflight and its rolled-back UTXOs reached input selection. + it('throws when the tracker is halted, even at lag 0 with synced=true', async () => { + let callCount = 0 + axios.post = async () => { + callCount++ + if (callCount === 1) { + return { data: { result: { lag: 0, synced: true, halted: true, halt_reason: 'rolled back past the recovery window' } } } + } + return { data: { result: { utxos: [] } } } + } + const t = makeTracker() + await assert.rejects( + () => t.getUtxosFromAddress(ADDRESS), + /halted \(rolled back past the recovery window\)/ + ) + }) + + // A negative lag means the node reset or reindexed below our committed + // tip, so the outputs we would select live in blocks it no longer recognizes. The + // encoder checks it itself because it fails open on trackers lacking the fix. + it('throws when the tracker is ahead of the node (negative lag)', async () => { + let callCount = 0 + axios.post = async () => { + callCount++ + if (callCount === 1) { + return { data: { result: { lag: -100, synced: true } } } + } + return { data: { result: { utxos: [] } } } + } + const t = makeTracker() + await assert.rejects( + () => t.getUtxosFromAddress(ADDRESS), + /100 blocks ahead of the node/ + ) + }) +}) + +describe('UtxoTracker.getUtxosFromAddress()', () => { + installAxiosHooks() + + // Block sync flips true before the first mempool rebuild finishes, so at + // lag 0 with synced:true the tracker can still hand back a confirmed output already + // spent in the node's mempool, which its empty index cannot filter. + it('throws when the tracker has not reconverged its mempool, even at lag 0', async () => { + let callCount = 0 + axios.post = async () => { + callCount++ + if (callCount === 1) { + return { data: { result: { lag: 0, synced: true, mempool_ready: false } } } + } + return { data: { result: { utxos: [] } } } + } + const t = makeTracker() + await assert.rejects( + () => t.getUtxosFromAddress(ADDRESS), + /has not reconverged its mempool/ + ) + }) + + // Refusal-cause attribution. get_sync_status derives mempool_ready as + // `synced && isMempoolReconverged()`, so a tracker that is merely lagging publishes + // mempool_ready:false as well and BOTH gates are eligible. The operator must be told + // the block lag, which is actionable and self-clearing, not sent hunting a mempool + // rebuild that is not the fault. Pins the ordering, so moving the readiness gate back + // above the sync gate reddens here. + it('names the block lag, not mempool reconvergence, when a lagging tracker de-asserts both', async () => { + let callCount = 0 + axios.post = async () => { + callCount++ + if (callCount === 1) { + return { data: { result: { lag: 50, synced: false, mempool_ready: false } } } + } + return { data: { result: { utxos: [] } } } + } + const t = makeTracker() + await assert.rejects( + () => t.getUtxosFromAddress(ADDRESS), + /lagging by 50 blocks/ + ) + }) +}) + +describe('UtxoTracker.getUtxosFromAddress()', () => { + installAxiosHooks() + + // Fail-open parity with create_tx: a tracker predating the field omits it entirely + // and must still be fetchable, or every un-upgraded tracker in the fleet goes dark. + it('fetches normally from a tracker that omits mempool_ready', async () => { + let callCount = 0 + axios.post = async () => { + callCount++ + if (callCount === 1) { + return { data: { result: { lag: 0, synced: true } } } + } + return { data: { result: { utxos: [] } } } + } + const t = makeTracker() + assert.deepStrictEqual(await t.getUtxosFromAddress(ADDRESS), { utxos: [] }) + }) + + it('throws when lag is null (tracker has not indexed any blocks)', async () => { + let callCount = 0 + axios.post = async () => { + callCount++ + if (callCount === 1) { + return { data: { result: { lag: null, synced: false } } } + } + return { data: { result: { utxos: [] } } } + } + const t = makeTracker() + await assert.rejects( + () => t.getUtxosFromAddress(ADDRESS), + /has not indexed any blocks/ + ) + }) +}) + +describe('UtxoTracker.getUtxosFromAddress()', () => { + installAxiosHooks() + + it('throws when lag is undefined', async () => { + let callCount = 0 + axios.post = async () => { + callCount++ + if (callCount === 1) { + return { data: { result: { synced: false } } } + } + return { data: { result: { utxos: [] } } } + } + const t = makeTracker() + await assert.rejects( + () => t.getUtxosFromAddress(ADDRESS), + /has not indexed any blocks/ + ) + }) + + it('throws when getSyncStatus transport fails', async () => { + stubAxiosPostThrow(new Error('tracker offline')) + const t = makeTracker() + await assert.rejects( + () => t.getUtxosFromAddress(ADDRESS), + /tracker offline/ + ) + }) +}) diff --git a/test/unit/utxo_tracker.test/03_get_utxos_from_address_validation.test.js b/test/unit/utxo_tracker.test/03_get_utxos_from_address_validation.test.js new file mode 100644 index 0000000..256063b --- /dev/null +++ b/test/unit/utxo_tracker.test/03_get_utxos_from_address_validation.test.js @@ -0,0 +1,217 @@ +// 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, + installAxiosHooks, + makeTracker, + makeUtxo, + stubSyncedThenUtxos +} = require('./helpers/support') + +const ADDRESS = 'mhqpGkU1tUYKFrmtFDXEcBiMqzaZTbEPxX' + +describe('UtxoTracker.getUtxosFromAddress()', () => { + installAxiosHooks() + + it('throws when UTXO result is missing', async () => { + let callCount = 0 + axios.post = async () => { + callCount++ + if (callCount === 1) return { data: { result: { lag: 0, synced: true } } } + return { data: { result: null } } + } + const t = makeTracker() + await assert.rejects( + () => t.getUtxosFromAddress(ADDRESS), + /Error getting utxos: empty result/ + ) + }) + + it('throws when UTXO result is not an object', async () => { + let callCount = 0 + axios.post = async () => { + callCount++ + if (callCount === 1) return { data: { result: { lag: 0, synced: true } } } + return { data: { result: 'bad' } } + } + const t = makeTracker() + await assert.rejects( + () => t.getUtxosFromAddress(ADDRESS), + /Error getting utxos: empty result/ + ) + }) + + it('throws TypeError when utxos is not an array', async () => { + let callCount = 0 + axios.post = async () => { + callCount++ + if (callCount === 1) return { data: { result: { lag: 0, synced: true } } } + return { data: { result: { utxos: 'not-an-array' } } } + } + const t = makeTracker() + await assert.rejects( + () => t.getUtxosFromAddress(ADDRESS), + { name: 'TypeError', message: /UTXO tracker result missing utxos array/ } + ) + }) +}) + +describe('UtxoTracker.getUtxosFromAddress()', () => { + installAxiosHooks() + + it('throws TypeError for malformed UTXO (missing txid)', async () => { + const badUtxo = makeUtxo() + delete badUtxo.txid + stubSyncedThenUtxos([badUtxo]) + const t = makeTracker() + await assert.rejects( + () => t.getUtxosFromAddress(ADDRESS), + { name: 'TypeError', message: /malformed utxo at index 0/ } + ) + }) + + it('throws TypeError for malformed UTXO (txid not a string)', async () => { + const badUtxo = makeUtxo({ txid: 12345 }) + stubSyncedThenUtxos([badUtxo]) + const t = makeTracker() + await assert.rejects( + () => t.getUtxosFromAddress(ADDRESS), + { name: 'TypeError', message: /malformed utxo at index 0/ } + ) + }) + + it('throws TypeError when txid is not a 64-char hex string (too short)', async () => { + const badUtxo = makeUtxo({ txid: 'abc123' }) + stubSyncedThenUtxos([badUtxo]) + const t = makeTracker() + await assert.rejects( + () => t.getUtxosFromAddress(ADDRESS), + { name: 'TypeError', message: /64-character hex string/ } + ) + }) + + it('throws TypeError when txid is 64 chars but not hex', async () => { + const badUtxo = makeUtxo({ txid: 'z'.repeat(64) }) + stubSyncedThenUtxos([badUtxo]) + const t = makeTracker() + await assert.rejects( + () => t.getUtxosFromAddress(ADDRESS), + { name: 'TypeError', message: /64-character hex string/ } + ) + }) +}) + +describe('UtxoTracker.getUtxosFromAddress()', () => { + installAxiosHooks() + + it('throws TypeError for malformed UTXO (missing vout)', async () => { + const badUtxo = makeUtxo() + delete badUtxo.vout + stubSyncedThenUtxos([badUtxo]) + const t = makeTracker() + await assert.rejects( + () => t.getUtxosFromAddress(ADDRESS), + { name: 'TypeError', message: /malformed utxo at index 0/ } + ) + }) + + it('throws TypeError for malformed UTXO (missing value)', async () => { + const badUtxo = makeUtxo() + delete badUtxo.value + stubSyncedThenUtxos([badUtxo]) + const t = makeTracker() + await assert.rejects( + () => t.getUtxosFromAddress(ADDRESS), + { name: 'TypeError', message: /malformed utxo at index 0/ } + ) + }) + + it('throws TypeError when scriptPubKey is missing', async () => { + const badUtxo = makeUtxo() + delete badUtxo.scriptPubKey + stubSyncedThenUtxos([badUtxo]) + const t = makeTracker() + await assert.rejects( + () => t.getUtxosFromAddress(ADDRESS), + { name: 'TypeError', message: /scriptPubKey must be a non-empty string/ } + ) + }) +}) + +describe('UtxoTracker.getUtxosFromAddress()', () => { + installAxiosHooks() + + it('throws TypeError when scriptPubKey is an empty string', async () => { + const badUtxo = makeUtxo({ scriptPubKey: '' }) + stubSyncedThenUtxos([badUtxo]) + const t = makeTracker() + await assert.rejects( + () => t.getUtxosFromAddress(ADDRESS), + { name: 'TypeError', message: /scriptPubKey must be a non-empty string/ } + ) + }) + + it('throws TypeError when a UTXO element is null', async () => { + stubSyncedThenUtxos([null]) + const t = makeTracker() + await assert.rejects( + () => t.getUtxosFromAddress(ADDRESS), + { name: 'TypeError', message: /malformed utxo at index 0/ } + ) + }) + + it('accepts an empty utxos array', async () => { + stubSyncedThenUtxos([]) + const t = makeTracker() + const result = await t.getUtxosFromAddress(ADDRESS) + assert.deepStrictEqual(result.utxos, []) + }) +}) + +describe('UtxoTracker.getUtxosFromAddress()', () => { + installAxiosHooks() + + it('handles multiple valid UTXOs correctly', async () => { + const utxo1 = makeUtxo({ txid: 'a'.repeat(64), vout: 0, value: 10000 }) + const utxo2 = makeUtxo({ txid: 'b'.repeat(64), vout: 1, value: 20000 }) + stubSyncedThenUtxos([utxo1, utxo2]) + const t = makeTracker() + const result = await t.getUtxosFromAddress(ADDRESS) + assert.strictEqual(result.utxos.length, 2) + assert.strictEqual(result.utxos[1].txid, 'b'.repeat(64)) + }) + + it('reports the correct index in malformed UTXO error for second item', async () => { + const good = makeUtxo({ txid: 'a'.repeat(64) }) + const bad = makeUtxo({ txid: 'short' }) + stubSyncedThenUtxos([good, bad]) + const t = makeTracker() + await assert.rejects( + () => t.getUtxosFromAddress(ADDRESS), + /index 1/ + ) + }) + + it('rethrows get_utxos transport errors', async () => { + let callCount = 0 + axios.post = async () => { + callCount++ + if (callCount === 1) return { data: { result: { lag: 0, synced: true } } } + throw new Error('Connection reset by peer') + } + const t = makeTracker() + await assert.rejects( + () => t.getUtxosFromAddress(ADDRESS), + /Connection reset by peer/ + ) + }) +}) diff --git a/test/unit/utxo_tracker.test/helpers/support.js b/test/unit/utxo_tracker.test/helpers/support.js new file mode 100644 index 0000000..5dc5c6a --- /dev/null +++ b/test/unit/utxo_tracker.test/helpers/support.js @@ -0,0 +1,79 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const axios = require('axios') +const UtxoTracker = require('../../../../src/build/utxo_tracker') + +function makeTracker () { + return new UtxoTracker('127.0.0.1', 18420) +} + +function installAxiosHooks () { + let originalPost + beforeEach(() => { + originalPost = axios.post + }) + afterEach(() => { + axios.post = originalPost + }) +} + +function stubAxiosPost (response) { + axios.post = async () => response +} + +function stubAxiosPostThrow (err) { + axios.post = async () => { throw err } +} + +// A minimal valid UTXO fixture +function makeUtxo (overrides) { + return Object.assign({ + txid: 'a'.repeat(64), + vout: 0, + value: 50000000, + scriptPubKey: '76a914' + 'ab'.repeat(20) + '88ac', + confirmations: 6 + }, overrides) +} + +// A stub that returns synced status first, then UTXOs +function stubSyncedThenUtxos (utxos) { + let callCount = 0 + axios.post = async (url, data) => { + callCount++ + if (callCount === 1) { + // getSyncStatus call + return { + data: { + result: { + committed_height: 100, + tracker_height: 100, + node_height: 100, + lag: 0, + synced: true + } + } + } + } + // get_utxos call + return { data: { result: { utxos } } } + } +} + +module.exports = { + axios, + installAxiosHooks, + makeTracker, + makeUtxo, + stubAxiosPost, + stubAxiosPostThrow, + stubSyncedThenUtxos +} From 25d148fbc1184018b2c32ad2107b8fb0a48edb44 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 07:41:41 -0700 Subject: [PATCH 117/148] test: split the createTransaction suite into numbered parts by behaviour --- .../xchain_encoder_create_transaction.test.js | 1253 +---------------- .../01_utxo_deduplication.test.js | 37 + .../02_unconfirmed_filtering.test.js | 52 + .../03_utxo_sorting.test.js | 37 + .../04_replace_by_fee_sequence.test.js | 75 + .../05_fee_handling.test.js | 121 ++ .../06_custom_dust_parameter.test.js | 88 ++ .../07_change_output.test.js | 53 + .../08_no_utxos_available.test.js | 128 ++ .../09_custom_outputs.test.js | 72 + .../10_op_return_encoding_path.test.js | 63 + .../11_p2sh_encoding_funding.test.js | 57 + .../12_p2sh_encoding_spending.test.js | 54 + .../13_p2sh_native_fee_custom_outputs.test.js | 159 +++ .../14_multisign_encoding_path.test.js | 167 +++ .../15_return_value.test.js | 36 + .../16_estimate_spending_p2sh_tx.test.js | 44 + .../17_payment_only.test.js | 82 ++ .../18_payload_guards.test.js | 219 +++ .../fixtures/transaction.js | 117 ++ 20 files changed, 1673 insertions(+), 1241 deletions(-) create mode 100644 test/unit/xchain_encoder_create_transaction.test/01_utxo_deduplication.test.js create mode 100644 test/unit/xchain_encoder_create_transaction.test/02_unconfirmed_filtering.test.js create mode 100644 test/unit/xchain_encoder_create_transaction.test/03_utxo_sorting.test.js create mode 100644 test/unit/xchain_encoder_create_transaction.test/04_replace_by_fee_sequence.test.js create mode 100644 test/unit/xchain_encoder_create_transaction.test/05_fee_handling.test.js create mode 100644 test/unit/xchain_encoder_create_transaction.test/06_custom_dust_parameter.test.js create mode 100644 test/unit/xchain_encoder_create_transaction.test/07_change_output.test.js create mode 100644 test/unit/xchain_encoder_create_transaction.test/08_no_utxos_available.test.js create mode 100644 test/unit/xchain_encoder_create_transaction.test/09_custom_outputs.test.js create mode 100644 test/unit/xchain_encoder_create_transaction.test/10_op_return_encoding_path.test.js create mode 100644 test/unit/xchain_encoder_create_transaction.test/11_p2sh_encoding_funding.test.js create mode 100644 test/unit/xchain_encoder_create_transaction.test/12_p2sh_encoding_spending.test.js create mode 100644 test/unit/xchain_encoder_create_transaction.test/13_p2sh_native_fee_custom_outputs.test.js create mode 100644 test/unit/xchain_encoder_create_transaction.test/14_multisign_encoding_path.test.js create mode 100644 test/unit/xchain_encoder_create_transaction.test/15_return_value.test.js create mode 100644 test/unit/xchain_encoder_create_transaction.test/16_estimate_spending_p2sh_tx.test.js create mode 100644 test/unit/xchain_encoder_create_transaction.test/17_payment_only.test.js create mode 100644 test/unit/xchain_encoder_create_transaction.test/18_payload_guards.test.js create mode 100644 test/unit/xchain_encoder_create_transaction.test/fixtures/transaction.js diff --git a/test/unit/xchain_encoder_create_transaction.test.js b/test/unit/xchain_encoder_create_transaction.test.js index 3fc4dd1..1a5d6d2 100644 --- a/test/unit/xchain_encoder_create_transaction.test.js +++ b/test/unit/xchain_encoder_create_transaction.test.js @@ -8,97 +8,18 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. -const assert = require('assert') -const bitcoin = require('bitcoinjs-lib') -const crypto = require('crypto') -const XChainEncoder = require('../../src/XChainEncoder') - -const pubkeyBuf = Buffer.from( - '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', - 'hex' -) -// A valid-looking 64-char hex txid -const TXID_A = 'a'.repeat(64) -const TXID_B = 'b'.repeat(64) -// A TXID that produces valid EC points when used with MULTISIGN test data -// (found by brute-force: 'A'.repeat(59) compiled to 60 bytes → 64-byte chunk after magic) -const TXID_MS = '4e3472b63a459d2188711abcff6aa2548948f90c527aa60ec4a1101136879fe8' - -function buildRawTxHex (value) { - const tx = new bitcoin.Transaction() - tx.addInput(Buffer.alloc(32, 0x11), 0) - const p2pkhScript = bitcoin.payments.p2pkh({ - pubkey: pubkeyBuf, - network: bitcoin.networks.regtest - }).output - tx.addOutput(p2pkhScript, value) - return tx.toHex() -} - -const RAW_TX_HEX = buildRawTxHex(100000000) // 1 BTC in sats - -// Build P2WPKH UTXO fixtures. The scriptPubKey is the witness program itself, -// so it is network-independent and valid on any segwit-capable regtest chain. -function makeSegwitUtxo (txid, vout, value) { - const p2wpkh = bitcoin.payments.p2wpkh({ - pubkey: pubkeyBuf, - network: bitcoin.networks.regtest - }) - return { - txid, - vout, - value, - confirmations: 6, - scriptPubKey: p2wpkh.output.toString('hex') - } -} - -// P2PKH fixture for the chains without segwit, whose UTXOs can only be legacy. -function makeLegacyUtxo (txid, vout, value) { - const p2pkh = bitcoin.payments.p2pkh({ - pubkey: pubkeyBuf, - network: bitcoin.networks.regtest - }) - return { - txid, - vout, - value, - confirmations: 6, - scriptPubKey: p2pkh.output.toString('hex') - } -} - -// Segwit-capable network, because every fixture below spends a P2WPKH input and -// the builder refuses a witness-program input on a chain without segwit. It also -// carries a dustThreshold, which the change math reads. -function makeEncoder (networkName = 'litecoin-regtest') { - const encoder = new XChainEncoder( - networkName, '127.0.0.1', '8333', 'rpc', 'rpc', '', '' - ) - encoder.connector = { - getFeePerKilobyte: async () => 0.00001, - // Returns a bare hex STRING, which is what BlockchainConnector.getTransactionHex - // actually resolves with (`responseData.result.hex`). It used to answer - // `{ hex }`, a shape the real class has never returned; that went unnoticed - // because every fixture here is segwit and nothing called it. Now that the - // segwit path calls it too, a fake modelled on nothing would be modelling - // the code under test. - getTransactionHex: async () => RAW_TX_HEX - } - encoder.utxoTrackerConnector = { - getUtxosFromAddress: async () => ({ - utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)] - }) - } - return encoder -} - -const LTC_REGTEST = require('../../src/build/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') -const TxSizeEstimator = require('../../src/build/tx_size_estimator'); -const TEST_ADDRESS = bitcoin.payments.p2pkh({ - pubkey: pubkeyBuf, - network: LTC_REGTEST -}).address +const { + assert, + bitcoin, + pubkeyBuf, + TXID_A, + TXID_B, + RAW_TX_HEX, + makeSegwitUtxo, + makeEncoder, + LTC_REGTEST, + TEST_ADDRESS +} = require('./xchain_encoder_create_transaction.test/fixtures/transaction') describe('XChainEncoder.createTransaction()', () => { @@ -158,1154 +79,4 @@ describe('XChainEncoder.createTransaction()', () => { }) }) - describe('UTXO deduplication', () => { - it('removes duplicate UTXOs with same txid+vout', async () => { - const encoder = makeEncoder() - const dup1 = makeSegwitUtxo(TXID_A, 0, 50000000) - const dup2 = makeSegwitUtxo(TXID_A, 0, 50000000) - const unique = makeSegwitUtxo(TXID_B, 1, 30000000) - - const result = await encoder.createTransaction( - [dup1, dup2, unique], TEST_ADDRESS, null, - 'test', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - assert.ok(result.psbt.data.inputs.length <= 2) - }) - }) - - describe('unconfirmed filtering', () => { - it('excludes mempool UTXOs when unconfirmed=false', async () => { - const encoder = makeEncoder() - const confirmed = makeSegwitUtxo(TXID_A, 0, 100000000) - confirmed.confirmations = 6 - const mempool = makeSegwitUtxo(TXID_B, 0, 50000000) - mempool.confirmations = 0 - - const result = await encoder.createTransaction( - [mempool, confirmed], TEST_ADDRESS, null, - 'test', null, 10000, false, null, TEST_ADDRESS, - null, null, null, false, 0.00001 - ) - - assert.strictEqual(result.psbt.data.inputs.length, 1) - }) - - it('keeps mempool UTXOs when unconfirmed=true', async () => { - const encoder = makeEncoder() - const mempool = makeSegwitUtxo(TXID_A, 0, 100000000) - mempool.confirmations = 0 - - const result = await encoder.createTransaction( - [mempool], TEST_ADDRESS, null, - 'test', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(result.psbt.data.inputs.length, 1) - }) - }) - - describe('UTXO sorting (largest first)', () => { - it('uses the largest UTXO first', async () => { - const encoder = makeEncoder() - const small = makeSegwitUtxo(TXID_A, 0, 10000000) - const large = makeSegwitUtxo(TXID_B, 0, 100000000) - - const result = await encoder.createTransaction( - [small, large], TEST_ADDRESS, null, - 'test', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - // The large UTXO alone covers the fee, so only 1 input needed - assert.strictEqual(result.psbt.data.inputs.length, 1) - }) - }) - - describe('replace-by-fee sequence', () => { - it('sets sequence to 0xfffffffd when rbf=true (RBF armed, BIP68 still disabled)', async () => { - const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', null, 10000, true, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - const inputData = result.psbt.txInputs[0] - assert.strictEqual(inputData.sequence, 0xfffffffd) - }) - - // The literal above pins WHICH value; this pins WHY, and it is the assertion - // that would have caught the original bug. nSequence=0x00000001 also signals - // RBF, so every value-only test passed while the encoder was quietly enabling - // BIP68 relative locktime of 1 block on every RBF transaction. That is - // invisible against confirmed inputs and rejects any spend of unconfirmed - // change as non-BIP68-final, which made RBF and chained sends mutually - // exclusive, measured on BTC testnet4. - it('arms RBF without enabling BIP68 relative locktime', async () => { - const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', null, 10000, true, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - const seq = result.psbt.txInputs[0].sequence - // Below 0xfffffffe: BIP125 sees this as replaceable. - assert.ok(seq < 0xfffffffe, 'sequence ' + seq.toString(16) + ' does not signal RBF') - // Bit 31 SET: BIP68 relative locktime is disabled, so the input carries no - // "must be N blocks old" assertion and unconfirmed change stays spendable. - assert.notStrictEqual(seq & 0x80000000, 0, - 'sequence ' + seq.toString(16) + ' leaves BIP68 enabled; unconfirmed change becomes unspendable') - }) - - it('sets sequence to 0xffffffff when rbf=false', async () => { - const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - const inputData = result.psbt.txInputs[0] - assert.strictEqual(inputData.sequence, 0xffffffff) - }) - }) - - describe('fee handling', () => { - it('uses custom fee when provided', async () => { - const encoder = makeEncoder() - // The relative fee-rate cap has its own suite (xchain_encoder_fee_rate_cap.test.js) - // and the absolute burn backstop has its own test below; disable/isolate - // both here so only the passthrough behaviour is under test. - encoder.maxFeeRateMultiplier = null - // Isolate the custom-fee passthrough from the network dust floor (exercised - // by the dust-floor tests below); a fee at or above dust would otherwise be - // floored up and mask the value being tested. - encoder.dustAmount = 546 - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - // Kept under the 100x-fair-fee burn backstop (fair fee ~131 sats for this - // ~131-byte tx, so the backstop ceiling is ~13100 sats). - const customFee = 5000 - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', null, customFee, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - // Change = input - fee (no other outputs besides OP_RETURN which is 0) - const changeOutput = result.psbt.txOutputs.find(o => o.value > 0) - assert.strictEqual(changeOutput.value, 100000000 - customFee) - }) - - it('throws RangeError when the custom fee exceeds 100x the fair-fee estimate (burn backstop)', async () => { - const encoder = makeEncoder() - // Disable the relative rate cap so the fixed burn backstop is the only - // guard in play; the backstop holds even with the rate cap turned off. - encoder.maxFeeRateMultiplier = null - encoder.dustAmount = 546 - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - // Fair fee is ~131 sats for this ~131-byte tx, so the backstop ceiling - // is ~13100 sats; 20000 is comfortably over it. - const customFee = 20000 - - await assert.rejects( - encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', null, customFee, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ), - (err) => err instanceof RangeError && /100x the estimated fair fee/.test(err.message) - ) - }) - - it('floors fee to dustAmount when computed fee is lower', async () => { - const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - // Very low feePerKb should produce a fee below dust - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', null, null, false, null, TEST_ADDRESS, - null, null, null, true, 0.0000001 // very low fee rate - ) - - const changeOutput = result.psbt.txOutputs.find(o => o.value > 0) - const impliedFee = 100000000 - changeOutput.value - assert.ok(impliedFee >= encoder.dustAmount, - `fee ${impliedFee} should be >= dustAmount ${encoder.dustAmount}`) - }) - - it('uses feePerKb parameter when provided (no RPC call)', async () => { - const encoder = makeEncoder() - // Make the RPC mock throw to prove it's not called - encoder.connector.getFeePerKilobyte = async () => { - throw new Error('should not be called') - } - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', null, null, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - assert.ok(result.psbt) - }) - - it('calls connector.getFeePerKilobyte when feePerKb is null', async () => { - const encoder = makeEncoder() - let called = false - encoder.connector.getFeePerKilobyte = async () => { - called = true - return 0.00001 - } - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - - await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', null, null, false, null, TEST_ADDRESS, - null, null, null, true, null - ) - assert.strictEqual(called, true) - }) - }) - - describe('custom dust parameter', () => { - it('overrides finalDust for MULTISIGN output value when it raises the floor', async () => { - 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 - assert.ok(customDust > encoder.outputFloor) - - const MS_DATA = 'A'.repeat(59) - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - MS_DATA, null, 10000, false, 'MULTISIGN', TEST_ADDRESS, - null, null, compressedPubKey, - true, 0.00001, customDust - ) - - const msOutput = result.psbt.txOutputs.find(o => o.value === customDust) - assert.ok(msOutput, 'MULTISIGN output should use custom dust value of 6000') - }) - - it('clamps a caller dust below the output floor up to the floor', async () => { - // 1234 litoshi is under LTC's 5460 dust threshold: an output that size is - // non-standard and the whole transaction unrelayable, so a caller may - // raise the floor but never lower it. - const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_MS, 0, 100000000) - const compressedPubKey = pubkeyBuf.toString('hex') - const customDust = 1234 - assert.ok(customDust < encoder.outputFloor) - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'A'.repeat(59), null, 10000, false, 'MULTISIGN', TEST_ADDRESS, - null, null, compressedPubKey, - true, 0.00001, customDust - ) - - 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') - }) - - it('does NOT override the fee floor (fee floor uses this.dustAmount)', async () => { - const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - - // Pass custom dust smaller than network dustAmount - // Fee floor should still use this.dustAmount (546), not the custom dust - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', null, null, false, null, TEST_ADDRESS, - null, null, null, - true, 0.0000001, 100 // custom dust = 100, but network dust = 546 - ) - - const changeOutput = result.psbt.txOutputs.find(o => o.value > 0) - const impliedFee = 100000000 - changeOutput.value - assert.ok(impliedFee >= encoder.dustAmount, - `fee ${impliedFee} should be >= network dustAmount ${encoder.dustAmount}`) - }) - }) - - describe('change output', () => { - it('adds change output when there is leftover and change address given', async () => { - const encoder = makeEncoder() - // Isolate the change math from the network dust floor so the 10000 custom fee - // flows into change verbatim (the dust floor has its own suite). - encoder.dustAmount = 546 - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - const changeOutput = result.psbt.txOutputs.find(o => o.value > 0) - assert.ok(changeOutput, 'change output should exist') - assert.strictEqual(changeOutput.value, 100000000 - 10000) - }) - - it('throws when change address is falsy and change would be burned', async () => { - const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - - await assert.rejects( - () => encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', null, 10000, false, null, null, // no change address - null, null, null, true, 0.00001 - ), - /change address/i - ) - }) - }) - - describe('no UTXOs available', () => { - it('throws when utxos is empty and tracker returns nothing', async () => { - const encoder = makeEncoder() - encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ - utxos: [] - }) - - await assert.rejects( - () => encoder.createTransaction( - [], TEST_ADDRESS, null, - 'test', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ), - /no utxos/i - ) - }) - - it('throws when utxos is null and tracker returns null', async () => { - const encoder = makeEncoder() - encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ - utxos: null - }) - - await assert.rejects( - () => encoder.createTransaction( - null, TEST_ADDRESS, null, - 'test', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ), - /no utxos/i - ) - }) - - it('falls back to utxoTracker when utxos param is null', async () => { - const encoder = makeEncoder() - let trackerCalled = false - encoder.utxoTrackerConnector.getUtxosFromAddress = async () => { - trackerCalled = true - return { utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)] } - } - - await encoder.createTransaction( - null, TEST_ADDRESS, null, - 'test', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(trackerCalled, true) - }) - - // Regression: every wallet flow sends its source as a raw compressed - // pubkey hex (xchain-wallet's `source.publicKey`), not an address - the - // "pubkey" param name is legacy/misleading (validator.js documents it as - // "actually the sender address"). Before this fix, that raw pubkey hex - // reached getUtxosFromAddress unresolved, and bitcoinjs-lib's - // address.toOutputScript rejected it as neither valid base58 nor bech32 - // (" has no matching Script"), breaking every UTXO-tracker-backed - // compose (any call that does not pre-supply `utxos`) for a live wallet. - it('resolves a raw pubkey (not an address) to P2WPKH before querying the tracker on a segwit network', async () => { - const encoder = makeEncoder() - let queriedAddress = null - encoder.utxoTrackerConnector.getUtxosFromAddress = async (address) => { - queriedAddress = address - return { utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)] } - } - - const rawPubkeyHex = pubkeyBuf.toString('hex') - await encoder.createTransaction( - null, rawPubkeyHex, null, - 'test', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - // A segwit-capable network's default address type is P2WPKH. - const expected = bitcoin.payments.p2wpkh({ pubkey: pubkeyBuf, network: LTC_REGTEST }).address - assert.strictEqual(queriedAddress, expected) - }) - - // The other branch of the same resolution: a chain without segwit defaults to - // P2PKH, and its tracker query must carry a base58 address. - it('resolves a raw pubkey to P2PKH before querying the tracker on a network without segwit', async () => { - const encoder = makeEncoder('dogecoin-regtest') - let queriedAddress = null - encoder.utxoTrackerConnector.getUtxosFromAddress = async (address) => { - queriedAddress = address - return { utxos: [makeLegacyUtxo(TXID_A, 0, 100000000)] } - } - - const dogeNetwork = require('../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') - const dogeAddress = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: dogeNetwork }).address - const rawPubkeyHex = pubkeyBuf.toString('hex') - await encoder.createTransaction( - null, rawPubkeyHex, null, - 'test', null, 10000, false, null, dogeAddress, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(queriedAddress, dogeAddress) - }) - }) - - describe('custom outputs', () => { - it('adds custom outputs with correct address and value', async () => { - const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - - const customOutputs = [ - { address: TEST_ADDRESS, value: '500000' }, - { address: TEST_ADDRESS, value: '300000' } - ] - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, customOutputs, - 'test', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - // Should have: OP_RETURN (0 value) + 2 custom + 1 change = 4 outputs - const outputs = result.psbt.txOutputs - const customValues = outputs.filter(o => - o.value === 500000 || o.value === 300000 - ) - assert.strictEqual(customValues.length, 2) - }) - - it('skips custom outputs when customOutputs is not an array', async () => { - const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - - // Pass an object (not array); should be silently skipped - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, { address: TEST_ADDRESS, value: 1000 }, - 'test', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - // Should only have OP_RETURN + change = 2 outputs - assert.strictEqual(result.psbt.txOutputs.length, 2) - }) - - it('skips custom outputs when customOutputs is null', async () => { - const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(result.psbt.txOutputs.length, 2) - }) - }) - - describe('OP_RETURN encoding path', () => { - it('auto-selects OP_RETURN for small data and returns correct encoding', async () => { - const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'Small', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(result.encoding, 'OP_RETURN') - }) - - it('OP_RETURN output has value 0', async () => { - const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'Data', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - const opReturnOutput = result.psbt.txOutputs.find(o => o.value === 0) - assert.ok(opReturnOutput, 'should have an OP_RETURN output with value 0') - }) - - it('OP_RETURN output script starts with OP_RETURN opcode (0x6a)', async () => { - const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'Data', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - const opReturnOutput = result.psbt.txOutputs.find(o => o.value === 0) - assert.strictEqual(opReturnOutput.script[0], bitcoin.opcodes.OP_RETURN) - }) - }) - - describe('P2SH encoding path (tx1: funding)', () => { - it('creates P2SH output for large data', async () => { - const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - const bigData = 'x'.repeat(80) // exceeds OP_RETURN limit - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - bigData, null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(result.encoding, 'P2SH') - // Should have P2SH output(s) + change - const nonZeroOutputs = result.psbt.txOutputs.filter(o => o.value > 0) - assert.ok(nonZeroOutputs.length >= 2, 'should have P2SH output + change') - }) - - it('P2SH output value covers the spending tx fee', async () => { - const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - const bigData = 'x'.repeat(80) - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - bigData, null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - const p2shOutput = result.psbt.txOutputs.find(o => - o.value > 0 && o.value < 100000000 - ) - assert.ok(p2shOutput, 'P2SH output should exist') - assert.ok(p2shOutput.value >= encoder.dustAmount, - 'P2SH output value should be >= dust') - }) - }) - - describe('P2SH encoding path (tx2: spending)', () => { - it('creates tx2 with P2SH input and OP_RETURN marker', async () => { - const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - const bigData = 'x'.repeat(80) - - // First create tx1 to get the hex - const tx1Result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - bigData, null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - // Extract tx1 hex (unsigned, but structurally valid for our test) - const tx1Psbt = tx1Result.psbt - const tx1Hex = tx1Psbt.__CACHE.__TX.toHex() - const tx1Id = tx1Psbt.__CACHE.__TX.getId() - - // Now create tx2 - const tx2Result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - bigData, null, 10000, false, null, TEST_ADDRESS, - tx1Id, tx1Hex, null, true, 0.00001 - ) - - assert.strictEqual(tx2Result.encoding, 'P2SH') - // tx2 should have at least 1 input (the P2SH spend) - assert.ok(tx2Result.psbt.data.inputs.length >= 1) - // tx2 should have an OP_RETURN marker output - const opReturnOutput = tx2Result.psbt.txOutputs.find(o => o.value === 0) - assert.ok(opReturnOutput, 'tx2 should have OP_RETURN marker') - }) - }) - - // On native-fee chains a real FEE_DESTINATION rides as a customOutputs entry. - // The indexer treats the P2SH REVEAL (phase 2) as the action and reads the fee - // output from it, so the fee output must land on the reveal, never on the - // funding tx. But the reveal's only inputs are the funding outputs, so the - // funding tx must over-fund by the fee value (without emitting the output) or - // the reveal cannot pay it. These tests pin both halves of that contract. - describe('P2SH native-fee customOutputs', () => { - const FEE_VALUE = 10678 // a representative native-fee output value (sats/koinu) - const bigData = 'x'.repeat(80) // exceeds OP_RETURN limit, forces P2SH - - function feeOutputs () { - return [{ address: TEST_ADDRESS, value: FEE_VALUE }] - } - - // Find the P2SH funding output (a positive-value output that is not the change). - // Change equals input minus everything else and is by far the largest output. - function findFundingOutput (psbt) { - const positive = psbt.txOutputs.filter(o => o.value > 0) - positive.sort((a, b) => a.value - b.value) - return positive[0] - } - - it('phase 1 (funding) does NOT emit the fee-destination output', async () => { - const encoder = makeEncoder() - encoder.dustAmount = 546 - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, feeOutputs(), - bigData, null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(result.encoding, 'P2SH') - // The P2SH funding tx is just [P2SH funding output, change] (the OP_RETURN - // marker rides the reveal, not the funding tx). No fee-destination output - // is emitted here: a standalone output worth exactly FEE_VALUE would mean - // the fee was put on the wrong tx and later double-paid on the reveal. - const emittedFee = result.psbt.txOutputs.find(o => o.value === FEE_VALUE) - assert.ok(!emittedFee, 'funding tx must not emit a standalone fee-destination output') - assert.strictEqual(result.psbt.txOutputs.length, 2, - 'funding tx must be [P2SH funding output, change] only') - }) - - it('phase 1 (funding) over-funds the P2SH output by the fee value plus its byte cost', async () => { - const encoder = makeEncoder() - // Pin both floors: the reveal's change headroom keys on outputFloor, the fee - // floor on dustAmount, and the delta below assumes one value for each. - encoder.dustAmount = 546 - encoder.outputFloor = 546 - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - - // Baseline funding output value with no customOutputs. - const base = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - bigData, null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - const baseFunding = findFundingOutput(base.psbt).value - - // Same call but with the fee customOutput: the funding output must grow by - // FEE_VALUE so the reveal can pay the fee, plus the reveal's own miner - // fee for the bytes that output adds to the reveal. Without the second - // term the reveal lands under the node's min-relay floor. - // - // It grows by one dust LESS than that, and the difference is the point. - // The baseline reveal emits no value output of its own, so its funding - // must also carry a dust for the change output that keeps it from burning - // every satoshi to miners. A reveal already carrying a - // fee output leaves value by definition and needs no change output, so - // funding that dust too would buy the caller a third output they never - // asked for - which is what REG-14 pins against on this exact shape. - // - // Same input as the baseline on purpose: release its reservation first. - encoder.clearReservations() - const withFee = await encoder.createTransaction( - [utxo], TEST_ADDRESS, feeOutputs(), - bigData, null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - const feeFunding = findFundingOutput(withFee.psbt).value - - // Same conversion the encoder applies: feePerKb -> internal coin/byte. - const feePerBytes = 0.00001 / 1000 / 1e8 - const feeOutputBytes = TxSizeEstimator.estimateOutputSizeForAddress(TEST_ADDRESS, LTC_REGTEST) - const revealByteFee = Math.ceil(feeOutputBytes * feePerBytes * 1e8) - - assert.strictEqual(feeFunding - baseFunding, FEE_VALUE + revealByteFee - encoder.outputFloor, - 'funding output must grow by the fee value plus the fee output byte cost, ' + - 'less the change headroom (one output floor) only the outputless baseline needs') - }) - - it('phase 2 (reveal) emits the fee-destination output funded by phase 1', async () => { - const encoder = makeEncoder() - encoder.dustAmount = 546 - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - - // Build phase 1 (funding) with the fee customOutput. - const tx1 = await encoder.createTransaction( - [utxo], TEST_ADDRESS, feeOutputs(), - bigData, null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - const tx1Hex = tx1.psbt.__CACHE.__TX.toHex() - const tx1Id = tx1.psbt.__CACHE.__TX.getId() - - // Build phase 2 (reveal) spending phase 1, with the same fee customOutput. - const tx2 = await encoder.createTransaction( - [utxo], TEST_ADDRESS, feeOutputs(), - bigData, null, 10000, false, null, TEST_ADDRESS, - tx1Id, tx1Hex, null, true, 0.00001 - ) - - assert.strictEqual(tx2.encoding, 'P2SH') - // The reveal must carry the fee-destination output (value FEE_VALUE). - const feeOut = tx2.psbt.txOutputs.find(o => o.value === FEE_VALUE) - assert.ok(feeOut, 'reveal must emit the fee-destination output') - - // And it is not a bare OP_RETURN-only tx (the fee-drop failure mode). - const opReturnOnly = tx2.psbt.txOutputs.every(o => o.value === 0) - assert.ok(!opReturnOnly, 'reveal must not be OP_RETURN-only') - - // The reveal's input value (the funded P2SH output) must cover the fee - // output so bitcoinjs does not reject it as outputs-exceed-inputs. - const fundingOut = findFundingOutput(tx1.psbt) - assert.ok(fundingOut.value >= FEE_VALUE, - 'phase-1 funding output must cover the reveal fee output') - }) - }) - - describe('MULTISIGN encoding path', () => { - // MULTISIGN requires fake pubkeys that are valid EC points on secp256k1. - // dataToPubkey() prepends 0x02 and zero-pads to 33 bytes, but the x-coordinate - // must correspond to a real curve point. We use: - // - TXID_MS: a brute-forced txid that produces valid points - // - 'A'.repeat(59): data that compiles to exactly 60 bytes, producing a 64-byte - // chunk (4 magic + 60 data), so both 32-byte slices are fully populated - - const MS_DATA = 'A'.repeat(59) // compiles to 60 bytes → 64-byte chunk after XCHN prefix - - it('creates multisig output with correct structure', async () => { - const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_MS, 0, 100000000) - const compressedPubKey = pubkeyBuf.toString('hex') - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - MS_DATA, null, 10000, false, 'MULTISIGN', TEST_ADDRESS, - null, null, compressedPubKey, true, 0.00001 - ) - - assert.strictEqual(result.encoding, 'MULTISIGN') - - // Find the multisig output by its script shape (a bare p2ms script ends - // in OP_CHECKMULTISIG). Its value is the relay-fee dust floor computed - // from the actual script size, which is larger than the flat P2PKH - // dustAmount (546), so we must not match on dustAmount here. - const msOutput = result.psbt.txOutputs.find(o => { - const d = bitcoin.script.decompile(o.script) - return d && d[d.length - 1] === bitcoin.opcodes.OP_CHECKMULTISIG - }) - assert.ok(msOutput, 'should have a bare-multisig output') - assert.ok(msOutput.value >= encoder.dustAmount, 'multisig output value should be at least the dust floor') - - // Decompile the script to verify 1-of-3 structure - const decompiled = bitcoin.script.decompile(msOutput.script) - // OP_1 OP_3 OP_CHECKMULTISIG - assert.strictEqual(decompiled[0], bitcoin.opcodes.OP_1) // m = 1 - assert.ok(Buffer.isBuffer(decompiled[1])) // pubkey1 - assert.ok(Buffer.isBuffer(decompiled[2])) // pubkey2 - assert.ok(Buffer.isBuffer(decompiled[3])) // pubkey3 - assert.strictEqual(decompiled[4], bitcoin.opcodes.OP_3) // n = 3 - assert.strictEqual(decompiled[5], bitcoin.opcodes.OP_CHECKMULTISIG) - }) - - it('third pubkey is the real compressed public key', async () => { - const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_MS, 0, 100000000) - const compressedPubKey = pubkeyBuf.toString('hex') - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - MS_DATA, null, 10000, false, 'MULTISIGN', TEST_ADDRESS, - null, null, compressedPubKey, true, 0.00001 - ) - - const msOutput = result.psbt.txOutputs.find(o => { - const d = bitcoin.script.decompile(o.script) - return d && d[d.length - 1] === bitcoin.opcodes.OP_CHECKMULTISIG - }) - assert.ok(msOutput, 'should have a bare-multisig output') - const decompiled = bitcoin.script.decompile(msOutput.script) - assert.deepStrictEqual(decompiled[3], pubkeyBuf) - }) - - // The MULTISIGN data output carries real value (a relay-fee dust floor), - // so that value MUST be counted toward the running output total before - // change is computed. If it isn't, change is over-credited by exactly the - // data-output value and total outputs exceed total inputs: bitcoinjs-lib - // rejects the tx at extractTransaction ("Outputs are spending more than - // Inputs") and the network rejects it as invalid. - it('builds a valid tx where total outputs do not exceed inputs', async () => { - const encoder = makeEncoder() - const inputValue = 100000000 - const utxo = makeSegwitUtxo(TXID_MS, 0, inputValue) - const compressedPubKey = pubkeyBuf.toString('hex') - - // A large dust value with a tiny fee is the exact condition that exposes - // the bug: when the data-output value exceeds the fee, omitting it from - // the output total over-credits change past the input total. fee=100, - // dust=50000. - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - MS_DATA, null, 100, false, 'MULTISIGN', TEST_ADDRESS, - null, null, compressedPubKey, true, 0.00001, 50000 - ) - - const outputTotal = result.psbt.txOutputs.reduce((sum, o) => sum + o.value, 0) - assert.ok( - outputTotal <= inputValue, - `total outputs (${outputTotal}) must not exceed total inputs (${inputValue})` - ) - }) - - // A payload whose last chunk holds ≤28 data bytes produces a final - // magic(4)+data buffer of ≤32 bytes. Before each chunk was padded to a - // full 64-byte slot, obfuscatedData.slice(32) was empty, so dataToPubkey() - // returned an all-zero x-coordinate (0x02 || 0x00×32), a secp256k1 x=0 - // point that p2ms() rejects at construction time, surfacing as a 500. - // This payload compiles to 88 bytes → chunks of 60 + 28, exercising that - // path end-to-end. TXID_MS_SHORT is brute-forced so every obfuscated - // pubkey half across both chunks is a valid curve point. - const TXID_MS_SHORT = '5efa379f1f220474a1c6d3114efb4bcac3e38d54e2003db334d1cb97c2f8bfba' - const MS_DATA_SHORT = 'Z'.repeat(86) // compiles to 88 bytes → last chunk = magic(4) + 28 - - it('encodes a short final chunk without throwing (x=0 pubkey regression)', async () => { - const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_MS_SHORT, 0, 100000000) - const compressedPubKey = pubkeyBuf.toString('hex') - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - MS_DATA_SHORT, null, 10000, false, 'MULTISIGN', TEST_ADDRESS, - null, null, compressedPubKey, true, 0.00001 - ) - - assert.strictEqual(result.encoding, 'MULTISIGN') - - // Two MULTISIGN outputs (one per 64-byte chunk), each a well-formed - // 1-of-3 p2ms script, proving both chunks' pubkey halves are valid points. - const msOutputs = result.psbt.txOutputs.filter(o => { - const d = bitcoin.script.decompile(o.script) - return d && d[d.length - 1] === bitcoin.opcodes.OP_CHECKMULTISIG - }) - assert.strictEqual(msOutputs.length, 2, 'should emit one MULTISIGN output per chunk') - for (const out of msOutputs) { - const decompiled = bitcoin.script.decompile(out.script) - assert.strictEqual(decompiled[0], bitcoin.opcodes.OP_1) - assert.strictEqual(decompiled[4], bitcoin.opcodes.OP_3) - assert.strictEqual(decompiled[5], bitcoin.opcodes.OP_CHECKMULTISIG) - } - }) - }) - - describe('return value', () => { - it('returns { psbt, encoding } where psbt is a Psbt instance', async () => { - const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - assert.ok(result.psbt instanceof bitcoin.Psbt) - assert.strictEqual(typeof result.encoding, 'string') - }) - }) - - describe('estimateSpendingP2shTx()', () => { - it('returns 10 + P2SH input + OP_RETURN + 8-byte safety margin', () => { - const encoder = makeEncoder() - const redeemData = Buffer.alloc(200, 0xAA) - - const expected = 10 - + TxSizeEstimator.estimateP2shInputWithRedeem(redeemData) - + TxSizeEstimator.estimateOpReturnOutput( - Buffer.concat([Buffer.from('XCHN'), Buffer.from('p2sh')]) - ) - + 8 // safety margin for DER-sig length jitter - - assert.strictEqual(encoder.estimateSpendingP2shTx(redeemData), expected) - }) - - it('increases monotonically with redeem data size', () => { - const encoder = makeEncoder() - const small = encoder.estimateSpendingP2shTx(Buffer.alloc(100)) - const large = encoder.estimateSpendingP2shTx(Buffer.alloc(400)) - assert.ok(large > small) - // Delta is at least the raw data delta (300); the larger redeem may - // cross the OP_PUSHDATA2 (256) and scriptSig-varint (253) boundaries, - // each adding bytes, so we assert monotonicity, not exact equality. - assert.ok(large - small >= 300) - }) - }) -}) - -// `data` has always been optional on the wire ("payment-only tx"), but the -// emission path still wrote a nulldata output for it: the empty payload -// compiled to an OP_0 push, took the 4-byte magic word, and shipped as a -// magic-word-only OP_RETURN carrying no action. Every plain native-coin send -// the wallet made paid for that output and announced itself as an XChain -// transaction while containing nothing to decode. - -describe('XChainEncoder.createTransaction() payment-only', () => { - const opReturnOutputs = (psbt) => - psbt.txOutputs.filter((o) => bitcoin.script.toASM(o.script).startsWith('OP_RETURN')) - - it('emits NO OP_RETURN output when there is no action payload', async () => { - const encoder = makeEncoder() - const result = await encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, - [{ address: TEST_ADDRESS, value: '1000000' }], - null, null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(opReturnOutputs(result.psbt).length, 0, - 'a payment with no action must not carry a nulldata output') - // The payment itself still has to be there, or "no OP_RETURN" would pass - // on a transaction that also lost the thing it was meant to send. - assert.ok(result.psbt.txOutputs.some((o) => Number(o.value) === 1000000), - 'the customOutput payment is missing') - }) - - it('treats an empty-string action the same as an absent one', async () => { - const encoder = makeEncoder() - const result = await encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, - [{ address: TEST_ADDRESS, value: '1000000' }], - '', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - assert.strictEqual(opReturnOutputs(result.psbt).length, 0) - }) - - it('still emits the OP_RETURN when an action IS present', async () => { - const encoder = makeEncoder() - const result = await encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, - [{ address: TEST_ADDRESS, value: '1000000' }], - 'SEND|0|TOKEN|1|' + TEST_ADDRESS, null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - assert.strictEqual(opReturnOutputs(result.psbt).length, 1, - 'dropping the nulldata output for real actions would break every action') - }) - - it('still emits a nulldata output for a rawData-only payload', async () => { - // rawData with no `data` is a deliberately-supported shape and IS content; - // "no action string" must not be read as "nothing to write". - const encoder = makeEncoder() - const result = await encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, - null, 'rawpayload', 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - assert.strictEqual(opReturnOutputs(result.psbt).length, 1) - }) -}) - -// The builder is a supported library entry point, and api.js's validateAll sits -// in front of the JSON-RPC surface only. Every payload guard asserted here is -// asserted through encoder.createTransaction() with NO validator call in front -// of it: a suite that only exercises validator.validateAll stays green with the -// guards absent from the builder, which is exactly how these bypasses shipped. -describe('XChainEncoder.createTransaction() payload guards (library boundary)', () => { - - const nulldataOutputs = (psbt) => - psbt.txOutputs.filter((o) => bitcoin.script.toASM(o.script).startsWith('OP_RETURN')) - - // Latin-1 truncation: U+0100 silently became the byte 0x00 on a fee-paid - // transaction, and the compiled-size ceiling cannot see it (the length does - // not change). Written as fromCharCode so the source carries no literal high - // character, matching validator.js's firstNonLatin1. - it('rejects a rawData code unit above U+00FF instead of truncating it', async () => { - const encoder = makeEncoder() - await assert.rejects( - () => encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, - 'SEND|0|TOKEN|1|' + TEST_ADDRESS, String.fromCharCode(0x0100), 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ), - (err) => err instanceof RangeError && /U\+00FF/.test(err.message) - ) - }) - - it('rejects a `data` string that is not well-formed Unicode', async () => { - const encoder = makeEncoder() - await assert.rejects( - () => encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, - 'SEND|\uD800|x', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ), - (err) => err instanceof RangeError && /well-formed/.test(err.message) - ) - }) - - // Minimal-op canonicalization: a lone 0x05 compiles to a bare OP_5 and the - // decoder's Buffer.isBuffer element test discards it. - it('rejects a single minimal-opcode rawData byte', async () => { - const encoder = makeEncoder() - await assert.rejects( - () => encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, - 'FILE|0|doc', String.fromCharCode(0x05), 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ), - (err) => err instanceof RangeError && /rawData/.test(err.message) - ) - }) - - it('rejects a single minimal-opcode `data` byte', async () => { - const encoder = makeEncoder() - await assert.rejects( - () => encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, - String.fromCharCode(0x05), null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ), - (err) => err instanceof RangeError && /data/.test(err.message) - ) - }) - - // The guards must close a data-loss path without narrowing the shapes the - // library already builds. These four are the ones the two placements could - // plausibly have broken. - it('still builds the deliberately-supported rawData-only shape', async () => { - const encoder = makeEncoder() - const result = await encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, - null, 'rawpayload', 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - assert.strictEqual(nulldataOutputs(result.psbt).length, 1) - }) - - it('still builds a payment-only transaction with no payload at all', async () => { - const encoder = makeEncoder() - const result = await encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, - [{ address: TEST_ADDRESS, value: '1000000' }], - null, null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - assert.strictEqual(nulldataOutputs(result.psbt).length, 0) - }) - - it('still builds an ordinary multi-byte data + rawData payload', async () => { - const encoder = makeEncoder() - const result = await encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, - 'FILE|0|doc', 'file-bytes-here', 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - assert.ok(result.psbt instanceof bitcoin.Psbt) - }) - - // The decodability guard must see Buffer inputs too. A Buffer is copied byte - // for byte, which is why the latin-1 guard can stay string-only, but - // canonicalization is a property of the COMPILED push, so a one-byte Buffer - // in the minimal-op range loses everything the same way its string spelling - // does. Scoping this guard to strings left the hole open on exactly the - // surface the finding is about (direct library callers). - it('rejects a single minimal-opcode rawData byte passed as a Buffer', async () => { - const encoder = makeEncoder() - await assert.rejects( - () => encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, - 'FILE|0|doc', Buffer.from([0x05]), 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ), - (err) => err instanceof RangeError && /rawData/.test(err.message) - ) - }) - - it('rejects a single minimal-opcode `data` byte passed as a Buffer', async () => { - const encoder = makeEncoder() - await assert.rejects( - () => encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, - Buffer.from([0x05]), null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ), - (err) => err instanceof RangeError && /data/.test(err.message) - ) - }) - - // A multi-byte Buffer rawData is copied byte-for-byte by - // Buffer.from(rawData,'binary'), so nothing is lost and nothing here may - // start refusing it: the guards close a data-loss path, they do not become - // argument-shape policy. - it('still accepts a Buffer rawData from a library caller', async () => { - const encoder = makeEncoder() - const result = await encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, - 'FILE|0|doc', Buffer.from('file-bytes-here', 'binary'), 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - assert.ok(result.psbt instanceof bitcoin.Psbt) - }) - - // The ACTION-name parity gap. validateAll refuses an unrecognized leading - // token on the JSON-RPC path, so the direct library entry point is the one - // surface that can compile and pay for a payload every decoder then drops. The - // builder reports it instead of refusing, because a non-ACTION payload is a - // supported library shape; what must not happen is the silence. - const warningCodes = (result) => (result.warnings || []).map((w) => w.code) - - it('warns a library caller whose data leads with an unknown ACTION name', async () => { - const encoder = makeEncoder() - const result = await encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, - 'TRANSFRE|0|TOKEN|1|^2', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - assert.ok(result.psbt instanceof bitcoin.Psbt, 'the build must still succeed') - assert.ok(warningCodes(result).includes('UNKNOWN_ACTION_NAME')) - assert.ok(/TRANSFRE/.test(result.warnings.find((w) => w.code === 'UNKNOWN_ACTION_NAME').message)) - }) - - it('warns when an unknown ACTION name arrives as a Buffer', async () => { - const encoder = makeEncoder() - const result = await encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, - Buffer.from('TRANSFRE|0|TOKEN|1|^2', 'utf8'), null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - assert.ok(warningCodes(result).includes('UNKNOWN_ACTION_NAME')) - }) - - // The negative controls: a canonical name and an alias must produce NO - // advisory, or the warning is decoration rather than a signal. - it('does not warn on a canonical ACTION name', async () => { - const encoder = makeEncoder() - const result = await encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, - 'SEND|0|TOKEN|1|^2', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - assert.ok(!warningCodes(result).includes('UNKNOWN_ACTION_NAME')) - }) - - it('does not warn on a known ACTION alias', async () => { - const encoder = makeEncoder() - const result = await encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, - 'TRANSFER|0|TOKEN|1|^2', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - assert.ok(!warningCodes(result).includes('UNKNOWN_ACTION_NAME')) - }) }) diff --git a/test/unit/xchain_encoder_create_transaction.test/01_utxo_deduplication.test.js b/test/unit/xchain_encoder_create_transaction.test/01_utxo_deduplication.test.js new file mode 100644 index 0000000..9e399c1 --- /dev/null +++ b/test/unit/xchain_encoder_create_transaction.test/01_utxo_deduplication.test.js @@ -0,0 +1,37 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { + assert, + TXID_A, + TXID_B, + makeSegwitUtxo, + makeEncoder, + TEST_ADDRESS +} = require('./fixtures/transaction') + +describe('XChainEncoder.createTransaction()', () => { + describe('UTXO deduplication', () => { + it('removes duplicate UTXOs with same txid+vout', async () => { + const encoder = makeEncoder() + const dup1 = makeSegwitUtxo(TXID_A, 0, 50000000) + const dup2 = makeSegwitUtxo(TXID_A, 0, 50000000) + const unique = makeSegwitUtxo(TXID_B, 1, 30000000) + + const result = await encoder.createTransaction( + [dup1, dup2, unique], TEST_ADDRESS, null, + 'test', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + assert.ok(result.psbt.data.inputs.length <= 2) + }) + }) +}) diff --git a/test/unit/xchain_encoder_create_transaction.test/02_unconfirmed_filtering.test.js b/test/unit/xchain_encoder_create_transaction.test/02_unconfirmed_filtering.test.js new file mode 100644 index 0000000..fa2e6dd --- /dev/null +++ b/test/unit/xchain_encoder_create_transaction.test/02_unconfirmed_filtering.test.js @@ -0,0 +1,52 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { + assert, + TXID_A, + TXID_B, + makeSegwitUtxo, + makeEncoder, + TEST_ADDRESS +} = require('./fixtures/transaction') + +describe('XChainEncoder.createTransaction()', () => { + describe('unconfirmed filtering', () => { + it('excludes mempool UTXOs when unconfirmed=false', async () => { + const encoder = makeEncoder() + const confirmed = makeSegwitUtxo(TXID_A, 0, 100000000) + confirmed.confirmations = 6 + const mempool = makeSegwitUtxo(TXID_B, 0, 50000000) + mempool.confirmations = 0 + + const result = await encoder.createTransaction( + [mempool, confirmed], TEST_ADDRESS, null, + 'test', null, 10000, false, null, TEST_ADDRESS, + null, null, null, false, 0.00001 + ) + + assert.strictEqual(result.psbt.data.inputs.length, 1) + }) + + it('keeps mempool UTXOs when unconfirmed=true', async () => { + const encoder = makeEncoder() + const mempool = makeSegwitUtxo(TXID_A, 0, 100000000) + mempool.confirmations = 0 + + const result = await encoder.createTransaction( + [mempool], TEST_ADDRESS, null, + 'test', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(result.psbt.data.inputs.length, 1) + }) + }) +}) diff --git a/test/unit/xchain_encoder_create_transaction.test/03_utxo_sorting.test.js b/test/unit/xchain_encoder_create_transaction.test/03_utxo_sorting.test.js new file mode 100644 index 0000000..ad12622 --- /dev/null +++ b/test/unit/xchain_encoder_create_transaction.test/03_utxo_sorting.test.js @@ -0,0 +1,37 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { + assert, + TXID_A, + TXID_B, + makeSegwitUtxo, + makeEncoder, + TEST_ADDRESS +} = require('./fixtures/transaction') + +describe('XChainEncoder.createTransaction()', () => { + describe('UTXO sorting (largest first)', () => { + it('uses the largest UTXO first', async () => { + const encoder = makeEncoder() + const small = makeSegwitUtxo(TXID_A, 0, 10000000) + const large = makeSegwitUtxo(TXID_B, 0, 100000000) + + const result = await encoder.createTransaction( + [small, large], TEST_ADDRESS, null, + 'test', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + // The large UTXO alone covers the fee, so only 1 input needed + assert.strictEqual(result.psbt.data.inputs.length, 1) + }) + }) +}) diff --git a/test/unit/xchain_encoder_create_transaction.test/04_replace_by_fee_sequence.test.js b/test/unit/xchain_encoder_create_transaction.test/04_replace_by_fee_sequence.test.js new file mode 100644 index 0000000..0b019b5 --- /dev/null +++ b/test/unit/xchain_encoder_create_transaction.test/04_replace_by_fee_sequence.test.js @@ -0,0 +1,75 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { + assert, + TXID_A, + makeSegwitUtxo, + makeEncoder, + TEST_ADDRESS +} = require('./fixtures/transaction') + +describe('XChainEncoder.createTransaction()', () => { + describe('replace-by-fee sequence', () => { + it('sets sequence to 0xfffffffd when rbf=true (RBF armed, BIP68 still disabled)', async () => { + const encoder = makeEncoder() + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', null, 10000, true, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + const inputData = result.psbt.txInputs[0] + assert.strictEqual(inputData.sequence, 0xfffffffd) + }) + + // The literal above pins WHICH value; this pins WHY, and it is the assertion + // that would have caught the original bug. nSequence=0x00000001 also signals + // RBF, so every value-only test passed while the encoder was quietly enabling + // BIP68 relative locktime of 1 block on every RBF transaction. That is + // invisible against confirmed inputs and rejects any spend of unconfirmed + // change as non-BIP68-final, which made RBF and chained sends mutually + // exclusive, measured on BTC testnet4. + it('arms RBF without enabling BIP68 relative locktime', async () => { + const encoder = makeEncoder() + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', null, 10000, true, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + const seq = result.psbt.txInputs[0].sequence + // Below 0xfffffffe: BIP125 sees this as replaceable. + assert.ok(seq < 0xfffffffe, 'sequence ' + seq.toString(16) + ' does not signal RBF') + // Bit 31 SET: BIP68 relative locktime is disabled, so the input carries no + // "must be N blocks old" assertion and unconfirmed change stays spendable. + assert.notStrictEqual(seq & 0x80000000, 0, + 'sequence ' + seq.toString(16) + ' leaves BIP68 enabled; unconfirmed change becomes unspendable') + }) + + it('sets sequence to 0xffffffff when rbf=false', async () => { + const encoder = makeEncoder() + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + const inputData = result.psbt.txInputs[0] + assert.strictEqual(inputData.sequence, 0xffffffff) + }) + }) +}) diff --git a/test/unit/xchain_encoder_create_transaction.test/05_fee_handling.test.js b/test/unit/xchain_encoder_create_transaction.test/05_fee_handling.test.js new file mode 100644 index 0000000..604e9af --- /dev/null +++ b/test/unit/xchain_encoder_create_transaction.test/05_fee_handling.test.js @@ -0,0 +1,121 @@ +// 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, + TXID_A, + makeSegwitUtxo, + makeEncoder, + TEST_ADDRESS +} = require('./fixtures/transaction') + +describe('XChainEncoder.createTransaction()', () => { + describe('fee handling', () => { + it('uses custom fee when provided', async () => { + const encoder = makeEncoder() + // The relative fee-rate cap has its own suite (xchain_encoder_fee_rate_cap.test.js) + // and the absolute burn backstop has its own test below; disable/isolate + // both here so only the passthrough behaviour is under test. + encoder.maxFeeRateMultiplier = null + // Isolate the custom-fee passthrough from the network dust floor (exercised + // by the dust-floor tests below); a fee at or above dust would otherwise be + // floored up and mask the value being tested. + encoder.dustAmount = 546 + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + // Kept under the 100x-fair-fee burn backstop (fair fee ~131 sats for this + // ~131-byte tx, so the backstop ceiling is ~13100 sats). + const customFee = 5000 + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', null, customFee, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + // Change = input - fee (no other outputs besides OP_RETURN which is 0) + const changeOutput = result.psbt.txOutputs.find(o => o.value > 0) + assert.strictEqual(changeOutput.value, 100000000 - customFee) + }) + + it('throws RangeError when the custom fee exceeds 100x the fair-fee estimate (burn backstop)', async () => { + const encoder = makeEncoder() + // Disable the relative rate cap so the fixed burn backstop is the only + // guard in play; the backstop holds even with the rate cap turned off. + encoder.maxFeeRateMultiplier = null + encoder.dustAmount = 546 + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + // Fair fee is ~131 sats for this ~131-byte tx, so the backstop ceiling + // is ~13100 sats; 20000 is comfortably over it. + const customFee = 20000 + + await assert.rejects( + encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', null, customFee, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ), + (err) => err instanceof RangeError && /100x the estimated fair fee/.test(err.message) + ) + }) + }) +}) + +describe('XChainEncoder.createTransaction()', () => { + describe('fee handling', () => { + it('floors fee to dustAmount when computed fee is lower', async () => { + const encoder = makeEncoder() + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + // Very low feePerKb should produce a fee below dust + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', null, null, false, null, TEST_ADDRESS, + null, null, null, true, 0.0000001 // very low fee rate + ) + + const changeOutput = result.psbt.txOutputs.find(o => o.value > 0) + const impliedFee = 100000000 - changeOutput.value + assert.ok(impliedFee >= encoder.dustAmount, + `fee ${impliedFee} should be >= dustAmount ${encoder.dustAmount}`) + }) + + it('uses feePerKb parameter when provided (no RPC call)', async () => { + const encoder = makeEncoder() + // Make the RPC mock throw to prove it's not called + encoder.connector.getFeePerKilobyte = async () => { + throw new Error('should not be called') + } + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', null, null, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + assert.ok(result.psbt) + }) + + it('calls connector.getFeePerKilobyte when feePerKb is null', async () => { + const encoder = makeEncoder() + let called = false + encoder.connector.getFeePerKilobyte = async () => { + called = true + return 0.00001 + } + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + + await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', null, null, false, null, TEST_ADDRESS, + null, null, null, true, null + ) + assert.strictEqual(called, true) + }) + }) +}) diff --git a/test/unit/xchain_encoder_create_transaction.test/06_custom_dust_parameter.test.js b/test/unit/xchain_encoder_create_transaction.test/06_custom_dust_parameter.test.js new file mode 100644 index 0000000..6b65e0f --- /dev/null +++ b/test/unit/xchain_encoder_create_transaction.test/06_custom_dust_parameter.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. + +const { + assert, + pubkeyBuf, + TXID_A, + TXID_MS, + makeSegwitUtxo, + makeEncoder, + TEST_ADDRESS +} = require('./fixtures/transaction') + +describe('XChainEncoder.createTransaction()', () => { + describe('custom dust parameter', () => { + it('overrides finalDust for MULTISIGN output value when it raises the floor', async () => { + 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 + assert.ok(customDust > encoder.outputFloor) + + const MS_DATA = 'A'.repeat(59) + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + MS_DATA, null, 10000, false, 'MULTISIGN', TEST_ADDRESS, + null, null, compressedPubKey, + true, 0.00001, customDust + ) + + const msOutput = result.psbt.txOutputs.find(o => o.value === customDust) + assert.ok(msOutput, 'MULTISIGN output should use custom dust value of 6000') + }) + }) +}) + +describe('XChainEncoder.createTransaction()', () => { + describe('custom dust parameter', () => { + it('clamps a caller dust below the output floor up to the floor', async () => { + // 1234 litoshi is under LTC's 5460 dust threshold: an output that size is + // non-standard and the whole transaction unrelayable, so a caller may + // raise the floor but never lower it. + const encoder = makeEncoder() + const utxo = makeSegwitUtxo(TXID_MS, 0, 100000000) + const compressedPubKey = pubkeyBuf.toString('hex') + const customDust = 1234 + assert.ok(customDust < encoder.outputFloor) + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'A'.repeat(59), null, 10000, false, 'MULTISIGN', TEST_ADDRESS, + null, null, compressedPubKey, + true, 0.00001, customDust + ) + + 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') + }) + + it('does NOT override the fee floor (fee floor uses this.dustAmount)', async () => { + const encoder = makeEncoder() + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + + // Pass custom dust smaller than network dustAmount + // Fee floor should still use this.dustAmount (546), not the custom dust + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', null, null, false, null, TEST_ADDRESS, + null, null, null, + true, 0.0000001, 100 // custom dust = 100, but network dust = 546 + ) + + const changeOutput = result.psbt.txOutputs.find(o => o.value > 0) + const impliedFee = 100000000 - changeOutput.value + assert.ok(impliedFee >= encoder.dustAmount, + `fee ${impliedFee} should be >= network dustAmount ${encoder.dustAmount}`) + }) + }) +}) diff --git a/test/unit/xchain_encoder_create_transaction.test/07_change_output.test.js b/test/unit/xchain_encoder_create_transaction.test/07_change_output.test.js new file mode 100644 index 0000000..33f9595 --- /dev/null +++ b/test/unit/xchain_encoder_create_transaction.test/07_change_output.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. + +const { + assert, + TXID_A, + makeSegwitUtxo, + makeEncoder, + TEST_ADDRESS +} = require('./fixtures/transaction') + +describe('XChainEncoder.createTransaction()', () => { + describe('change output', () => { + it('adds change output when there is leftover and change address given', async () => { + const encoder = makeEncoder() + // Isolate the change math from the network dust floor so the 10000 custom fee + // flows into change verbatim (the dust floor has its own suite). + encoder.dustAmount = 546 + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + const changeOutput = result.psbt.txOutputs.find(o => o.value > 0) + assert.ok(changeOutput, 'change output should exist') + assert.strictEqual(changeOutput.value, 100000000 - 10000) + }) + + it('throws when change address is falsy and change would be burned', async () => { + const encoder = makeEncoder() + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + + await assert.rejects( + () => encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', null, 10000, false, null, null, // no change address + null, null, null, true, 0.00001 + ), + /change address/i + ) + }) + }) +}) diff --git a/test/unit/xchain_encoder_create_transaction.test/08_no_utxos_available.test.js b/test/unit/xchain_encoder_create_transaction.test/08_no_utxos_available.test.js new file mode 100644 index 0000000..33e94e7 --- /dev/null +++ b/test/unit/xchain_encoder_create_transaction.test/08_no_utxos_available.test.js @@ -0,0 +1,128 @@ +// 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, + bitcoin, + pubkeyBuf, + TXID_A, + makeSegwitUtxo, + makeLegacyUtxo, + makeEncoder, + LTC_REGTEST, + TEST_ADDRESS +} = require('./fixtures/transaction') + +describe('XChainEncoder.createTransaction()', () => { + describe('no UTXOs available', () => { + it('throws when utxos is empty and tracker returns nothing', async () => { + const encoder = makeEncoder() + encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ + utxos: [] + }) + + await assert.rejects( + () => encoder.createTransaction( + [], TEST_ADDRESS, null, + 'test', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ), + /no utxos/i + ) + }) + + it('throws when utxos is null and tracker returns null', async () => { + const encoder = makeEncoder() + encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ + utxos: null + }) + + await assert.rejects( + () => encoder.createTransaction( + null, TEST_ADDRESS, null, + 'test', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ), + /no utxos/i + ) + }) + + it('falls back to utxoTracker when utxos param is null', async () => { + const encoder = makeEncoder() + let trackerCalled = false + encoder.utxoTrackerConnector.getUtxosFromAddress = async () => { + trackerCalled = true + return { utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)] } + } + + await encoder.createTransaction( + null, TEST_ADDRESS, null, + 'test', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(trackerCalled, true) + }) + }) +}) + +describe('XChainEncoder.createTransaction()', () => { + describe('no UTXOs available', () => { + // Regression: every wallet flow sends its source as a raw compressed + // pubkey hex (xchain-wallet's `source.publicKey`), not an address - the + // "pubkey" param name is legacy/misleading (validator.js documents it as + // "actually the sender address"). Before this fix, that raw pubkey hex + // reached getUtxosFromAddress unresolved, and bitcoinjs-lib's + // address.toOutputScript rejected it as neither valid base58 nor bech32 + // (" has no matching Script"), breaking every UTXO-tracker-backed + // compose (any call that does not pre-supply `utxos`) for a live wallet. + it('resolves a raw pubkey (not an address) to P2WPKH before querying the tracker on a segwit network', async () => { + const encoder = makeEncoder() + let queriedAddress = null + encoder.utxoTrackerConnector.getUtxosFromAddress = async (address) => { + queriedAddress = address + return { utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)] } + } + + const rawPubkeyHex = pubkeyBuf.toString('hex') + await encoder.createTransaction( + null, rawPubkeyHex, null, + 'test', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + // A segwit-capable network's default address type is P2WPKH. + const expected = bitcoin.payments.p2wpkh({ pubkey: pubkeyBuf, network: LTC_REGTEST }).address + assert.strictEqual(queriedAddress, expected) + }) + + // The other branch of the same resolution: a chain without segwit defaults to + // P2PKH, and its tracker query must carry a base58 address. + it('resolves a raw pubkey to P2PKH before querying the tracker on a network without segwit', async () => { + const encoder = makeEncoder('dogecoin-regtest') + let queriedAddress = null + encoder.utxoTrackerConnector.getUtxosFromAddress = async (address) => { + queriedAddress = address + return { utxos: [makeLegacyUtxo(TXID_A, 0, 100000000)] } + } + + const dogeNetwork = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') + const dogeAddress = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: dogeNetwork }).address + const rawPubkeyHex = pubkeyBuf.toString('hex') + await encoder.createTransaction( + null, rawPubkeyHex, null, + 'test', null, 10000, false, null, dogeAddress, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(queriedAddress, dogeAddress) + }) + }) +}) diff --git a/test/unit/xchain_encoder_create_transaction.test/09_custom_outputs.test.js b/test/unit/xchain_encoder_create_transaction.test/09_custom_outputs.test.js new file mode 100644 index 0000000..743a654 --- /dev/null +++ b/test/unit/xchain_encoder_create_transaction.test/09_custom_outputs.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. + +const { + assert, + TXID_A, + makeSegwitUtxo, + makeEncoder, + TEST_ADDRESS +} = require('./fixtures/transaction') + +describe('XChainEncoder.createTransaction()', () => { + describe('custom outputs', () => { + it('adds custom outputs with correct address and value', async () => { + const encoder = makeEncoder() + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + + const customOutputs = [ + { address: TEST_ADDRESS, value: '500000' }, + { address: TEST_ADDRESS, value: '300000' } + ] + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, customOutputs, + 'test', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + // Should have: OP_RETURN (0 value) + 2 custom + 1 change = 4 outputs + const outputs = result.psbt.txOutputs + const customValues = outputs.filter(o => + o.value === 500000 || o.value === 300000 + ) + assert.strictEqual(customValues.length, 2) + }) + + it('skips custom outputs when customOutputs is not an array', async () => { + const encoder = makeEncoder() + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + + // Pass an object (not array); should be silently skipped + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, { address: TEST_ADDRESS, value: 1000 }, + 'test', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + // Should only have OP_RETURN + change = 2 outputs + assert.strictEqual(result.psbt.txOutputs.length, 2) + }) + + it('skips custom outputs when customOutputs is null', async () => { + const encoder = makeEncoder() + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(result.psbt.txOutputs.length, 2) + }) + }) +}) diff --git a/test/unit/xchain_encoder_create_transaction.test/10_op_return_encoding_path.test.js b/test/unit/xchain_encoder_create_transaction.test/10_op_return_encoding_path.test.js new file mode 100644 index 0000000..03a4dd9 --- /dev/null +++ b/test/unit/xchain_encoder_create_transaction.test/10_op_return_encoding_path.test.js @@ -0,0 +1,63 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { + assert, + bitcoin, + TXID_A, + makeSegwitUtxo, + makeEncoder, + TEST_ADDRESS +} = require('./fixtures/transaction') + +describe('XChainEncoder.createTransaction()', () => { + describe('OP_RETURN encoding path', () => { + it('auto-selects OP_RETURN for small data and returns correct encoding', async () => { + const encoder = makeEncoder() + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'Small', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(result.encoding, 'OP_RETURN') + }) + + it('OP_RETURN output has value 0', async () => { + const encoder = makeEncoder() + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'Data', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + const opReturnOutput = result.psbt.txOutputs.find(o => o.value === 0) + assert.ok(opReturnOutput, 'should have an OP_RETURN output with value 0') + }) + + it('OP_RETURN output script starts with OP_RETURN opcode (0x6a)', async () => { + const encoder = makeEncoder() + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'Data', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + const opReturnOutput = result.psbt.txOutputs.find(o => o.value === 0) + assert.strictEqual(opReturnOutput.script[0], bitcoin.opcodes.OP_RETURN) + }) + }) +}) diff --git a/test/unit/xchain_encoder_create_transaction.test/11_p2sh_encoding_funding.test.js b/test/unit/xchain_encoder_create_transaction.test/11_p2sh_encoding_funding.test.js new file mode 100644 index 0000000..c90b7a5 --- /dev/null +++ b/test/unit/xchain_encoder_create_transaction.test/11_p2sh_encoding_funding.test.js @@ -0,0 +1,57 @@ +// 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, + TXID_A, + makeSegwitUtxo, + makeEncoder, + TEST_ADDRESS +} = require('./fixtures/transaction') + +describe('XChainEncoder.createTransaction()', () => { + describe('P2SH encoding path (tx1: funding)', () => { + it('creates P2SH output for large data', async () => { + const encoder = makeEncoder() + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const bigData = 'x'.repeat(80) // exceeds OP_RETURN limit + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + bigData, null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(result.encoding, 'P2SH') + // Should have P2SH output(s) + change + const nonZeroOutputs = result.psbt.txOutputs.filter(o => o.value > 0) + assert.ok(nonZeroOutputs.length >= 2, 'should have P2SH output + change') + }) + + it('P2SH output value covers the spending tx fee', async () => { + const encoder = makeEncoder() + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const bigData = 'x'.repeat(80) + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + bigData, null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + const p2shOutput = result.psbt.txOutputs.find(o => + o.value > 0 && o.value < 100000000 + ) + assert.ok(p2shOutput, 'P2SH output should exist') + assert.ok(p2shOutput.value >= encoder.dustAmount, + 'P2SH output value should be >= dust') + }) + }) +}) diff --git a/test/unit/xchain_encoder_create_transaction.test/12_p2sh_encoding_spending.test.js b/test/unit/xchain_encoder_create_transaction.test/12_p2sh_encoding_spending.test.js new file mode 100644 index 0000000..2974a93 --- /dev/null +++ b/test/unit/xchain_encoder_create_transaction.test/12_p2sh_encoding_spending.test.js @@ -0,0 +1,54 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const { + assert, + crypto, + TXID_A, + makeSegwitUtxo, + makeEncoder, + TEST_ADDRESS +} = require('./fixtures/transaction') + +describe('XChainEncoder.createTransaction()', () => { + describe('P2SH encoding path (tx2: spending)', () => { + it('creates tx2 with P2SH input and OP_RETURN marker', async () => { + const encoder = makeEncoder() + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const bigData = 'x'.repeat(80) + + // First create tx1 to get the hex + const tx1Result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + bigData, null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + // Extract tx1 hex (unsigned, but structurally valid for our test) + const tx1Psbt = tx1Result.psbt + const tx1Hex = tx1Psbt.__CACHE.__TX.toHex() + const tx1Id = tx1Psbt.__CACHE.__TX.getId() + + // Create tx2 + const tx2Result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + bigData, null, 10000, false, null, TEST_ADDRESS, + tx1Id, tx1Hex, null, true, 0.00001 + ) + + assert.strictEqual(tx2Result.encoding, 'P2SH') + // tx2 should have at least 1 input (the P2SH spend) + assert.ok(tx2Result.psbt.data.inputs.length >= 1) + // tx2 should have an OP_RETURN marker output + const opReturnOutput = tx2Result.psbt.txOutputs.find(o => o.value === 0) + assert.ok(opReturnOutput, 'tx2 should have OP_RETURN marker') + }) + }) +}) diff --git a/test/unit/xchain_encoder_create_transaction.test/13_p2sh_native_fee_custom_outputs.test.js b/test/unit/xchain_encoder_create_transaction.test/13_p2sh_native_fee_custom_outputs.test.js new file mode 100644 index 0000000..211d171 --- /dev/null +++ b/test/unit/xchain_encoder_create_transaction.test/13_p2sh_native_fee_custom_outputs.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. + +const { + assert, + TXID_A, + makeSegwitUtxo, + makeEncoder, + LTC_REGTEST, + TxSizeEstimator, + TEST_ADDRESS +} = require('./fixtures/transaction') + +// On native-fee chains a real FEE_DESTINATION rides as a customOutputs entry. +// The indexer treats the P2SH REVEAL (phase 2) as the action and reads the fee +// output from it, so the fee output must land on the reveal, never on the +// funding tx. But the reveal's only inputs are the funding outputs, so the +// funding tx must over-fund by the fee value (without emitting the output) or +// the reveal cannot pay it. These tests pin both halves of that contract. +const FEE_VALUE = 10678 // a representative native-fee output value (sats/koinu) +const bigData = 'x'.repeat(80) // exceeds OP_RETURN limit, forces P2SH + +function feeOutputs () { + return [{ address: TEST_ADDRESS, value: FEE_VALUE }] +} + +// Find the P2SH funding output (a positive-value output that is not the change). +// Change equals input minus everything else and is by far the largest output. +function findFundingOutput (psbt) { + const positive = psbt.txOutputs.filter(o => o.value > 0) + positive.sort((a, b) => a.value - b.value) + return positive[0] +} + +describe('XChainEncoder.createTransaction()', () => { + describe('P2SH native-fee customOutputs', () => { + it('phase 1 (funding) does NOT emit the fee-destination output', async () => { + const encoder = makeEncoder() + encoder.dustAmount = 546 + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, feeOutputs(), + bigData, null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(result.encoding, 'P2SH') + // The P2SH funding tx is just [P2SH funding output, change] (the OP_RETURN + // marker rides the reveal, not the funding tx). No fee-destination output + // is emitted here: a standalone output worth exactly FEE_VALUE would mean + // the fee was put on the wrong tx and later double-paid on the reveal. + const emittedFee = result.psbt.txOutputs.find(o => o.value === FEE_VALUE) + assert.ok(!emittedFee, 'funding tx must not emit a standalone fee-destination output') + assert.strictEqual(result.psbt.txOutputs.length, 2, + 'funding tx must be [P2SH funding output, change] only') + }) + }) +}) + +describe('XChainEncoder.createTransaction()', () => { + describe('P2SH native-fee customOutputs', () => { + it('phase 1 (funding) over-funds the P2SH output by the fee value plus its byte cost', async () => { + const encoder = makeEncoder() + // Pin both floors: the reveal's change headroom keys on outputFloor, the fee + // floor on dustAmount, and the delta below assumes one value for each. + encoder.dustAmount = 546 + encoder.outputFloor = 546 + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + + // Baseline funding output value with no customOutputs. + const base = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + bigData, null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + const baseFunding = findFundingOutput(base.psbt).value + + // Same call but with the fee customOutput: the funding output must grow by + // FEE_VALUE so the reveal can pay the fee, plus the reveal's own miner + // fee for the bytes that output adds to the reveal. Without the second + // term the reveal lands under the node's min-relay floor. + // + // It grows by one dust LESS than that, and the difference is the point. + // The baseline reveal emits no value output of its own, so its funding + // must also carry a dust for the change output that keeps it from burning + // every satoshi to miners. A reveal already carrying a + // fee output leaves value by definition and needs no change output, so + // funding that dust too would buy the caller a third output they never + // asked for - which is what REG-14 pins against on this exact shape. + // + // Same input as the baseline on purpose: release its reservation first. + encoder.clearReservations() + const withFee = await encoder.createTransaction( + [utxo], TEST_ADDRESS, feeOutputs(), + bigData, null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + const feeFunding = findFundingOutput(withFee.psbt).value + + // Same conversion the encoder applies: feePerKb -> internal coin/byte. + const feePerBytes = 0.00001 / 1000 / 1e8 + const feeOutputBytes = TxSizeEstimator.estimateOutputSizeForAddress(TEST_ADDRESS, LTC_REGTEST) + const revealByteFee = Math.ceil(feeOutputBytes * feePerBytes * 1e8) + + assert.strictEqual(feeFunding - baseFunding, FEE_VALUE + revealByteFee - encoder.outputFloor, + 'funding output must grow by the fee value plus the fee output byte cost, ' + + 'less the change headroom (one output floor) only the outputless baseline needs') + }) + }) +}) + +describe('XChainEncoder.createTransaction()', () => { + describe('P2SH native-fee customOutputs', () => { + it('phase 2 (reveal) emits the fee-destination output funded by phase 1', async () => { + const encoder = makeEncoder() + encoder.dustAmount = 546 + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + + // Build phase 1 (funding) with the fee customOutput. + const tx1 = await encoder.createTransaction( + [utxo], TEST_ADDRESS, feeOutputs(), + bigData, null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + const tx1Hex = tx1.psbt.__CACHE.__TX.toHex() + const tx1Id = tx1.psbt.__CACHE.__TX.getId() + + // Build phase 2 (reveal) spending phase 1, with the same fee customOutput. + const tx2 = await encoder.createTransaction( + [utxo], TEST_ADDRESS, feeOutputs(), + bigData, null, 10000, false, null, TEST_ADDRESS, + tx1Id, tx1Hex, null, true, 0.00001 + ) + + assert.strictEqual(tx2.encoding, 'P2SH') + // The reveal must carry the fee-destination output (value FEE_VALUE). + const feeOut = tx2.psbt.txOutputs.find(o => o.value === FEE_VALUE) + assert.ok(feeOut, 'reveal must emit the fee-destination output') + + // And it is not a bare OP_RETURN-only tx (the fee-drop failure mode). + const opReturnOnly = tx2.psbt.txOutputs.every(o => o.value === 0) + assert.ok(!opReturnOnly, 'reveal must not be OP_RETURN-only') + + // The reveal's input value (the funded P2SH output) must cover the fee + // output so bitcoinjs does not reject it as outputs-exceed-inputs. + const fundingOut = findFundingOutput(tx1.psbt) + assert.ok(fundingOut.value >= FEE_VALUE, + 'phase-1 funding output must cover the reveal fee output') + }) + }) +}) diff --git a/test/unit/xchain_encoder_create_transaction.test/14_multisign_encoding_path.test.js b/test/unit/xchain_encoder_create_transaction.test/14_multisign_encoding_path.test.js new file mode 100644 index 0000000..b45efaf --- /dev/null +++ b/test/unit/xchain_encoder_create_transaction.test/14_multisign_encoding_path.test.js @@ -0,0 +1,167 @@ +// 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, + bitcoin, + pubkeyBuf, + TXID_MS, + makeSegwitUtxo, + makeEncoder, + TEST_ADDRESS +} = require('./fixtures/transaction') + +// MULTISIGN requires fake pubkeys that are valid EC points on secp256k1. +// dataToPubkey() prepends 0x02 and zero-pads to 33 bytes, but the x-coordinate +// must correspond to a real curve point. We use: +// - TXID_MS: a brute-forced txid that produces valid points +// - 'A'.repeat(59): data that compiles to exactly 60 bytes, producing a 64-byte +// chunk (4 magic + 60 data), so both 32-byte slices are fully populated + +const MS_DATA = 'A'.repeat(59) // compiles to 60 bytes → 64-byte chunk after XCHN prefix + +describe('XChainEncoder.createTransaction()', () => { + describe('MULTISIGN encoding path', () => { + it('creates multisig output with correct structure', async () => { + const encoder = makeEncoder() + const utxo = makeSegwitUtxo(TXID_MS, 0, 100000000) + const compressedPubKey = pubkeyBuf.toString('hex') + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + MS_DATA, null, 10000, false, 'MULTISIGN', TEST_ADDRESS, + null, null, compressedPubKey, true, 0.00001 + ) + + assert.strictEqual(result.encoding, 'MULTISIGN') + + // Find the multisig output by its script shape (a bare p2ms script ends + // in OP_CHECKMULTISIG). Its value is the relay-fee dust floor computed + // from the actual script size, which is larger than the flat P2PKH + // dustAmount (546), so we must not match on dustAmount here. + const msOutput = result.psbt.txOutputs.find(o => { + const d = bitcoin.script.decompile(o.script) + return d && d[d.length - 1] === bitcoin.opcodes.OP_CHECKMULTISIG + }) + assert.ok(msOutput, 'should have a bare-multisig output') + assert.ok(msOutput.value >= encoder.dustAmount, 'multisig output value should be at least the dust floor') + + // Decompile the script to verify 1-of-3 structure + const decompiled = bitcoin.script.decompile(msOutput.script) + // OP_1 OP_3 OP_CHECKMULTISIG + assert.strictEqual(decompiled[0], bitcoin.opcodes.OP_1) // m = 1 + assert.ok(Buffer.isBuffer(decompiled[1])) // pubkey1 + assert.ok(Buffer.isBuffer(decompiled[2])) // pubkey2 + assert.ok(Buffer.isBuffer(decompiled[3])) // pubkey3 + assert.strictEqual(decompiled[4], bitcoin.opcodes.OP_3) // n = 3 + assert.strictEqual(decompiled[5], bitcoin.opcodes.OP_CHECKMULTISIG) + }) + }) +}) + +describe('XChainEncoder.createTransaction()', () => { + describe('MULTISIGN encoding path', () => { + it('third pubkey is the real compressed public key', async () => { + const encoder = makeEncoder() + const utxo = makeSegwitUtxo(TXID_MS, 0, 100000000) + const compressedPubKey = pubkeyBuf.toString('hex') + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + MS_DATA, null, 10000, false, 'MULTISIGN', TEST_ADDRESS, + null, null, compressedPubKey, true, 0.00001 + ) + + const msOutput = result.psbt.txOutputs.find(o => { + const d = bitcoin.script.decompile(o.script) + return d && d[d.length - 1] === bitcoin.opcodes.OP_CHECKMULTISIG + }) + assert.ok(msOutput, 'should have a bare-multisig output') + const decompiled = bitcoin.script.decompile(msOutput.script) + assert.deepStrictEqual(decompiled[3], pubkeyBuf) + }) + }) +}) + +describe('XChainEncoder.createTransaction()', () => { + describe('MULTISIGN encoding path', () => { + // The MULTISIGN data output carries real value (a relay-fee dust floor), + // so that value MUST be counted toward the running output total before + // change is computed. If it isn't, change is over-credited by exactly the + // data-output value and total outputs exceed total inputs: bitcoinjs-lib + // rejects the tx at extractTransaction ("Outputs are spending more than + // Inputs") and the network rejects it as invalid. + it('builds a valid tx where total outputs do not exceed inputs', async () => { + const encoder = makeEncoder() + const inputValue = 100000000 + const utxo = makeSegwitUtxo(TXID_MS, 0, inputValue) + const compressedPubKey = pubkeyBuf.toString('hex') + + // A large dust value with a tiny fee is the exact condition that exposes + // the bug: when the data-output value exceeds the fee, omitting it from + // the output total over-credits change past the input total. fee=100, + // dust=50000. + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + MS_DATA, null, 100, false, 'MULTISIGN', TEST_ADDRESS, + null, null, compressedPubKey, true, 0.00001, 50000 + ) + + const outputTotal = result.psbt.txOutputs.reduce((sum, o) => sum + o.value, 0) + assert.ok( + outputTotal <= inputValue, + `total outputs (${outputTotal}) must not exceed total inputs (${inputValue})` + ) + }) + }) +}) + +describe('XChainEncoder.createTransaction()', () => { + describe('MULTISIGN encoding path', () => { + // A payload whose last chunk holds ≤28 data bytes produces a final + // magic(4)+data buffer of ≤32 bytes. Before each chunk was padded to a + // full 64-byte slot, obfuscatedData.slice(32) was empty, so dataToPubkey() + // returned an all-zero x-coordinate (0x02 || 0x00×32), a secp256k1 x=0 + // point that p2ms() rejects at construction time, surfacing as a 500. + // This payload compiles to 88 bytes → chunks of 60 + 28, exercising that + // path end-to-end. TXID_MS_SHORT is brute-forced so every obfuscated + // pubkey half across both chunks is a valid curve point. + const TXID_MS_SHORT = '5efa379f1f220474a1c6d3114efb4bcac3e38d54e2003db334d1cb97c2f8bfba' + const MS_DATA_SHORT = 'Z'.repeat(86) // compiles to 88 bytes → last chunk = magic(4) + 28 + + it('encodes a short final chunk without throwing (x=0 pubkey regression)', async () => { + const encoder = makeEncoder() + const utxo = makeSegwitUtxo(TXID_MS_SHORT, 0, 100000000) + const compressedPubKey = pubkeyBuf.toString('hex') + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + MS_DATA_SHORT, null, 10000, false, 'MULTISIGN', TEST_ADDRESS, + null, null, compressedPubKey, true, 0.00001 + ) + + assert.strictEqual(result.encoding, 'MULTISIGN') + + // Two MULTISIGN outputs (one per 64-byte chunk), each a well-formed + // 1-of-3 p2ms script, proving both chunks' pubkey halves are valid points. + const msOutputs = result.psbt.txOutputs.filter(o => { + const d = bitcoin.script.decompile(o.script) + return d && d[d.length - 1] === bitcoin.opcodes.OP_CHECKMULTISIG + }) + assert.strictEqual(msOutputs.length, 2, 'should emit one MULTISIGN output per chunk') + for (const out of msOutputs) { + const decompiled = bitcoin.script.decompile(out.script) + assert.strictEqual(decompiled[0], bitcoin.opcodes.OP_1) + assert.strictEqual(decompiled[4], bitcoin.opcodes.OP_3) + assert.strictEqual(decompiled[5], bitcoin.opcodes.OP_CHECKMULTISIG) + } + }) + }) +}) diff --git a/test/unit/xchain_encoder_create_transaction.test/15_return_value.test.js b/test/unit/xchain_encoder_create_transaction.test/15_return_value.test.js new file mode 100644 index 0000000..25c2cfd --- /dev/null +++ b/test/unit/xchain_encoder_create_transaction.test/15_return_value.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. + +const { + assert, + bitcoin, + TXID_A, + makeSegwitUtxo, + makeEncoder, + TEST_ADDRESS +} = require('./fixtures/transaction') + +describe('XChainEncoder.createTransaction()', () => { + describe('return value', () => { + it('returns { psbt, encoding } where psbt is a Psbt instance', async () => { + const encoder = makeEncoder() + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + assert.ok(result.psbt instanceof bitcoin.Psbt) + assert.strictEqual(typeof result.encoding, 'string') + }) + }) +}) diff --git a/test/unit/xchain_encoder_create_transaction.test/16_estimate_spending_p2sh_tx.test.js b/test/unit/xchain_encoder_create_transaction.test/16_estimate_spending_p2sh_tx.test.js new file mode 100644 index 0000000..3f2b444 --- /dev/null +++ b/test/unit/xchain_encoder_create_transaction.test/16_estimate_spending_p2sh_tx.test.js @@ -0,0 +1,44 @@ +// 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, + makeEncoder, + TxSizeEstimator +} = require('./fixtures/transaction') + +describe('XChainEncoder.createTransaction()', () => { + describe('estimateSpendingP2shTx()', () => { + it('returns 10 + P2SH input + OP_RETURN + 8-byte safety margin', () => { + const encoder = makeEncoder() + const redeemData = Buffer.alloc(200, 0xAA) + + const expected = 10 + + TxSizeEstimator.estimateP2shInputWithRedeem(redeemData) + + TxSizeEstimator.estimateOpReturnOutput( + Buffer.concat([Buffer.from('XCHN'), Buffer.from('p2sh')]) + ) + + 8 // safety margin for DER-sig length jitter + + assert.strictEqual(encoder.estimateSpendingP2shTx(redeemData), expected) + }) + + it('increases monotonically with redeem data size', () => { + const encoder = makeEncoder() + const small = encoder.estimateSpendingP2shTx(Buffer.alloc(100)) + const large = encoder.estimateSpendingP2shTx(Buffer.alloc(400)) + assert.ok(large > small) + // Delta is at least the raw data delta (300); the larger redeem may + // cross the OP_PUSHDATA2 (256) and scriptSig-varint (253) boundaries, + // each adding bytes, so we assert monotonicity, not exact equality. + assert.ok(large - small >= 300) + }) + }) +}) diff --git a/test/unit/xchain_encoder_create_transaction.test/17_payment_only.test.js b/test/unit/xchain_encoder_create_transaction.test/17_payment_only.test.js new file mode 100644 index 0000000..fbbc914 --- /dev/null +++ b/test/unit/xchain_encoder_create_transaction.test/17_payment_only.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. + +const { + assert, + bitcoin, + TXID_A, + makeSegwitUtxo, + makeEncoder, + TEST_ADDRESS +} = require('./fixtures/transaction') + +// `data` has always been optional on the wire ("payment-only tx"), but the +// emission path still wrote a nulldata output for it: the empty payload +// compiled to an OP_0 push, took the 4-byte magic word, and shipped as a +// magic-word-only OP_RETURN carrying no action. Every plain native-coin send +// the wallet made paid for that output and announced itself as an XChain +// transaction while containing nothing to decode. + +describe('XChainEncoder.createTransaction() payment-only', () => { + const opReturnOutputs = (psbt) => + psbt.txOutputs.filter((o) => bitcoin.script.toASM(o.script).startsWith('OP_RETURN')) + + it('emits NO OP_RETURN output when there is no action payload', async () => { + const encoder = makeEncoder() + const result = await encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, + [{ address: TEST_ADDRESS, value: '1000000' }], + null, null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(opReturnOutputs(result.psbt).length, 0, + 'a payment with no action must not carry a nulldata output') + // The payment itself still has to be there, or "no OP_RETURN" would pass + // on a transaction that also lost the thing it was meant to send. + assert.ok(result.psbt.txOutputs.some((o) => Number(o.value) === 1000000), + 'the customOutput payment is missing') + }) + + it('treats an empty-string action the same as an absent one', async () => { + const encoder = makeEncoder() + const result = await encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, + [{ address: TEST_ADDRESS, value: '1000000' }], + '', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + assert.strictEqual(opReturnOutputs(result.psbt).length, 0) + }) + + it('still emits the OP_RETURN when an action IS present', async () => { + const encoder = makeEncoder() + const result = await encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, + [{ address: TEST_ADDRESS, value: '1000000' }], + 'SEND|0|TOKEN|1|' + TEST_ADDRESS, null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + assert.strictEqual(opReturnOutputs(result.psbt).length, 1, + 'dropping the nulldata output for real actions would break every action') + }) + + it('still emits a nulldata output for a rawData-only payload', async () => { + // rawData with no `data` is a deliberately-supported shape and IS content; + // "no action string" must not be read as "nothing to write". + const encoder = makeEncoder() + const result = await encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + null, 'rawpayload', 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + assert.strictEqual(opReturnOutputs(result.psbt).length, 1) + }) +}) diff --git a/test/unit/xchain_encoder_create_transaction.test/18_payload_guards.test.js b/test/unit/xchain_encoder_create_transaction.test/18_payload_guards.test.js new file mode 100644 index 0000000..9e9ea25 --- /dev/null +++ b/test/unit/xchain_encoder_create_transaction.test/18_payload_guards.test.js @@ -0,0 +1,219 @@ +// 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, + bitcoin, + TXID_A, + makeSegwitUtxo, + makeEncoder, + TEST_ADDRESS +} = require('./fixtures/transaction') + +// The builder is a supported library entry point, and api.js's validateAll sits +// in front of the JSON-RPC surface only. Every payload guard asserted here is +// asserted through encoder.createTransaction() with NO validator call in front +// of it: a suite that only exercises validator.validateAll stays green with the +// guards absent from the builder, which is exactly how these bypasses shipped. +const nulldataOutputs = (psbt) => + psbt.txOutputs.filter((o) => bitcoin.script.toASM(o.script).startsWith('OP_RETURN')) + +describe('XChainEncoder.createTransaction() payload guards (library boundary)', () => { + // Latin-1 truncation: U+0100 silently became the byte 0x00 on a fee-paid + // transaction, and the compiled-size ceiling cannot see it (the length does + // not change). Written as fromCharCode so the source carries no literal high + // character, matching validator.js's firstNonLatin1. + it('rejects a rawData code unit above U+00FF instead of truncating it', async () => { + const encoder = makeEncoder() + await assert.rejects( + () => encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + 'SEND|0|TOKEN|1|' + TEST_ADDRESS, String.fromCharCode(0x0100), 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ), + (err) => err instanceof RangeError && /U\+00FF/.test(err.message) + ) + }) + + it('rejects a `data` string that is not well-formed Unicode', async () => { + const encoder = makeEncoder() + await assert.rejects( + () => encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + 'SEND|\uD800|x', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ), + (err) => err instanceof RangeError && /well-formed/.test(err.message) + ) + }) + + // Minimal-op canonicalization: a lone 0x05 compiles to a bare OP_5 and the + // decoder's Buffer.isBuffer element test discards it. + it('rejects a single minimal-opcode rawData byte', async () => { + const encoder = makeEncoder() + await assert.rejects( + () => encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + 'FILE|0|doc', String.fromCharCode(0x05), 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ), + (err) => err instanceof RangeError && /rawData/.test(err.message) + ) + }) + + it('rejects a single minimal-opcode `data` byte', async () => { + const encoder = makeEncoder() + await assert.rejects( + () => encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + String.fromCharCode(0x05), null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ), + (err) => err instanceof RangeError && /data/.test(err.message) + ) + }) +}) + +describe('XChainEncoder.createTransaction() payload guards (library boundary)', () => { + // The guards must close a data-loss path without narrowing the shapes the + // library already builds. These four are the ones the two placements could + // plausibly have broken. + it('still builds the deliberately-supported rawData-only shape', async () => { + const encoder = makeEncoder() + const result = await encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + null, 'rawpayload', 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + assert.strictEqual(nulldataOutputs(result.psbt).length, 1) + }) + + it('still builds a payment-only transaction with no payload at all', async () => { + const encoder = makeEncoder() + const result = await encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, + [{ address: TEST_ADDRESS, value: '1000000' }], + null, null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + assert.strictEqual(nulldataOutputs(result.psbt).length, 0) + }) + + it('still builds an ordinary multi-byte data + rawData payload', async () => { + const encoder = makeEncoder() + const result = await encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + 'FILE|0|doc', 'file-bytes-here', 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + assert.ok(result.psbt instanceof bitcoin.Psbt) + }) +}) + +describe('XChainEncoder.createTransaction() payload guards (library boundary)', () => { + // The decodability guard must see Buffer inputs too. A Buffer is copied byte + // for byte, which is why the latin-1 guard can stay string-only, but + // canonicalization is a property of the COMPILED push, so a one-byte Buffer + // in the minimal-op range loses everything the same way its string spelling + // does. Scoping this guard to strings left the hole open on exactly the + // surface the finding is about (direct library callers). + it('rejects a single minimal-opcode rawData byte passed as a Buffer', async () => { + const encoder = makeEncoder() + await assert.rejects( + () => encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + 'FILE|0|doc', Buffer.from([0x05]), 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ), + (err) => err instanceof RangeError && /rawData/.test(err.message) + ) + }) + + it('rejects a single minimal-opcode `data` byte passed as a Buffer', async () => { + const encoder = makeEncoder() + await assert.rejects( + () => encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + Buffer.from([0x05]), null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ), + (err) => err instanceof RangeError && /data/.test(err.message) + ) + }) + + // A multi-byte Buffer rawData is copied byte-for-byte by + // Buffer.from(rawData,'binary'), so nothing is lost and nothing here may + // start refusing it: the guards close a data-loss path, they do not become + // argument-shape policy. + it('still accepts a Buffer rawData from a library caller', async () => { + const encoder = makeEncoder() + const result = await encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + 'FILE|0|doc', Buffer.from('file-bytes-here', 'binary'), 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + assert.ok(result.psbt instanceof bitcoin.Psbt) + }) +}) + +// The ACTION-name parity gap. validateAll refuses an unrecognized leading +// token on the JSON-RPC path, so the direct library entry point is the one +// surface that can compile and pay for a payload every decoder then drops. The +// builder reports it instead of refusing, because a non-ACTION payload is a +// supported library shape; what must not happen is the silence. +const warningCodes = (result) => (result.warnings || []).map((w) => w.code) + +describe('XChainEncoder.createTransaction() payload guards (library boundary)', () => { + it('warns a library caller whose data leads with an unknown ACTION name', async () => { + const encoder = makeEncoder() + const result = await encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + 'TRANSFRE|0|TOKEN|1|^2', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + assert.ok(result.psbt instanceof bitcoin.Psbt, 'the build must still succeed') + assert.ok(warningCodes(result).includes('UNKNOWN_ACTION_NAME')) + assert.ok(/TRANSFRE/.test(result.warnings.find((w) => w.code === 'UNKNOWN_ACTION_NAME').message)) + }) + + it('warns when an unknown ACTION name arrives as a Buffer', async () => { + const encoder = makeEncoder() + const result = await encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + Buffer.from('TRANSFRE|0|TOKEN|1|^2', 'utf8'), null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + assert.ok(warningCodes(result).includes('UNKNOWN_ACTION_NAME')) + }) +}) + +describe('XChainEncoder.createTransaction() payload guards (library boundary)', () => { + // The negative controls: a canonical name and an alias must produce NO + // advisory, or the warning is decoration rather than a signal. + it('does not warn on a canonical ACTION name', async () => { + const encoder = makeEncoder() + const result = await encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + 'SEND|0|TOKEN|1|^2', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + assert.ok(!warningCodes(result).includes('UNKNOWN_ACTION_NAME')) + }) + + it('does not warn on a known ACTION alias', async () => { + const encoder = makeEncoder() + const result = await encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + 'TRANSFER|0|TOKEN|1|^2', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + assert.ok(!warningCodes(result).includes('UNKNOWN_ACTION_NAME')) + }) +}) diff --git a/test/unit/xchain_encoder_create_transaction.test/fixtures/transaction.js b/test/unit/xchain_encoder_create_transaction.test/fixtures/transaction.js new file mode 100644 index 0000000..5105229 --- /dev/null +++ b/test/unit/xchain_encoder_create_transaction.test/fixtures/transaction.js @@ -0,0 +1,117 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const crypto = require('crypto') +const XChainEncoder = require('../../../../src/XChainEncoder') + +const pubkeyBuf = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex' +) +// A valid-looking 64-char hex txid +const TXID_A = 'a'.repeat(64) +const TXID_B = 'b'.repeat(64) +// A TXID that produces valid EC points when used with MULTISIGN test data +// (found by brute-force: 'A'.repeat(59) compiled to 60 bytes → 64-byte chunk after magic) +const TXID_MS = '4e3472b63a459d2188711abcff6aa2548948f90c527aa60ec4a1101136879fe8' + +function buildRawTxHex (value) { + const tx = new bitcoin.Transaction() + tx.addInput(Buffer.alloc(32, 0x11), 0) + const p2pkhScript = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }).output + tx.addOutput(p2pkhScript, value) + return tx.toHex() +} + +const RAW_TX_HEX = buildRawTxHex(100000000) // 1 BTC in sats + +// Build P2WPKH UTXO fixtures. The scriptPubKey is the witness program itself, +// so it is network-independent and valid on any segwit-capable regtest chain. +function makeSegwitUtxo (txid, vout, value) { + const p2wpkh = bitcoin.payments.p2wpkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }) + return { + txid, + vout, + value, + confirmations: 6, + scriptPubKey: p2wpkh.output.toString('hex') + } +} + +// P2PKH fixture for the chains without segwit, whose UTXOs can only be legacy. +function makeLegacyUtxo (txid, vout, value) { + const p2pkh = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }) + return { + txid, + vout, + value, + confirmations: 6, + scriptPubKey: p2pkh.output.toString('hex') + } +} + +// Segwit-capable network, because every fixture below spends a P2WPKH input and +// the builder refuses a witness-program input on a chain without segwit. It also +// carries a dustThreshold, which the change math reads. +function makeEncoder (networkName = 'litecoin-regtest') { + const encoder = new XChainEncoder( + networkName, '127.0.0.1', '8333', 'rpc', 'rpc', '', '' + ) + encoder.connector = { + getFeePerKilobyte: async () => 0.00001, + // Returns a bare hex STRING, which is what BlockchainConnector.getTransactionHex + // actually resolves with (`responseData.result.hex`). An `{ hex }` answer + // models a shape the real class has never returned. The segwit path requires + // the transaction too, so the mock must match the code under test. + getTransactionHex: async () => RAW_TX_HEX + } + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => ({ + utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)] + }) + } + return encoder +} + +const LTC_REGTEST = require('../../../../src/build/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') +const TxSizeEstimator = require('../../../../src/build/tx_size_estimator'); +const TEST_ADDRESS = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: LTC_REGTEST +}).address + +module.exports = { + assert, + bitcoin, + crypto, + XChainEncoder, + pubkeyBuf, + TXID_A, + TXID_B, + TXID_MS, + RAW_TX_HEX, + makeSegwitUtxo, + makeLegacyUtxo, + makeEncoder, + LTC_REGTEST, + TxSizeEstimator, + TEST_ADDRESS +} From a25cf6319c5cb63ed431b7e529bc293de8d1285f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 07:42:56 -0700 Subject: [PATCH 118/148] test: split the encoder extra coverage suite into numbered parts by behaviour --- test/unit/xchain_encoder_extra.test.js | 547 ------------------ .../01_p2wsh_tx1_funding.test.js | 153 +++++ .../02_p2wsh_tx2_spending.test.js | 178 ++++++ .../03_payload_size_guard.test.js | 103 ++++ .../04_fee_quote_injection.test.js | 139 +++++ .../05_max_fee_rate_kb_cap.test.js | 118 ++++ .../06_change_edge_cases.test.js | 131 +++++ .../07_invalid_fee.test.js | 115 ++++ .../08_estimate_spending_p2wsh_tx.test.js | 195 +++++++ .../09_remaining_branch_coverage.test.js | 152 +++++ .../10_raw_data_parameter.test.js | 117 ++++ 11 files changed, 1401 insertions(+), 547 deletions(-) create mode 100644 test/unit/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js create mode 100644 test/unit/xchain_encoder_extra.test/02_p2wsh_tx2_spending.test.js create mode 100644 test/unit/xchain_encoder_extra.test/03_payload_size_guard.test.js create mode 100644 test/unit/xchain_encoder_extra.test/04_fee_quote_injection.test.js create mode 100644 test/unit/xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.js create mode 100644 test/unit/xchain_encoder_extra.test/06_change_edge_cases.test.js create mode 100644 test/unit/xchain_encoder_extra.test/07_invalid_fee.test.js create mode 100644 test/unit/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js create mode 100644 test/unit/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js create mode 100644 test/unit/xchain_encoder_extra.test/10_raw_data_parameter.test.js diff --git a/test/unit/xchain_encoder_extra.test.js b/test/unit/xchain_encoder_extra.test.js index fe09573..f46fe9d 100644 --- a/test/unit/xchain_encoder_extra.test.js +++ b/test/unit/xchain_encoder_extra.test.js @@ -67,18 +67,11 @@ function makeP2pkhUtxo (txid, vout, value) { } const DOGE_REGTEST = require('../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') -const TxSizeEstimator = require('../../src/build/tx_size_estimator'); const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST }).address -// A bitcoin-regtest address for P2WSH tests (segwit-capable) -const BTC_REGTEST_ADDR = bitcoin.payments.p2pkh({ - pubkey: pubkeyBuf, - network: bitcoin.networks.regtest -}).address - const RAW_TX_HEX = buildRawP2pkhTxHex(100000000) function makeEncoder (network) { @@ -162,543 +155,3 @@ describe('XChainEncoder.createTransaction(): non-segwit UTXO path', () => { assert.strictEqual(result.psbt.data.inputs.length, 2) }) }) - -describe('XChainEncoder.createTransaction(): P2WSH tx1 (funding)', () => { - it('throws TypeError when P2WSH is used on a no-segwit network', async () => { - // dogecoin-regtest has supportsSegwit=false - const encoder = makeEncoder('dogecoin-regtest') - const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) - - await assert.rejects( - () => encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', null, 10000, false, 'P2WSH', TEST_ADDRESS, - null, null, null, true, 0.00001 - ), - { name: 'TypeError', message: /P2WSH encoding is not supported/ } - ) - }) - - it('creates P2WSH output for small data on a segwit-capable network', async () => { - const encoder = makeEncoder('bitcoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - - const result = await encoder.createTransaction( - [utxo], BTC_REGTEST_ADDR, null, - 'test', null, 10000, false, 'P2WSH', BTC_REGTEST_ADDR, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(result.encoding, 'P2WSH') - // Should have at least one non-zero output (the P2WSH data output) - const nonZeroOutputs = result.psbt.txOutputs.filter(o => o.value > 0) - assert.ok(nonZeroOutputs.length >= 1) - }) - - it('P2WSH output value covers at least the dust floor', async () => { - const encoder = makeEncoder('bitcoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - - const result = await encoder.createTransaction( - [utxo], BTC_REGTEST_ADDR, null, - 'test', null, 10000, false, 'P2WSH', BTC_REGTEST_ADDR, - null, null, null, true, 0.00001 - ) - - const p2wshOutput = result.psbt.txOutputs.find(o => - o.value > 0 && o.value < 100000000 && o.value !== 10000 - ) - assert.ok(p2wshOutput, 'should have a P2WSH data output') - assert.ok(p2wshOutput.value >= encoder.dustAmount, - `P2WSH output value ${p2wshOutput.value} should be >= dust ${encoder.dustAmount}`) - }) - - it('total outputs do not exceed total inputs for P2WSH tx1', async () => { - const encoder = makeEncoder('bitcoin-regtest') - const inputValue = 100000000 - const utxo = makeSegwitUtxo(TXID_A, 0, inputValue) - - const result = await encoder.createTransaction( - [utxo], BTC_REGTEST_ADDR, null, - 'test', null, 10000, false, 'P2WSH', BTC_REGTEST_ADDR, - null, null, null, true, 0.00001 - ) - - const outputTotal = result.psbt.txOutputs.reduce((sum, o) => sum + o.value, 0) - assert.ok(outputTotal <= inputValue, - `total outputs (${outputTotal}) must not exceed total inputs (${inputValue})`) - }) -}) - -describe('XChainEncoder.createTransaction(): P2WSH tx2 (spending)', () => { - it('creates P2WSH input with witnessScript and OP_RETURN marker', async () => { - const encoder = makeEncoder('bitcoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - - // Create tx1 first - const tx1Result = await encoder.createTransaction( - [utxo], BTC_REGTEST_ADDR, null, - 'test', null, 10000, false, 'P2WSH', BTC_REGTEST_ADDR, - null, null, null, true, 0.00001 - ) - - // Extract tx1 hex from the PSBT's internal TX - const tx1Hex = tx1Result.psbt.__CACHE.__TX.toHex() - const tx1Id = tx1Result.psbt.__CACHE.__TX.getId() - - // Create tx2 (spending) - const tx2Result = await encoder.createTransaction( - [utxo], BTC_REGTEST_ADDR, null, - 'test', null, 10000, false, 'P2WSH', BTC_REGTEST_ADDR, - tx1Id, tx1Hex, null, true, 0.00001 - ) - - assert.strictEqual(tx2Result.encoding, 'P2WSH') - // tx2 should have at least one input (the P2WSH spend) - assert.ok(tx2Result.psbt.data.inputs.length >= 1, 'should have at least one input') - // tx2 should have an OP_RETURN marker output (value 0) - const opReturnOutput = tx2Result.psbt.txOutputs.find(o => o.value === 0) - assert.ok(opReturnOutput, 'tx2 should have an OP_RETURN marker output') - }) - - it('throws RangeError when p2shHex has no output at the expected voutPsbtIndex', async () => { - 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 - 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, - bigData, null, 10000, false, 'P2WSH', BTC_REGTEST_ADDR, - smallTxId, smallTxHex, null, true, 0.00001 - ), - { name: 'RangeError', message: /does not have output at index/ } - ) - }) - - it('throws RangeError when p2shHex has no output at the expected voutPsbtIndex (P2SH)', async () => { - // P2SH counterpart to the P2WSH guard above: the reveal branch must bounds- - // check voutPsbtIndex against the funding tx's outputs before addInput. - 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 - 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, - bigData, null, 10000, false, 'P2SH', TEST_ADDRESS, - smallTxId, smallTxHex, null, true, 0.00001 - ), - { name: 'RangeError', message: /does not have output at index/ } - ) - }) -}) - -describe('XChainEncoder.createTransaction(): payload size guard', () => { - it('throws RangeError when compiled payload exceeds MAX_COMPILED_ACTION_DATA_LENGTH (8192)', async () => { - const encoder = makeEncoder() - const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) - // A very large data string that will compile to > 8192 bytes - const hugeData = 'X'.repeat(8200) - - await assert.rejects( - () => encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - hugeData, null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ), - { name: 'RangeError', message: /Payload too large/ } - ) - }) -}) - -describe('XChainEncoder.createTransaction() - feeQuote injection', () => { - it('adds feeQuote as an extra output when address and amount > 0', async () => { - const encoder = makeEncoder() - const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) - const feeQuote = { address: TEST_ADDRESS, amount: 99000 } - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001, - null, // dust - feeQuote - ) - - // Should have OP_RETURN (0) + feeQuote (99000) + change - const feeQuoteOutput = result.psbt.txOutputs.find(o => o.value === 99000) - assert.ok(feeQuoteOutput, 'should have a feeQuote output with the correct value') - }) - - it('does not add feeQuote when amount is 0', async () => { - const encoder = makeEncoder() - const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) - const feeQuote = { address: TEST_ADDRESS, amount: 0 } - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001, - null, - feeQuote - ) - - // Only OP_RETURN (0) + change (no feeQuote output) - assert.strictEqual(result.psbt.txOutputs.length, 2) - }) - - it('does not add feeQuote when feeQuote has no address', async () => { - const encoder = makeEncoder() - const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) - const feeQuote = { amount: 50000 } // no address - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001, - null, - feeQuote - ) - - // No feeQuote output; just OP_RETURN + change - assert.strictEqual(result.psbt.txOutputs.length, 2) - }) -}) - -describe('XChainEncoder.createTransaction() - maxFeeRateKb cap', () => { - it('caps the fee rate when connector returns a rate above maxFeeRateKb', async () => { - // Create encoder with maxFeeRateKb = 1 (sat/kB, very low cap) - const encoder = new XChainEncoder( - 'dogecoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '', 1 - ) - encoder.connector = { - // Return an excessively high fee rate - getFeePerKilobyte: async () => 1.0, // 1 BTC/kB - getTransactionHex: async () => RAW_TX_HEX - } - encoder.utxoTrackerConnector = { - getUtxosFromAddress: async () => ({ utxos: [] }) - } - - const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', null, null, false, null, TEST_ADDRESS, - null, null, null, true, null // null feePerKb → will call connector - ) - - // The fee must be capped; without cap this would consume most of the 1 BTC input - const changeOutput = result.psbt.txOutputs.find(o => o.value > 0) - const impliedFee = 100000000 - changeOutput.value - // maxFeeRateKb=1 sat/kB → maxFeePerBytes = 1/1000/100000000 BTC/byte - // That's tiny (0.00000000001 BTC/byte); floor kicks in at dustAmount - assert.ok(impliedFee < 100000000 * 0.01, - `fee ${impliedFee} should be well under 1% of input with cap applied`) - }) -}) - -describe('XChainEncoder.createTransaction() - change edge cases', () => { - it('does not add change output when changeSatoshis is 0', async () => { - const encoder = makeEncoder() - // fee == input value is drain-shaped on purpose (to zero the change); the - // relative fee-rate cap would reject it, so disable the cap for this test. - encoder.maxFeeRateMultiplier = null - // dogecoin-regtest's dust floor is 100000 koinu, which is also the fixed - // burn backstop's effective ceiling here (100x the ~131-sat fair fee is - // smaller than the dust floor, so the floor wins the max()). Keep the - // UTXO and fee at that ceiling so the fee is not rejected or floored up. - const utxo = makeP2pkhUtxo(TXID_A, 0, 100000) - const fee = 100000 - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', null, fee, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - // No change output (value 0 is OP_RETURN, no positive change) - const changeOutput = result.psbt.txOutputs.find(o => o.value > 0) - assert.ok(!changeOutput, 'should not have a positive change output') - }) - - it('does not throw when changeSatoshis <= dustAmount and no change address', async () => { - const encoder = makeEncoder() - // Leaving sub-dust change is drain-shaped on purpose; the relative - // fee-rate cap would reject it, so disable the cap. - encoder.maxFeeRateMultiplier = null - // Fee at the dust-floor/burn-backstop ceiling (see comment above), with a - // slightly larger UTXO so change lands under dogecoin-regtest's - // 100000-koinu dust floor. - const utxo = makeP2pkhUtxo(TXID_A, 0, 150000) - const fee = 100000 // leaves 50000 sats change, below the 100000 dust floor - - // Should not throw; change below dust with no change address is fine (burned as fee) - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', null, fee, false, null, null, // no change address - null, null, null, true, 0.00001 - ) - - assert.ok(result.psbt) - }) -}) - -describe('XChainEncoder.createTransaction() - invalid fee', () => { - it('throws RangeError for a NaN fee string', async () => { - const encoder = makeEncoder() - const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) - - await assert.rejects( - () => encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', null, 'notanumber', false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ), - { name: 'RangeError', message: /fee must be a non-negative integer/ } - ) - }) - - it('throws RangeError for a negative fee', async () => { - const encoder = makeEncoder() - const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) - - await assert.rejects( - () => encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', null, -1, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ), - { name: 'RangeError', message: /fee must be a non-negative integer/ } - ) - }) -}) - -describe('XChainEncoder.estimateSpendingP2wshTx()', () => { - const MAGIC_WORD = 'XCHN' - - function expectedSize (witnessData) { - const len = witnessData.length - // Witness stack items are framed by a compactSize varint (1 byte below 253, - // 3 bytes through 65535), NOT by script push opcodes. The old mirror here - // used the 76/256 script-push brackets and so pinned the defect that was - // fixed in estimateSpendingP2wshTx. - const witnessScriptPrefix = len < 253 ? 1 : 3 - const witnessBytes = 2 + 1 + (1 + 72) + (1 + 33) + (witnessScriptPrefix + len) - const nonWitnessBytes = - 10 - + (36 + 1 + 4) - + TxSizeEstimator.estimateOpReturnOutput( - Buffer.concat([Buffer.from(MAGIC_WORD), Buffer.from('p2wsh')]) - ) - return nonWitnessBytes + Math.ceil(witnessBytes / 4) + 8 - } - - it('uses a 1-byte compactSize prefix for a witness script < 76 bytes', () => { - const encoder = makeEncoder() - const data = Buffer.alloc(50) // < 253 → 1-byte varint - assert.strictEqual(encoder.estimateSpendingP2wshTx(data), expectedSize(data)) - }) - - it('uses a 1-byte compactSize prefix for a witness script in 76..252', () => { - const encoder = makeEncoder() - const data = Buffer.alloc(200) // still < 253 → 1-byte varint, NOT OP_PUSHDATA1 - assert.strictEqual(encoder.estimateSpendingP2wshTx(data), expectedSize(data)) - }) - - it('uses a 3-byte compactSize prefix for a witness script >= 253 bytes', () => { - const encoder = makeEncoder() - const data = Buffer.alloc(400) // ≥ 253 → 3-byte varint - assert.strictEqual(encoder.estimateSpendingP2wshTx(data), expectedSize(data)) - }) - - // Regression: the mirror above moves with the implementation, - // so these two cases pin the framing rule independently of it, in the only - // places where the old script-push model is observable through the ÷4 witness - // discount. estimateSpendingP2wshTx returns - // nonWitness + ceil((110 + prefix + len)/4) + 8, - // so a one-byte prefix error only survives the ceil when (110 + prefix + len) - // is a multiple of 4. For the 1-byte varint that is len ≡ 1 (mod 4). - it('does not add an OP_PUSHDATA1 byte in the 76..252 band (len ≡ 1 mod 4)', () => { - const encoder = makeEncoder() - // len 201 and 249: 1-byte varint. The old code charged 2 bytes here, which - // pushed ceil(witnessBytes/4) up by one and over-funded the reveal output. - for (const len of [201, 249]) { - const data = Buffer.alloc(len) - const withVarint = expectedSize(data) - const withPushdata1 = withVarint + 1 // what the script-push model produced - const actual = encoder.estimateSpendingP2wshTx(data) - assert.strictEqual(actual, withVarint, - `len=${len}: expected compactSize framing (${withVarint}), got ${actual}`) - assert.notStrictEqual(actual, withPushdata1, - `len=${len}: estimate still carries the OP_PUSHDATA1 byte`) - } - }) - - it('switches prefix width at 253, not at 76 or 256', () => { - const encoder = makeEncoder() - // Adding 4 data bytes with no prefix change costs exactly 1 vbyte - // (4 witness bytes ÷ 4). A prefix change shows up as a departure from that. - const step = len => - encoder.estimateSpendingP2wshTx(Buffer.alloc(len + 4)) - - encoder.estimateSpendingP2wshTx(Buffer.alloc(len)) - // step(73) spans 73→77 and is the discriminating case: the old script-push - // model widened at 76 and reported 2 here. - assert.strictEqual(step(73), 1, 'no prefix change across 76') - assert.strictEqual(step(252), 2, 'prefix widens 1 to 3 across 253') - assert.strictEqual(step(256), 1, 'no prefix change across 260') - }) - - it('is smaller than estimateSpendingP2shTx for the same data (witness discount)', () => { - const encoder = makeEncoder() - const data = Buffer.alloc(200) - const p2wshSize = encoder.estimateSpendingP2wshTx(data) - const p2shSize = encoder.estimateSpendingP2shTx(data) - assert.ok(p2wshSize < p2shSize, - `P2WSH estimated size (${p2wshSize}) should be less than P2SH (${p2shSize}) for the same data`) - }) - - it('increases monotonically with data size', () => { - const encoder = makeEncoder() - const small = encoder.estimateSpendingP2wshTx(Buffer.alloc(50)) - const medium = encoder.estimateSpendingP2wshTx(Buffer.alloc(200)) - const large = encoder.estimateSpendingP2wshTx(Buffer.alloc(400)) - assert.ok(medium > small, 'medium should be larger than small') - assert.ok(large > medium, 'large should be larger than medium') - }) - - it('includes the 8-byte safety margin in the result', () => { - // The safety margin is 8 bytes. A 0-byte witness script → minimal estimate. - // We verify by comparing adjacent data sizes with the known arithmetic. - const encoder = makeEncoder() - const data = Buffer.alloc(0) - const size = encoder.estimateSpendingP2wshTx(data) - // result = nonWitnessBytes + ceil(witnessBytes/4) + 8 - // verify it equals our reference formula - assert.strictEqual(size, expectedSize(data)) - }) -}) - -// Remaining uncovered branches: customOutputs invalid value, UTXO invalid value, -// unconfirmed=false depletes all UTXOs, changeSatoshis non-finite. -describe('XChainEncoder.createTransaction() - remaining branch coverage', () => { - it('throws RangeError when customOutputs[i].value is not a valid satoshi amount', async () => { - const encoder = makeEncoder() - const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) - - await assert.rejects( - () => encoder.createTransaction( - [utxo], TEST_ADDRESS, [{ address: TEST_ADDRESS, value: 'bad' }], - 'test', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ), - { name: 'RangeError', message: /customOutputs\[0\].value must be a non-negative integer/ } - ) - }) - - it('throws RangeError when customOutputs[i].value is negative', async () => { - const encoder = makeEncoder() - const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) - - await assert.rejects( - () => encoder.createTransaction( - [utxo], TEST_ADDRESS, [{ address: TEST_ADDRESS, value: -1 }], - 'test', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ), - { name: 'RangeError', message: /customOutputs\[0\].value must be a non-negative integer/ } - ) - }) - - it('throws RangeError when a UTXO value is not a valid satoshi amount', async () => { - const encoder = makeEncoder() - // A UTXO with a NaN value string (parsed inside the UTXO loop) - const badUtxo = makeP2pkhUtxo(TXID_A, 0, 'notanumber') - - await assert.rejects( - () => encoder.createTransaction( - [badUtxo], TEST_ADDRESS, null, - 'test', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ), - { name: 'RangeError', message: /utxos\[0\].value must be a non-negative integer/ } - ) - }) - - it('throws when unconfirmed=false strips all UTXOs (line 325 path)', async () => { - const encoder = makeEncoder() - // All UTXOs have confirmations=0 (mempool), unconfirmed=false strips them all - const mempoolUtxo = makeP2pkhUtxo(TXID_A, 0, 100000000) - mempoolUtxo.confirmations = 0 - - await assert.rejects( - () => encoder.createTransaction( - [mempoolUtxo], TEST_ADDRESS, null, - 'test', null, 10000, false, null, TEST_ADDRESS, - null, null, null, - false, // unconfirmed=false → strips the mempool UTXO - 0.00001 - ), - /no utxos were provided and no utxos found on the blockchain/ - ) - }) -}) - -describe('XChainEncoder.createTransaction() - rawData parameter', () => { - it('accepts rawData and includes it in the compiled payload', async () => { - const encoder = makeEncoder() - const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) - // rawData is a binary string appended after the text data - const rawData = '\x01\x02\x03binary content' - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', rawData, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - // Should build successfully; the rawData is compiled in via 'binary' encoding - assert.ok(result.psbt, 'should return a valid PSBT') - assert.ok(result.encoding, 'should return an encoding') - }) - - it('rawData does not affect result when null (false branch of rawData != null)', async () => { - const encoder = makeEncoder() - const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) - - const result = await encoder.createTransaction( - [utxo], TEST_ADDRESS, null, - 'test', null, 10000, false, null, TEST_ADDRESS, - null, null, null, true, 0.00001 - ) - - assert.ok(result.psbt) - }) -}) diff --git a/test/unit/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js b/test/unit/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js new file mode 100644 index 0000000..8de3e17 --- /dev/null +++ b/test/unit/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js @@ -0,0 +1,153 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') + +const pubkeyBuf = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex' +) +const TXID_A = 'a'.repeat(64) + +function buildRawP2pkhTxHex (value) { + const tx = new bitcoin.Transaction() + tx.addInput(Buffer.alloc(32, 0x11), 0) + const script = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }).output + tx.addOutput(script, value) + return tx.toHex() +} + +function makeSegwitUtxo (txid, vout, value) { + const p2wpkh = bitcoin.payments.p2wpkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }) + return { + txid, + vout, + value, + confirmations: 6, + scriptPubKey: p2wpkh.output.toString('hex') + } +} + +function makeP2pkhUtxo (txid, vout, value) { + const script = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }).output.toString('hex') + return { txid, vout, value, confirmations: 6, scriptPubKey: script } +} + +const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const TEST_ADDRESS = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: DOGE_REGTEST +}).address + +// A bitcoin-regtest address for P2WSH tests (segwit-capable) +const BTC_REGTEST_ADDR = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest +}).address + +const RAW_TX_HEX = buildRawP2pkhTxHex(100000000) + +function makeEncoder (network) { + const net = network || 'dogecoin-regtest' + const encoder = new XChainEncoder( + net, '127.0.0.1', '8333', 'rpc', 'rpc', '', '' + ) + encoder.connector = { + getFeePerKilobyte: async () => 0.00001, + getTransactionHex: async () => RAW_TX_HEX + } + const trackerUtxo = encoder.network.supportsSegwit === false ? makeP2pkhUtxo : makeSegwitUtxo + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => ({ + utxos: [trackerUtxo(TXID_A, 0, 100000000)] + }) + } + return encoder +} + +describe('XChainEncoder.createTransaction(): P2WSH tx1 (funding)', () => { + it('throws TypeError when P2WSH is used on a no-segwit network', async () => { + // dogecoin-regtest has supportsSegwit=false + const encoder = makeEncoder('dogecoin-regtest') + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) + + await assert.rejects( + () => encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', null, 10000, false, 'P2WSH', TEST_ADDRESS, + null, null, null, true, 0.00001 + ), + { name: 'TypeError', message: /P2WSH encoding is not supported/ } + ) + }) +}) + +describe('XChainEncoder.createTransaction(): P2WSH tx1 (funding)', () => { + it('creates P2WSH output for small data on a segwit-capable network', async () => { + const encoder = makeEncoder('bitcoin-regtest') + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + + const result = await encoder.createTransaction( + [utxo], BTC_REGTEST_ADDR, null, + 'test', null, 10000, false, 'P2WSH', BTC_REGTEST_ADDR, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(result.encoding, 'P2WSH') + // Should have at least one non-zero output (the P2WSH data output) + const nonZeroOutputs = result.psbt.txOutputs.filter(o => o.value > 0) + assert.ok(nonZeroOutputs.length >= 1) + }) + + it('P2WSH output value covers at least the dust floor', async () => { + const encoder = makeEncoder('bitcoin-regtest') + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + + const result = await encoder.createTransaction( + [utxo], BTC_REGTEST_ADDR, null, + 'test', null, 10000, false, 'P2WSH', BTC_REGTEST_ADDR, + null, null, null, true, 0.00001 + ) + + const p2wshOutput = result.psbt.txOutputs.find(o => + o.value > 0 && o.value < 100000000 && o.value !== 10000 + ) + assert.ok(p2wshOutput, 'should have a P2WSH data output') + assert.ok(p2wshOutput.value >= encoder.dustAmount, + `P2WSH output value ${p2wshOutput.value} should be >= dust ${encoder.dustAmount}`) + }) + + it('total outputs do not exceed total inputs for P2WSH tx1', async () => { + const encoder = makeEncoder('bitcoin-regtest') + const inputValue = 100000000 + const utxo = makeSegwitUtxo(TXID_A, 0, inputValue) + + const result = await encoder.createTransaction( + [utxo], BTC_REGTEST_ADDR, null, + 'test', null, 10000, false, 'P2WSH', BTC_REGTEST_ADDR, + null, null, null, true, 0.00001 + ) + + const outputTotal = result.psbt.txOutputs.reduce((sum, o) => sum + o.value, 0) + assert.ok(outputTotal <= inputValue, + `total outputs (${outputTotal}) must not exceed total inputs (${inputValue})`) + }) +}) diff --git a/test/unit/xchain_encoder_extra.test/02_p2wsh_tx2_spending.test.js b/test/unit/xchain_encoder_extra.test/02_p2wsh_tx2_spending.test.js new file mode 100644 index 0000000..99ea321 --- /dev/null +++ b/test/unit/xchain_encoder_extra.test/02_p2wsh_tx2_spending.test.js @@ -0,0 +1,178 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') + +const pubkeyBuf = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex' +) +const TXID_A = 'a'.repeat(64) + +function buildRawP2pkhTxHex (value) { + const tx = new bitcoin.Transaction() + tx.addInput(Buffer.alloc(32, 0x11), 0) + const script = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }).output + tx.addOutput(script, value) + return tx.toHex() +} + +function makeSegwitUtxo (txid, vout, value) { + const p2wpkh = bitcoin.payments.p2wpkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }) + return { + txid, + vout, + value, + confirmations: 6, + scriptPubKey: p2wpkh.output.toString('hex') + } +} + +function makeP2pkhUtxo (txid, vout, value) { + const script = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }).output.toString('hex') + return { txid, vout, value, confirmations: 6, scriptPubKey: script } +} + +const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const TEST_ADDRESS = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: DOGE_REGTEST +}).address + +// A bitcoin-regtest address for P2WSH tests (segwit-capable) +const BTC_REGTEST_ADDR = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest +}).address + +const RAW_TX_HEX = buildRawP2pkhTxHex(100000000) + +function makeEncoder (network) { + const net = network || 'dogecoin-regtest' + const encoder = new XChainEncoder( + net, '127.0.0.1', '8333', 'rpc', 'rpc', '', '' + ) + encoder.connector = { + getFeePerKilobyte: async () => 0.00001, + getTransactionHex: async () => RAW_TX_HEX + } + const trackerUtxo = encoder.network.supportsSegwit === false ? makeP2pkhUtxo : makeSegwitUtxo + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => ({ + utxos: [trackerUtxo(TXID_A, 0, 100000000)] + }) + } + return encoder +} + +describe('XChainEncoder.createTransaction(): P2WSH tx2 (spending)', () => { + it('creates P2WSH input with witnessScript and OP_RETURN marker', async () => { + const encoder = makeEncoder('bitcoin-regtest') + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + + // Create tx1 first + const tx1Result = await encoder.createTransaction( + [utxo], BTC_REGTEST_ADDR, null, + 'test', null, 10000, false, 'P2WSH', BTC_REGTEST_ADDR, + null, null, null, true, 0.00001 + ) + + // Extract tx1 hex from the PSBT's internal TX + const tx1Hex = tx1Result.psbt.__CACHE.__TX.toHex() + const tx1Id = tx1Result.psbt.__CACHE.__TX.getId() + + // Create tx2 (spending) + const tx2Result = await encoder.createTransaction( + [utxo], BTC_REGTEST_ADDR, null, + 'test', null, 10000, false, 'P2WSH', BTC_REGTEST_ADDR, + tx1Id, tx1Hex, null, true, 0.00001 + ) + + assert.strictEqual(tx2Result.encoding, 'P2WSH') + // tx2 should have at least one input (the P2WSH spend) + assert.ok(tx2Result.psbt.data.inputs.length >= 1, 'should have at least one input') + // tx2 should have an OP_RETURN marker output (value 0) + const opReturnOutput = tx2Result.psbt.txOutputs.find(o => o.value === 0) + assert.ok(opReturnOutput, 'tx2 should have an OP_RETURN marker output') + }) +}) + +describe('XChainEncoder.createTransaction(): P2WSH tx2 (spending)', () => { + it('throws RangeError when p2shHex has no output at the expected voutPsbtIndex', async () => { + 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 + 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, + bigData, null, 10000, false, 'P2WSH', BTC_REGTEST_ADDR, + smallTxId, smallTxHex, null, true, 0.00001 + ), + { name: 'RangeError', message: /does not have output at index/ } + ) + }) +}) + +describe('XChainEncoder.createTransaction(): P2WSH tx2 (spending)', () => { + it('throws RangeError when p2shHex has no output at the expected voutPsbtIndex (P2SH)', async () => { + // P2SH counterpart to the P2WSH guard above: the reveal branch must bounds- + // check voutPsbtIndex against the funding tx's outputs before addInput. + 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 + 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, + bigData, null, 10000, false, 'P2SH', TEST_ADDRESS, + smallTxId, smallTxHex, null, true, 0.00001 + ), + { name: 'RangeError', message: /does not have output at index/ } + ) + }) +}) diff --git a/test/unit/xchain_encoder_extra.test/03_payload_size_guard.test.js b/test/unit/xchain_encoder_extra.test/03_payload_size_guard.test.js new file mode 100644 index 0000000..4c49ab7 --- /dev/null +++ b/test/unit/xchain_encoder_extra.test/03_payload_size_guard.test.js @@ -0,0 +1,103 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') + +const pubkeyBuf = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex' +) +const TXID_A = 'a'.repeat(64) + +// Build a raw legacy P2PKH transaction hex (nonWitnessUtxo) +function buildRawP2pkhTxHex (value) { + const tx = new bitcoin.Transaction() + tx.addInput(Buffer.alloc(32, 0x11), 0) + const script = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }).output + tx.addOutput(script, value) + return tx.toHex() +} + +// A P2WPKH scriptPubKey (segwit) +function makeSegwitUtxo (txid, vout, value) { + const p2wpkh = bitcoin.payments.p2wpkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }) + return { + txid, + vout, + value, + confirmations: 6, + scriptPubKey: p2wpkh.output.toString('hex') + } +} + +// A P2PKH (legacy, non-segwit) UTXO +function makeP2pkhUtxo (txid, vout, value) { + const script = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }).output.toString('hex') + return { txid, vout, value, confirmations: 6, scriptPubKey: script } +} + +const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const TEST_ADDRESS = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: DOGE_REGTEST +}).address + +const RAW_TX_HEX = buildRawP2pkhTxHex(100000000) + +function makeEncoder (network) { + const net = network || 'dogecoin-regtest' + const encoder = new XChainEncoder( + net, '127.0.0.1', '8333', 'rpc', 'rpc', '', '' + ) + encoder.connector = { + getFeePerKilobyte: async () => 0.00001, + getTransactionHex: async () => RAW_TX_HEX + } + // Serve the fixture type the chain can actually hold: a witness-program UTXO + // only exists where consensus knows segwit, and the builder refuses one where + // it does not. + const trackerUtxo = encoder.network.supportsSegwit === false ? makeP2pkhUtxo : makeSegwitUtxo + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => ({ + utxos: [trackerUtxo(TXID_A, 0, 100000000)] + }) + } + return encoder +} + + +describe('XChainEncoder.createTransaction(): payload size guard', () => { + it('throws RangeError when compiled payload exceeds MAX_COMPILED_ACTION_DATA_LENGTH (8192)', async () => { + const encoder = makeEncoder() + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) + // A very large data string that will compile to > 8192 bytes + const hugeData = 'X'.repeat(8200) + + await assert.rejects( + () => encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + hugeData, null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ), + { name: 'RangeError', message: /Payload too large/ } + ) + }) +}) diff --git a/test/unit/xchain_encoder_extra.test/04_fee_quote_injection.test.js b/test/unit/xchain_encoder_extra.test/04_fee_quote_injection.test.js new file mode 100644 index 0000000..84b338f --- /dev/null +++ b/test/unit/xchain_encoder_extra.test/04_fee_quote_injection.test.js @@ -0,0 +1,139 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') + +const pubkeyBuf = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex' +) +const TXID_A = 'a'.repeat(64) + +// Build a raw legacy P2PKH transaction hex (nonWitnessUtxo) +function buildRawP2pkhTxHex (value) { + const tx = new bitcoin.Transaction() + tx.addInput(Buffer.alloc(32, 0x11), 0) + const script = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }).output + tx.addOutput(script, value) + return tx.toHex() +} + +// A P2WPKH scriptPubKey (segwit) +function makeSegwitUtxo (txid, vout, value) { + const p2wpkh = bitcoin.payments.p2wpkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }) + return { + txid, + vout, + value, + confirmations: 6, + scriptPubKey: p2wpkh.output.toString('hex') + } +} + +// A P2PKH (legacy, non-segwit) UTXO +function makeP2pkhUtxo (txid, vout, value) { + const script = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }).output.toString('hex') + return { txid, vout, value, confirmations: 6, scriptPubKey: script } +} + +const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const TEST_ADDRESS = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: DOGE_REGTEST +}).address + +const RAW_TX_HEX = buildRawP2pkhTxHex(100000000) + +function makeEncoder (network) { + const net = network || 'dogecoin-regtest' + const encoder = new XChainEncoder( + net, '127.0.0.1', '8333', 'rpc', 'rpc', '', '' + ) + encoder.connector = { + getFeePerKilobyte: async () => 0.00001, + getTransactionHex: async () => RAW_TX_HEX + } + // Serve the fixture type the chain can actually hold: a witness-program UTXO + // only exists where consensus knows segwit, and the builder refuses one where + // it does not. + const trackerUtxo = encoder.network.supportsSegwit === false ? makeP2pkhUtxo : makeSegwitUtxo + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => ({ + utxos: [trackerUtxo(TXID_A, 0, 100000000)] + }) + } + return encoder +} + + +describe('XChainEncoder.createTransaction() - feeQuote injection', () => { + it('adds feeQuote as an extra output when address and amount > 0', async () => { + const encoder = makeEncoder() + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) + const feeQuote = { address: TEST_ADDRESS, amount: 99000 } + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001, + null, // dust + feeQuote + ) + + // Should have OP_RETURN (0) + feeQuote (99000) + change + const feeQuoteOutput = result.psbt.txOutputs.find(o => o.value === 99000) + assert.ok(feeQuoteOutput, 'should have a feeQuote output with the correct value') + }) + + it('does not add feeQuote when amount is 0', async () => { + const encoder = makeEncoder() + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) + const feeQuote = { address: TEST_ADDRESS, amount: 0 } + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001, + null, + feeQuote + ) + + // Only OP_RETURN (0) + change (no feeQuote output) + assert.strictEqual(result.psbt.txOutputs.length, 2) + }) + + it('does not add feeQuote when feeQuote has no address', async () => { + const encoder = makeEncoder() + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) + const feeQuote = { amount: 50000 } // no address + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001, + null, + feeQuote + ) + + // No feeQuote output; just OP_RETURN + change + assert.strictEqual(result.psbt.txOutputs.length, 2) + }) +}) diff --git a/test/unit/xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.js b/test/unit/xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.js new file mode 100644 index 0000000..12c39d1 --- /dev/null +++ b/test/unit/xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.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. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') + +const pubkeyBuf = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex' +) +const TXID_A = 'a'.repeat(64) + +// Build a raw legacy P2PKH transaction hex (nonWitnessUtxo) +function buildRawP2pkhTxHex (value) { + const tx = new bitcoin.Transaction() + tx.addInput(Buffer.alloc(32, 0x11), 0) + const script = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }).output + tx.addOutput(script, value) + return tx.toHex() +} + +// A P2WPKH scriptPubKey (segwit) +function makeSegwitUtxo (txid, vout, value) { + const p2wpkh = bitcoin.payments.p2wpkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }) + return { + txid, + vout, + value, + confirmations: 6, + scriptPubKey: p2wpkh.output.toString('hex') + } +} + +// A P2PKH (legacy, non-segwit) UTXO +function makeP2pkhUtxo (txid, vout, value) { + const script = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }).output.toString('hex') + return { txid, vout, value, confirmations: 6, scriptPubKey: script } +} + +const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const TEST_ADDRESS = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: DOGE_REGTEST +}).address + +const RAW_TX_HEX = buildRawP2pkhTxHex(100000000) + +function makeEncoder (network) { + const net = network || 'dogecoin-regtest' + const encoder = new XChainEncoder( + net, '127.0.0.1', '8333', 'rpc', 'rpc', '', '' + ) + encoder.connector = { + getFeePerKilobyte: async () => 0.00001, + getTransactionHex: async () => RAW_TX_HEX + } + // Serve the fixture type the chain can actually hold: a witness-program UTXO + // only exists where consensus knows segwit, and the builder refuses one where + // it does not. + const trackerUtxo = encoder.network.supportsSegwit === false ? makeP2pkhUtxo : makeSegwitUtxo + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => ({ + utxos: [trackerUtxo(TXID_A, 0, 100000000)] + }) + } + return encoder +} + + +describe('XChainEncoder.createTransaction() - maxFeeRateKb cap', () => { + it('caps the fee rate when connector returns a rate above maxFeeRateKb', async () => { + // Create encoder with maxFeeRateKb = 1 (sat/kB, very low cap) + const encoder = new XChainEncoder( + 'dogecoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '', 1 + ) + encoder.connector = { + // Return an excessively high fee rate + getFeePerKilobyte: async () => 1.0, // 1 BTC/kB + getTransactionHex: async () => RAW_TX_HEX + } + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => ({ utxos: [] }) + } + + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', null, null, false, null, TEST_ADDRESS, + null, null, null, true, null // null feePerKb → will call connector + ) + + // The fee must be capped; without cap this would consume most of the 1 BTC input + const changeOutput = result.psbt.txOutputs.find(o => o.value > 0) + const impliedFee = 100000000 - changeOutput.value + // maxFeeRateKb=1 sat/kB → maxFeePerBytes = 1/1000/100000000 BTC/byte + // That's tiny (0.00000000001 BTC/byte); floor kicks in at dustAmount + assert.ok(impliedFee < 100000000 * 0.01, + `fee ${impliedFee} should be well under 1% of input with cap applied`) + }) +}) diff --git a/test/unit/xchain_encoder_extra.test/06_change_edge_cases.test.js b/test/unit/xchain_encoder_extra.test/06_change_edge_cases.test.js new file mode 100644 index 0000000..b344f84 --- /dev/null +++ b/test/unit/xchain_encoder_extra.test/06_change_edge_cases.test.js @@ -0,0 +1,131 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') + +const pubkeyBuf = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex' +) +const TXID_A = 'a'.repeat(64) + +// Build a raw legacy P2PKH transaction hex (nonWitnessUtxo) +function buildRawP2pkhTxHex (value) { + const tx = new bitcoin.Transaction() + tx.addInput(Buffer.alloc(32, 0x11), 0) + const script = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }).output + tx.addOutput(script, value) + return tx.toHex() +} + +// A P2WPKH scriptPubKey (segwit) +function makeSegwitUtxo (txid, vout, value) { + const p2wpkh = bitcoin.payments.p2wpkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }) + return { + txid, + vout, + value, + confirmations: 6, + scriptPubKey: p2wpkh.output.toString('hex') + } +} + +// A P2PKH (legacy, non-segwit) UTXO +function makeP2pkhUtxo (txid, vout, value) { + const script = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }).output.toString('hex') + return { txid, vout, value, confirmations: 6, scriptPubKey: script } +} + +const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const TEST_ADDRESS = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: DOGE_REGTEST +}).address + +const RAW_TX_HEX = buildRawP2pkhTxHex(100000000) + +function makeEncoder (network) { + const net = network || 'dogecoin-regtest' + const encoder = new XChainEncoder( + net, '127.0.0.1', '8333', 'rpc', 'rpc', '', '' + ) + encoder.connector = { + getFeePerKilobyte: async () => 0.00001, + getTransactionHex: async () => RAW_TX_HEX + } + // Serve the fixture type the chain can actually hold: a witness-program UTXO + // only exists where consensus knows segwit, and the builder refuses one where + // it does not. + const trackerUtxo = encoder.network.supportsSegwit === false ? makeP2pkhUtxo : makeSegwitUtxo + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => ({ + utxos: [trackerUtxo(TXID_A, 0, 100000000)] + }) + } + return encoder +} + + +describe('XChainEncoder.createTransaction() - change edge cases', () => { + it('does not add change output when changeSatoshis is 0', async () => { + const encoder = makeEncoder() + // fee == input value is drain-shaped on purpose (to zero the change); the + // relative fee-rate cap would reject it, so disable the cap for this test. + encoder.maxFeeRateMultiplier = null + // dogecoin-regtest's dust floor is 100000 koinu, which is also the fixed + // burn backstop's effective ceiling here (100x the ~131-sat fair fee is + // smaller than the dust floor, so the floor wins the max()). Keep the + // UTXO and fee at that ceiling so the fee is not rejected or floored up. + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000) + const fee = 100000 + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', null, fee, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + // No change output (value 0 is OP_RETURN, no positive change) + const changeOutput = result.psbt.txOutputs.find(o => o.value > 0) + assert.ok(!changeOutput, 'should not have a positive change output') + }) + + it('does not throw when changeSatoshis <= dustAmount and no change address', async () => { + const encoder = makeEncoder() + // Leaving sub-dust change is drain-shaped on purpose; the relative + // fee-rate cap would reject it, so disable the cap. + encoder.maxFeeRateMultiplier = null + // Fee at the dust-floor/burn-backstop ceiling (see comment above), with a + // slightly larger UTXO so change lands under dogecoin-regtest's + // 100000-koinu dust floor. + const utxo = makeP2pkhUtxo(TXID_A, 0, 150000) + const fee = 100000 // leaves 50000 sats change, below the 100000 dust floor + + // Should not throw; change below dust with no change address is fine (burned as fee) + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', null, fee, false, null, null, // no change address + null, null, null, true, 0.00001 + ) + + assert.ok(result.psbt) + }) +}) diff --git a/test/unit/xchain_encoder_extra.test/07_invalid_fee.test.js b/test/unit/xchain_encoder_extra.test/07_invalid_fee.test.js new file mode 100644 index 0000000..e630ca5 --- /dev/null +++ b/test/unit/xchain_encoder_extra.test/07_invalid_fee.test.js @@ -0,0 +1,115 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') + +const pubkeyBuf = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex' +) +const TXID_A = 'a'.repeat(64) + +// Build a raw legacy P2PKH transaction hex (nonWitnessUtxo) +function buildRawP2pkhTxHex (value) { + const tx = new bitcoin.Transaction() + tx.addInput(Buffer.alloc(32, 0x11), 0) + const script = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }).output + tx.addOutput(script, value) + return tx.toHex() +} + +// A P2WPKH scriptPubKey (segwit) +function makeSegwitUtxo (txid, vout, value) { + const p2wpkh = bitcoin.payments.p2wpkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }) + return { + txid, + vout, + value, + confirmations: 6, + scriptPubKey: p2wpkh.output.toString('hex') + } +} + +// A P2PKH (legacy, non-segwit) UTXO +function makeP2pkhUtxo (txid, vout, value) { + const script = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }).output.toString('hex') + return { txid, vout, value, confirmations: 6, scriptPubKey: script } +} + +const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const TEST_ADDRESS = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: DOGE_REGTEST +}).address + +const RAW_TX_HEX = buildRawP2pkhTxHex(100000000) + +function makeEncoder (network) { + const net = network || 'dogecoin-regtest' + const encoder = new XChainEncoder( + net, '127.0.0.1', '8333', 'rpc', 'rpc', '', '' + ) + encoder.connector = { + getFeePerKilobyte: async () => 0.00001, + getTransactionHex: async () => RAW_TX_HEX + } + // Serve the fixture type the chain can actually hold: a witness-program UTXO + // only exists where consensus knows segwit, and the builder refuses one where + // it does not. + const trackerUtxo = encoder.network.supportsSegwit === false ? makeP2pkhUtxo : makeSegwitUtxo + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => ({ + utxos: [trackerUtxo(TXID_A, 0, 100000000)] + }) + } + return encoder +} + + +describe('XChainEncoder.createTransaction() - invalid fee', () => { + it('throws RangeError for a NaN fee string', async () => { + const encoder = makeEncoder() + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) + + await assert.rejects( + () => encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', null, 'notanumber', false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ), + { name: 'RangeError', message: /fee must be a non-negative integer/ } + ) + }) + + it('throws RangeError for a negative fee', async () => { + const encoder = makeEncoder() + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) + + await assert.rejects( + () => encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', null, -1, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ), + { name: 'RangeError', message: /fee must be a non-negative integer/ } + ) + }) +}) diff --git a/test/unit/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js b/test/unit/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js new file mode 100644 index 0000000..f81a7bc --- /dev/null +++ b/test/unit/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js @@ -0,0 +1,195 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') + +const pubkeyBuf = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex' +) +const TXID_A = 'a'.repeat(64) + +// Build a raw legacy P2PKH transaction hex (nonWitnessUtxo) +function buildRawP2pkhTxHex (value) { + const tx = new bitcoin.Transaction() + tx.addInput(Buffer.alloc(32, 0x11), 0) + const script = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }).output + tx.addOutput(script, value) + return tx.toHex() +} + +// A P2WPKH scriptPubKey (segwit) +function makeSegwitUtxo (txid, vout, value) { + const p2wpkh = bitcoin.payments.p2wpkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }) + return { + txid, + vout, + value, + confirmations: 6, + scriptPubKey: p2wpkh.output.toString('hex') + } +} + +// A P2PKH (legacy, non-segwit) UTXO +function makeP2pkhUtxo (txid, vout, value) { + const script = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }).output.toString('hex') + return { txid, vout, value, confirmations: 6, scriptPubKey: script } +} + +const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const TxSizeEstimator = require('../../../src/build/tx_size_estimator'); +const TEST_ADDRESS = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: DOGE_REGTEST +}).address + +const RAW_TX_HEX = buildRawP2pkhTxHex(100000000) + +function makeEncoder (network) { + const net = network || 'dogecoin-regtest' + const encoder = new XChainEncoder( + net, '127.0.0.1', '8333', 'rpc', 'rpc', '', '' + ) + encoder.connector = { + getFeePerKilobyte: async () => 0.00001, + getTransactionHex: async () => RAW_TX_HEX + } + // Serve the fixture type the chain can actually hold: a witness-program UTXO + // only exists where consensus knows segwit, and the builder refuses one where + // it does not. + const trackerUtxo = encoder.network.supportsSegwit === false ? makeP2pkhUtxo : makeSegwitUtxo + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => ({ + utxos: [trackerUtxo(TXID_A, 0, 100000000)] + }) + } + return encoder +} + + +const MAGIC_WORD = 'XCHN' + +function expectedSize (witnessData) { + const len = witnessData.length + // Witness stack items are framed by a compactSize varint (1 byte below 253, + // 3 bytes through 65535), NOT by script push opcodes. The old mirror here + // used the 76/256 script-push brackets and so pinned the defect that was + // fixed in estimateSpendingP2wshTx. + const witnessScriptPrefix = len < 253 ? 1 : 3 + const witnessBytes = 2 + 1 + (1 + 72) + (1 + 33) + (witnessScriptPrefix + len) + const nonWitnessBytes = + 10 + + (36 + 1 + 4) + + TxSizeEstimator.estimateOpReturnOutput( + Buffer.concat([Buffer.from(MAGIC_WORD), Buffer.from('p2wsh')]) + ) + return nonWitnessBytes + Math.ceil(witnessBytes / 4) + 8 +} + +describe('XChainEncoder.estimateSpendingP2wshTx()', () => { + it('uses a 1-byte compactSize prefix for a witness script < 76 bytes', () => { + const encoder = makeEncoder() + const data = Buffer.alloc(50) // < 253 → 1-byte varint + assert.strictEqual(encoder.estimateSpendingP2wshTx(data), expectedSize(data)) + }) + + it('uses a 1-byte compactSize prefix for a witness script in 76..252', () => { + const encoder = makeEncoder() + const data = Buffer.alloc(200) // still < 253 → 1-byte varint, NOT OP_PUSHDATA1 + assert.strictEqual(encoder.estimateSpendingP2wshTx(data), expectedSize(data)) + }) + + it('uses a 3-byte compactSize prefix for a witness script >= 253 bytes', () => { + const encoder = makeEncoder() + const data = Buffer.alloc(400) // ≥ 253 → 3-byte varint + assert.strictEqual(encoder.estimateSpendingP2wshTx(data), expectedSize(data)) + }) +}) + +describe('XChainEncoder.estimateSpendingP2wshTx()', () => { + // Regression: the mirror above moves with the implementation, + // so these two cases pin the framing rule independently of it, in the only + // places where the old script-push model is observable through the ÷4 witness + // discount. estimateSpendingP2wshTx returns + // nonWitness + ceil((110 + prefix + len)/4) + 8, + // so a one-byte prefix error only survives the ceil when (110 + prefix + len) + // is a multiple of 4. For the 1-byte varint that is len ≡ 1 (mod 4). + it('does not add an OP_PUSHDATA1 byte in the 76..252 band (len ≡ 1 mod 4)', () => { + const encoder = makeEncoder() + // len 201 and 249: 1-byte varint. The old code charged 2 bytes here, which + // pushed ceil(witnessBytes/4) up by one and over-funded the reveal output. + for (const len of [201, 249]) { + const data = Buffer.alloc(len) + const withVarint = expectedSize(data) + const withPushdata1 = withVarint + 1 // what the script-push model produced + const actual = encoder.estimateSpendingP2wshTx(data) + assert.strictEqual(actual, withVarint, + `len=${len}: expected compactSize framing (${withVarint}), got ${actual}`) + assert.notStrictEqual(actual, withPushdata1, + `len=${len}: estimate still carries the OP_PUSHDATA1 byte`) + } + }) + + it('switches prefix width at 253, not at 76 or 256', () => { + const encoder = makeEncoder() + // Adding 4 data bytes with no prefix change costs exactly 1 vbyte + // (4 witness bytes ÷ 4). A prefix change shows up as a departure from that. + const step = len => + encoder.estimateSpendingP2wshTx(Buffer.alloc(len + 4)) - + encoder.estimateSpendingP2wshTx(Buffer.alloc(len)) + // step(73) spans 73→77 and is the discriminating case: the old script-push + // model widened at 76 and reported 2 here. + assert.strictEqual(step(73), 1, 'no prefix change across 76') + assert.strictEqual(step(252), 2, 'prefix widens 1 to 3 across 253') + assert.strictEqual(step(256), 1, 'no prefix change across 260') + }) +}) + +describe('XChainEncoder.estimateSpendingP2wshTx()', () => { + it('is smaller than estimateSpendingP2shTx for the same data (witness discount)', () => { + const encoder = makeEncoder() + const data = Buffer.alloc(200) + const p2wshSize = encoder.estimateSpendingP2wshTx(data) + const p2shSize = encoder.estimateSpendingP2shTx(data) + assert.ok(p2wshSize < p2shSize, + `P2WSH estimated size (${p2wshSize}) should be less than P2SH (${p2shSize}) for the same data`) + }) + + it('increases monotonically with data size', () => { + const encoder = makeEncoder() + const small = encoder.estimateSpendingP2wshTx(Buffer.alloc(50)) + const medium = encoder.estimateSpendingP2wshTx(Buffer.alloc(200)) + const large = encoder.estimateSpendingP2wshTx(Buffer.alloc(400)) + assert.ok(medium > small, 'medium should be larger than small') + assert.ok(large > medium, 'large should be larger than medium') + }) + + it('includes the 8-byte safety margin in the result', () => { + // The safety margin is 8 bytes. A 0-byte witness script → minimal estimate. + // We verify by comparing adjacent data sizes with the known arithmetic. + const encoder = makeEncoder() + const data = Buffer.alloc(0) + const size = encoder.estimateSpendingP2wshTx(data) + // result = nonWitnessBytes + ceil(witnessBytes/4) + 8 + // verify it equals our reference formula + assert.strictEqual(size, expectedSize(data)) + }) +}) diff --git a/test/unit/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js b/test/unit/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js new file mode 100644 index 0000000..7e428d8 --- /dev/null +++ b/test/unit/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js @@ -0,0 +1,152 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') + +const pubkeyBuf = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex' +) +const TXID_A = 'a'.repeat(64) + +// Build a raw legacy P2PKH transaction hex (nonWitnessUtxo) +function buildRawP2pkhTxHex (value) { + const tx = new bitcoin.Transaction() + tx.addInput(Buffer.alloc(32, 0x11), 0) + const script = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }).output + tx.addOutput(script, value) + return tx.toHex() +} + +// A P2WPKH scriptPubKey (segwit) +function makeSegwitUtxo (txid, vout, value) { + const p2wpkh = bitcoin.payments.p2wpkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }) + return { + txid, + vout, + value, + confirmations: 6, + scriptPubKey: p2wpkh.output.toString('hex') + } +} + +// A P2PKH (legacy, non-segwit) UTXO +function makeP2pkhUtxo (txid, vout, value) { + const script = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }).output.toString('hex') + return { txid, vout, value, confirmations: 6, scriptPubKey: script } +} + +const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const TEST_ADDRESS = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: DOGE_REGTEST +}).address + +const RAW_TX_HEX = buildRawP2pkhTxHex(100000000) + +function makeEncoder (network) { + const net = network || 'dogecoin-regtest' + const encoder = new XChainEncoder( + net, '127.0.0.1', '8333', 'rpc', 'rpc', '', '' + ) + encoder.connector = { + getFeePerKilobyte: async () => 0.00001, + getTransactionHex: async () => RAW_TX_HEX + } + // Serve the fixture type the chain can actually hold: a witness-program UTXO + // only exists where consensus knows segwit, and the builder refuses one where + // it does not. + const trackerUtxo = encoder.network.supportsSegwit === false ? makeP2pkhUtxo : makeSegwitUtxo + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => ({ + utxos: [trackerUtxo(TXID_A, 0, 100000000)] + }) + } + return encoder +} + + +// Remaining uncovered branches: customOutputs invalid value, UTXO invalid value, +// unconfirmed=false depletes all UTXOs, changeSatoshis non-finite. +describe('XChainEncoder.createTransaction() - remaining branch coverage', () => { + it('throws RangeError when customOutputs[i].value is not a valid satoshi amount', async () => { + const encoder = makeEncoder() + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) + + await assert.rejects( + () => encoder.createTransaction( + [utxo], TEST_ADDRESS, [{ address: TEST_ADDRESS, value: 'bad' }], + 'test', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ), + { name: 'RangeError', message: /customOutputs\[0\].value must be a non-negative integer/ } + ) + }) + + it('throws RangeError when customOutputs[i].value is negative', async () => { + const encoder = makeEncoder() + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) + + await assert.rejects( + () => encoder.createTransaction( + [utxo], TEST_ADDRESS, [{ address: TEST_ADDRESS, value: -1 }], + 'test', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ), + { name: 'RangeError', message: /customOutputs\[0\].value must be a non-negative integer/ } + ) + }) +}) + +describe('XChainEncoder.createTransaction() - remaining branch coverage', () => { + it('throws RangeError when a UTXO value is not a valid satoshi amount', async () => { + const encoder = makeEncoder() + // A UTXO with a NaN value string (parsed inside the UTXO loop) + const badUtxo = makeP2pkhUtxo(TXID_A, 0, 'notanumber') + + await assert.rejects( + () => encoder.createTransaction( + [badUtxo], TEST_ADDRESS, null, + 'test', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ), + { name: 'RangeError', message: /utxos\[0\].value must be a non-negative integer/ } + ) + }) + + it('throws when unconfirmed=false strips all UTXOs (line 325 path)', async () => { + const encoder = makeEncoder() + // All UTXOs have confirmations=0 (mempool), unconfirmed=false strips them all + const mempoolUtxo = makeP2pkhUtxo(TXID_A, 0, 100000000) + mempoolUtxo.confirmations = 0 + + await assert.rejects( + () => encoder.createTransaction( + [mempoolUtxo], TEST_ADDRESS, null, + 'test', null, 10000, false, null, TEST_ADDRESS, + null, null, null, + false, // unconfirmed=false → strips the mempool UTXO + 0.00001 + ), + /no utxos were provided and no utxos found on the blockchain/ + ) + }) +}) diff --git a/test/unit/xchain_encoder_extra.test/10_raw_data_parameter.test.js b/test/unit/xchain_encoder_extra.test/10_raw_data_parameter.test.js new file mode 100644 index 0000000..09f6645 --- /dev/null +++ b/test/unit/xchain_encoder_extra.test/10_raw_data_parameter.test.js @@ -0,0 +1,117 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') + +const pubkeyBuf = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex' +) +const TXID_A = 'a'.repeat(64) + +// Build a raw legacy P2PKH transaction hex (nonWitnessUtxo) +function buildRawP2pkhTxHex (value) { + const tx = new bitcoin.Transaction() + tx.addInput(Buffer.alloc(32, 0x11), 0) + const script = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }).output + tx.addOutput(script, value) + return tx.toHex() +} + +// A P2WPKH scriptPubKey (segwit) +function makeSegwitUtxo (txid, vout, value) { + const p2wpkh = bitcoin.payments.p2wpkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }) + return { + txid, + vout, + value, + confirmations: 6, + scriptPubKey: p2wpkh.output.toString('hex') + } +} + +// A P2PKH (legacy, non-segwit) UTXO +function makeP2pkhUtxo (txid, vout, value) { + const script = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }).output.toString('hex') + return { txid, vout, value, confirmations: 6, scriptPubKey: script } +} + +const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const TEST_ADDRESS = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: DOGE_REGTEST +}).address + +const RAW_TX_HEX = buildRawP2pkhTxHex(100000000) + +function makeEncoder (network) { + const net = network || 'dogecoin-regtest' + const encoder = new XChainEncoder( + net, '127.0.0.1', '8333', 'rpc', 'rpc', '', '' + ) + encoder.connector = { + getFeePerKilobyte: async () => 0.00001, + getTransactionHex: async () => RAW_TX_HEX + } + // Serve the fixture type the chain can actually hold: a witness-program UTXO + // only exists where consensus knows segwit, and the builder refuses one where + // it does not. + const trackerUtxo = encoder.network.supportsSegwit === false ? makeP2pkhUtxo : makeSegwitUtxo + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => ({ + utxos: [trackerUtxo(TXID_A, 0, 100000000)] + }) + } + return encoder +} + + +describe('XChainEncoder.createTransaction() - rawData parameter', () => { + it('accepts rawData and includes it in the compiled payload', async () => { + const encoder = makeEncoder() + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) + // rawData is a binary string appended after the text data + const rawData = '\x01\x02\x03binary content' + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', rawData, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + // Should build successfully; the rawData is compiled in via 'binary' encoding + assert.ok(result.psbt, 'should return a valid PSBT') + assert.ok(result.encoding, 'should return an encoding') + }) + + it('rawData does not affect result when null (false branch of rawData != null)', async () => { + const encoder = makeEncoder() + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'test', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + + assert.ok(result.psbt) + }) +}) From ab52b218300cdca760c9b0684f5a01f9167e0b23 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 07:44:48 -0700 Subject: [PATCH 119/148] test: split the TAPROOT envelope suite into numbered parts by behaviour --- .../xchain_encoder_taproot_envelope.test.js | 442 +----------------- ...eate_transaction_pair_construction.test.js | 284 +++++++++++ ...2_sign_finalize_extract_round_trip.test.js | 120 +++++ ...cel_from_persisted_recovery_record.test.js | 200 ++++++++ ..._tx_size_estimator_envelope_sizing.test.js | 34 ++ 5 files changed, 644 insertions(+), 436 deletions(-) create mode 100644 test/unit/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js create mode 100644 test/unit/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js create mode 100644 test/unit/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js create mode 100644 test/unit/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js diff --git a/test/unit/xchain_encoder_taproot_envelope.test.js b/test/unit/xchain_encoder_taproot_envelope.test.js index a5eec4b..a39fdb8 100644 --- a/test/unit/xchain_encoder_taproot_envelope.test.js +++ b/test/unit/xchain_encoder_taproot_envelope.test.js @@ -23,8 +23,6 @@ const bitcoin = require('bitcoinjs-lib') const ecc = require('tiny-secp256k1') const { ECPairFactory } = require('ecpair') const XChainEncoder = require('../../src/XChainEncoder') -const TxSizeEstimator = require('../../src/build/tx_size_estimator') -const { ENVELOPE_MAX_PAYLOAD, MAX_COMPILED_ACTION_DATA_LENGTH, MAX_STANDARD_TX_WEIGHT } = require('../../src/common/validator') const vectors = require('../../../xchain-documentation/protocol/test-vectors/taproot_envelope.json'); bitcoin.initEccLib(ecc) @@ -36,21 +34,6 @@ const KEY = ECPair.fromPrivateKey(Buffer.alloc(32, 7)) const PUBKEY_HEX = Buffer.from(KEY.publicKey).toString('hex') const XONLY = Buffer.from(KEY.publicKey).subarray(1, 33) -const TXID_A = 'a'.repeat(64) -const TXID_B = 'b'.repeat(64) - -const TAPROOT_LEAF_VERSION = 0xc0 - -function makeSegwitUtxo (network, txid, vout, value) { - const p2wpkh = bitcoin.payments.p2wpkh({ pubkey: Buffer.from(KEY.publicKey), network }) - return { txid, vout, value, confirmations: 6, scriptPubKey: p2wpkh.output.toString('hex') } -} - -function makeLegacyUtxo (network, txid, vout, value) { - const p2pkh = bitcoin.payments.p2pkh({ pubkey: Buffer.from(KEY.publicKey), network }) - return { txid, vout, value, confirmations: 6, scriptPubKey: p2pkh.output.toString('hex') } -} - function makeEncoder (networkName = 'bitcoin-regtest') { const encoder = new XChainEncoder(networkName, '127.0.0.1', '8333', 'rpc', 'rpc', '', '') encoder.connector = { @@ -67,12 +50,6 @@ function callerAddress (network) { return bitcoin.payments.p2wpkh({ pubkey: Buffer.from(KEY.publicKey), network }).address } -// Extract the unsigned tx from a PSBT (what the network will see for a -// segwit-only transaction, minus witnesses). -function unsignedTx (psbt) { - return bitcoin.Transaction.fromBuffer(psbt.data.globalMap.unsignedTx.toBuffer()) -} - // Decompile helper: returns the envelope script's chunks between the format // byte and OP_ENDIF, i.e. the payload pushes. function payloadPushes (envelopeScript) { @@ -137,6 +114,12 @@ describe('XChainEncoder TAPROOT envelope', function () { } assert.deepStrictEqual(Buffer.concat(pushes), payload, 'reassembly is still byte-identical') }) + }) +}) + +describe('XChainEncoder TAPROOT envelope', function () { + describe('prepareData grammar (§3.2)', function () { + const encoder = makeEncoder() it('requires compressedPubKey and validates its shape', function () { const payload = bitcoin.script.compile([Buffer.from('FILE|0|test', 'utf8')]) @@ -153,417 +136,4 @@ describe('XChainEncoder TAPROOT envelope', function () { assert.strictEqual(prepared.dataBufferArray[0].toString('hex'), v.envelope_script_hex, 'golden envelope bytes') }) }) - - describe('createTransaction pair construction (§3.5/§6)', function () { - it('returns {commit psbt, revealPsbt, envelope} with the commit output at vout 0', async function () { - const encoder = makeEncoder() - const network = encoder.network - const utxos = [makeSegwitUtxo(network, TXID_A, 0, 10000000)] - const raw = crypto.randomBytes(9000).toString('binary') // over the legacy 8192 ceiling on purpose - const result = await encoder.createTransaction( - utxos, callerAddress(network), null, 'FILE|0|envelope-test', raw, - null, false, 'TAPROOT', callerAddress(network), null, null, PUBKEY_HEX) - - assert.strictEqual(result.encoding, 'TAPROOT') - assert.ok(result.revealPsbt, 'reveal PSBT present') - assert.ok(result.envelope, 'envelope recovery record present') - assert.ok(Array.isArray(result.carrierScripts) && result.carrierScripts.length === 1) - - const commitTx = unsignedTx(result.psbt) - // vout 0 is the P2TR commit output committing to the envelope script. - const envelopeScript = Buffer.from(result.carrierScripts[0], 'hex') - const expected = bitcoin.payments.p2tr({ - internalPubkey: XONLY, scriptTree: { output: envelopeScript }, network - }).output - assert.strictEqual(result.envelope.commitVout, 0) - assert.deepStrictEqual(commitTx.outs[0].script, expected) - assert.strictEqual(commitTx.outs[0].value, result.envelope.commitValue) - assert.strictEqual(result.envelope.commitTxid, commitTx.getId()) - - // Reveal input 0 is the commit outpoint, with the tapLeafScript attached. - const revealIn = result.revealPsbt.txInputs[0] - assert.strictEqual(Buffer.from(revealIn.hash).reverse().toString('hex'), commitTx.getId()) - assert.strictEqual(revealIn.index, 0) - const tapLeaf = result.revealPsbt.data.inputs[0].tapLeafScript[0] - assert.strictEqual(tapLeaf.leafVersion, TAPROOT_LEAF_VERSION) - assert.deepStrictEqual(tapLeaf.script, envelopeScript) - assert.strictEqual(tapLeaf.controlBlock.toString('hex'), result.envelope.controlBlock) - - // §3.5: every commit input carries an explicit SIGHASH_ALL. - for (const input of result.psbt.data.inputs) { - assert.strictEqual(input.sighashType, bitcoin.Transaction.SIGHASH_ALL) - } - - // Prefund arithmetic: commit value = reveal fee + dust change (no LTC pad on BTC). - assert.strictEqual(result.envelope.commitValue, result.envelope.revealFee + encoder.dustAmount) - const revealOut = result.revealPsbt.txOutputs[0] - assert.strictEqual(Number(revealOut.value), encoder.dustAmount, 'reveal change lands exactly at dust') - }) - - it('emits customOutputs on the COMMIT (fee-destination outputs ride the commit, §3.5)', async function () { - const encoder = makeEncoder() - const network = encoder.network - const feeDest = bitcoin.payments.p2wpkh({ pubkey: Buffer.from(KEY.publicKey), network }).address - const utxos = [makeSegwitUtxo(network, TXID_A, 0, 10000000)] - const result = await encoder.createTransaction( - utxos, callerAddress(network), [{ address: feeDest, value: 12345 }], - 'FILE|0|feeout', 'x'.repeat(200), - null, false, 'TAPROOT', callerAddress(network), null, null, PUBKEY_HEX) - const commitTx = unsignedTx(result.psbt) - // vout 0 = envelope commit; the custom output follows it on the commit. - const found = commitTx.outs.slice(1).some(o => o.value === 12345) - assert.ok(found, 'custom output present on the commit after the envelope output') - // ...and the reveal carries no custom outputs (change/pad only). - for (const out of result.revealPsbt.txOutputs) { - assert.notStrictEqual(Number(out.value), 12345) - } - }) - - it('refuses non-segwit commit inputs (§3.5, fail closed)', async function () { - const encoder = makeEncoder() - const network = encoder.network - const utxos = [makeLegacyUtxo(network, TXID_A, 0, 10000000)] - await assert.rejects( - encoder.createTransaction( - utxos, callerAddress(network), null, 'FILE|0|legacy', 'x'.repeat(100), - null, false, 'TAPROOT', callerAddress(network), null, null, PUBKEY_HEX), - /TAPROOT commit inputs must be native-segwit/) - }) - - it('refuses TAPROOT on DOGE with the P2WSH gate error shape (§3.6)', async function () { - const encoder = makeEncoder('dogecoin-regtest') - await assert.rejects( - encoder.createTransaction( - [makeSegwitUtxo(bitcoin.networks.regtest, TXID_A, 0, 10000000)], - callerAddress(bitcoin.networks.regtest), null, 'FILE|0|doge', 'x'.repeat(100), - null, false, 'TAPROOT', null, null, null, PUBKEY_HEX), - /TAPROOT encoding is not supported on this network \(no segwit support\)/) - }) - - it('refuses the p2shHash reveal flow for TAPROOT', async function () { - const encoder = makeEncoder() - const network = encoder.network - await assert.rejects( - encoder.createTransaction( - [makeSegwitUtxo(network, TXID_A, 0, 10000000)], callerAddress(network), null, - 'FILE|0|x', 'y'.repeat(100), null, false, 'TAPROOT', null, - TXID_B, 'deadbeef', PUBKEY_HEX), - /TAPROOT encoding does not use the p2shHash reveal flow/) - }) - - it('applies the per-encoding §4 ceiling: over ENVELOPE_MAX_PAYLOAD rejects, over 8192 passes', async function () { - const encoder = makeEncoder() - const network = encoder.network - // Over the envelope ceiling: rejected with the envelope-specific message. - const tooBig = 'z'.repeat(ENVELOPE_MAX_PAYLOAD + 10) - await assert.rejects( - encoder.createTransaction( - [makeSegwitUtxo(network, TXID_A, 0, 1000000000)], callerAddress(network), null, - 'FILE|0|big', tooBig, null, false, 'TAPROOT', callerAddress(network), null, null, PUBKEY_HEX), - /TAPROOT envelope payload ceiling/) - // Comfortably over the legacy ceiling but under the envelope one: builds. - const big = 'z'.repeat(MAX_COMPILED_ACTION_DATA_LENGTH * 2) - const result = await encoder.createTransaction( - [makeSegwitUtxo(network, TXID_A, 0, 1000000000)], callerAddress(network), null, - 'FILE|0|big', big, null, false, 'TAPROOT', callerAddress(network), null, null, PUBKEY_HEX) - assert.ok(result.revealPsbt) - }) - - // The ceiling is DERIVED from MAX_STANDARD_TX_WEIGHT, so the - // derivation itself needs a test: a payload at exactly the ceiling must - // still build a reveal a node will relay. The original 400,000 passed every - // validator-level boundary test while producing a 402,789 WU reveal that no - // node accepts, because nothing measured the thing that actually binds. A - // byte-count assertion cannot catch that; only weight can. - it('a payload at exactly the ceiling builds a reveal within MAX_STANDARD_TX_WEIGHT (§4)', async function () { - this.timeout(30000) - const encoder = makeEncoder() - const network = encoder.network - // Size rawData so the COMPILED payload lands exactly on the ceiling - // (action push + OP_PUSHDATA4 framing are part of the measurand), rather - // than hardcoding an offset that silently drifts if either changes. - const action = 'FILE|0|max' - const frame = (n) => bitcoin.script.compile( - [Buffer.from(action, 'utf8'), Buffer.alloc(n, 0x7a)]).length - n - const rawLen = ENVELOPE_MAX_PAYLOAD - frame(ENVELOPE_MAX_PAYLOAD) - assert.strictEqual( - bitcoin.script.compile([Buffer.from(action, 'utf8'), Buffer.alloc(rawLen, 0x7a)]).length, - ENVELOPE_MAX_PAYLOAD, 'payload sized exactly to the ceiling') - - const result = await encoder.createTransaction( - [makeSegwitUtxo(network, TXID_A, 0, 1000000000)], callerAddress(network), null, - action, 'z'.repeat(rawLen), null, false, 'TAPROOT', - callerAddress(network), null, null, PUBKEY_HEX) - - // Measure a FULLY SIGNED reveal. An unsigned PSBT carries no witness, and - // the payload lives entirely in the witness, so weighing the unsigned tx - // reports ~1/1000th of the truth and would pass at any ceiling. - result.psbt.signAllInputs({ - publicKey: Buffer.from(KEY.publicKey), - sign: (h) => Buffer.from(KEY.sign(h)) - }) - result.psbt.finalizeAllInputs() - result.revealPsbt.signInput(0, { - publicKey: Buffer.from(KEY.publicKey), - signSchnorr: (h) => Buffer.from(ecc.signSchnorr(h, KEY.privateKey)) - }) - result.revealPsbt.finalizeAllInputs() - const revealTx = result.revealPsbt.extractTransaction() - - const weight = revealTx.weight() - assert.strictEqual(revealTx.ins[0].witness.length, 3, 'sig + envelope script + control block') - assert.ok(weight <= MAX_STANDARD_TX_WEIGHT, - `reveal weight ${weight} must not exceed MAX_STANDARD_TX_WEIGHT ${MAX_STANDARD_TX_WEIGHT}`) - // Margin, not a bare pass: the §3.5 extra reveal inputs/outputs and a - // larger (P2TR) change output must all still fit under the limit. - assert.ok(MAX_STANDARD_TX_WEIGHT - weight >= 2000, - `only ${MAX_STANDARD_TX_WEIGHT - weight} WU of headroom at the ceiling; lower ENVELOPE_MAX_PAYLOAD`) - }) - - it('propagates RBF to both transactions of the pair', async function () { - const encoder = makeEncoder() - const network = encoder.network - const result = await encoder.createTransaction( - [makeSegwitUtxo(network, TXID_A, 0, 10000000)], callerAddress(network), null, - 'FILE|0|rbf', 'x'.repeat(100), null, true, 'TAPROOT', callerAddress(network), null, null, PUBKEY_HEX) - assert.strictEqual(result.psbt.txInputs[0].sequence, 0xfffffffd) - assert.strictEqual(result.revealPsbt.txInputs[0].sequence, 0xfffffffd) - }) - - it('pads the reveal over the LTC stripped-size floor and prefunds the pad (§3.5)', async function () { - const encoder = makeEncoder('litecoin-regtest') - const network = encoder.network - const result = await encoder.createTransaction( - [makeSegwitUtxo(network, TXID_A, 0, 100000000)], callerAddress(network), null, - 'FILE|0|ltc', 'x'.repeat(300), null, false, 'TAPROOT', callerAddress(network), null, null, PUBKEY_HEX) - // p2wpkh change output (31 B) leaves the stripped reveal at 82 B, under - // LTC's 85-byte floor: expect change + one dust pad output. - assert.strictEqual(result.revealPsbt.txOutputs.length, 2, 'change + floor pad') - assert.strictEqual(Number(result.revealPsbt.txOutputs[1].value), encoder.dustAmount) - assert.strictEqual(result.envelope.commitValue, - result.envelope.revealFee + 2 * encoder.dustAmount, 'pad dust prefunded on the commit') - // The padded reveal now clears the floor. - const stripped = 10 + 41 * result.revealPsbt.txInputs.length + - result.revealPsbt.txOutputs.reduce((a, o) => a + 8 + 1 + o.script.length, 0) - assert.ok(stripped >= network.minStandardTxNonWitnessSize) - }) - }) - - describe('sign/finalize/extract round trip (commit -> reveal -> estimator accuracy)', function () { - it('signs both halves; the signed commit txid matches the pre-built reveal outpoint', async function () { - const encoder = makeEncoder() - const network = encoder.network - const utxos = [makeSegwitUtxo(network, TXID_A, 0, 10000000)] - const raw = crypto.randomBytes(4000).toString('binary') - const result = await encoder.createTransaction( - utxos, callerAddress(network), null, 'FILE|0|sign-test', raw, - null, false, 'TAPROOT', callerAddress(network), null, null, PUBKEY_HEX) - - // Sign the commit (ordinary p2wpkh inputs, SIGHASH_ALL as instructed). - const signer = { - publicKey: Buffer.from(KEY.publicKey), - sign: (h) => Buffer.from(KEY.sign(h)) - } - result.psbt.signAllInputs(signer) - result.psbt.finalizeAllInputs() - const commitTx = result.psbt.extractTransaction() - assert.strictEqual(commitTx.getId(), result.envelope.commitTxid, - 'signing did not move the segwit-only commit txid') - - // Sign the reveal via the script path with the internal key. - const revealSigner = { - publicKey: Buffer.from(KEY.publicKey), - signSchnorr: (h) => Buffer.from(ecc.signSchnorr(h, KEY.privateKey)) - } - result.revealPsbt.signInput(0, revealSigner) - result.revealPsbt.finalizeAllInputs() - const revealTx = result.revealPsbt.extractTransaction() - - // Witness stack: [schnorr sig, envelope script, control block]. - const witness = revealTx.ins[0].witness - assert.strictEqual(witness.length, 3) - assert.strictEqual(witness[1].toString('hex'), result.carrierScripts[0]) - assert.strictEqual(witness[2].toString('hex'), result.envelope.controlBlock) - assert.strictEqual(Buffer.from(revealTx.ins[0].hash).reverse().toString('hex'), commitTx.getId()) - - // The schnorr signature verifies against the leaf sighash. - const leafHash = bitcoin.crypto.taggedHash('TapLeaf', Buffer.concat([ - Buffer.from([TAPROOT_LEAF_VERSION]), - // compactSize prefix of the script length (script < 65536 here -> 0xfd form or single byte) - (function (n) { - if (n < 253) return Buffer.from([n]) - const b = Buffer.alloc(3); b[0] = 0xfd; b.writeUInt16LE(n, 1); return b - })(witness[1].length), - witness[1] - ])) - assert.strictEqual(leafHash.toString('hex'), result.envelope.tapleafHash) - const sighash = revealTx.hashForWitnessV1( - 0, [commitTx.outs[0].script], [commitTx.outs[0].value], - bitcoin.Transaction.SIGHASH_DEFAULT, leafHash) - assert.ok(ecc.verifySchnorr(sighash, XONLY, witness[0]), 'schnorr signature verifies') - - // Estimator accuracy: the funded reveal fee covers the actual vsize at - // the quoted 1 sat/vB and does not over-fund by more than the margin. - const actualVsize = revealTx.virtualSize() - assert.ok(result.envelope.revealFee >= actualVsize, - `reveal prefund ${result.envelope.revealFee} covers actual vsize ${actualVsize}`) - assert.ok(result.envelope.revealFee <= actualVsize + 16, - `reveal prefund ${result.envelope.revealFee} within margin of actual ${actualVsize}`) - }) - }) - - describe('key-path cancel from the persisted recovery record (§3.5/§3.7)', function () { - it('builds, signs and extracts a sweep using only the recovery record', async function () { - const encoder = makeEncoder() - const network = encoder.network - - // 1. Build the pair and capture ONLY what the wallet persists. - const result = await encoder.createTransaction( - [makeSegwitUtxo(network, TXID_A, 0, 10000000)], callerAddress(network), null, - 'FILE|0|cancel-test', crypto.randomBytes(2000).toString('binary'), - null, false, 'TAPROOT', callerAddress(network), null, null, PUBKEY_HEX) - const record = { - commitTxid: result.envelope.commitTxid, - commitVout: result.envelope.commitVout, - commitValue: result.envelope.commitValue, - internalPubkey: result.envelope.internalPubkey, - tapleafHash: result.envelope.tapleafHash - } - - // 2. Simulated crash: a fresh encoder builds the cancel from the record. - const encoder2 = makeEncoder() - const dest = callerAddress(network) - const cancel = await encoder2.createEnvelopeCancelTransaction( - Object.assign({ destination: dest }, record)) - assert.strictEqual(cancel.encoding, 'TAPROOT') - assert.strictEqual(cancel.cancel, true) - - // The reconstructed input spends the commit outpoint with the real - // commit scriptPubKey (recomputed from internal key + merkle root). - const commitTx = unsignedTx(result.psbt) - assert.deepStrictEqual(cancel.psbt.data.inputs[0].witnessUtxo.script, commitTx.outs[0].script) - assert.strictEqual( - Buffer.from(cancel.psbt.txInputs[0].hash).reverse().toString('hex'), record.commitTxid) - - // 3. Key-path sign with the BIP341-tweaked key, as a wallet would. - const tweaked = KEY.tweak(bitcoin.crypto.taggedHash('TapTweak', - Buffer.concat([XONLY, Buffer.from(record.tapleafHash, 'hex')]))) - cancel.psbt.signInput(0, { - publicKey: Buffer.from(tweaked.publicKey), - signSchnorr: (h) => Buffer.from(ecc.signSchnorr(h, tweaked.privateKey)) - }) - cancel.psbt.finalizeAllInputs() - const cancelTx = cancel.psbt.extractTransaction() - assert.strictEqual(cancelTx.ins[0].witness.length, 1, 'key-path spend: single witness item') - - // The signature verifies against the commit's tweaked output key. - const outputKey = commitTx.outs[0].script.subarray(2) - const sighash = cancelTx.hashForWitnessV1( - 0, [commitTx.outs[0].script], [record.commitValue], bitcoin.Transaction.SIGHASH_DEFAULT) - assert.ok(ecc.verifySchnorr(sighash, outputKey, cancelTx.ins[0].witness[0])) - - // Sweep value arithmetic: commit value minus the funded fee. - assert.strictEqual(Number(cancelTx.outs[0].value), record.commitValue - cancel.fee) - }) - - it('fails closed when the sweep would land under dust', async function () { - const encoder = makeEncoder() - await assert.rejects( - encoder.createEnvelopeCancelTransaction({ - commitTxid: TXID_A, commitVout: 0, commitValue: 600, - internalPubkey: PUBKEY_HEX, tapleafHash: 'c'.repeat(64), - destination: callerAddress(encoder.network) - }), - (err) => err.xchainCode === 'ENVELOPE_CANCEL_BELOW_DUST') - }) - - it('validates the recovery-record fields', async function () { - const encoder = makeEncoder() - const base = { - commitTxid: TXID_A, commitVout: 0, commitValue: 100000, - internalPubkey: PUBKEY_HEX, tapleafHash: 'c'.repeat(64), - destination: callerAddress(encoder.network) - } - await assert.rejects(encoder.createEnvelopeCancelTransaction( - Object.assign({}, base, { commitTxid: 'xyz' })), /commitTxid/) - await assert.rejects(encoder.createEnvelopeCancelTransaction( - Object.assign({}, base, { commitVout: -1 })), /commitVout/) - await assert.rejects(encoder.createEnvelopeCancelTransaction( - Object.assign({}, base, { internalPubkey: '04' + 'a'.repeat(128) })), /internalPubkey/) - await assert.rejects(encoder.createEnvelopeCancelTransaction( - Object.assign({}, base, { tapleafHash: 'nope' })), /tapleafHash/) - await assert.rejects(encoder.createEnvelopeCancelTransaction( - Object.assign({}, base, { destination: '' })), /destination/) - // The 100-char shared address cap, which this path used to miss because - // it is the one create path api.js does not route through validateAll. - // Uncapped, a multi-megabyte destination under the 3 MB body limit reaches - // bs58check's quadratic decode on a single-instance service. - await assert.rejects(encoder.createEnvelopeCancelTransaction( - Object.assign({}, base, { destination: 'a'.repeat(101) })), - (err) => err instanceof TypeError && /destination exceeds maximum length \(100\)/.test(err.message)) - await assert.rejects(encoder.createEnvelopeCancelTransaction( - Object.assign({}, base, { destination: 'a'.repeat(3_000_000) })), - (err) => err instanceof TypeError && /destination exceeds maximum length \(100\)/.test(err.message)) - await assert.rejects(encoder.createEnvelopeCancelTransaction( - Object.assign({}, base, { destination: 12345 })), - (err) => err instanceof TypeError && /destination must be a non-empty string/.test(err.message)) - }) - - it('applies the create_tx fee guards to feePerKb and replacebyfee', async function () { - const encoder = makeEncoder() - const base = { - commitTxid: TXID_A, commitVout: 0, commitValue: 100000, - internalPubkey: PUBKEY_HEX, tapleafHash: 'c'.repeat(64), - destination: callerAddress(encoder.network) - } - // Unguarded, these spellings became NaN, skipped both dust comparisons - // and surfaced as an opaque internal error instead of invalid-params. - for (const bad of ['abc', '0x20', '1e3', 'Infinity']) { - await assert.rejects(encoder.createEnvelopeCancelTransaction( - Object.assign({}, base, { feePerKb: bad })), TypeError, - 'feePerKb ' + bad + ' must be rejected as a typed error') - } - await assert.rejects(encoder.createEnvelopeCancelTransaction( - Object.assign({}, base, { feePerKb: -1 })), RangeError) - // The JSON string 'false' is truthy, so it used to arm RBF silently. - await assert.rejects(encoder.createEnvelopeCancelTransaction( - Object.assign({}, base, { replacebyfee: 'false' })), /replacebyfee/) - // Valid shapes still build, and a real boolean still arms RBF. - const armed = await encoder.createEnvelopeCancelTransaction( - Object.assign({}, base, { feePerKb: 5000, replacebyfee: true })) - assert.strictEqual(armed.psbt.txInputs[0].sequence, 0xfffffffd) - const plain = await encoder.createEnvelopeCancelTransaction( - Object.assign({}, base, { feePerKb: '5000' })) - assert.strictEqual(plain.psbt.txInputs[0].sequence, 0xffffffff) - }) - - it('accepts the x-only internal key form', async function () { - const encoder = makeEncoder() - const cancel = await encoder.createEnvelopeCancelTransaction({ - commitTxid: TXID_A, commitVout: 0, commitValue: 100000, - internalPubkey: XONLY.toString('hex'), tapleafHash: 'c'.repeat(64), - destination: callerAddress(encoder.network) - }) - assert.deepStrictEqual(cancel.psbt.data.inputs[0].tapInternalKey, XONLY) - }) - }) - - describe('TxSizeEstimator envelope sizing', function () { - it('estimateEnvelopeRevealTx covers and does not wildly over-fund a real reveal', function () { - // Direct sanity independent of the encoder round trip above: a - // ~10 KB envelope script with one p2tr-sized output. - const est = TxSizeEstimator.estimateEnvelopeRevealTx(10000, 43, 65) - // nonwitness 10+41+43 = 94; witness 2+1+66+3+10000+34 -> /4 ≈ 2527; +2 - assert.ok(est > 2600 && est < 2700, `estimate ${est} in the expected band`) - }) - it('estimateTaprootOutput matches the serialized P2TR output size', function () { - assert.strictEqual(TxSizeEstimator.estimateTaprootOutput(), 43) - }) - it('uses compactSize framing for scripts above 65535 bytes (5-byte prefix band)', function () { - const below = TxSizeEstimator.estimateEnvelopeRevealTx(65535, 43, 65) - const above = TxSizeEstimator.estimateEnvelopeRevealTx(65536, 43, 65) - // +1 script byte and +2 varint bytes, ÷4 and ceiled: at most +1 vbyte step. - assert.ok(above >= below, 'monotonic across the varint band switch') - }) - }) }) diff --git a/test/unit/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js b/test/unit/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js new file mode 100644 index 0000000..e79b149 --- /dev/null +++ b/test/unit/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js @@ -0,0 +1,284 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. +// + +const assert = require('assert') +const crypto = require('crypto') +const bitcoin = require('bitcoinjs-lib') +const ecc = require('tiny-secp256k1') +const { ECPairFactory } = require('ecpair') +const XChainEncoder = require('../../../src/XChainEncoder') +const { ENVELOPE_MAX_PAYLOAD, MAX_COMPILED_ACTION_DATA_LENGTH, MAX_STANDARD_TX_WEIGHT } = require('../../../src/common/validator') + +bitcoin.initEccLib(ecc) +const ECPair = ECPairFactory(ecc) + +const KEY = ECPair.fromPrivateKey(Buffer.alloc(32, 7)) +const PUBKEY_HEX = Buffer.from(KEY.publicKey).toString('hex') +const XONLY = Buffer.from(KEY.publicKey).subarray(1, 33) + +const TXID_A = 'a'.repeat(64) +const TXID_B = 'b'.repeat(64) + +const TAPROOT_LEAF_VERSION = 0xc0 + +function makeSegwitUtxo (network, txid, vout, value) { + const p2wpkh = bitcoin.payments.p2wpkh({ pubkey: Buffer.from(KEY.publicKey), network }) + return { txid, vout, value, confirmations: 6, scriptPubKey: p2wpkh.output.toString('hex') } +} + +function makeLegacyUtxo (network, txid, vout, value) { + const p2pkh = bitcoin.payments.p2pkh({ pubkey: Buffer.from(KEY.publicKey), network }) + return { txid, vout, value, confirmations: 6, scriptPubKey: p2pkh.output.toString('hex') } +} + +function makeEncoder (networkName = 'bitcoin-regtest') { + const encoder = new XChainEncoder(networkName, '127.0.0.1', '8333', 'rpc', 'rpc', '', '') + encoder.connector = { + getFeePerKilobyte: async () => 0.00001, // 1 sat/byte + getTransactionHex: async () => { throw new Error('unit test: no node') } + } + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => { throw new Error('unit test: no tracker') } + } + return encoder +} + +function callerAddress (network) { + return bitcoin.payments.p2wpkh({ pubkey: Buffer.from(KEY.publicKey), network }).address +} + +// Extract the unsigned tx from a PSBT (what the network will see for a +// segwit-only transaction, minus witnesses). +function unsignedTx (psbt) { + return bitcoin.Transaction.fromBuffer(psbt.data.globalMap.unsignedTx.toBuffer()) +} + +describe('XChainEncoder TAPROOT envelope', function () { + describe('createTransaction pair construction (§3.5/§6)', function () { + it('returns {commit psbt, revealPsbt, envelope} with the commit output at vout 0', async function () { + const encoder = makeEncoder() + const network = encoder.network + const utxos = [makeSegwitUtxo(network, TXID_A, 0, 10000000)] + const raw = crypto.randomBytes(9000).toString('binary') // over the legacy 8192 ceiling on purpose + const result = await encoder.createTransaction( + utxos, callerAddress(network), null, 'FILE|0|envelope-test', raw, + null, false, 'TAPROOT', callerAddress(network), null, null, PUBKEY_HEX) + + assert.strictEqual(result.encoding, 'TAPROOT') + assert.ok(result.revealPsbt, 'reveal PSBT present') + assert.ok(result.envelope, 'envelope recovery record present') + assert.ok(Array.isArray(result.carrierScripts) && result.carrierScripts.length === 1) + + const commitTx = unsignedTx(result.psbt) + // vout 0 is the P2TR commit output committing to the envelope script. + const envelopeScript = Buffer.from(result.carrierScripts[0], 'hex') + const expected = bitcoin.payments.p2tr({ + internalPubkey: XONLY, scriptTree: { output: envelopeScript }, network + }).output + assert.strictEqual(result.envelope.commitVout, 0) + assert.deepStrictEqual(commitTx.outs[0].script, expected) + assert.strictEqual(commitTx.outs[0].value, result.envelope.commitValue) + assert.strictEqual(result.envelope.commitTxid, commitTx.getId()) + + // Reveal input 0 is the commit outpoint, with the tapLeafScript attached. + const revealIn = result.revealPsbt.txInputs[0] + assert.strictEqual(Buffer.from(revealIn.hash).reverse().toString('hex'), commitTx.getId()) + assert.strictEqual(revealIn.index, 0) + const tapLeaf = result.revealPsbt.data.inputs[0].tapLeafScript[0] + assert.strictEqual(tapLeaf.leafVersion, TAPROOT_LEAF_VERSION) + assert.deepStrictEqual(tapLeaf.script, envelopeScript) + assert.strictEqual(tapLeaf.controlBlock.toString('hex'), result.envelope.controlBlock) + + // §3.5: every commit input carries an explicit SIGHASH_ALL. + for (const input of result.psbt.data.inputs) { + assert.strictEqual(input.sighashType, bitcoin.Transaction.SIGHASH_ALL) + } + + // Prefund arithmetic: commit value = reveal fee + dust change (no LTC pad on BTC). + assert.strictEqual(result.envelope.commitValue, result.envelope.revealFee + encoder.dustAmount) + const revealOut = result.revealPsbt.txOutputs[0] + assert.strictEqual(Number(revealOut.value), encoder.dustAmount, 'reveal change lands exactly at dust') + }) + }) +}) + +describe('XChainEncoder TAPROOT envelope', function () { + describe('createTransaction pair construction (§3.5/§6)', function () { + + it('emits customOutputs on the COMMIT (fee-destination outputs ride the commit, §3.5)', async function () { + const encoder = makeEncoder() + const network = encoder.network + const feeDest = bitcoin.payments.p2wpkh({ pubkey: Buffer.from(KEY.publicKey), network }).address + const utxos = [makeSegwitUtxo(network, TXID_A, 0, 10000000)] + const result = await encoder.createTransaction( + utxos, callerAddress(network), [{ address: feeDest, value: 12345 }], + 'FILE|0|feeout', 'x'.repeat(200), + null, false, 'TAPROOT', callerAddress(network), null, null, PUBKEY_HEX) + const commitTx = unsignedTx(result.psbt) + // vout 0 = envelope commit; the custom output follows it on the commit. + const found = commitTx.outs.slice(1).some(o => o.value === 12345) + assert.ok(found, 'custom output present on the commit after the envelope output') + // ...and the reveal carries no custom outputs (change/pad only). + for (const out of result.revealPsbt.txOutputs) { + assert.notStrictEqual(Number(out.value), 12345) + } + }) + }) +}) + +describe('XChainEncoder TAPROOT envelope', function () { + describe('createTransaction pair construction (§3.5/§6)', function () { + + it('refuses non-segwit commit inputs (§3.5, fail closed)', async function () { + const encoder = makeEncoder() + const network = encoder.network + const utxos = [makeLegacyUtxo(network, TXID_A, 0, 10000000)] + await assert.rejects( + encoder.createTransaction( + utxos, callerAddress(network), null, 'FILE|0|legacy', 'x'.repeat(100), + null, false, 'TAPROOT', callerAddress(network), null, null, PUBKEY_HEX), + /TAPROOT commit inputs must be native-segwit/) + }) + + it('refuses TAPROOT on DOGE with the P2WSH gate error shape (§3.6)', async function () { + const encoder = makeEncoder('dogecoin-regtest') + await assert.rejects( + encoder.createTransaction( + [makeSegwitUtxo(bitcoin.networks.regtest, TXID_A, 0, 10000000)], + callerAddress(bitcoin.networks.regtest), null, 'FILE|0|doge', 'x'.repeat(100), + null, false, 'TAPROOT', null, null, null, PUBKEY_HEX), + /TAPROOT encoding is not supported on this network \(no segwit support\)/) + }) + + it('refuses the p2shHash reveal flow for TAPROOT', async function () { + const encoder = makeEncoder() + const network = encoder.network + await assert.rejects( + encoder.createTransaction( + [makeSegwitUtxo(network, TXID_A, 0, 10000000)], callerAddress(network), null, + 'FILE|0|x', 'y'.repeat(100), null, false, 'TAPROOT', null, + TXID_B, 'deadbeef', PUBKEY_HEX), + /TAPROOT encoding does not use the p2shHash reveal flow/) + }) + }) +}) + +describe('XChainEncoder TAPROOT envelope', function () { + describe('createTransaction pair construction (§3.5/§6)', function () { + + it('applies the per-encoding §4 ceiling: over ENVELOPE_MAX_PAYLOAD rejects, over 8192 passes', async function () { + const encoder = makeEncoder() + const network = encoder.network + // Over the envelope ceiling: rejected with the envelope-specific message. + const tooBig = 'z'.repeat(ENVELOPE_MAX_PAYLOAD + 10) + await assert.rejects( + encoder.createTransaction( + [makeSegwitUtxo(network, TXID_A, 0, 1000000000)], callerAddress(network), null, + 'FILE|0|big', tooBig, null, false, 'TAPROOT', callerAddress(network), null, null, PUBKEY_HEX), + /TAPROOT envelope payload ceiling/) + // Comfortably over the legacy ceiling but under the envelope one: builds. + const big = 'z'.repeat(MAX_COMPILED_ACTION_DATA_LENGTH * 2) + const result = await encoder.createTransaction( + [makeSegwitUtxo(network, TXID_A, 0, 1000000000)], callerAddress(network), null, + 'FILE|0|big', big, null, false, 'TAPROOT', callerAddress(network), null, null, PUBKEY_HEX) + assert.ok(result.revealPsbt) + }) + }) +}) + +describe('XChainEncoder TAPROOT envelope', function () { + describe('createTransaction pair construction (§3.5/§6)', function () { + + // The ceiling is DERIVED from MAX_STANDARD_TX_WEIGHT, so the + // derivation itself needs a test: a payload at exactly the ceiling must + // still build a reveal a node will relay. The original 400,000 passed every + // validator-level boundary test while producing a 402,789 WU reveal that no + // node accepts, because nothing measured the thing that actually binds. A + // byte-count assertion cannot catch that; only weight can. + it('a payload at exactly the ceiling builds a reveal within MAX_STANDARD_TX_WEIGHT (§4)', async function () { + this.timeout(30000) + const encoder = makeEncoder() + const network = encoder.network + // Size rawData so the COMPILED payload lands exactly on the ceiling + // (action push + OP_PUSHDATA4 framing are part of the measurand), rather + // than hardcoding an offset that silently drifts if either changes. + const action = 'FILE|0|max' + const frame = (n) => bitcoin.script.compile( + [Buffer.from(action, 'utf8'), Buffer.alloc(n, 0x7a)]).length - n + const rawLen = ENVELOPE_MAX_PAYLOAD - frame(ENVELOPE_MAX_PAYLOAD) + assert.strictEqual( + bitcoin.script.compile([Buffer.from(action, 'utf8'), Buffer.alloc(rawLen, 0x7a)]).length, + ENVELOPE_MAX_PAYLOAD, 'payload sized exactly to the ceiling') + + const result = await encoder.createTransaction( + [makeSegwitUtxo(network, TXID_A, 0, 1000000000)], callerAddress(network), null, + action, 'z'.repeat(rawLen), null, false, 'TAPROOT', + callerAddress(network), null, null, PUBKEY_HEX) + + // Measure a FULLY SIGNED reveal. An unsigned PSBT carries no witness, and + // the payload lives entirely in the witness, so weighing the unsigned tx + // reports ~1/1000th of the truth and would pass at any ceiling. + result.psbt.signAllInputs({ + publicKey: Buffer.from(KEY.publicKey), + sign: (h) => Buffer.from(KEY.sign(h)) + }) + result.psbt.finalizeAllInputs() + result.revealPsbt.signInput(0, { + publicKey: Buffer.from(KEY.publicKey), + signSchnorr: (h) => Buffer.from(ecc.signSchnorr(h, KEY.privateKey)) + }) + result.revealPsbt.finalizeAllInputs() + const revealTx = result.revealPsbt.extractTransaction() + + const weight = revealTx.weight() + assert.strictEqual(revealTx.ins[0].witness.length, 3, 'sig + envelope script + control block') + assert.ok(weight <= MAX_STANDARD_TX_WEIGHT, + `reveal weight ${weight} must not exceed MAX_STANDARD_TX_WEIGHT ${MAX_STANDARD_TX_WEIGHT}`) + // Margin, not a bare pass: the §3.5 extra reveal inputs/outputs and a + // larger (P2TR) change output must all still fit under the limit. + assert.ok(MAX_STANDARD_TX_WEIGHT - weight >= 2000, + `only ${MAX_STANDARD_TX_WEIGHT - weight} WU of headroom at the ceiling; lower ENVELOPE_MAX_PAYLOAD`) + }) + }) +}) + +describe('XChainEncoder TAPROOT envelope', function () { + describe('createTransaction pair construction (§3.5/§6)', function () { + + it('propagates RBF to both transactions of the pair', async function () { + const encoder = makeEncoder() + const network = encoder.network + const result = await encoder.createTransaction( + [makeSegwitUtxo(network, TXID_A, 0, 10000000)], callerAddress(network), null, + 'FILE|0|rbf', 'x'.repeat(100), null, true, 'TAPROOT', callerAddress(network), null, null, PUBKEY_HEX) + assert.strictEqual(result.psbt.txInputs[0].sequence, 0xfffffffd) + assert.strictEqual(result.revealPsbt.txInputs[0].sequence, 0xfffffffd) + }) + + it('pads the reveal over the LTC stripped-size floor and prefunds the pad (§3.5)', async function () { + const encoder = makeEncoder('litecoin-regtest') + const network = encoder.network + const result = await encoder.createTransaction( + [makeSegwitUtxo(network, TXID_A, 0, 100000000)], callerAddress(network), null, + 'FILE|0|ltc', 'x'.repeat(300), null, false, 'TAPROOT', callerAddress(network), null, null, PUBKEY_HEX) + // p2wpkh change output (31 B) leaves the stripped reveal at 82 B, under + // LTC's 85-byte floor: expect change + one dust pad output. + assert.strictEqual(result.revealPsbt.txOutputs.length, 2, 'change + floor pad') + assert.strictEqual(Number(result.revealPsbt.txOutputs[1].value), encoder.dustAmount) + assert.strictEqual(result.envelope.commitValue, + result.envelope.revealFee + 2 * encoder.dustAmount, 'pad dust prefunded on the commit') + // The padded reveal now clears the floor. + const stripped = 10 + 41 * result.revealPsbt.txInputs.length + + result.revealPsbt.txOutputs.reduce((a, o) => a + 8 + 1 + o.script.length, 0) + assert.ok(stripped >= network.minStandardTxNonWitnessSize) + }) + }) +}) diff --git a/test/unit/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js b/test/unit/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js new file mode 100644 index 0000000..a944232 --- /dev/null +++ b/test/unit/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js @@ -0,0 +1,120 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. +// + +const assert = require('assert') +const crypto = require('crypto') +const bitcoin = require('bitcoinjs-lib') +const ecc = require('tiny-secp256k1') +const { ECPairFactory } = require('ecpair') +const XChainEncoder = require('../../../src/XChainEncoder') + +bitcoin.initEccLib(ecc) +const ECPair = ECPairFactory(ecc) + +const KEY = ECPair.fromPrivateKey(Buffer.alloc(32, 7)) +const PUBKEY_HEX = Buffer.from(KEY.publicKey).toString('hex') +const XONLY = Buffer.from(KEY.publicKey).subarray(1, 33) + +const TXID_A = 'a'.repeat(64) + +const TAPROOT_LEAF_VERSION = 0xc0 + +function makeSegwitUtxo (network, txid, vout, value) { + const p2wpkh = bitcoin.payments.p2wpkh({ pubkey: Buffer.from(KEY.publicKey), network }) + return { txid, vout, value, confirmations: 6, scriptPubKey: p2wpkh.output.toString('hex') } +} + +function makeEncoder (networkName = 'bitcoin-regtest') { + const encoder = new XChainEncoder(networkName, '127.0.0.1', '8333', 'rpc', 'rpc', '', '') + encoder.connector = { + getFeePerKilobyte: async () => 0.00001, // 1 sat/byte + getTransactionHex: async () => { throw new Error('unit test: no node') } + } + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => { throw new Error('unit test: no tracker') } + } + return encoder +} + +function callerAddress (network) { + return bitcoin.payments.p2wpkh({ pubkey: Buffer.from(KEY.publicKey), network }).address +} + +function unsignedTx (psbt) { + return bitcoin.Transaction.fromBuffer(psbt.data.globalMap.unsignedTx.toBuffer()) +} + +async function signsBothHalves () { + const encoder = makeEncoder() + const network = encoder.network + const utxos = [makeSegwitUtxo(network, TXID_A, 0, 10000000)] + const raw = crypto.randomBytes(4000).toString('binary') + const result = await encoder.createTransaction( + utxos, callerAddress(network), null, 'FILE|0|sign-test', raw, + null, false, 'TAPROOT', callerAddress(network), null, null, PUBKEY_HEX) + + // Sign the commit (ordinary p2wpkh inputs, SIGHASH_ALL as instructed). + const signer = { + publicKey: Buffer.from(KEY.publicKey), + sign: (h) => Buffer.from(KEY.sign(h)) + } + result.psbt.signAllInputs(signer) + result.psbt.finalizeAllInputs() + const commitTx = result.psbt.extractTransaction() + assert.strictEqual(commitTx.getId(), result.envelope.commitTxid, + 'signing did not move the segwit-only commit txid') + + // Sign the reveal via the script path with the internal key. + const revealSigner = { + publicKey: Buffer.from(KEY.publicKey), + signSchnorr: (h) => Buffer.from(ecc.signSchnorr(h, KEY.privateKey)) + } + result.revealPsbt.signInput(0, revealSigner) + result.revealPsbt.finalizeAllInputs() + const revealTx = result.revealPsbt.extractTransaction() + + // Witness stack: [schnorr sig, envelope script, control block]. + const witness = revealTx.ins[0].witness + assert.strictEqual(witness.length, 3) + assert.strictEqual(witness[1].toString('hex'), result.carrierScripts[0]) + assert.strictEqual(witness[2].toString('hex'), result.envelope.controlBlock) + assert.strictEqual(Buffer.from(revealTx.ins[0].hash).reverse().toString('hex'), commitTx.getId()) + + // The schnorr signature verifies against the leaf sighash. + const leafHash = bitcoin.crypto.taggedHash('TapLeaf', Buffer.concat([ + Buffer.from([TAPROOT_LEAF_VERSION]), + // compactSize prefix of the script length (script < 65536 here -> 0xfd form or single byte) + (function (n) { + if (n < 253) return Buffer.from([n]) + const b = Buffer.alloc(3); b[0] = 0xfd; b.writeUInt16LE(n, 1); return b + })(witness[1].length), + witness[1] + ])) + assert.strictEqual(leafHash.toString('hex'), result.envelope.tapleafHash) + const sighash = revealTx.hashForWitnessV1( + 0, [commitTx.outs[0].script], [commitTx.outs[0].value], + bitcoin.Transaction.SIGHASH_DEFAULT, leafHash) + assert.ok(ecc.verifySchnorr(sighash, XONLY, witness[0]), 'schnorr signature verifies') + + // Estimator accuracy: the funded reveal fee covers the actual vsize at + // the quoted 1 sat/vB and does not over-fund by more than the margin. + const actualVsize = revealTx.virtualSize() + assert.ok(result.envelope.revealFee >= actualVsize, + `reveal prefund ${result.envelope.revealFee} covers actual vsize ${actualVsize}`) + assert.ok(result.envelope.revealFee <= actualVsize + 16, + `reveal prefund ${result.envelope.revealFee} within margin of actual ${actualVsize}`) +} + +describe('XChainEncoder TAPROOT envelope', function () { + describe('sign/finalize/extract round trip (commit -> reveal -> estimator accuracy)', function () { + it('signs both halves; the signed commit txid matches the pre-built reveal outpoint', signsBothHalves) + }) +}) diff --git a/test/unit/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js b/test/unit/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js new file mode 100644 index 0000000..ead7ace --- /dev/null +++ b/test/unit/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js @@ -0,0 +1,200 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. +// + +const assert = require('assert') +const crypto = require('crypto') +const bitcoin = require('bitcoinjs-lib') +const ecc = require('tiny-secp256k1') +const { ECPairFactory } = require('ecpair') +const XChainEncoder = require('../../../src/XChainEncoder') + +bitcoin.initEccLib(ecc) +const ECPair = ECPairFactory(ecc) + +const KEY = ECPair.fromPrivateKey(Buffer.alloc(32, 7)) +const PUBKEY_HEX = Buffer.from(KEY.publicKey).toString('hex') +const XONLY = Buffer.from(KEY.publicKey).subarray(1, 33) + +const TXID_A = 'a'.repeat(64) + +function makeSegwitUtxo (network, txid, vout, value) { + const p2wpkh = bitcoin.payments.p2wpkh({ pubkey: Buffer.from(KEY.publicKey), network }) + return { txid, vout, value, confirmations: 6, scriptPubKey: p2wpkh.output.toString('hex') } +} + +function makeEncoder (networkName = 'bitcoin-regtest') { + const encoder = new XChainEncoder(networkName, '127.0.0.1', '8333', 'rpc', 'rpc', '', '') + encoder.connector = { + getFeePerKilobyte: async () => 0.00001, // 1 sat/byte + getTransactionHex: async () => { throw new Error('unit test: no node') } + } + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => { throw new Error('unit test: no tracker') } + } + return encoder +} + +function callerAddress (network) { + return bitcoin.payments.p2wpkh({ pubkey: Buffer.from(KEY.publicKey), network }).address +} + +function unsignedTx (psbt) { + return bitcoin.Transaction.fromBuffer(psbt.data.globalMap.unsignedTx.toBuffer()) +} + +describe('XChainEncoder TAPROOT envelope', function () { + describe('key-path cancel from the persisted recovery record (§3.5/§3.7)', function () { + it('builds, signs and extracts a sweep using only the recovery record', async function () { + const encoder = makeEncoder() + const network = encoder.network + + // 1. Build the pair and capture ONLY what the wallet persists. + const result = await encoder.createTransaction( + [makeSegwitUtxo(network, TXID_A, 0, 10000000)], callerAddress(network), null, + 'FILE|0|cancel-test', crypto.randomBytes(2000).toString('binary'), + null, false, 'TAPROOT', callerAddress(network), null, null, PUBKEY_HEX) + const record = { + commitTxid: result.envelope.commitTxid, + commitVout: result.envelope.commitVout, + commitValue: result.envelope.commitValue, + internalPubkey: result.envelope.internalPubkey, + tapleafHash: result.envelope.tapleafHash + } + + // 2. Simulated crash: a fresh encoder builds the cancel from the record. + const encoder2 = makeEncoder() + const dest = callerAddress(network) + const cancel = await encoder2.createEnvelopeCancelTransaction( + Object.assign({ destination: dest }, record)) + assert.strictEqual(cancel.encoding, 'TAPROOT') + assert.strictEqual(cancel.cancel, true) + + // The reconstructed input spends the commit outpoint with the real + // commit scriptPubKey (recomputed from internal key + merkle root). + const commitTx = unsignedTx(result.psbt) + assert.deepStrictEqual(cancel.psbt.data.inputs[0].witnessUtxo.script, commitTx.outs[0].script) + assert.strictEqual( + Buffer.from(cancel.psbt.txInputs[0].hash).reverse().toString('hex'), record.commitTxid) + + // 3. Key-path sign with the BIP341-tweaked key, as a wallet would. + const tweaked = KEY.tweak(bitcoin.crypto.taggedHash('TapTweak', + Buffer.concat([XONLY, Buffer.from(record.tapleafHash, 'hex')]))) + cancel.psbt.signInput(0, { + publicKey: Buffer.from(tweaked.publicKey), + signSchnorr: (h) => Buffer.from(ecc.signSchnorr(h, tweaked.privateKey)) + }) + cancel.psbt.finalizeAllInputs() + const cancelTx = cancel.psbt.extractTransaction() + assert.strictEqual(cancelTx.ins[0].witness.length, 1, 'key-path spend: single witness item') + + // The signature verifies against the commit's tweaked output key. + const outputKey = commitTx.outs[0].script.subarray(2) + const sighash = cancelTx.hashForWitnessV1( + 0, [commitTx.outs[0].script], [record.commitValue], bitcoin.Transaction.SIGHASH_DEFAULT) + assert.ok(ecc.verifySchnorr(sighash, outputKey, cancelTx.ins[0].witness[0])) + + // Sweep value arithmetic: commit value minus the funded fee. + assert.strictEqual(Number(cancelTx.outs[0].value), record.commitValue - cancel.fee) + }) + }) +}) + +describe('XChainEncoder TAPROOT envelope', function () { + describe('key-path cancel from the persisted recovery record (§3.5/§3.7)', function () { + + it('fails closed when the sweep would land under dust', async function () { + const encoder = makeEncoder() + await assert.rejects( + encoder.createEnvelopeCancelTransaction({ + commitTxid: TXID_A, commitVout: 0, commitValue: 600, + internalPubkey: PUBKEY_HEX, tapleafHash: 'c'.repeat(64), + destination: callerAddress(encoder.network) + }), + (err) => err.xchainCode === 'ENVELOPE_CANCEL_BELOW_DUST') + }) + + it('validates the recovery-record fields', async function () { + const encoder = makeEncoder() + const base = { + commitTxid: TXID_A, commitVout: 0, commitValue: 100000, + internalPubkey: PUBKEY_HEX, tapleafHash: 'c'.repeat(64), + destination: callerAddress(encoder.network) + } + await assert.rejects(encoder.createEnvelopeCancelTransaction( + Object.assign({}, base, { commitTxid: 'xyz' })), /commitTxid/) + await assert.rejects(encoder.createEnvelopeCancelTransaction( + Object.assign({}, base, { commitVout: -1 })), /commitVout/) + await assert.rejects(encoder.createEnvelopeCancelTransaction( + Object.assign({}, base, { internalPubkey: '04' + 'a'.repeat(128) })), /internalPubkey/) + await assert.rejects(encoder.createEnvelopeCancelTransaction( + Object.assign({}, base, { tapleafHash: 'nope' })), /tapleafHash/) + await assert.rejects(encoder.createEnvelopeCancelTransaction( + Object.assign({}, base, { destination: '' })), /destination/) + // The shared 100-character address cap also applies to this create path, + // which api.js does not route through validateAll. + // Uncapped, a multi-megabyte destination under the 3 MB body limit reaches + // bs58check's quadratic decode on a single-instance service. + await assert.rejects(encoder.createEnvelopeCancelTransaction( + Object.assign({}, base, { destination: 'a'.repeat(101) })), + (err) => err instanceof TypeError && /destination exceeds maximum length \(100\)/.test(err.message)) + await assert.rejects(encoder.createEnvelopeCancelTransaction( + Object.assign({}, base, { destination: 'a'.repeat(3_000_000) })), + (err) => err instanceof TypeError && /destination exceeds maximum length \(100\)/.test(err.message)) + await assert.rejects(encoder.createEnvelopeCancelTransaction( + Object.assign({}, base, { destination: 12345 })), + (err) => err instanceof TypeError && /destination must be a non-empty string/.test(err.message)) + }) + }) +}) + +describe('XChainEncoder TAPROOT envelope', function () { + describe('key-path cancel from the persisted recovery record (§3.5/§3.7)', function () { + + it('applies the create_tx fee guards to feePerKb and replacebyfee', async function () { + const encoder = makeEncoder() + const base = { + commitTxid: TXID_A, commitVout: 0, commitValue: 100000, + internalPubkey: PUBKEY_HEX, tapleafHash: 'c'.repeat(64), + destination: callerAddress(encoder.network) + } + // Unguarded, these spellings became NaN, skipped both dust comparisons + // and surfaced as an opaque internal error instead of invalid-params. + for (const bad of ['abc', '0x20', '1e3', 'Infinity']) { + await assert.rejects(encoder.createEnvelopeCancelTransaction( + Object.assign({}, base, { feePerKb: bad })), TypeError, + 'feePerKb ' + bad + ' must be rejected as a typed error') + } + await assert.rejects(encoder.createEnvelopeCancelTransaction( + Object.assign({}, base, { feePerKb: -1 })), RangeError) + // The truthy JSON string 'false' would arm RBF silently without this guard. + await assert.rejects(encoder.createEnvelopeCancelTransaction( + Object.assign({}, base, { replacebyfee: 'false' })), /replacebyfee/) + // Valid shapes still build, and a real boolean still arms RBF. + const armed = await encoder.createEnvelopeCancelTransaction( + Object.assign({}, base, { feePerKb: 5000, replacebyfee: true })) + assert.strictEqual(armed.psbt.txInputs[0].sequence, 0xfffffffd) + const plain = await encoder.createEnvelopeCancelTransaction( + Object.assign({}, base, { feePerKb: '5000' })) + assert.strictEqual(plain.psbt.txInputs[0].sequence, 0xffffffff) + }) + + it('accepts the x-only internal key form', async function () { + const encoder = makeEncoder() + const cancel = await encoder.createEnvelopeCancelTransaction({ + commitTxid: TXID_A, commitVout: 0, commitValue: 100000, + internalPubkey: XONLY.toString('hex'), tapleafHash: 'c'.repeat(64), + destination: callerAddress(encoder.network) + }) + assert.deepStrictEqual(cancel.psbt.data.inputs[0].tapInternalKey, XONLY) + }) + }) + +}) diff --git a/test/unit/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js b/test/unit/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js new file mode 100644 index 0000000..632498d --- /dev/null +++ b/test/unit/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js @@ -0,0 +1,34 @@ +// 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 TxSizeEstimator = require('../../../src/build/tx_size_estimator') + +describe('XChainEncoder TAPROOT envelope', function () { + describe('TxSizeEstimator envelope sizing', function () { + it('estimateEnvelopeRevealTx covers and does not wildly over-fund a real reveal', function () { + // Direct sanity independent of the encoder round trip above: a + // ~10 KB envelope script with one p2tr-sized output. + const est = TxSizeEstimator.estimateEnvelopeRevealTx(10000, 43, 65) + // nonwitness 10+41+43 = 94; witness 2+1+66+3+10000+34 -> /4 ≈ 2527; +2 + assert.ok(est > 2600 && est < 2700, `estimate ${est} in the expected band`) + }) + it('estimateTaprootOutput matches the serialized P2TR output size', function () { + assert.strictEqual(TxSizeEstimator.estimateTaprootOutput(), 43) + }) + it('uses compactSize framing for scripts above 65535 bytes (5-byte prefix band)', function () { + const below = TxSizeEstimator.estimateEnvelopeRevealTx(65535, 43, 65) + const above = TxSizeEstimator.estimateEnvelopeRevealTx(65536, 43, 65) + // +1 script byte and +2 varint bytes, ÷4 and ceiled: at most +1 vbyte step. + assert.ok(above >= below, 'monotonic across the varint band switch') + }) + }) +}) From 627e34d469613b5892fa45315984aea9868a34c6 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 07:44:48 -0700 Subject: [PATCH 120/148] test: split the legacy encoder suite into numbered parts by transaction type --- test/xchain_encoder.test.js | 448 +----------------- .../01_should_create_a_p2sh_tx.test.js | 228 +++++++++ .../02_should_create_a_multisign_tx.test.js | 151 ++++++ .../03_should_create_a_p2wsh_tx.test.js | 221 +++++++++ 4 files changed, 616 insertions(+), 432 deletions(-) create mode 100644 test/xchain_encoder.test/01_should_create_a_p2sh_tx.test.js create mode 100644 test/xchain_encoder.test/02_should_create_a_multisign_tx.test.js create mode 100644 test/xchain_encoder.test/03_should_create_a_p2wsh_tx.test.js diff --git a/test/xchain_encoder.test.js b/test/xchain_encoder.test.js index b574080..d7008ec 100644 --- a/test/xchain_encoder.test.js +++ b/test/xchain_encoder.test.js @@ -17,75 +17,13 @@ import * as ecc from 'tiny-secp256k1' const bip32 = BIP32Factory(ecc) import bip39 from 'bip39' import bitcoin from 'bitcoinjs-lib' -import psbtutils from 'bitcoinjs-lib/src/psbt/psbtutils.js' import assert from 'assert' import XChainEncoder from '../src/XChainEncoder.js' import nodeHelper from './helpers/node_helper.js' -import crypto from 'node:crypto' import {ECPairFactory} from 'ecpair' -function xchainP2shFinalizer(inputIndex, input, script, isSegwit, isP2SH, isP2WSH){ - if (isP2SH){ - const decompiled = bitcoin.script.decompile(script); - // The redeem-script shape check is deliberately not enforced here: it - // would require [data] OP_DROP OP_DUP OP_HASH160 OP_EQUALVERIFY - // OP_CHECKSIG and throw "Can not finalize input #n" on any other script. - - let payment = { - network: bitcoin.networks.regtest, - input: - bitcoin.script.compile([ - input.partialSig[0].signature, - input.partialSig[0].pubkey - ]), - output:script - } - - payment = bitcoin.payments.p2sh({ - network: bitcoin.networks.regtest, - redeem: payment, - }); - - return { - finalScriptSig: payment.input, - finalScriptWitness:undefined - }; - } else if (isP2WSH){ - const decompiled = bitcoin.script.decompile(script); - - let payment = { - network: bitcoin.networks.regtest, - input: - bitcoin.script.compile([ - input.partialSig[0].signature, - input.partialSig[0].pubkey - ]), - output:script - } - - payment = bitcoin.payments.p2wsh({ - network: bitcoin.networks.regtest, - redeem: payment, - }); - - return { - finalScriptSig: undefined, - finalScriptWitness: psbtutils.witnessStackToScriptWitness(payment.witness) - }; - } else { - throw new Error(`Can not finalize input #${inputIndex}. This finalizer is meant for only p2sh inputs`); - } - - - const decompiled = bitcoin.script.decompile(script); - -} - - -describe('XChainEncoder', () => { - describe('#createTransaction', () => { - it('should create a simple OP_RETURN', async () => { +async function loadOpReturnFunds () { var network = bitcoin.networks.regtest var mnemonic = bip39.generateMnemonic() @@ -118,7 +56,11 @@ describe('XChainEncoder', () => { break } } - + + return { network, address, testAddress, utxos } +} + +async function createOpReturnTransaction ({ network, address, testAddress, utxos }) { // Configurar instancias y datos de prueba const encoder = new XChainEncoder("bitcoin-regtest", "127.0.0.1", "8333", "rpc", "rpc", ""); const pubkey = testAddress; @@ -152,7 +94,15 @@ describe('XChainEncoder', () => { txHex = psbtParaFirmar.extractTransaction().toHex() let txHash = await nodeHelper.broadcastTx(txHex) - + + return { txHash, data, rawData } +} + +describe('XChainEncoder', () => { + describe('#createTransaction', () => { + it('should create a simple OP_RETURN', async () => { + const { txHash, data, rawData } = await createOpReturnTransaction(await loadOpReturnFunds()) + assert((txHash != null) && (txHash.length == 64)) await nodeClientTest.generateToAddress(1, mainTestAddress) @@ -180,371 +130,5 @@ describe('XChainEncoder', () => { assert(decompiledData[0] == data) assert(decompiledData[1] == rawData) }); - it('should create a P2SH tx', async () => { - var network = bitcoin.networks.regtest - - var mnemonic = bip39.generateMnemonic() - console.log("The seed used for p2sh is: " + mnemonic) - - var seed = bip39.mnemonicToSeedSync(mnemonic) - var root = bip32.fromSeed(seed, network) - var account = root.derivePath("m/44'/0'/0'/0") - var address = account.derive(0).derive(0) - var testAddress = bitcoin.payments.p2pkh({ pubkey: address.publicKey, network }).address - console.log("address for P2SH: "+testAddress) - - let txId1 = await nodeClientTest.sendToAddress(testAddress, 1) - let rawTx1 = await nodeClientTest.getRawTransaction(txId1, true) - await nodeClientTest.generateToAddress(1, mainTestAddress) - - let utxos = [] - let utxo1 = null - for (let nextVoutIndex in rawTx1["vout"]){ - let nextVout = rawTx1["vout"][nextVoutIndex] - - if (("scriptPubKey" in nextVout) && ("address" in nextVout["scriptPubKey"]) && (nextVout["scriptPubKey"]["address"] == testAddress)){ - utxo1 = { - "txid": rawTx1["txid"], - "vout": nextVout["n"], - "value": nextVout["value"]*100000000 - } - - utxos.push(utxo1) - break - } - } - - // Configurar instancias y datos de prueba - const encoder = new XChainEncoder("bitcoin-regtest", "127.0.0.1", "8333", "rpc", "rpc", ""); - const pubkey = testAddress; - const customOutputs = {}; - const data = "Really big data for p2sh test, it should weight more than 80 bytes to use p2sh encode"; - const rawData = "Raw data should be big too" - const replacebyfee = true; - const outputType = null; - const changeAddress = testAddress; - const exactFee = 10000; - - // Llamar a la función createTransaction - console.log("Creating the first transaction with XchainEncoder") - const psbtResultante = await encoder.createTransaction(utxos, pubkey, customOutputs, data, rawData, exactFee, replacebyfee, outputType, changeAddress); - const psbtBase64 = psbtResultante.toBase64() - - //Cargar el psbt - const psbtParaFirmar = bitcoin.Psbt.fromBase64(psbtBase64) - var ECPair = ECPairFactory(ecc); - - let keyToSign = ECPair.fromPrivateKey(address.privateKey, { network }); - - for (let proxInputIndex in psbtParaFirmar.data.inputs){ - let proxInput = psbtParaFirmar.data.inputs[proxInputIndex] - psbtParaFirmar.signInput(parseInt(proxInputIndex), keyToSign); - } - - psbtParaFirmar.finalizeAllInputs(); - let tx1 = psbtParaFirmar.extractTransaction() - let tx1Hex = tx1.toHex() - let tx1Id = tx1.getId() - - //Cargar el psbt2 - console.log("Creating the second transaction with XchainEncoder") - const psbt2Resultante = await encoder.createTransaction(utxos, pubkey, customOutputs, data, rawData, exactFee, replacebyfee, outputType, changeAddress, tx1Id, tx1Hex); - const psbt2Base64 = psbt2Resultante.toBase64() - const psbt2ParaFirmar = bitcoin.Psbt.fromBase64(psbt2Base64) - - for (let proxInputIndex in psbt2ParaFirmar.data.inputs){ - let intIndex = parseInt(proxInputIndex) - psbt2ParaFirmar.signInput(intIndex, keyToSign); - } - - psbt2ParaFirmar.finalizeInput(0,xchainP2shFinalizer); - - let tx2 = psbt2ParaFirmar.extractTransaction() - let tx2Hex = tx2.toHex() - let tx2Id = tx2.getId() - - //Broadcast first p2sh tx - console.log("Broadcasting the first p2sh transacion to the node") - let txHash = await nodeHelper.broadcastTx(tx1Hex) - - assert((txHash != null) && (txHash.length == 64)) - await nodeClientTest.generateToAddress(1, mainTestAddress) - - console.log("Getting the first p2sh transacion from the node and verifying its structure") - let txObj = await nodeHelper.getTransaction(txHash, false) - assert(txObj["vin"].length == 1) - assert(txObj["vout"].length == 2) - - //Broadcast second p2sh tx - console.log("Broadcasting the second p2sh transacion to the node") - let tx2Hash = await nodeHelper.broadcastTx(tx2Hex) - assert((tx2Hash != null) && (tx2Hash.length == 64)) - await nodeClientTest.generateToAddress(1, mainTestAddress) - - console.log("Getting the second p2sh transacion from the node and verifying its structure and its data") - let tx2Obj = await nodeHelper.getTransaction(tx2Hash, false) - assert(tx2Obj["vin"].length == 1) - assert(tx2Obj["vout"].length == 1) - - let scriptPubKeyData = tx2Obj["vout"][0]["scriptPubKey"]["hex"].substr(4,64) - let txData = await nodeHelper.removeObfuscation( - new Buffer.from(scriptPubKeyData, 'hex'), - tx2Obj["vin"][0]["txid"] - ) - - let txDataStr = txData.toString("utf-8") - - assert(txDataStr.substr(0,4) == "XCHN") - assert(txDataStr.substr(4) == "p2sh") - - let decodedScriptSig = bitcoin.script.decompile(Buffer.from(tx2Obj["vin"][0]["scriptSig"]["hex"], "hex")) - let decodedRedeemScript = bitcoin.script.decompile(decodedScriptSig[2]) - let decodedData = Buffer.from(decodedRedeemScript[0]) - - - let decompiledData = bitcoin.script.decompile(decodedData) - assert(decompiledData.length == 2) - - assert(decompiledData[0] == data) - assert(decompiledData[1] == rawData) - - }); - it('should create a multisign tx', async () => { - var network = bitcoin.networks.regtest - - var mnemonic = bip39.generateMnemonic() - console.log("The seed used for multisign is: " + mnemonic) - - var seed = bip39.mnemonicToSeedSync(mnemonic) - var root = bip32.fromSeed(seed, network) - var account = root.derivePath("m/44'/0'/0'/0") - var address = account.derive(0).derive(0) - - var testAddress = bitcoin.payments.p2pkh({ pubkey: address.publicKey, network }).address - console.log("address for multisig: "+testAddress) - - let txId1 = await nodeClientTest.sendToAddress(testAddress, 1) - let rawTx1 = await nodeClientTest.getRawTransaction(txId1, true) - await nodeClientTest.generateToAddress(1, mainTestAddress) - - let utxos = [] - let utxo1 = null - for (let nextVoutIndex in rawTx1["vout"]){ - let nextVout = rawTx1["vout"][nextVoutIndex] - - if (("scriptPubKey" in nextVout) && ("address" in nextVout["scriptPubKey"]) && (nextVout["scriptPubKey"]["address"] == testAddress)){ - utxo1 = { - "txid": rawTx1["txid"], - "vout": nextVout["n"], - "value": nextVout["value"]*100000000 - } - - utxos.push(utxo1) - break - } - } - - // Configurar instancias y datos de prueba - const encoder = new XChainEncoder("bitcoin-regtest", "127.0.0.1", "8333", "rpc", "rpc", ""); - const pubkey = testAddress; - const customOutputs = {}; - const data = "Data for only one multisign output"; - const rawData = "Raw" - const replacebyfee = true; - const outputType = "MULTISIGN"; - const changeAddress = testAddress; - const exactFee = 10000; - const compressedPubKey = address.publicKey.toString("hex") - - - // Llamar a la función createTransaction - console.log("Creating the first transaction with XchainEncoder") - const psbtResultante = await encoder.createTransaction(utxos, pubkey, customOutputs, data, rawData, exactFee, replacebyfee, outputType, changeAddress, null, null, compressedPubKey); - const psbtBase64 = psbtResultante.toBase64() - - //Cargar el psbt - const psbtParaFirmar = bitcoin.Psbt.fromBase64(psbtBase64) - var ECPair = ECPairFactory(ecc); - - let keyToSign = ECPair.fromPrivateKey(address.privateKey, { network }); - - for (let proxInputIndex in psbtParaFirmar.data.inputs){ - let proxInput = psbtParaFirmar.data.inputs[proxInputIndex] - psbtParaFirmar.signInput(parseInt(proxInputIndex), keyToSign); - } - - psbtParaFirmar.finalizeAllInputs(); - let tx1 = psbtParaFirmar.extractTransaction() - let tx1Hex = tx1.toHex() - let tx1Id = tx1.getId() - - //Broadcast multisign tx - console.log("Broadcasting the multisign transacion to the node") - let txHash = await nodeHelper.broadcastTx(tx1Hex) - - assert((txHash != null) && (txHash.length == 64)) - await nodeClientTest.generateToAddress(1, mainTestAddress) - - console.log("Getting the multisign transaction from the node and verifying its structure") - let txObj = await nodeHelper.getTransaction(txHash, false) - - assert(txObj["vin"].length == 1) - assert(txObj["vout"].length == 2) - - - let scriptPubKeyData = Buffer.from(txObj["vout"][0]["scriptPubKey"]["hex"],"hex") - let decompiledScript = bitcoin.script.decompile(scriptPubKeyData) - - let pubkey1 = decompiledScript[1].subarray(1) //removing the 02 at the beginning - let pubkey2 = decompiledScript[2].subarray(1) //removing the 02 at the beginning - - let dataConcat = Buffer.concat([pubkey1, pubkey2]) - - for (let i=dataConcat.length - 1;i>=0;i--){ - if (dataConcat[i] !== 0){ - dataConcat = dataConcat.slice(0, i + 1) - break - } - } - - - let decodedData = await nodeHelper.removeObfuscation(dataConcat, txObj["vin"][0]["txid"]) - decodedDataString = decodedData.toString("utf-8") - - assert(decodedDataString.substr(0,4) == "XCHN") - - let decompiledData = bitcoin.script.decompile(decodedData.slice(4)) - assert(decompiledData.length == 2) - - assert(decompiledData[0] == data) - assert(decompiledData[1] == rawData) - - }); - it('should create a P2WSH tx', async () => { - var network = bitcoin.networks.regtest - - var mnemonic = bip39.generateMnemonic() - console.log("The seed used for p2wsh is: " + mnemonic) - - var seed = bip39.mnemonicToSeedSync(mnemonic) - var root = bip32.fromSeed(seed, network) - var account = root.derivePath("m/44'/0'/0'/0") - var address = account.derive(0).derive(0) - var testAddress = bitcoin.payments.p2pkh({ pubkey: address.publicKey, network }).address - console.log("address for P2WSH: "+testAddress) - - let txId1 = await nodeClientTest.sendToAddress(testAddress, 1) - let rawTx1 = await nodeClientTest.getRawTransaction(txId1, true) - await nodeClientTest.generateToAddress(1, mainTestAddress) - - let utxos = [] - let utxo1 = null - for (let nextVoutIndex in rawTx1["vout"]){ - let nextVout = rawTx1["vout"][nextVoutIndex] - - if (("scriptPubKey" in nextVout) && ("address" in nextVout["scriptPubKey"]) && (nextVout["scriptPubKey"]["address"] == testAddress)){ - utxo1 = { - "txid": rawTx1["txid"], - "vout": nextVout["n"], - "value": nextVout["value"]*100000000 - } - - utxos.push(utxo1) - break - } - } - - // Configurar instancias y datos de prueba - const encoder = new XChainEncoder("bitcoin-regtest", "127.0.0.1", "8333", "rpc", "rpc", ""); - const pubkey = testAddress; - const customOutputs = {}; - const data = "ABCDEFGHIJKLMNOPQRSTUVWXYZ1ABCDEFGHIJKLMNOPQRSTUVWXYZ2ABCDEFGHIJKLMNOPQRSTUVWXYZ3ABCDEFGHIJKLMNOPQRSTUVWXYZ4ABCDEFGHIJKLMNOPQRSTUVWXYZ5ABCDEFGHIJKLMNOPQRSTUVWXYZ6ABCDEFGHIJKLMNOPQRSTUVWXYZ7ABCDEFGHIJKLMNOPQRSTUVWXYZ8"; - const rawData = "Raw" - const replacebyfee = true; - const outputType = "P2WSH"; - const changeAddress = testAddress; - const exactFee = 10000; - - // Llamar a la función createTransaction - console.log("Creating the first transaction with XchainEncoder") - const psbtResultante = await encoder.createTransaction(utxos, pubkey, customOutputs, data, rawData, exactFee, replacebyfee, outputType, changeAddress); - const psbtBase64 = psbtResultante.toBase64() - - //Cargar el psbt - const psbtParaFirmar = bitcoin.Psbt.fromBase64(psbtBase64) - var ECPair = ECPairFactory(ecc); - - let keyToSign = ECPair.fromPrivateKey(address.privateKey, { network }); - - for (let proxInputIndex in psbtParaFirmar.data.inputs){ - let proxInput = psbtParaFirmar.data.inputs[proxInputIndex] - psbtParaFirmar.signInput(parseInt(proxInputIndex), keyToSign); - } - - psbtParaFirmar.finalizeAllInputs(); - let tx1 = psbtParaFirmar.extractTransaction() - let tx1Hex = tx1.toHex() - let tx1Id = tx1.getId() - - //Cargar el psbt2 - console.log("Creating the second transaction with XchainEncoder") - const psbt2Resultante = await encoder.createTransaction(utxos, pubkey, customOutputs, data, rawData, exactFee, replacebyfee, outputType, changeAddress, tx1Id, tx1Hex); - const psbt2Base64 = psbt2Resultante.toBase64() - const psbt2ParaFirmar = bitcoin.Psbt.fromBase64(psbt2Base64) - - for (let proxInputIndex in psbt2ParaFirmar.data.inputs){ - let intIndex = parseInt(proxInputIndex) - psbt2ParaFirmar.signInput(intIndex, keyToSign); - } - - psbt2ParaFirmar.finalizeInput(0,xchainP2shFinalizer); - - let tx2 = psbt2ParaFirmar.extractTransaction() - let tx2Hex = tx2.toHex() - let tx2Id = tx2.getId() - - //Broadcast first p2wsh tx - console.log("Broadcasting the first p2wsh transacion to the node") - let txHash = await nodeHelper.broadcastTx(tx1Hex) - - assert((txHash != null) && (txHash.length == 64)) - await nodeClientTest.generateToAddress(1, mainTestAddress) - - console.log("Getting the first p2wsh transacion from the node and verifying its structure") - let txObj = await nodeHelper.getTransaction(txHash, false) - assert(txObj["vin"].length == 1) - assert(txObj["vout"].length == 2) - - //Broadcast second p2wsh tx - console.log("Broadcasting the second p2wsh transacion to the node") - let tx2Hash = await nodeHelper.broadcastTx(tx2Hex) - assert((tx2Hash != null) && (tx2Hash.length == 64)) - await nodeClientTest.generateToAddress(1, mainTestAddress) - - console.log("Getting the second p2wsh transacion from the node and verifying its structure and its data") - let tx2Obj = await nodeHelper.getTransaction(tx2Hash, false) - assert(tx2Obj["vin"].length == 1) - assert(tx2Obj["vout"].length == 1) - - let scriptPubKeyData = tx2Obj["vout"][0]["scriptPubKey"]["hex"].substr(4,64) - let txData = await nodeHelper.removeObfuscation( - new Buffer.from(scriptPubKeyData, 'hex'), - tx2Obj["vin"][0]["txid"] - ) - let txDataStr = txData.toString("utf-8") - - assert(txDataStr.substr(0,4) == "XCHN") - assert(txDataStr.substr(4) == "p2wsh") - - let decodedRedeemScript = bitcoin.script.decompile(Buffer.from(tx2Obj["vin"][0]["txinwitness"][2], "hex")) - let decodedData = Buffer.from(decodedRedeemScript[0]) - - let decompiledData = bitcoin.script.decompile(decodedData) - assert(decompiledData.length == 2) - - assert(decompiledData[0] == data) - assert(decompiledData[1] == rawData) - - }); }); -}); \ No newline at end of file +}); diff --git a/test/xchain_encoder.test/01_should_create_a_p2sh_tx.test.js b/test/xchain_encoder.test/01_should_create_a_p2sh_tx.test.js new file mode 100644 index 0000000..e8d3df7 --- /dev/null +++ b/test/xchain_encoder.test/01_should_create_a_p2sh_tx.test.js @@ -0,0 +1,228 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************/ + +import { BIP32Factory } from 'bip32' +import * as ecc from 'tiny-secp256k1' +const bip32 = BIP32Factory(ecc) +import bip39 from 'bip39' +import bitcoin from 'bitcoinjs-lib' +import psbtutils from 'bitcoinjs-lib/src/psbt/psbtutils.js' +import assert from 'assert' +import XChainEncoder from '../../src/XChainEncoder.js' +import nodeHelper from '../helpers/node_helper.js' +import {ECPairFactory} from 'ecpair' + +function xchainP2shFinalizer(inputIndex, input, script, isSegwit, isP2SH, isP2WSH){ + if (isP2SH){ + const decompiled = bitcoin.script.decompile(script); + + // The redeem-script shape check is deliberately not enforced here: it + // would require [data] OP_DROP OP_DUP OP_HASH160 OP_EQUALVERIFY + // OP_CHECKSIG and throw "Can not finalize input #n" on any other script. + + let payment = { + network: bitcoin.networks.regtest, + input: + bitcoin.script.compile([ + input.partialSig[0].signature, + input.partialSig[0].pubkey + ]), + output:script + } + + payment = bitcoin.payments.p2sh({ + network: bitcoin.networks.regtest, + redeem: payment, + }); + + return { + finalScriptSig: payment.input, + finalScriptWitness:undefined + }; + } else if (isP2WSH){ + const decompiled = bitcoin.script.decompile(script); + + let payment = { + network: bitcoin.networks.regtest, + input: + bitcoin.script.compile([ + input.partialSig[0].signature, + input.partialSig[0].pubkey + ]), + output:script + } + + payment = bitcoin.payments.p2wsh({ + network: bitcoin.networks.regtest, + redeem: payment, + }); + + return { + finalScriptSig: undefined, + finalScriptWitness: psbtutils.witnessStackToScriptWitness(payment.witness) + }; + } else { + throw new Error(`Can not finalize input #${inputIndex}. This finalizer is meant for only p2sh inputs`); + } + + + const decompiled = bitcoin.script.decompile(script); + +} + + + +async function loadP2shFunds () { + var network = bitcoin.networks.regtest + + var mnemonic = bip39.generateMnemonic() + console.log("The seed used for p2sh is: " + mnemonic) + + var seed = bip39.mnemonicToSeedSync(mnemonic) + var root = bip32.fromSeed(seed, network) + var account = root.derivePath("m/44'/0'/0'/0") + var address = account.derive(0).derive(0) + var testAddress = bitcoin.payments.p2pkh({ pubkey: address.publicKey, network }).address + console.log("address for P2SH: "+testAddress) + + let txId1 = await nodeClientTest.sendToAddress(testAddress, 1) + let rawTx1 = await nodeClientTest.getRawTransaction(txId1, true) + await nodeClientTest.generateToAddress(1, mainTestAddress) + + let utxos = [] + let utxo1 = null + for (let nextVoutIndex in rawTx1["vout"]){ + let nextVout = rawTx1["vout"][nextVoutIndex] + + if (("scriptPubKey" in nextVout) && ("address" in nextVout["scriptPubKey"]) && (nextVout["scriptPubKey"]["address"] == testAddress)){ + utxo1 = { + "txid": rawTx1["txid"], + "vout": nextVout["n"], + "value": nextVout["value"]*100000000 + } + + utxos.push(utxo1) + break + } + } + + return { network, address, testAddress, utxos } +} + +async function createP2shTransactions ({ network, address, testAddress, utxos }) { + // Configurar instancias y datos de prueba + const encoder = new XChainEncoder("bitcoin-regtest", "127.0.0.1", "8333", "rpc", "rpc", ""); + const pubkey = testAddress; + const customOutputs = {}; + const data = "Really big data for p2sh test, it should weight more than 80 bytes to use p2sh encode"; + const rawData = "Raw data should be big too" + const replacebyfee = true; + const outputType = null; + const changeAddress = testAddress; + const exactFee = 10000; + + // Llamar a la función createTransaction + console.log("Creating the first transaction with XchainEncoder") + const psbtResultante = await encoder.createTransaction(utxos, pubkey, customOutputs, data, rawData, exactFee, replacebyfee, outputType, changeAddress); + const psbtBase64 = psbtResultante.toBase64() + + //Cargar el psbt + const psbtParaFirmar = bitcoin.Psbt.fromBase64(psbtBase64) + var ECPair = ECPairFactory(ecc); + + let keyToSign = ECPair.fromPrivateKey(address.privateKey, { network }); + + for (let proxInputIndex in psbtParaFirmar.data.inputs){ + let proxInput = psbtParaFirmar.data.inputs[proxInputIndex] + psbtParaFirmar.signInput(parseInt(proxInputIndex), keyToSign); + } + + psbtParaFirmar.finalizeAllInputs(); + let tx1 = psbtParaFirmar.extractTransaction() + let tx1Hex = tx1.toHex() + let tx1Id = tx1.getId() + + //Cargar el psbt2 + console.log("Creating the second transaction with XchainEncoder") + const psbt2Resultante = await encoder.createTransaction(utxos, pubkey, customOutputs, data, rawData, exactFee, replacebyfee, outputType, changeAddress, tx1Id, tx1Hex); + const psbt2Base64 = psbt2Resultante.toBase64() + const psbt2ParaFirmar = bitcoin.Psbt.fromBase64(psbt2Base64) + + for (let proxInputIndex in psbt2ParaFirmar.data.inputs){ + let intIndex = parseInt(proxInputIndex) + psbt2ParaFirmar.signInput(intIndex, keyToSign); + } + + psbt2ParaFirmar.finalizeInput(0,xchainP2shFinalizer); + + let tx2 = psbt2ParaFirmar.extractTransaction() + let tx2Hex = tx2.toHex() + let tx2Id = tx2.getId() + + return { tx1Hex, tx2Hex, data, rawData } +} + +describe('XChainEncoder', () => { + describe('#createTransaction', () => { + it('should create a P2SH tx', async () => { + const { tx1Hex, tx2Hex, data, rawData } = await createP2shTransactions(await loadP2shFunds()) + + //Broadcast first p2sh tx + console.log("Broadcasting the first p2sh transacion to the node") + let txHash = await nodeHelper.broadcastTx(tx1Hex) + + assert((txHash != null) && (txHash.length == 64)) + await nodeClientTest.generateToAddress(1, mainTestAddress) + + console.log("Getting the first p2sh transacion from the node and verifying its structure") + let txObj = await nodeHelper.getTransaction(txHash, false) + assert(txObj["vin"].length == 1) + assert(txObj["vout"].length == 2) + + //Broadcast second p2sh tx + console.log("Broadcasting the second p2sh transacion to the node") + let tx2Hash = await nodeHelper.broadcastTx(tx2Hex) + assert((tx2Hash != null) && (tx2Hash.length == 64)) + await nodeClientTest.generateToAddress(1, mainTestAddress) + + console.log("Getting the second p2sh transacion from the node and verifying its structure and its data") + let tx2Obj = await nodeHelper.getTransaction(tx2Hash, false) + assert(tx2Obj["vin"].length == 1) + assert(tx2Obj["vout"].length == 1) + + let scriptPubKeyData = tx2Obj["vout"][0]["scriptPubKey"]["hex"].substr(4,64) + let txData = await nodeHelper.removeObfuscation( + new Buffer.from(scriptPubKeyData, 'hex'), + tx2Obj["vin"][0]["txid"] + ) + + let txDataStr = txData.toString("utf-8") + + assert(txDataStr.substr(0,4) == "XCHN") + assert(txDataStr.substr(4) == "p2sh") + + let decodedScriptSig = bitcoin.script.decompile(Buffer.from(tx2Obj["vin"][0]["scriptSig"]["hex"], "hex")) + let decodedRedeemScript = bitcoin.script.decompile(decodedScriptSig[2]) + let decodedData = Buffer.from(decodedRedeemScript[0]) + + + let decompiledData = bitcoin.script.decompile(decodedData) + assert(decompiledData.length == 2) + + assert(decompiledData[0] == data) + assert(decompiledData[1] == rawData) + + }); + }); +}); diff --git a/test/xchain_encoder.test/02_should_create_a_multisign_tx.test.js b/test/xchain_encoder.test/02_should_create_a_multisign_tx.test.js new file mode 100644 index 0000000..139ae38 --- /dev/null +++ b/test/xchain_encoder.test/02_should_create_a_multisign_tx.test.js @@ -0,0 +1,151 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************/ + +import { BIP32Factory } from 'bip32' +import * as ecc from 'tiny-secp256k1' +const bip32 = BIP32Factory(ecc) +import bip39 from 'bip39' +import bitcoin from 'bitcoinjs-lib' +import assert from 'assert' +import XChainEncoder from '../../src/XChainEncoder.js' +import nodeHelper from '../helpers/node_helper.js' +import {ECPairFactory} from 'ecpair' + + + +async function loadMultisignFunds () { + var network = bitcoin.networks.regtest + + var mnemonic = bip39.generateMnemonic() + console.log("The seed used for multisign is: " + mnemonic) + + var seed = bip39.mnemonicToSeedSync(mnemonic) + var root = bip32.fromSeed(seed, network) + var account = root.derivePath("m/44'/0'/0'/0") + var address = account.derive(0).derive(0) + + var testAddress = bitcoin.payments.p2pkh({ pubkey: address.publicKey, network }).address + console.log("address for multisig: "+testAddress) + + let txId1 = await nodeClientTest.sendToAddress(testAddress, 1) + let rawTx1 = await nodeClientTest.getRawTransaction(txId1, true) + await nodeClientTest.generateToAddress(1, mainTestAddress) + + let utxos = [] + let utxo1 = null + for (let nextVoutIndex in rawTx1["vout"]){ + let nextVout = rawTx1["vout"][nextVoutIndex] + + if (("scriptPubKey" in nextVout) && ("address" in nextVout["scriptPubKey"]) && (nextVout["scriptPubKey"]["address"] == testAddress)){ + utxo1 = { + "txid": rawTx1["txid"], + "vout": nextVout["n"], + "value": nextVout["value"]*100000000 + } + + utxos.push(utxo1) + break + } + } + + return { network, address, testAddress, utxos } +} + +async function createMultisignTransaction ({ network, address, testAddress, utxos }) { + // Configurar instancias y datos de prueba + const encoder = new XChainEncoder("bitcoin-regtest", "127.0.0.1", "8333", "rpc", "rpc", ""); + const pubkey = testAddress; + const customOutputs = {}; + const data = "Data for only one multisign output"; + const rawData = "Raw" + const replacebyfee = true; + const outputType = "MULTISIGN"; + const changeAddress = testAddress; + const exactFee = 10000; + const compressedPubKey = address.publicKey.toString("hex") + + + // Llamar a la función createTransaction + console.log("Creating the first transaction with XchainEncoder") + const psbtResultante = await encoder.createTransaction(utxos, pubkey, customOutputs, data, rawData, exactFee, replacebyfee, outputType, changeAddress, null, null, compressedPubKey); + const psbtBase64 = psbtResultante.toBase64() + + //Cargar el psbt + const psbtParaFirmar = bitcoin.Psbt.fromBase64(psbtBase64) + var ECPair = ECPairFactory(ecc); + + let keyToSign = ECPair.fromPrivateKey(address.privateKey, { network }); + + for (let proxInputIndex in psbtParaFirmar.data.inputs){ + let proxInput = psbtParaFirmar.data.inputs[proxInputIndex] + psbtParaFirmar.signInput(parseInt(proxInputIndex), keyToSign); + } + + psbtParaFirmar.finalizeAllInputs(); + let tx1 = psbtParaFirmar.extractTransaction() + let tx1Hex = tx1.toHex() + let tx1Id = tx1.getId() + + return { tx1Hex, data, rawData } +} + +describe('XChainEncoder', () => { + describe('#createTransaction', () => { + it('should create a multisign tx', async () => { + const { tx1Hex, data, rawData } = await createMultisignTransaction(await loadMultisignFunds()) + + //Broadcast multisign tx + console.log("Broadcasting the multisign transacion to the node") + let txHash = await nodeHelper.broadcastTx(tx1Hex) + + assert((txHash != null) && (txHash.length == 64)) + await nodeClientTest.generateToAddress(1, mainTestAddress) + + console.log("Getting the multisign transaction from the node and verifying its structure") + let txObj = await nodeHelper.getTransaction(txHash, false) + + assert(txObj["vin"].length == 1) + assert(txObj["vout"].length == 2) + + + let scriptPubKeyData = Buffer.from(txObj["vout"][0]["scriptPubKey"]["hex"],"hex") + let decompiledScript = bitcoin.script.decompile(scriptPubKeyData) + + let pubkey1 = decompiledScript[1].subarray(1) //removing the 02 at the beginning + let pubkey2 = decompiledScript[2].subarray(1) //removing the 02 at the beginning + + let dataConcat = Buffer.concat([pubkey1, pubkey2]) + + for (let i=dataConcat.length - 1;i>=0;i--){ + if (dataConcat[i] !== 0){ + dataConcat = dataConcat.slice(0, i + 1) + break + } + } + + + let decodedData = await nodeHelper.removeObfuscation(dataConcat, txObj["vin"][0]["txid"]) + decodedDataString = decodedData.toString("utf-8") + + assert(decodedDataString.substr(0,4) == "XCHN") + + let decompiledData = bitcoin.script.decompile(decodedData.slice(4)) + assert(decompiledData.length == 2) + + assert(decompiledData[0] == data) + assert(decompiledData[1] == rawData) + + }); + }); +}); diff --git a/test/xchain_encoder.test/03_should_create_a_p2wsh_tx.test.js b/test/xchain_encoder.test/03_should_create_a_p2wsh_tx.test.js new file mode 100644 index 0000000..9d8ae47 --- /dev/null +++ b/test/xchain_encoder.test/03_should_create_a_p2wsh_tx.test.js @@ -0,0 +1,221 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************/ + +import { BIP32Factory } from 'bip32' +import * as ecc from 'tiny-secp256k1' +const bip32 = BIP32Factory(ecc) +import bip39 from 'bip39' +import bitcoin from 'bitcoinjs-lib' +import psbtutils from 'bitcoinjs-lib/src/psbt/psbtutils.js' +import assert from 'assert' +import XChainEncoder from '../../src/XChainEncoder.js' +import nodeHelper from '../helpers/node_helper.js' +import {ECPairFactory} from 'ecpair' + +function xchainP2shFinalizer(inputIndex, input, script, isSegwit, isP2SH, isP2WSH){ + if (isP2SH){ + const decompiled = bitcoin.script.decompile(script); + + let payment = { + network: bitcoin.networks.regtest, + input: + bitcoin.script.compile([ + input.partialSig[0].signature, + input.partialSig[0].pubkey + ]), + output:script + } + + payment = bitcoin.payments.p2sh({ + network: bitcoin.networks.regtest, + redeem: payment, + }); + + return { + finalScriptSig: payment.input, + finalScriptWitness:undefined + }; + } else if (isP2WSH){ + const decompiled = bitcoin.script.decompile(script); + + let payment = { + network: bitcoin.networks.regtest, + input: + bitcoin.script.compile([ + input.partialSig[0].signature, + input.partialSig[0].pubkey + ]), + output:script + } + + payment = bitcoin.payments.p2wsh({ + network: bitcoin.networks.regtest, + redeem: payment, + }); + + return { + finalScriptSig: undefined, + finalScriptWitness: psbtutils.witnessStackToScriptWitness(payment.witness) + }; + } else { + throw new Error(`Can not finalize input #${inputIndex}. This finalizer is meant for only p2sh inputs`); + } + + + const decompiled = bitcoin.script.decompile(script); + +} + + + +async function loadP2wshFunds () { + var network = bitcoin.networks.regtest + + var mnemonic = bip39.generateMnemonic() + console.log("The seed used for p2wsh is: " + mnemonic) + + var seed = bip39.mnemonicToSeedSync(mnemonic) + var root = bip32.fromSeed(seed, network) + var account = root.derivePath("m/44'/0'/0'/0") + var address = account.derive(0).derive(0) + var testAddress = bitcoin.payments.p2pkh({ pubkey: address.publicKey, network }).address + console.log("address for P2WSH: "+testAddress) + + let txId1 = await nodeClientTest.sendToAddress(testAddress, 1) + let rawTx1 = await nodeClientTest.getRawTransaction(txId1, true) + await nodeClientTest.generateToAddress(1, mainTestAddress) + + let utxos = [] + let utxo1 = null + for (let nextVoutIndex in rawTx1["vout"]){ + let nextVout = rawTx1["vout"][nextVoutIndex] + + if (("scriptPubKey" in nextVout) && ("address" in nextVout["scriptPubKey"]) && (nextVout["scriptPubKey"]["address"] == testAddress)){ + utxo1 = { + "txid": rawTx1["txid"], + "vout": nextVout["n"], + "value": nextVout["value"]*100000000 + } + + utxos.push(utxo1) + break + } + } + + return { network, address, testAddress, utxos } +} + +async function createP2wshTransactions ({ network, address, testAddress, utxos }) { + // Configurar instancias y datos de prueba + const encoder = new XChainEncoder("bitcoin-regtest", "127.0.0.1", "8333", "rpc", "rpc", ""); + const pubkey = testAddress; + const customOutputs = {}; + const data = "ABCDEFGHIJKLMNOPQRSTUVWXYZ1ABCDEFGHIJKLMNOPQRSTUVWXYZ2ABCDEFGHIJKLMNOPQRSTUVWXYZ3ABCDEFGHIJKLMNOPQRSTUVWXYZ4ABCDEFGHIJKLMNOPQRSTUVWXYZ5ABCDEFGHIJKLMNOPQRSTUVWXYZ6ABCDEFGHIJKLMNOPQRSTUVWXYZ7ABCDEFGHIJKLMNOPQRSTUVWXYZ8"; + const rawData = "Raw" + const replacebyfee = true; + const outputType = "P2WSH"; + const changeAddress = testAddress; + const exactFee = 10000; + + // Llamar a la función createTransaction + console.log("Creating the first transaction with XchainEncoder") + const psbtResultante = await encoder.createTransaction(utxos, pubkey, customOutputs, data, rawData, exactFee, replacebyfee, outputType, changeAddress); + const psbtBase64 = psbtResultante.toBase64() + + //Cargar el psbt + const psbtParaFirmar = bitcoin.Psbt.fromBase64(psbtBase64) + var ECPair = ECPairFactory(ecc); + + let keyToSign = ECPair.fromPrivateKey(address.privateKey, { network }); + + for (let proxInputIndex in psbtParaFirmar.data.inputs){ + let proxInput = psbtParaFirmar.data.inputs[proxInputIndex] + psbtParaFirmar.signInput(parseInt(proxInputIndex), keyToSign); + } + + psbtParaFirmar.finalizeAllInputs(); + let tx1 = psbtParaFirmar.extractTransaction() + let tx1Hex = tx1.toHex() + let tx1Id = tx1.getId() + + //Cargar el psbt2 + console.log("Creating the second transaction with XchainEncoder") + const psbt2Resultante = await encoder.createTransaction(utxos, pubkey, customOutputs, data, rawData, exactFee, replacebyfee, outputType, changeAddress, tx1Id, tx1Hex); + const psbt2Base64 = psbt2Resultante.toBase64() + const psbt2ParaFirmar = bitcoin.Psbt.fromBase64(psbt2Base64) + + for (let proxInputIndex in psbt2ParaFirmar.data.inputs){ + let intIndex = parseInt(proxInputIndex) + psbt2ParaFirmar.signInput(intIndex, keyToSign); + } + + psbt2ParaFirmar.finalizeInput(0,xchainP2shFinalizer); + + let tx2 = psbt2ParaFirmar.extractTransaction() + let tx2Hex = tx2.toHex() + let tx2Id = tx2.getId() + + return { tx1Hex, tx2Hex, data, rawData } +} + +describe('XChainEncoder', () => { + describe('#createTransaction', () => { + it('should create a P2WSH tx', async () => { + const { tx1Hex, tx2Hex, data, rawData } = await createP2wshTransactions(await loadP2wshFunds()) + + //Broadcast first p2wsh tx + console.log("Broadcasting the first p2wsh transacion to the node") + let txHash = await nodeHelper.broadcastTx(tx1Hex) + + assert((txHash != null) && (txHash.length == 64)) + await nodeClientTest.generateToAddress(1, mainTestAddress) + + console.log("Getting the first p2wsh transacion from the node and verifying its structure") + let txObj = await nodeHelper.getTransaction(txHash, false) + assert(txObj["vin"].length == 1) + assert(txObj["vout"].length == 2) + + //Broadcast second p2wsh tx + console.log("Broadcasting the second p2wsh transacion to the node") + let tx2Hash = await nodeHelper.broadcastTx(tx2Hex) + assert((tx2Hash != null) && (tx2Hash.length == 64)) + await nodeClientTest.generateToAddress(1, mainTestAddress) + + console.log("Getting the second p2wsh transacion from the node and verifying its structure and its data") + let tx2Obj = await nodeHelper.getTransaction(tx2Hash, false) + assert(tx2Obj["vin"].length == 1) + assert(tx2Obj["vout"].length == 1) + + let scriptPubKeyData = tx2Obj["vout"][0]["scriptPubKey"]["hex"].substr(4,64) + let txData = await nodeHelper.removeObfuscation( + new Buffer.from(scriptPubKeyData, 'hex'), + tx2Obj["vin"][0]["txid"] + ) + let txDataStr = txData.toString("utf-8") + + assert(txDataStr.substr(0,4) == "XCHN") + assert(txDataStr.substr(4) == "p2wsh") + + let decodedRedeemScript = bitcoin.script.decompile(Buffer.from(tx2Obj["vin"][0]["txinwitness"][2], "hex")) + let decodedData = Buffer.from(decodedRedeemScript[0]) + + let decompiledData = bitcoin.script.decompile(decodedData) + assert(decompiledData.length == 2) + + assert(decompiledData[0] == data) + assert(decompiledData[1] == rawData) + + }); + }); +}); From a0b45b2ed63184b78d967d45e13049a1602a9a3f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 07:45:38 -0700 Subject: [PATCH 121/148] test: declare the nine numbered test-file splits in the suite title split map --- bin/pins/suite-title-splits.json | 89 +++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/bin/pins/suite-title-splits.json b/bin/pins/suite-title-splits.json index eafdb51..044a1e2 100644 --- a/bin/pins/suite-title-splits.json +++ b/bin/pins/suite-title-splits.json @@ -1,6 +1,6 @@ { "note": "The declared test-file splits, read by bin/suite-title-map.js --split-map against bin/pins/at1-suite-titles.json. splits maps each pinned test file that became several files to the full list of files its tests now live in, keyed by the path the compare sees after the rename map is applied. For every npm test script that pinned the old file, the titles those files collect together must equal the old file's titles exactly: none lost, none added, none carried by two parts, and none moved into a file the list does not name. A part may keep the old path; a single destination is a rename and belongs in the rename map. This repository's rename map is flat paths only, so a split carries its titles unchanged: a title that differs across a split is a regression, not a restructure, and a title change lands on its own.", - "date": "2026-09-14", + "date": "2026-09-15", "pin": "bin/pins/at1-suite-titles.json", "pin_before_sha256": "8aebbd8e81f86b6f62018c989896c15d9afdc0ea46593b7542b57fafc1875515", "compare": "node bin/suite-title-map.js --compare bin/pins/at1-suite-titles.json --rename-map bin/pins/test-rename-map.json --split-map bin/pins/suite-title-splits.json", @@ -10,5 +10,90 @@ "test/unit/big_suite/writes.test.js" ] }, - "splits": {} + "splits": { + "test/unit/blockchain_connector.test.js": [ + "test/unit/blockchain_connector.test.js", + "test/unit/blockchain_connector.test/01_get_network_info.test.js", + "test/unit/blockchain_connector.test/02_is_regtest.test.js", + "test/unit/blockchain_connector.test/03_get_transaction_hex.test.js", + "test/unit/blockchain_connector.test/04_send_raw_transaction.test.js", + "test/unit/blockchain_connector.test/05_send_raw_transaction_maxfeerate_retry.test.js", + "test/unit/blockchain_connector.test/06_get_fee_per_kilobyte.test.js", + "test/unit/blockchain_connector.test/07_rpc_credential_log_sanitization.test.js" + ], + "test/unit/compression.test.js": [ + "test/unit/compression.test.js", + "test/unit/compression.test/01_action_string_helpers.test.js", + "test/unit/compression.test/02_create_transaction_integration.test.js", + "test/unit/compression.test/03_the_compression_report_carries_the_bytes_that_were_written.test.js", + "test/unit/compression.test/04_validator.test.js", + "test/unit/compression.test/05_constants_conformance.test.js" + ], + "test/unit/package_fee_sizing.test.js": [ + "test/unit/package_fee_sizing.test.js", + "test/unit/package_fee_sizing.test/01_package_fee_uplift_satoshis.test.js", + "test/unit/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js", + "test/unit/package_fee_sizing.test/03_two_phase_reveal_package_prefund.test.js", + "test/unit/package_fee_sizing.test/04_p2sh_two_phase_package_prefund.test.js" + ], + "test/unit/single_instance_guard.test.js": [ + "test/unit/single_instance_guard.test.js", + "test/unit/single_instance_guard.test/01_acquire_instance_lock.test.js", + "test/unit/single_instance_guard.test/02_is_pid_alive.test.js", + "test/unit/single_instance_guard.test/03_release_lock_on_signals.test.js" + ], + "test/unit/utxo_tracker.test.js": [ + "test/unit/utxo_tracker.test.js", + "test/unit/utxo_tracker.test/01_get_sync_status.test.js", + "test/unit/utxo_tracker.test/02_get_utxos_from_address.test.js", + "test/unit/utxo_tracker.test/03_get_utxos_from_address_validation.test.js" + ], + "test/unit/xchain_encoder_create_transaction.test.js": [ + "test/unit/xchain_encoder_create_transaction.test.js", + "test/unit/xchain_encoder_create_transaction.test/01_utxo_deduplication.test.js", + "test/unit/xchain_encoder_create_transaction.test/02_unconfirmed_filtering.test.js", + "test/unit/xchain_encoder_create_transaction.test/03_utxo_sorting.test.js", + "test/unit/xchain_encoder_create_transaction.test/04_replace_by_fee_sequence.test.js", + "test/unit/xchain_encoder_create_transaction.test/05_fee_handling.test.js", + "test/unit/xchain_encoder_create_transaction.test/06_custom_dust_parameter.test.js", + "test/unit/xchain_encoder_create_transaction.test/07_change_output.test.js", + "test/unit/xchain_encoder_create_transaction.test/08_no_utxos_available.test.js", + "test/unit/xchain_encoder_create_transaction.test/09_custom_outputs.test.js", + "test/unit/xchain_encoder_create_transaction.test/10_op_return_encoding_path.test.js", + "test/unit/xchain_encoder_create_transaction.test/11_p2sh_encoding_funding.test.js", + "test/unit/xchain_encoder_create_transaction.test/12_p2sh_encoding_spending.test.js", + "test/unit/xchain_encoder_create_transaction.test/13_p2sh_native_fee_custom_outputs.test.js", + "test/unit/xchain_encoder_create_transaction.test/14_multisign_encoding_path.test.js", + "test/unit/xchain_encoder_create_transaction.test/15_return_value.test.js", + "test/unit/xchain_encoder_create_transaction.test/16_estimate_spending_p2sh_tx.test.js", + "test/unit/xchain_encoder_create_transaction.test/17_payment_only.test.js", + "test/unit/xchain_encoder_create_transaction.test/18_payload_guards.test.js" + ], + "test/unit/xchain_encoder_extra.test.js": [ + "test/unit/xchain_encoder_extra.test.js", + "test/unit/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js", + "test/unit/xchain_encoder_extra.test/02_p2wsh_tx2_spending.test.js", + "test/unit/xchain_encoder_extra.test/03_payload_size_guard.test.js", + "test/unit/xchain_encoder_extra.test/04_fee_quote_injection.test.js", + "test/unit/xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.js", + "test/unit/xchain_encoder_extra.test/06_change_edge_cases.test.js", + "test/unit/xchain_encoder_extra.test/07_invalid_fee.test.js", + "test/unit/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js", + "test/unit/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js", + "test/unit/xchain_encoder_extra.test/10_raw_data_parameter.test.js" + ], + "test/unit/xchain_encoder_taproot_envelope.test.js": [ + "test/unit/xchain_encoder_taproot_envelope.test.js", + "test/unit/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js", + "test/unit/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js", + "test/unit/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js", + "test/unit/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js" + ], + "test/xchain_encoder.test.js": [ + "test/xchain_encoder.test.js", + "test/xchain_encoder.test/01_should_create_a_p2sh_tx.test.js", + "test/xchain_encoder.test/02_should_create_a_multisign_tx.test.js", + "test/xchain_encoder.test/03_should_create_a_p2wsh_tx.test.js" + ] + } } From a98ad14c212eafff7411faa3dc321d854d34fe37 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 08:15:40 -0700 Subject: [PATCH 122/148] test: split encoding boundary and action pipeline suites into numbered parts --- .../encoding_chunk_boundaries.test.js | 434 ------------------ ...nk_split_threshold_forced_encoding.test.js | 128 ++++++ .../02_p2sh_chunk_split_threshold.test.js | 131 ++++++ ...orld_boundary_bitcoin_regtest_only.test.js | 112 +++++ ...gecoin_no_bech32_in_network_config.test.js | 68 +++ .../05_multisign_encoding_boundaries.test.js | 135 ++++++ .../06_data_to_pubkey_unit_boundaries.test.js | 105 +++++ .../07_forced_encoding_override.test.js | 83 ++++ ...icy_enforcement_always_fail_closed.test.js | 84 ++++ ..._p2wsh_compiled_chunk_size_ceiling.test.js | 75 +++ test/e2e/action_pipeline_e2e.test.js | 240 +--------- .../01_e2e_1_14_airdrop.test.js | 134 ++++++ .../02_e2e_1_21_broadcast.test.js | 155 +++++++ .../03_e2e_1_28_batch.test.js | 105 +++++ ...invariants_across_all_action_types.test.js | 120 +++++ 15 files changed, 1439 insertions(+), 670 deletions(-) create mode 100644 test/boundary/encoding_chunk_boundaries.test/01_op_return_chunk_split_threshold_forced_encoding.test.js create mode 100644 test/boundary/encoding_chunk_boundaries.test/02_p2sh_chunk_split_threshold.test.js create mode 100644 test/boundary/encoding_chunk_boundaries.test/03_p2wsh_real_world_boundary_bitcoin_regtest_only.test.js create mode 100644 test/boundary/encoding_chunk_boundaries.test/04_p2wsh_on_dogecoin_no_bech32_in_network_config.test.js create mode 100644 test/boundary/encoding_chunk_boundaries.test/05_multisign_encoding_boundaries.test.js create mode 100644 test/boundary/encoding_chunk_boundaries.test/06_data_to_pubkey_unit_boundaries.test.js create mode 100644 test/boundary/encoding_chunk_boundaries.test/07_forced_encoding_override.test.js create mode 100644 test/boundary/encoding_chunk_boundaries.test/08_single_op_return_policy_enforcement_always_fail_closed.test.js create mode 100644 test/boundary/encoding_chunk_boundaries.test/09_p2sh_p2wsh_compiled_chunk_size_ceiling.test.js create mode 100644 test/e2e/action_pipeline_e2e.test/01_e2e_1_14_airdrop.test.js create mode 100644 test/e2e/action_pipeline_e2e.test/02_e2e_1_21_broadcast.test.js create mode 100644 test/e2e/action_pipeline_e2e.test/03_e2e_1_28_batch.test.js create mode 100644 test/e2e/action_pipeline_e2e.test/04_structural_invariants_across_all_action_types.test.js diff --git a/test/boundary/encoding_chunk_boundaries.test.js b/test/boundary/encoding_chunk_boundaries.test.js index 35fe9a9..7d55417 100644 --- a/test/boundary/encoding_chunk_boundaries.test.js +++ b/test/boundary/encoding_chunk_boundaries.test.js @@ -50,9 +50,7 @@ const NETWORK = 'dogecoin-regtest' function standardUtxo (txid = TXID_A) { return makeUtxo(NETWORK, txid, 0, 100000000) } - describe('Encoding Chunk Boundaries: Full Pipeline', () => { - describe('OP_RETURN auto-select threshold (75/76 chars)', () => { it('75-char data (compiled=76) → OP_RETURN (exactly fits 76+4=80)', async () => { const encoder = makeEncoder(NETWORK) @@ -95,436 +93,4 @@ describe('Encoding Chunk Boundaries: Full Pipeline', () => { assert.strictEqual(result.encoding, 'OP_RETURN') }) }) - - describe('OP_RETURN chunk-split threshold (forced encoding)', () => { - it('75-char data → 1 OP_RETURN output', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - - const result = await encoder.createTransaction( - [standardUtxo()], address, null, - 'A'.repeat(75), null, 10000, false, 'OP_RETURN', address, - null, null, null, true, 0.00001 - ) - - const opReturnOutputs = result.psbt.txOutputs.filter(o => o.value === 0) - assert.strictEqual(opReturnOutputs.length, 1, - '75-char data (compiled=76) fits in one 76-byte chunk + 4 magic = 80') - }) - - // A transaction may carry at most one OP_RETURN output; Bitcoin Core - // rejects multi-OP_RETURN transactions as non-standard at broadcast. - // Forcing OP_RETURN with a payload that would not fit a single 76-byte - // chunk must therefore be rejected at construction, not split into outputs - // that always fail to relay. - it('76-char data (compiled=78) → rejected (would exceed single output)', async () => { - // Rejects an oversized forced OP_RETURN on every coin: prepareData enforces the - // ceiling unconditionally, so bitcoin-regtest here is a fixture, not a condition. - const orNet = 'bitcoin-regtest' - const encoder = makeEncoder(orNet) - const address = getTestAddress(orNet) - - await assert.rejects( - encoder.createTransaction( - [standardUtxo()], address, null, - 'A'.repeat(76), null, 10000, false, 'OP_RETURN', address, - null, null, null, true, 0.00001 - ), - RangeError - ) - }) - - it('150-char data (compiled=152) → rejected', async () => { - // Rejects on every shipped coin; bitcoin-regtest here is a fixture, not a condition - const orNet = 'bitcoin-regtest' - const encoder = makeEncoder(orNet) - const address = getTestAddress(orNet) - - await assert.rejects( - encoder.createTransaction( - [standardUtxo()], address, null, - 'A'.repeat(150), null, 10000, false, 'OP_RETURN', address, - null, null, null, true, 0.00001 - ), - RangeError - ) - }) - - it('151-char data (compiled=153) → rejected', async () => { - // Rejects on every shipped coin; bitcoin-regtest here is a fixture, not a condition - const orNet = 'bitcoin-regtest' - const encoder = makeEncoder(orNet) - const address = getTestAddress(orNet) - - await assert.rejects( - encoder.createTransaction( - [standardUtxo()], address, null, - 'A'.repeat(151), null, 10000, false, 'OP_RETURN', address, - null, null, null, true, 0.00001 - ), - RangeError - ) - }) - }) - - describe('P2SH chunk-split threshold', () => { - // P2SH chunksSize = 520 - 44 = 476 - // For string of N chars (N <= 255): compiled = N + 2 (OP_PUSHDATA1 overhead) - // So compiled <= 476 when N <= 474... wait: for N <= 75, overhead is 1. - // For 76 <= N <= 255, overhead is 2. So compiled = N + 2. - // compiled <= 476 → N <= 474 (for N >= 76) - // But for a large string we expect: 473-char (compiled 475) → 1 chunk, - // 474-char (compiled 476) → 1 chunk, 475-char (compiled 477) → 2 chunks - - it('473-char data (compiled=476) → 1 P2SH output (exactly fits)', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - - const result = await encoder.createTransaction( - [standardUtxo()], address, null, - 'A'.repeat(473), null, 10000, false, 'P2SH', address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(result.encoding, 'P2SH') - // Count non-zero outputs: 1 P2SH output + 1 change output - const outputs = result.psbt.txOutputs.filter(o => o.value > 0) - assert.strictEqual(outputs.length, 2) - }) - - it('474-char data (compiled=477) → 2 P2SH outputs (first overflow)', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - - const result = await encoder.createTransaction( - [standardUtxo()], address, null, - 'A'.repeat(474), null, 10000, false, 'P2SH', address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(result.encoding, 'P2SH') - // 2 P2SH outputs + 1 change output - const outputs = result.psbt.txOutputs.filter(o => o.value > 0) - assert.strictEqual(outputs.length, 3) - }) - - it('P2SH data integrity: 475-char data reassembles from tx2 redeemScripts', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const data = 'A'.repeat(475) - - // Create tx1 - const tx1 = await encoder.createTransaction( - [standardUtxo()], address, null, - data, null, 10000, false, 'P2SH', address, - null, null, null, true, 0.00001 - ) - - const tx1Hex = tx1.psbt.__CACHE.__TX.toHex() - const tx1Id = tx1.psbt.__CACHE.__TX.getId() - - // Create tx2 - const tx2 = await encoder.createTransaction( - [standardUtxo()], address, null, - data, null, 10000, false, 'P2SH', address, - tx1Id, tx1Hex, null, true, 0.00001 - ) - - // tx2 inputs should have redeemScripts containing the data - const inputs = tx2.psbt.data.inputs - const dataChunks = inputs - .filter(i => i.redeemScript) - .map(i => bitcoin.script.decompile(i.redeemScript)[0]) - - assert.ok(dataChunks.length >= 2, - 'should have 2 redeemScript inputs for 475-char data') - }) - }) - - describe('P2WSH real-world boundary (bitcoin-regtest only)', () => { - // PW2SH_SIZE = 520, giving a chunk capacity of 476 bytes, the same as - // P2SH. Each data chunk is pushed as a single script element inside the - // witness script, so it is bound by consensus MAX_SCRIPT_ELEMENT_SIZE - // (520), NOT by the larger total witness-script policy limit. (A bigger - // chunk builds a witness script the node rejects at spend time with - // "Push value size limit exceeded".) Through the full pipeline a 473-char - // string (compiled to 476 bytes with a 3-byte OP_PUSHDATA2 prefix) is the - // last to fit in one chunk; 474 chars (compiled 477) splits to 2. - - it('473-char data → 1 P2WSH output (last single-chunk size)', async () => { - const p2wshNet = 'bitcoin-regtest' - const encoder = makeEncoder(p2wshNet) - const address = getTestAddress(p2wshNet) - - const result = await encoder.createTransaction( - [standardUtxo()], address, null, - 'A'.repeat(473), null, 10000, false, 'P2WSH', address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(result.encoding, 'P2WSH') - const nonZeroOutputs = result.psbt.txOutputs.filter(o => o.value > 0) - assert.strictEqual(nonZeroOutputs.length, 2) - }) - - it('474-char data (compiled=477) → 2 P2WSH outputs (crosses chunk boundary)', async () => { - // chunksSize = PW2SH_SIZE(520) - 44 = 476. Compiled 477 > 476 → 2 chunks. - const p2wshNet = 'bitcoin-regtest' - const encoder = makeEncoder(p2wshNet) - const address = getTestAddress(p2wshNet) - - const result = await encoder.createTransaction( - [standardUtxo()], address, null, - 'A'.repeat(474), null, 10000, false, 'P2WSH', address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(result.encoding, 'P2WSH') - // 2 P2WSH outputs + change - const nonZeroOutputs = result.psbt.txOutputs.filter(o => o.value > 0) - assert.strictEqual(nonZeroOutputs.length, 3) - }) - - it('small P2WSH data (500 chars) works correctly', async () => { - const p2wshNet = 'bitcoin-regtest' - const encoder = makeEncoder(p2wshNet) - const address = getTestAddress(p2wshNet) - - const result = await encoder.createTransaction( - [standardUtxo()], address, null, - 'A'.repeat(500), null, 10000, false, 'P2WSH', address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(result.encoding, 'P2WSH') - }) - }) - - describe('P2WSH on Dogecoin (no bech32 in network config)', () => { - it('throws because bitcoin.payments.p2wsh requires bech32 prefix', async () => { - const encoder = makeEncoder(NETWORK) // dogecoin-regtest has no bech32 - const address = getTestAddress(NETWORK) - - await assert.rejects( - () => encoder.createTransaction( - [standardUtxo()], address, null, - 'A'.repeat(100), null, 10000, false, 'P2WSH', address, - null, null, null, true, 0.00001 - ) - ) - }) - }) - - describe('MULTISIGN encoding boundaries', () => { - // MULTISIGN chunk capacity = MULTISIGN_SIZE(69) - 4(magic) - 5(overhead) = 60 - // bytes of raw data (src/XChainEncoder.js: MULTISIGN_SIZE = 69). - // Each obfuscated chunk is split across two fake pubkeys: pk1 = [0:32], pk2 = [32:end]. - // A full chunk is magic(4) + 60 = 64 obfuscated bytes, so pk2 = [32:64] is exactly - // 32 bytes - the largest dataToPubkey() slice that stays a valid 33-byte - // compressed key. prepareData() zero-pads every chunk up to the 64-byte slot so - // both pubkey halves are always complete 32-byte values. - // - // The bug is CLOSED: the former MULTISIGN_SIZE = 71 gave a 62-byte chunk whose - // pk2 could reach 33 bytes (an invalid EC point). With MULTISIGN_SIZE = 69 the - // chunk is capped at 60 and pk2 never exceeds 32 bytes, so a payload that would - // overflow one chunk now splits cleanly into two valid MULTISIGN outputs. - - it('59-char data (compiled=60): valid 1-MULTISIGN output', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const utxo = standardUtxo(TXID_MULTISIGN) - - const result = await encoder.createTransaction( - [utxo], address, null, - 'A'.repeat(59), null, 10000, false, 'MULTISIGN', address, - null, null, PUBKEY_BUF.toString('hex'), true, 0.00001 - ) - - assert.strictEqual(result.encoding, 'MULTISIGN') - const msOutputs = result.psbt.txOutputs.filter(o => - o.value === encoder.dustAmount) - assert.strictEqual(msOutputs.length, 1, 'should have 1 multisig output') - }) - - it('60-char data (compiled=61): produces exactly 2 MULTISIGN outputs', async () => { - // With MULTISIGN_SIZE=69 the chunk capacity is 60, so compiled data of 61 - // bytes splits into exactly 2 chunks. prepareData zero-pads each chunk to the - // full 64-byte slot, so both data pubkey halves (pk1=[0:32], pk2=[32:64]) are - // complete 33-byte compressed keys. The zero-pad fix is what this guards: drop - // it and the short final chunk would produce a malformed pk2 that typeforce - // rejects ("Expected property pubkeys.1"). No try/catch: the build must - // succeed unconditionally for this fixture. - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const utxo = standardUtxo(TXID_MULTISIGN) - - const result = await encoder.createTransaction( - [utxo], address, null, - 'A'.repeat(60), null, 10000, false, 'MULTISIGN', address, - null, null, PUBKEY_BUF.toString('hex'), true, 0.00001 - ) - - const msOutputs = result.psbt.txOutputs.filter(o => - o.value === encoder.dustAmount) - assert.strictEqual(msOutputs.length, 2, 'should have exactly 2 multisig outputs') - - // Each emitted MULTISIGN output's script must decompile to full 33-byte - // pubkey pushes for both data halves (the point of the zero-pad fix). - for (const out of msOutputs) { - const parts = bitcoin.script.decompile(out.script) - const pubkeyPushes = parts.filter(p => Buffer.isBuffer(p) && p.length === 33) - assert.ok(pubkeyPushes.length >= 2, - 'each MULTISIGN output should decompile to two 33-byte pubkey pushes (the data halves)') - } - }) - - it('MULTISIGN without compressedPubKey throws', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const utxo = standardUtxo(TXID_MULTISIGN) - - await assert.rejects( - () => encoder.createTransaction( - [utxo], address, null, - 'A'.repeat(59), null, 10000, false, 'MULTISIGN', address, - null, null, null, true, 0.00001 - ) - ) - }) - }) - - describe('dataToPubkey unit boundaries', () => { - const encoder = new (require('../../src/XChainEncoder'))( - 'bitcoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '' - ) - - it('exactly 32 bytes → 33-byte pubkey (02 prefix, no zero-fill)', async () => { - const data = Buffer.alloc(32, 0x42) - const pubkey = await encoder.dataToPubkey(data) - assert.strictEqual(pubkey.length, 33) - assert.strictEqual(pubkey[0], 0x02) - assert.deepStrictEqual(pubkey.subarray(1), data) - }) - - it('< 32 bytes (16 bytes) → 33-byte pubkey (02 prefix + data + zero-fill)', async () => { - const data = Buffer.alloc(16, 0x42) - const pubkey = await encoder.dataToPubkey(data) - assert.strictEqual(pubkey.length, 33) - assert.strictEqual(pubkey[0], 0x02) - assert.deepStrictEqual(pubkey.subarray(1, 17), data) - // Remaining 16 bytes should be zero-filled - const fill = pubkey.subarray(17) - assert.strictEqual(fill.length, 16) - for (const byte of fill) { - assert.strictEqual(byte, 0x00) - } - }) - - it('1 byte → 33-byte pubkey (02 + 1 byte data + 31 bytes zero-fill)', async () => { - const data = Buffer.from([0xFF]) - const pubkey = await encoder.dataToPubkey(data) - assert.strictEqual(pubkey.length, 33) - assert.strictEqual(pubkey[1], 0xFF) - }) - - it('0 bytes → 33-byte pubkey (02 + 32 bytes zero-fill)', async () => { - const data = Buffer.alloc(0) - const pubkey = await encoder.dataToPubkey(data) - assert.strictEqual(pubkey.length, 33) - assert.strictEqual(pubkey[0], 0x02) - }) - - it('> 32 bytes (34 bytes) → 35-byte result (no guard, exceeds compressed pubkey size)', async () => { - // This documents that dataToPubkey has no upper bound check. - // When data > 32 bytes, the condition (data.length < 32) is false, - // so no zero-fill occurs, and the result is 1 + data.length bytes. - const data = Buffer.alloc(34, 0x42) - const pubkey = await encoder.dataToPubkey(data) - assert.strictEqual(pubkey.length, 35, - 'no guard: 34-byte data produces 35-byte "pubkey" (invalid for EC)') - }) - }) - - describe('forced encoding override', () => { - it('small data forced to P2SH produces P2SH output', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - - const result = await encoder.createTransaction( - [standardUtxo()], address, null, - 'SEND|0|X|1|a', null, 10000, false, 'P2SH', address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(result.encoding, 'P2SH') - }) - - it('invalid encoding string throws TypeError from prepareData', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - - // prepareData throws TypeError for unknown encoding - await assert.rejects( - () => encoder.createTransaction( - [standardUtxo()], address, null, - 'test', null, 10000, false, 'INVALID_TYPE', address, - null, null, null, true, 0.00001 - ), - { name: 'TypeError' } - ) - }) - }) - - describe('singleOpReturnPolicy enforcement (always fail-closed)', () => { - it('throws RangeError for an oversized OP_RETURN when the flag is absent', () => { - // A network config that omits singleOpReturnPolicy must STILL enforce the - // single-output ceiling. - const encoder = makeEncoder(NETWORK) - delete encoder.network.singleOpReturnPolicy - const oversized = Buffer.alloc(100) // > the 76-byte OP_RETURN payload ceiling - assert.throws(() => encoder.prepareData(oversized, 'OP_RETURN'), RangeError) - }) - - it('throws RangeError for an oversized OP_RETURN even when singleOpReturnPolicy is explicitly false', () => { - // The multi-chunk split path this used to opt into is unreassemblable (no - // shipped decoder reads more than one OP_RETURN push) and unrelayable (Core's - // IsStandardTx rejects multi-OP_RETURN as non-standard). The flag no longer - // disarms the ceiling; an oversized OP_RETURN payload always throws. - const encoder = makeEncoder(NETWORK) - encoder.network.singleOpReturnPolicy = false - const oversized = Buffer.alloc(100) - assert.throws(() => encoder.prepareData(oversized, 'OP_RETURN'), RangeError) - }) - - it('a max-size single-chunk payload still encodes as exactly one OP_RETURN output regardless of the flag', () => { - const encoder = makeEncoder(NETWORK) - encoder.network.singleOpReturnPolicy = false - const atCeiling = Buffer.alloc(76) // exactly the 76-byte OP_RETURN payload ceiling - const prepared = encoder.prepareData(atCeiling, 'OP_RETURN') - assert.strictEqual(prepared.dataBufferArray.length, 1) - assert.strictEqual(prepared.dataBufferArray[0].length, 76 + 4) // + 4-byte magic word - }) - }) - - describe('P2SH/P2WSH compiled chunk size ceiling', () => { - it('every compiled chunk is <= 520 bytes at the chunk boundary (P2SH and P2WSH)', () => { - // Each data chunk is pushed as a single script element inside the redeem/ - // witness script, so it is bound by the 520-byte consensus - // MAX_SCRIPT_ELEMENT_SIZE. The `- 44` overhead reservation keeps it under. - const encoder = makeEncoder('bitcoin-regtest') - const address = getTestAddress('bitcoin-regtest') - // Mirror createTransaction: prepareData receives the already-compiled buffer. - // 474 chars compile to 477 bytes, crossing the 476-byte chunk boundary → 2 chunks. - const compiled = bitcoin.script.compile([Buffer.from('A'.repeat(474), 'utf8')]) - for (const encoding of ['P2SH', 'P2WSH']) { - const prepared = encoder.prepareData(compiled, encoding, address) - assert.ok(prepared.dataBufferArray.length >= 2, - `${encoding} should split into at least 2 chunks at the boundary`) - for (const chunk of prepared.dataBufferArray) { - assert.ok(chunk.length <= 520, - `${encoding} compiled chunk length ${chunk.length} must be <= 520 (MAX_SCRIPT_ELEMENT_SIZE)`) - } - } - }) - }) }) diff --git a/test/boundary/encoding_chunk_boundaries.test/01_op_return_chunk_split_threshold_forced_encoding.test.js b/test/boundary/encoding_chunk_boundaries.test/01_op_return_chunk_split_threshold_forced_encoding.test.js new file mode 100644 index 0000000..e3fc6b8 --- /dev/null +++ b/test/boundary/encoding_chunk_boundaries.test/01_op_return_chunk_split_threshold_forced_encoding.test.js @@ -0,0 +1,128 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * Encoding Chunk Boundary Tests + * + * Full-pipeline tests at exact byte thresholds where encoding type + * auto-selection and chunk splitting behavior changes. + * + * Key insight: createTransaction() calls bitcoin.script.compile([dataBuffer]) + * BEFORE prepareData(), adding push-opcode overhead: + * - 1 byte for data <= 75 bytes + * - 2 bytes (OP_PUSHDATA1 + length) for data 76-255 bytes + * - 3 bytes (OP_PUSHDATA2 + 2-byte length) for data 256+ bytes + * + * The auto-selection threshold in prepareData() is: + * compiledData.length + 4 (magic) <= 80 → OP_RETURN + * + * Therefore: string 75 chars → compiled 76 → 80 ≤ 80 → OP_RETURN (last fit) + * string 76 chars → compiled 78 → 82 > 80 → P2SH (first overflow) + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const { + TXID_A, + TXID_MULTISIGN, + PUBKEY_BUF, + makeUtxo, + makeEncoder, + getTestAddress +} = require('../../integration/helpers/utxoFactory') +const { + extractOpReturnPayload, + decompilePayload, + MAGIC_WORD +} = require('../../integration/helpers/deobfuscate') + +const NETWORK = 'dogecoin-regtest' + +function standardUtxo (txid = TXID_A) { + return makeUtxo(NETWORK, txid, 0, 100000000) +} +describe('Encoding Chunk Boundaries: Full Pipeline', () => { + describe('OP_RETURN chunk-split threshold (forced encoding)', () => { + it('75-char data → 1 OP_RETURN output', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + + const result = await encoder.createTransaction( + [standardUtxo()], address, null, + 'A'.repeat(75), null, 10000, false, 'OP_RETURN', address, + null, null, null, true, 0.00001 + ) + + const opReturnOutputs = result.psbt.txOutputs.filter(o => o.value === 0) + assert.strictEqual(opReturnOutputs.length, 1, + '75-char data (compiled=76) fits in one 76-byte chunk + 4 magic = 80') + }) + + // A transaction may carry at most one OP_RETURN output; Bitcoin Core + // rejects multi-OP_RETURN transactions as non-standard at broadcast. + // Forcing OP_RETURN with a payload that would not fit a single 76-byte + // chunk must therefore be rejected at construction, not split into outputs + // that always fail to relay. + it('76-char data (compiled=78) → rejected (would exceed single output)', async () => { + // Rejects an oversized forced OP_RETURN on every coin: prepareData enforces the + // ceiling unconditionally, so bitcoin-regtest here is a fixture, not a condition. + const orNet = 'bitcoin-regtest' + const encoder = makeEncoder(orNet) + const address = getTestAddress(orNet) + + await assert.rejects( + encoder.createTransaction( + [standardUtxo()], address, null, + 'A'.repeat(76), null, 10000, false, 'OP_RETURN', address, + null, null, null, true, 0.00001 + ), + RangeError + ) + }) + }) +}) + +describe('Encoding Chunk Boundaries: Full Pipeline', () => { + describe('OP_RETURN chunk-split threshold (forced encoding)', () => { + it('150-char data (compiled=152) → rejected', async () => { + // Rejects on every shipped coin; bitcoin-regtest here is a fixture, not a condition + const orNet = 'bitcoin-regtest' + const encoder = makeEncoder(orNet) + const address = getTestAddress(orNet) + + await assert.rejects( + encoder.createTransaction( + [standardUtxo()], address, null, + 'A'.repeat(150), null, 10000, false, 'OP_RETURN', address, + null, null, null, true, 0.00001 + ), + RangeError + ) + }) + + it('151-char data (compiled=153) → rejected', async () => { + // Rejects on every shipped coin; bitcoin-regtest here is a fixture, not a condition + const orNet = 'bitcoin-regtest' + const encoder = makeEncoder(orNet) + const address = getTestAddress(orNet) + + await assert.rejects( + encoder.createTransaction( + [standardUtxo()], address, null, + 'A'.repeat(151), null, 10000, false, 'OP_RETURN', address, + null, null, null, true, 0.00001 + ), + RangeError + ) + }) + }) +}) diff --git a/test/boundary/encoding_chunk_boundaries.test/02_p2sh_chunk_split_threshold.test.js b/test/boundary/encoding_chunk_boundaries.test/02_p2sh_chunk_split_threshold.test.js new file mode 100644 index 0000000..a67432d --- /dev/null +++ b/test/boundary/encoding_chunk_boundaries.test/02_p2sh_chunk_split_threshold.test.js @@ -0,0 +1,131 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * Encoding Chunk Boundary Tests + * + * Full-pipeline tests at exact byte thresholds where encoding type + * auto-selection and chunk splitting behavior changes. + * + * Key insight: createTransaction() calls bitcoin.script.compile([dataBuffer]) + * BEFORE prepareData(), adding push-opcode overhead: + * - 1 byte for data <= 75 bytes + * - 2 bytes (OP_PUSHDATA1 + length) for data 76-255 bytes + * - 3 bytes (OP_PUSHDATA2 + 2-byte length) for data 256+ bytes + * + * The auto-selection threshold in prepareData() is: + * compiledData.length + 4 (magic) <= 80 → OP_RETURN + * + * Therefore: string 75 chars → compiled 76 → 80 ≤ 80 → OP_RETURN (last fit) + * string 76 chars → compiled 78 → 82 > 80 → P2SH (first overflow) + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const { + TXID_A, + TXID_MULTISIGN, + PUBKEY_BUF, + makeUtxo, + makeEncoder, + getTestAddress +} = require('../../integration/helpers/utxoFactory') +const { + extractOpReturnPayload, + decompilePayload, + MAGIC_WORD +} = require('../../integration/helpers/deobfuscate') + +const NETWORK = 'dogecoin-regtest' + +function standardUtxo (txid = TXID_A) { + return makeUtxo(NETWORK, txid, 0, 100000000) +} +describe('Encoding Chunk Boundaries: Full Pipeline', () => { + describe('P2SH chunk-split threshold', () => { + // P2SH chunksSize = 520 - 44 = 476 + // For string of N chars (N <= 255): compiled = N + 2 (OP_PUSHDATA1 overhead) + // So compiled <= 476 when N <= 474... wait: for N <= 75, overhead is 1. + // For 76 <= N <= 255, overhead is 2. So compiled = N + 2. + // compiled <= 476 → N <= 474 (for N >= 76) + // But for a large string we expect: 473-char (compiled 475) → 1 chunk, + // 474-char (compiled 476) → 1 chunk, 475-char (compiled 477) → 2 chunks + + it('473-char data (compiled=476) → 1 P2SH output (exactly fits)', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + + const result = await encoder.createTransaction( + [standardUtxo()], address, null, + 'A'.repeat(473), null, 10000, false, 'P2SH', address, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(result.encoding, 'P2SH') + // Count non-zero outputs: 1 P2SH output + 1 change output + const outputs = result.psbt.txOutputs.filter(o => o.value > 0) + assert.strictEqual(outputs.length, 2) + }) + + it('474-char data (compiled=477) → 2 P2SH outputs (first overflow)', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + + const result = await encoder.createTransaction( + [standardUtxo()], address, null, + 'A'.repeat(474), null, 10000, false, 'P2SH', address, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(result.encoding, 'P2SH') + // 2 P2SH outputs + 1 change output + const outputs = result.psbt.txOutputs.filter(o => o.value > 0) + assert.strictEqual(outputs.length, 3) + }) + }) +}) + +describe('Encoding Chunk Boundaries: Full Pipeline', () => { + describe('P2SH chunk-split threshold', () => { + it('P2SH data integrity: 475-char data reassembles from tx2 redeemScripts', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const data = 'A'.repeat(475) + + // Create tx1 + const tx1 = await encoder.createTransaction( + [standardUtxo()], address, null, + data, null, 10000, false, 'P2SH', address, + null, null, null, true, 0.00001 + ) + + const tx1Hex = tx1.psbt.__CACHE.__TX.toHex() + const tx1Id = tx1.psbt.__CACHE.__TX.getId() + + // Create tx2 + const tx2 = await encoder.createTransaction( + [standardUtxo()], address, null, + data, null, 10000, false, 'P2SH', address, + tx1Id, tx1Hex, null, true, 0.00001 + ) + + // tx2 inputs should have redeemScripts containing the data + const inputs = tx2.psbt.data.inputs + const dataChunks = inputs + .filter(i => i.redeemScript) + .map(i => bitcoin.script.decompile(i.redeemScript)[0]) + + assert.ok(dataChunks.length >= 2, + 'should have 2 redeemScript inputs for 475-char data') + }) + }) +}) diff --git a/test/boundary/encoding_chunk_boundaries.test/03_p2wsh_real_world_boundary_bitcoin_regtest_only.test.js b/test/boundary/encoding_chunk_boundaries.test/03_p2wsh_real_world_boundary_bitcoin_regtest_only.test.js new file mode 100644 index 0000000..05cfffe --- /dev/null +++ b/test/boundary/encoding_chunk_boundaries.test/03_p2wsh_real_world_boundary_bitcoin_regtest_only.test.js @@ -0,0 +1,112 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Encoding Chunk Boundary Tests + * + * Full-pipeline tests at exact byte thresholds where encoding type + * auto-selection and chunk splitting behavior changes. + * + * Key insight: createTransaction() calls bitcoin.script.compile([dataBuffer]) + * BEFORE prepareData(), adding push-opcode overhead: + * - 1 byte for data <= 75 bytes + * - 2 bytes (OP_PUSHDATA1 + length) for data 76-255 bytes + * - 3 bytes (OP_PUSHDATA2 + 2-byte length) for data 256+ bytes + * + * The auto-selection threshold in prepareData() is: + * compiledData.length + 4 (magic) <= 80 → OP_RETURN + * + * Therefore: string 75 chars → compiled 76 → 80 ≤ 80 → OP_RETURN (last fit) + * string 76 chars → compiled 78 → 82 > 80 → P2SH (first overflow) + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const { + TXID_A, + TXID_MULTISIGN, + PUBKEY_BUF, + makeUtxo, + makeEncoder, + getTestAddress +} = require('../../integration/helpers/utxoFactory') +const { + extractOpReturnPayload, + decompilePayload, + MAGIC_WORD +} = require('../../integration/helpers/deobfuscate') + +const NETWORK = 'dogecoin-regtest' + +function standardUtxo (txid = TXID_A) { + return makeUtxo(NETWORK, txid, 0, 100000000) +} +describe('Encoding Chunk Boundaries: Full Pipeline', () => { + describe('P2WSH real-world boundary (bitcoin-regtest only)', () => { + // PW2SH_SIZE = 520, giving a chunk capacity of 476 bytes, the same as + // P2SH. Each data chunk is pushed as a single script element inside the + // witness script, so it is bound by consensus MAX_SCRIPT_ELEMENT_SIZE + // (520), NOT by the larger total witness-script policy limit. (A bigger + // chunk builds a witness script the node rejects at spend time with + // "Push value size limit exceeded".) Through the full pipeline a 473-char + // string (compiled to 476 bytes with a 3-byte OP_PUSHDATA2 prefix) is the + // last to fit in one chunk; 474 chars (compiled 477) splits to 2. + + it('473-char data → 1 P2WSH output (last single-chunk size)', async () => { + const p2wshNet = 'bitcoin-regtest' + const encoder = makeEncoder(p2wshNet) + const address = getTestAddress(p2wshNet) + + const result = await encoder.createTransaction( + [standardUtxo()], address, null, + 'A'.repeat(473), null, 10000, false, 'P2WSH', address, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(result.encoding, 'P2WSH') + const nonZeroOutputs = result.psbt.txOutputs.filter(o => o.value > 0) + assert.strictEqual(nonZeroOutputs.length, 2) + }) + + it('474-char data (compiled=477) → 2 P2WSH outputs (crosses chunk boundary)', async () => { + // chunksSize = PW2SH_SIZE(520) - 44 = 476. Compiled 477 > 476 → 2 chunks. + const p2wshNet = 'bitcoin-regtest' + const encoder = makeEncoder(p2wshNet) + const address = getTestAddress(p2wshNet) + + const result = await encoder.createTransaction( + [standardUtxo()], address, null, + 'A'.repeat(474), null, 10000, false, 'P2WSH', address, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(result.encoding, 'P2WSH') + // 2 P2WSH outputs + change + const nonZeroOutputs = result.psbt.txOutputs.filter(o => o.value > 0) + assert.strictEqual(nonZeroOutputs.length, 3) + }) + + it('small P2WSH data (500 chars) works correctly', async () => { + const p2wshNet = 'bitcoin-regtest' + const encoder = makeEncoder(p2wshNet) + const address = getTestAddress(p2wshNet) + + const result = await encoder.createTransaction( + [standardUtxo()], address, null, + 'A'.repeat(500), null, 10000, false, 'P2WSH', address, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(result.encoding, 'P2WSH') + }) + }) +}) diff --git a/test/boundary/encoding_chunk_boundaries.test/04_p2wsh_on_dogecoin_no_bech32_in_network_config.test.js b/test/boundary/encoding_chunk_boundaries.test/04_p2wsh_on_dogecoin_no_bech32_in_network_config.test.js new file mode 100644 index 0000000..663aff2 --- /dev/null +++ b/test/boundary/encoding_chunk_boundaries.test/04_p2wsh_on_dogecoin_no_bech32_in_network_config.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. + * + ********************************************************************** + * Encoding Chunk Boundary Tests + * + * Full-pipeline tests at exact byte thresholds where encoding type + * auto-selection and chunk splitting behavior changes. + * + * Key insight: createTransaction() calls bitcoin.script.compile([dataBuffer]) + * BEFORE prepareData(), adding push-opcode overhead: + * - 1 byte for data <= 75 bytes + * - 2 bytes (OP_PUSHDATA1 + length) for data 76-255 bytes + * - 3 bytes (OP_PUSHDATA2 + 2-byte length) for data 256+ bytes + * + * The auto-selection threshold in prepareData() is: + * compiledData.length + 4 (magic) <= 80 → OP_RETURN + * + * Therefore: string 75 chars → compiled 76 → 80 ≤ 80 → OP_RETURN (last fit) + * string 76 chars → compiled 78 → 82 > 80 → P2SH (first overflow) + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const { + TXID_A, + TXID_MULTISIGN, + PUBKEY_BUF, + makeUtxo, + makeEncoder, + getTestAddress +} = require('../../integration/helpers/utxoFactory') +const { + extractOpReturnPayload, + decompilePayload, + MAGIC_WORD +} = require('../../integration/helpers/deobfuscate') + +const NETWORK = 'dogecoin-regtest' + +function standardUtxo (txid = TXID_A) { + return makeUtxo(NETWORK, txid, 0, 100000000) +} +describe('Encoding Chunk Boundaries: Full Pipeline', () => { + describe('P2WSH on Dogecoin (no bech32 in network config)', () => { + it('throws because bitcoin.payments.p2wsh requires bech32 prefix', async () => { + const encoder = makeEncoder(NETWORK) // dogecoin-regtest has no bech32 + const address = getTestAddress(NETWORK) + + await assert.rejects( + () => encoder.createTransaction( + [standardUtxo()], address, null, + 'A'.repeat(100), null, 10000, false, 'P2WSH', address, + null, null, null, true, 0.00001 + ) + ) + }) + }) +}) diff --git a/test/boundary/encoding_chunk_boundaries.test/05_multisign_encoding_boundaries.test.js b/test/boundary/encoding_chunk_boundaries.test/05_multisign_encoding_boundaries.test.js new file mode 100644 index 0000000..6ebca88 --- /dev/null +++ b/test/boundary/encoding_chunk_boundaries.test/05_multisign_encoding_boundaries.test.js @@ -0,0 +1,135 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * Encoding Chunk Boundary Tests + * + * Full-pipeline tests at exact byte thresholds where encoding type + * auto-selection and chunk splitting behavior changes. + * + * Key insight: createTransaction() calls bitcoin.script.compile([dataBuffer]) + * BEFORE prepareData(), adding push-opcode overhead: + * - 1 byte for data <= 75 bytes + * - 2 bytes (OP_PUSHDATA1 + length) for data 76-255 bytes + * - 3 bytes (OP_PUSHDATA2 + 2-byte length) for data 256+ bytes + * + * The auto-selection threshold in prepareData() is: + * compiledData.length + 4 (magic) <= 80 → OP_RETURN + * + * Therefore: string 75 chars → compiled 76 → 80 ≤ 80 → OP_RETURN (last fit) + * string 76 chars → compiled 78 → 82 > 80 → P2SH (first overflow) + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const { + TXID_A, + TXID_MULTISIGN, + PUBKEY_BUF, + makeUtxo, + makeEncoder, + getTestAddress +} = require('../../integration/helpers/utxoFactory') +const { + extractOpReturnPayload, + decompilePayload, + MAGIC_WORD +} = require('../../integration/helpers/deobfuscate') + +const NETWORK = 'dogecoin-regtest' + +function standardUtxo (txid = TXID_A) { + return makeUtxo(NETWORK, txid, 0, 100000000) +} +describe('Encoding Chunk Boundaries: Full Pipeline', () => { + describe('MULTISIGN encoding boundaries', () => { + // MULTISIGN chunk capacity = MULTISIGN_SIZE(69) - 4(magic) - 5(overhead) = 60 + // bytes of raw data (src/XChainEncoder.js: MULTISIGN_SIZE = 69). + // Each obfuscated chunk is split across two fake pubkeys: pk1 = [0:32], pk2 = [32:end]. + // A full chunk is magic(4) + 60 = 64 obfuscated bytes, so pk2 = [32:64] is exactly + // 32 bytes - the largest dataToPubkey() slice that stays a valid 33-byte + // compressed key. prepareData() zero-pads every chunk up to the 64-byte slot so + // both pubkey halves are always complete 32-byte values. + // + // The bug is CLOSED: the former MULTISIGN_SIZE = 71 gave a 62-byte chunk whose + // pk2 could reach 33 bytes (an invalid EC point). With MULTISIGN_SIZE = 69 the + // chunk is capped at 60 and pk2 never exceeds 32 bytes, so a payload that would + // payload that overflows one chunk splits cleanly into two valid MULTISIGN outputs. + + it('59-char data (compiled=60): valid 1-MULTISIGN output', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const utxo = standardUtxo(TXID_MULTISIGN) + + const result = await encoder.createTransaction( + [utxo], address, null, + 'A'.repeat(59), null, 10000, false, 'MULTISIGN', address, + null, null, PUBKEY_BUF.toString('hex'), true, 0.00001 + ) + + assert.strictEqual(result.encoding, 'MULTISIGN') + const msOutputs = result.psbt.txOutputs.filter(o => + o.value === encoder.dustAmount) + assert.strictEqual(msOutputs.length, 1, 'should have 1 multisig output') + }) + }) +}) + +describe('Encoding Chunk Boundaries: Full Pipeline', () => { + describe('MULTISIGN encoding boundaries', () => { + it('60-char data (compiled=61): produces exactly 2 MULTISIGN outputs', async () => { + // With MULTISIGN_SIZE=69 the chunk capacity is 60, so compiled data of 61 + // bytes splits into exactly 2 chunks. prepareData zero-pads each chunk to the + // full 64-byte slot, so both data pubkey halves (pk1=[0:32], pk2=[32:64]) are + // complete 33-byte compressed keys. The zero-pad fix is what this guards: drop + // it and the short final chunk would produce a malformed pk2 that typeforce + // rejects ("Expected property pubkeys.1"). No try/catch: the build must + // succeed unconditionally for this fixture. + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const utxo = standardUtxo(TXID_MULTISIGN) + + const result = await encoder.createTransaction( + [utxo], address, null, + 'A'.repeat(60), null, 10000, false, 'MULTISIGN', address, + null, null, PUBKEY_BUF.toString('hex'), true, 0.00001 + ) + + const msOutputs = result.psbt.txOutputs.filter(o => + o.value === encoder.dustAmount) + assert.strictEqual(msOutputs.length, 2, 'should have exactly 2 multisig outputs') + + // Each emitted MULTISIGN output's script must decompile to full 33-byte + // pubkey pushes for both data halves (the point of the zero-pad fix). + for (const out of msOutputs) { + const parts = bitcoin.script.decompile(out.script) + const pubkeyPushes = parts.filter(p => Buffer.isBuffer(p) && p.length === 33) + assert.ok(pubkeyPushes.length >= 2, + 'each MULTISIGN output should decompile to two 33-byte pubkey pushes (the data halves)') + } + }) + + it('MULTISIGN without compressedPubKey throws', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const utxo = standardUtxo(TXID_MULTISIGN) + + await assert.rejects( + () => encoder.createTransaction( + [utxo], address, null, + 'A'.repeat(59), null, 10000, false, 'MULTISIGN', address, + null, null, null, true, 0.00001 + ) + ) + }) + }) +}) diff --git a/test/boundary/encoding_chunk_boundaries.test/06_data_to_pubkey_unit_boundaries.test.js b/test/boundary/encoding_chunk_boundaries.test/06_data_to_pubkey_unit_boundaries.test.js new file mode 100644 index 0000000..1004430 --- /dev/null +++ b/test/boundary/encoding_chunk_boundaries.test/06_data_to_pubkey_unit_boundaries.test.js @@ -0,0 +1,105 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * Encoding Chunk Boundary Tests + * + * Full-pipeline tests at exact byte thresholds where encoding type + * auto-selection and chunk splitting behavior changes. + * + * Key insight: createTransaction() calls bitcoin.script.compile([dataBuffer]) + * BEFORE prepareData(), adding push-opcode overhead: + * - 1 byte for data <= 75 bytes + * - 2 bytes (OP_PUSHDATA1 + length) for data 76-255 bytes + * - 3 bytes (OP_PUSHDATA2 + 2-byte length) for data 256+ bytes + * + * The auto-selection threshold in prepareData() is: + * compiledData.length + 4 (magic) <= 80 → OP_RETURN + * + * Therefore: string 75 chars → compiled 76 → 80 ≤ 80 → OP_RETURN (last fit) + * string 76 chars → compiled 78 → 82 > 80 → P2SH (first overflow) + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const { + TXID_A, + TXID_MULTISIGN, + PUBKEY_BUF, + makeUtxo, + makeEncoder, + getTestAddress +} = require('../../integration/helpers/utxoFactory') +const { + extractOpReturnPayload, + decompilePayload, + MAGIC_WORD +} = require('../../integration/helpers/deobfuscate') + +const NETWORK = 'dogecoin-regtest' + +function standardUtxo (txid = TXID_A) { + return makeUtxo(NETWORK, txid, 0, 100000000) +} +describe('Encoding Chunk Boundaries: Full Pipeline', () => { + describe('dataToPubkey unit boundaries', () => { + const encoder = new (require('../../../src/XChainEncoder'))( + 'bitcoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '' + ) + + it('exactly 32 bytes → 33-byte pubkey (02 prefix, no zero-fill)', async () => { + const data = Buffer.alloc(32, 0x42) + const pubkey = await encoder.dataToPubkey(data) + assert.strictEqual(pubkey.length, 33) + assert.strictEqual(pubkey[0], 0x02) + assert.deepStrictEqual(pubkey.subarray(1), data) + }) + + it('< 32 bytes (16 bytes) → 33-byte pubkey (02 prefix + data + zero-fill)', async () => { + const data = Buffer.alloc(16, 0x42) + const pubkey = await encoder.dataToPubkey(data) + assert.strictEqual(pubkey.length, 33) + assert.strictEqual(pubkey[0], 0x02) + assert.deepStrictEqual(pubkey.subarray(1, 17), data) + // Remaining 16 bytes should be zero-filled + const fill = pubkey.subarray(17) + assert.strictEqual(fill.length, 16) + for (const byte of fill) { + assert.strictEqual(byte, 0x00) + } + }) + + it('1 byte → 33-byte pubkey (02 + 1 byte data + 31 bytes zero-fill)', async () => { + const data = Buffer.from([0xFF]) + const pubkey = await encoder.dataToPubkey(data) + assert.strictEqual(pubkey.length, 33) + assert.strictEqual(pubkey[1], 0xFF) + }) + + it('0 bytes → 33-byte pubkey (02 + 32 bytes zero-fill)', async () => { + const data = Buffer.alloc(0) + const pubkey = await encoder.dataToPubkey(data) + assert.strictEqual(pubkey.length, 33) + assert.strictEqual(pubkey[0], 0x02) + }) + + it('> 32 bytes (34 bytes) → 35-byte result (no guard, exceeds compressed pubkey size)', async () => { + // This documents that dataToPubkey has no upper bound check. + // When data > 32 bytes, the condition (data.length < 32) is false, + // so no zero-fill occurs, and the result is 1 + data.length bytes. + const data = Buffer.alloc(34, 0x42) + const pubkey = await encoder.dataToPubkey(data) + assert.strictEqual(pubkey.length, 35, + 'no guard: 34-byte data produces 35-byte "pubkey" (invalid for EC)') + }) + }) +}) diff --git a/test/boundary/encoding_chunk_boundaries.test/07_forced_encoding_override.test.js b/test/boundary/encoding_chunk_boundaries.test/07_forced_encoding_override.test.js new file mode 100644 index 0000000..71b88cc --- /dev/null +++ b/test/boundary/encoding_chunk_boundaries.test/07_forced_encoding_override.test.js @@ -0,0 +1,83 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Encoding Chunk Boundary Tests + * + * Full-pipeline tests at exact byte thresholds where encoding type + * auto-selection and chunk splitting behavior changes. + * + * Key insight: createTransaction() calls bitcoin.script.compile([dataBuffer]) + * BEFORE prepareData(), adding push-opcode overhead: + * - 1 byte for data <= 75 bytes + * - 2 bytes (OP_PUSHDATA1 + length) for data 76-255 bytes + * - 3 bytes (OP_PUSHDATA2 + 2-byte length) for data 256+ bytes + * + * The auto-selection threshold in prepareData() is: + * compiledData.length + 4 (magic) <= 80 → OP_RETURN + * + * Therefore: string 75 chars → compiled 76 → 80 ≤ 80 → OP_RETURN (last fit) + * string 76 chars → compiled 78 → 82 > 80 → P2SH (first overflow) + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const { + TXID_A, + TXID_MULTISIGN, + PUBKEY_BUF, + makeUtxo, + makeEncoder, + getTestAddress +} = require('../../integration/helpers/utxoFactory') +const { + extractOpReturnPayload, + decompilePayload, + MAGIC_WORD +} = require('../../integration/helpers/deobfuscate') + +const NETWORK = 'dogecoin-regtest' + +function standardUtxo (txid = TXID_A) { + return makeUtxo(NETWORK, txid, 0, 100000000) +} +describe('Encoding Chunk Boundaries: Full Pipeline', () => { + describe('forced encoding override', () => { + it('small data forced to P2SH produces P2SH output', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + + const result = await encoder.createTransaction( + [standardUtxo()], address, null, + 'SEND|0|X|1|a', null, 10000, false, 'P2SH', address, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(result.encoding, 'P2SH') + }) + + it('invalid encoding string throws TypeError from prepareData', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + + // prepareData throws TypeError for unknown encoding + await assert.rejects( + () => encoder.createTransaction( + [standardUtxo()], address, null, + 'test', null, 10000, false, 'INVALID_TYPE', address, + null, null, null, true, 0.00001 + ), + { name: 'TypeError' } + ) + }) + }) +}) diff --git a/test/boundary/encoding_chunk_boundaries.test/08_single_op_return_policy_enforcement_always_fail_closed.test.js b/test/boundary/encoding_chunk_boundaries.test/08_single_op_return_policy_enforcement_always_fail_closed.test.js new file mode 100644 index 0000000..075ce61 --- /dev/null +++ b/test/boundary/encoding_chunk_boundaries.test/08_single_op_return_policy_enforcement_always_fail_closed.test.js @@ -0,0 +1,84 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Encoding Chunk Boundary Tests + * + * Full-pipeline tests at exact byte thresholds where encoding type + * auto-selection and chunk splitting behavior changes. + * + * Key insight: createTransaction() calls bitcoin.script.compile([dataBuffer]) + * BEFORE prepareData(), adding push-opcode overhead: + * - 1 byte for data <= 75 bytes + * - 2 bytes (OP_PUSHDATA1 + length) for data 76-255 bytes + * - 3 bytes (OP_PUSHDATA2 + 2-byte length) for data 256+ bytes + * + * The auto-selection threshold in prepareData() is: + * compiledData.length + 4 (magic) <= 80 → OP_RETURN + * + * Therefore: string 75 chars → compiled 76 → 80 ≤ 80 → OP_RETURN (last fit) + * string 76 chars → compiled 78 → 82 > 80 → P2SH (first overflow) + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const { + TXID_A, + TXID_MULTISIGN, + PUBKEY_BUF, + makeUtxo, + makeEncoder, + getTestAddress +} = require('../../integration/helpers/utxoFactory') +const { + extractOpReturnPayload, + decompilePayload, + MAGIC_WORD +} = require('../../integration/helpers/deobfuscate') + +const NETWORK = 'dogecoin-regtest' + +function standardUtxo (txid = TXID_A) { + return makeUtxo(NETWORK, txid, 0, 100000000) +} +describe('Encoding Chunk Boundaries: Full Pipeline', () => { + describe('singleOpReturnPolicy enforcement (always fail-closed)', () => { + it('throws RangeError for an oversized OP_RETURN when the flag is absent', () => { + // A network config that omits singleOpReturnPolicy must STILL enforce the + // single-output ceiling. + const encoder = makeEncoder(NETWORK) + delete encoder.network.singleOpReturnPolicy + const oversized = Buffer.alloc(100) // > the 76-byte OP_RETURN payload ceiling + assert.throws(() => encoder.prepareData(oversized, 'OP_RETURN'), RangeError) + }) + + it('throws RangeError for an oversized OP_RETURN even when singleOpReturnPolicy is explicitly false', () => { + // The multi-chunk split path selected by this flag is unreassemblable (no + // shipped decoder reads more than one OP_RETURN push) and unrelayable (Core's + // IsStandardTx rejects multi-OP_RETURN as non-standard). The flag cannot + // disarm the ceiling; an oversized OP_RETURN payload always throws. + const encoder = makeEncoder(NETWORK) + encoder.network.singleOpReturnPolicy = false + const oversized = Buffer.alloc(100) + assert.throws(() => encoder.prepareData(oversized, 'OP_RETURN'), RangeError) + }) + + it('a max-size single-chunk payload still encodes as exactly one OP_RETURN output regardless of the flag', () => { + const encoder = makeEncoder(NETWORK) + encoder.network.singleOpReturnPolicy = false + const atCeiling = Buffer.alloc(76) // exactly the 76-byte OP_RETURN payload ceiling + const prepared = encoder.prepareData(atCeiling, 'OP_RETURN') + assert.strictEqual(prepared.dataBufferArray.length, 1) + assert.strictEqual(prepared.dataBufferArray[0].length, 76 + 4) // + 4-byte magic word + }) + }) +}) diff --git a/test/boundary/encoding_chunk_boundaries.test/09_p2sh_p2wsh_compiled_chunk_size_ceiling.test.js b/test/boundary/encoding_chunk_boundaries.test/09_p2sh_p2wsh_compiled_chunk_size_ceiling.test.js new file mode 100644 index 0000000..6f82192 --- /dev/null +++ b/test/boundary/encoding_chunk_boundaries.test/09_p2sh_p2wsh_compiled_chunk_size_ceiling.test.js @@ -0,0 +1,75 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Encoding Chunk Boundary Tests + * + * Full-pipeline tests at exact byte thresholds where encoding type + * auto-selection and chunk splitting behavior changes. + * + * Key insight: createTransaction() calls bitcoin.script.compile([dataBuffer]) + * BEFORE prepareData(), adding push-opcode overhead: + * - 1 byte for data <= 75 bytes + * - 2 bytes (OP_PUSHDATA1 + length) for data 76-255 bytes + * - 3 bytes (OP_PUSHDATA2 + 2-byte length) for data 256+ bytes + * + * The auto-selection threshold in prepareData() is: + * compiledData.length + 4 (magic) <= 80 → OP_RETURN + * + * Therefore: string 75 chars → compiled 76 → 80 ≤ 80 → OP_RETURN (last fit) + * string 76 chars → compiled 78 → 82 > 80 → P2SH (first overflow) + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const { + TXID_A, + TXID_MULTISIGN, + PUBKEY_BUF, + makeUtxo, + makeEncoder, + getTestAddress +} = require('../../integration/helpers/utxoFactory') +const { + extractOpReturnPayload, + decompilePayload, + MAGIC_WORD +} = require('../../integration/helpers/deobfuscate') + +const NETWORK = 'dogecoin-regtest' + +function standardUtxo (txid = TXID_A) { + return makeUtxo(NETWORK, txid, 0, 100000000) +} +describe('Encoding Chunk Boundaries: Full Pipeline', () => { + describe('P2SH/P2WSH compiled chunk size ceiling', () => { + it('every compiled chunk is <= 520 bytes at the chunk boundary (P2SH and P2WSH)', () => { + // Each data chunk is pushed as a single script element inside the redeem/ + // witness script, so it is bound by the 520-byte consensus + // MAX_SCRIPT_ELEMENT_SIZE. The `- 44` overhead reservation keeps it under. + const encoder = makeEncoder('bitcoin-regtest') + const address = getTestAddress('bitcoin-regtest') + // Mirror createTransaction: prepareData receives the already-compiled buffer. + // 474 chars compile to 477 bytes, crossing the 476-byte chunk boundary → 2 chunks. + const compiled = bitcoin.script.compile([Buffer.from('A'.repeat(474), 'utf8')]) + for (const encoding of ['P2SH', 'P2WSH']) { + const prepared = encoder.prepareData(compiled, encoding, address) + assert.ok(prepared.dataBufferArray.length >= 2, + `${encoding} should split into at least 2 chunks at the boundary`) + for (const chunk of prepared.dataBufferArray) { + assert.ok(chunk.length <= 520, + `${encoding} compiled chunk length ${chunk.length} must be <= 520 (MAX_SCRIPT_ELEMENT_SIZE)`) + } + } + }) + }) +}) diff --git a/test/e2e/action_pipeline_e2e.test.js b/test/e2e/action_pipeline_e2e.test.js index 9fddae3..f0d825f 100644 --- a/test/e2e/action_pipeline_e2e.test.js +++ b/test/e2e/action_pipeline_e2e.test.js @@ -67,9 +67,7 @@ async function encodeFull (actionObj, opts = {}) { return { result, payload: null, decompiled: null, dataString: null } } - describe('E2E-1: Full ACTION-to-PSBT Pipeline', () => { - describe('E2E-1.1: Simple SEND v0', () => { it('SEND|0|JDOG|100| encodes to valid OP_RETURN PSBT with exact payload', async () => { const action = actions.makeSend('JDOG', '100', actions.ADDR_BTC) @@ -126,7 +124,8 @@ describe('E2E-1: Full ACTION-to-PSBT Pipeline', () => { assert.ok(dataString.includes('bye')) }) }) - +}) +describe('E2E-1: Full ACTION-to-PSBT Pipeline', () => { describe('E2E-1.6: Full ISSUE with all 25+ fields (P2SH)', () => { it('produces valid P2SH PSBT with correctly structured output', async () => { const action = actions.makeIssueFull('TESTTOKEN') @@ -181,7 +180,8 @@ describe('E2E-1: Full ACTION-to-PSBT Pipeline', () => { assert.ok(dataString.includes('999')) }) }) - +}) +describe('E2E-1: Full ACTION-to-PSBT Pipeline', () => { describe('E2E-1.10: DESTROY', () => { it('burn amount preserved', async () => { const action = actions.makeDestroy('JDOG', '50') @@ -214,236 +214,4 @@ describe('E2E-1: Full ACTION-to-PSBT Pipeline', () => { assert.strictEqual(dataString, action.data) }) }) - - describe('E2E-1.14: AIRDROP', () => { - it('list index reference preserved', async () => { - const action = actions.makeAirdrop('JDOG', '100', '10') - const { dataString } = await encodeFull(action) - assert.strictEqual(dataString, action.data) - }) - }) - - describe('E2E-1.15: DIVIDEND', () => { - it('two-tick relationship preserved', async () => { - const action = actions.makeDividend('JDOG', 'BRRR', '1000') - const { dataString } = await encodeFull(action) - assert.strictEqual(dataString, action.data) - assert.ok(dataString.includes('JDOG')) - assert.ok(dataString.includes('BRRR')) - }) - }) - - describe('E2E-1.16: ORDER BUY', () => { - it('all DEX fields preserved', async () => { - const action = actions.makeOrder('BUY', 'JDOG', '100', 'BRRR', '50', '0') - const { dataString } = await encodeFull(action) - assert.strictEqual(dataString, action.data) - assert.ok(dataString.includes('BUY')) - }) - }) - - describe('E2E-1.17: ORDER SELL', () => { - it('SELL type preserved', async () => { - const action = actions.makeOrder('SELL', 'JDOG', '100', 'BRRR', '50', '100') - const { dataString } = await encodeFull(action) - assert.strictEqual(dataString, action.data) - assert.ok(dataString.includes('SELL')) - }) - }) - - describe('E2E-1.18: COINPAY', () => { - it('order match index preserved', async () => { - const action = actions.makeCoinpay('42') - const { dataString } = await encodeFull(action) - assert.strictEqual(dataString, action.data) - assert.ok(dataString.includes('42')) - }) - }) - - describe('E2E-1.19: DISPENSER', () => { - it('all dispenser params preserved', async () => { - const action = actions.makeDispenser('JDOG', '100', '10', '1', actions.ADDR_BTC) - const { dataString } = await encodeFull(action) - assert.strictEqual(dataString, action.data) - }) - }) - - describe('E2E-1.20: SWAP', () => { - it('cross-chain identifiers preserved', async () => { - const action = actions.makeSwap('JDOG', '100', 'LTC', 'LDOG', '200') - const { dataString } = await encodeFull(action) - assert.strictEqual(dataString, action.data) - assert.ok(dataString.includes('LTC')) - assert.ok(dataString.includes('LDOG')) - }) - }) - - describe('E2E-1.21: BROADCAST', () => { - it('message text preserved', async () => { - const action = actions.makeBroadcast('Hello XChain World') - const { dataString } = await encodeFull(action) - assert.strictEqual(dataString, action.data) - }) - }) - - describe('E2E-1.22: MESSAGE', () => { - it('address + text preserved', async () => { - const action = actions.makeMessage(actions.ADDR_BTC, 'Hello') - const { dataString } = await encodeFull(action) - assert.strictEqual(dataString, action.data) - }) - }) - - describe('E2E-1.23: FILE small (P2SH)', () => { - it('file metadata + content encoded as P2SH', async () => { - const action = actions.makeFile('test.json', 'application/json') - const { result } = await encodeFull(action) - assert.strictEqual(result.encoding, 'P2SH') - - // Verify P2SH output exists - const p2shOutput = result.psbt.txOutputs.find(o => { - if (o.value <= 0) return false - const d = bitcoin.script.decompile(o.script) - return d && d[0] === bitcoin.opcodes.OP_HASH160 - }) - assert.ok(p2shOutput, 'should have P2SH output') - }) - }) - - describe('E2E-1.24: FILE large (P2WSH)', () => { - it('large file encoded as P2WSH when forced', async () => { - const action = actions.makeFileLarge() - const { result } = await encodeFull(action, { encoding: 'P2WSH', network: 'bitcoin-regtest' }) - assert.strictEqual(result.encoding, 'P2WSH') - - // Verify P2WSH output: OP_0 <32-byte-hash> - const p2wshOutput = result.psbt.txOutputs.find(o => { - if (o.value <= 0) return false - const d = bitcoin.script.decompile(o.script) - return d && d[0] === bitcoin.opcodes.OP_0 && - Buffer.isBuffer(d[1]) && d[1].length === 32 - }) - assert.ok(p2wshOutput, 'should have P2WSH output') - }) - }) - - describe('E2E-1.25: ADDRESS', () => { - it('require-memo flag preserved', async () => { - const action = actions.makeAddress('1') - const { dataString } = await encodeFull(action) - assert.strictEqual(dataString, action.data) - }) - }) - - describe('E2E-1.26: LINK', () => { - it('action indices preserved', async () => { - const action = actions.makeLink('42', '99') - const { dataString } = await encodeFull(action) - assert.strictEqual(dataString, action.data) - assert.ok(dataString.includes('42')) - assert.ok(dataString.includes('99')) - }) - }) - - describe('E2E-1.27: LIST', () => { - it('comma-separated items encoded successfully', async () => { - // Short addresses to fit OP_RETURN - const action = actions.makeList(['mfWxJ45', 'n1BNcx3']) - const { dataString, result } = await encodeFull(action) - assert.strictEqual(result.encoding, 'OP_RETURN') - assert.strictEqual(dataString, action.data) - assert.ok(dataString.includes(',')) - }) - - it('full addresses auto-select P2SH', async () => { - const action = actions.makeList([actions.ADDR_BTC, actions.ADDR_BTC_2]) - const { result } = await encodeFull(action) - assert.strictEqual(result.encoding, 'P2SH') - }) - }) - - describe('E2E-1.28: BATCH', () => { - it('semicolon-separated actions preserved', async () => { - const batch = actions.makeBatch([ - actions.makeSend('A', '1', actions.ADDR_BTC), - actions.makeDestroy('B', '2') - ]) - const { dataString } = await encodeFull(batch) - assert.strictEqual(dataString, batch.data) - assert.ok(dataString.includes(';')) - }) - }) - - describe('E2E-1.29: Large BATCH forces P2SH', () => { - it('5+ actions in batch exceeds OP_RETURN and auto-selects P2SH', async () => { - const batch = actions.makeBatch([ - actions.makeSend('A', '1', actions.ADDR_BTC), - actions.makeSend('B', '2', actions.ADDR_BTC), - actions.makeSend('C', '3', actions.ADDR_BTC), - actions.makeSend('D', '4', actions.ADDR_BTC), - actions.makeSend('E', '5', actions.ADDR_BTC) - ]) - const { result } = await encodeFull(batch) - assert.strictEqual(result.encoding, 'P2SH') - }) - }) - - describe('E2E-1.30: TICK by ID reference', () => { - it('caret prefix preserved', async () => { - const action = actions.makeSendByTickId('1234', '100', actions.ADDR_BTC) - const { dataString } = await encodeFull(action) - assert.strictEqual(dataString, action.data) - assert.ok(dataString.includes('^1234')) - }) - }) - - describe('Structural invariants across all ACTION types', () => { - const allActions = [ - { name: 'SEND v0', factory: () => actions.makeSend() }, - { name: 'SEND v1', factory: () => actions.makeMultiSendV1() }, - { name: 'SEND v2', factory: () => actions.makeMultiSendV2() }, - { name: 'SEND v3', factory: () => actions.makeMultiSendV3() }, - { name: 'ISSUE minimal', factory: () => actions.makeIssueMinimal() }, - { name: 'ISSUE full', factory: () => actions.makeIssueFull('TK') }, - { name: 'ISSUE v1', factory: () => actions.makeIssueEditDescription() }, - { name: 'MINT', factory: () => actions.makeMint() }, - { name: 'DESTROY', factory: () => actions.makeDestroy() }, - { name: 'CALLBACK', factory: () => actions.makeCallback() }, - { name: 'SLEEP', factory: () => actions.makeSleep() }, - { name: 'SWEEP', factory: () => actions.makeSweep() }, - { name: 'AIRDROP', factory: () => actions.makeAirdrop() }, - { name: 'DIVIDEND', factory: () => actions.makeDividend() }, - { name: 'ORDER', factory: () => actions.makeOrder() }, - { name: 'COINPAY', factory: () => actions.makeCoinpay() }, - { name: 'DISPENSER', factory: () => actions.makeDispenser() }, - { name: 'SWAP', factory: () => actions.makeSwap() }, - { name: 'BROADCAST', factory: () => actions.makeBroadcast() }, - { name: 'MESSAGE', factory: () => actions.makeMessage() }, - { name: 'FILE', factory: () => actions.makeFile() }, - { name: 'ADDRESS', factory: () => actions.makeAddress() }, - { name: 'LINK', factory: () => actions.makeLink() }, - { name: 'LIST', factory: () => actions.makeList() }, - { name: 'BATCH', factory: () => actions.makeBatch([actions.makeSend(), actions.makeMint()]) }, - { name: 'TICK by ID', factory: () => actions.makeSendByTickId() } - ] - - for (const { name, factory } of allActions) { - it(`${name}: produces valid Psbt with >= 1 input and >= 2 outputs`, async () => { - const action = factory() - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const utxo = stdUtxo() - - const result = await encoder.createTransaction( - [utxo], address, null, - action.data, action.rawData, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - assert.ok(result.psbt instanceof bitcoin.Psbt) - assert.ok(result.psbt.data.inputs.length >= 1, `${name}: should have >= 1 input`) - assert.ok(result.psbt.txOutputs.length >= 2, `${name}: should have >= 2 outputs`) - }) - } - }) }) diff --git a/test/e2e/action_pipeline_e2e.test/01_e2e_1_14_airdrop.test.js b/test/e2e/action_pipeline_e2e.test/01_e2e_1_14_airdrop.test.js new file mode 100644 index 0000000..084b7b1 --- /dev/null +++ b/test/e2e/action_pipeline_e2e.test/01_e2e_1_14_airdrop.test.js @@ -0,0 +1,134 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * E2E-1: Full ACTION-to-PSBT Pipeline + * + * Validates every supported ACTION type through the complete encoding pipeline: + * ACTION config → script compilation → prepareData() → obfuscation → PSBT. + * Each test verifies the returned PSBT is structurally valid and, for OP_RETURN + * payloads, that the deobfuscated data matches the original ACTION string. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const { + extractOpReturnPayload, + decompilePayload, + MAGIC_WORD +} = require('../../integration/helpers/deobfuscate') +const { + TXID_A, + makeUtxo, + makeEncoder, + getTestAddress +} = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') + +const NETWORK = 'dogecoin-regtest' + +function stdUtxo () { + return makeUtxo(NETWORK, TXID_A, 0, 100000000) +} + +/** + * Encode an ACTION and, if it fits OP_RETURN, verify full round-trip fidelity. + * For P2SH payloads, verify structural validity only. + */ +async function encodeFull (actionObj, opts = {}) { + const encoder = makeEncoder(opts.network || NETWORK) + const address = getTestAddress(opts.network || NETWORK) + const utxo = stdUtxo() + + const result = await encoder.createTransaction( + [utxo], address, null, + actionObj.data, actionObj.rawData, 10000, false, opts.encoding || null, address, + null, null, null, true, 0.00001 + ) + + assert.ok(result.psbt instanceof bitcoin.Psbt, 'result.psbt should be a Psbt instance') + assert.ok(['OP_RETURN', 'P2SH', 'P2WSH', 'MULTISIGN'].includes(result.encoding)) + + if (result.encoding === 'OP_RETURN') { + const payload = extractOpReturnPayload(result, TXID_A) + assert.strictEqual(payload.magic, MAGIC_WORD, 'magic word should be XCHN') + const decompiled = decompilePayload(payload.dataBuffer) + return { result, payload, decompiled, dataString: decompiled[0].toString('utf8') } + } + + return { result, payload: null, decompiled: null, dataString: null } +} +describe('E2E-1: Full ACTION-to-PSBT Pipeline', () => { + describe('E2E-1.14: AIRDROP', () => { + it('list index reference preserved', async () => { + const action = actions.makeAirdrop('JDOG', '100', '10') + const { dataString } = await encodeFull(action) + assert.strictEqual(dataString, action.data) + }) + }) + + describe('E2E-1.15: DIVIDEND', () => { + it('two-tick relationship preserved', async () => { + const action = actions.makeDividend('JDOG', 'BRRR', '1000') + const { dataString } = await encodeFull(action) + assert.strictEqual(dataString, action.data) + assert.ok(dataString.includes('JDOG')) + assert.ok(dataString.includes('BRRR')) + }) + }) + + describe('E2E-1.16: ORDER BUY', () => { + it('all DEX fields preserved', async () => { + const action = actions.makeOrder('BUY', 'JDOG', '100', 'BRRR', '50', '0') + const { dataString } = await encodeFull(action) + assert.strictEqual(dataString, action.data) + assert.ok(dataString.includes('BUY')) + }) + }) + + describe('E2E-1.17: ORDER SELL', () => { + it('SELL type preserved', async () => { + const action = actions.makeOrder('SELL', 'JDOG', '100', 'BRRR', '50', '100') + const { dataString } = await encodeFull(action) + assert.strictEqual(dataString, action.data) + assert.ok(dataString.includes('SELL')) + }) + }) + + describe('E2E-1.18: COINPAY', () => { + it('order match index preserved', async () => { + const action = actions.makeCoinpay('42') + const { dataString } = await encodeFull(action) + assert.strictEqual(dataString, action.data) + assert.ok(dataString.includes('42')) + }) + }) + + describe('E2E-1.19: DISPENSER', () => { + it('all dispenser params preserved', async () => { + const action = actions.makeDispenser('JDOG', '100', '10', '1', actions.ADDR_BTC) + const { dataString } = await encodeFull(action) + assert.strictEqual(dataString, action.data) + }) + }) +}) +describe('E2E-1: Full ACTION-to-PSBT Pipeline', () => { + describe('E2E-1.20: SWAP', () => { + it('cross-chain identifiers preserved', async () => { + const action = actions.makeSwap('JDOG', '100', 'LTC', 'LDOG', '200') + const { dataString } = await encodeFull(action) + assert.strictEqual(dataString, action.data) + assert.ok(dataString.includes('LTC')) + assert.ok(dataString.includes('LDOG')) + }) + }) +}) diff --git a/test/e2e/action_pipeline_e2e.test/02_e2e_1_21_broadcast.test.js b/test/e2e/action_pipeline_e2e.test/02_e2e_1_21_broadcast.test.js new file mode 100644 index 0000000..b59e0bc --- /dev/null +++ b/test/e2e/action_pipeline_e2e.test/02_e2e_1_21_broadcast.test.js @@ -0,0 +1,155 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * E2E-1: Full ACTION-to-PSBT Pipeline + * + * Validates every supported ACTION type through the complete encoding pipeline: + * ACTION config → script compilation → prepareData() → obfuscation → PSBT. + * Each test verifies the returned PSBT is structurally valid and, for OP_RETURN + * payloads, that the deobfuscated data matches the original ACTION string. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const { + extractOpReturnPayload, + decompilePayload, + MAGIC_WORD +} = require('../../integration/helpers/deobfuscate') +const { + TXID_A, + makeUtxo, + makeEncoder, + getTestAddress +} = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') + +const NETWORK = 'dogecoin-regtest' + +function stdUtxo () { + return makeUtxo(NETWORK, TXID_A, 0, 100000000) +} + +/** + * Encode an ACTION and, if it fits OP_RETURN, verify full round-trip fidelity. + * For P2SH payloads, verify structural validity only. + */ +async function encodeFull (actionObj, opts = {}) { + const encoder = makeEncoder(opts.network || NETWORK) + const address = getTestAddress(opts.network || NETWORK) + const utxo = stdUtxo() + + const result = await encoder.createTransaction( + [utxo], address, null, + actionObj.data, actionObj.rawData, 10000, false, opts.encoding || null, address, + null, null, null, true, 0.00001 + ) + + assert.ok(result.psbt instanceof bitcoin.Psbt, 'result.psbt should be a Psbt instance') + assert.ok(['OP_RETURN', 'P2SH', 'P2WSH', 'MULTISIGN'].includes(result.encoding)) + + if (result.encoding === 'OP_RETURN') { + const payload = extractOpReturnPayload(result, TXID_A) + assert.strictEqual(payload.magic, MAGIC_WORD, 'magic word should be XCHN') + const decompiled = decompilePayload(payload.dataBuffer) + return { result, payload, decompiled, dataString: decompiled[0].toString('utf8') } + } + + return { result, payload: null, decompiled: null, dataString: null } +} +describe('E2E-1: Full ACTION-to-PSBT Pipeline', () => { + describe('E2E-1.21: BROADCAST', () => { + it('message text preserved', async () => { + const action = actions.makeBroadcast('Hello XChain World') + const { dataString } = await encodeFull(action) + assert.strictEqual(dataString, action.data) + }) + }) + + describe('E2E-1.22: MESSAGE', () => { + it('address + text preserved', async () => { + const action = actions.makeMessage(actions.ADDR_BTC, 'Hello') + const { dataString } = await encodeFull(action) + assert.strictEqual(dataString, action.data) + }) + }) + + describe('E2E-1.23: FILE small (P2SH)', () => { + it('file metadata + content encoded as P2SH', async () => { + const action = actions.makeFile('test.json', 'application/json') + const { result } = await encodeFull(action) + assert.strictEqual(result.encoding, 'P2SH') + + // Verify P2SH output exists + const p2shOutput = result.psbt.txOutputs.find(o => { + if (o.value <= 0) return false + const d = bitcoin.script.decompile(o.script) + return d && d[0] === bitcoin.opcodes.OP_HASH160 + }) + assert.ok(p2shOutput, 'should have P2SH output') + }) + }) + + describe('E2E-1.24: FILE large (P2WSH)', () => { + it('large file encoded as P2WSH when forced', async () => { + const action = actions.makeFileLarge() + const { result } = await encodeFull(action, { encoding: 'P2WSH', network: 'bitcoin-regtest' }) + assert.strictEqual(result.encoding, 'P2WSH') + + // Verify P2WSH output: OP_0 <32-byte-hash> + const p2wshOutput = result.psbt.txOutputs.find(o => { + if (o.value <= 0) return false + const d = bitcoin.script.decompile(o.script) + return d && d[0] === bitcoin.opcodes.OP_0 && + Buffer.isBuffer(d[1]) && d[1].length === 32 + }) + assert.ok(p2wshOutput, 'should have P2WSH output') + }) + }) + + describe('E2E-1.25: ADDRESS', () => { + it('require-memo flag preserved', async () => { + const action = actions.makeAddress('1') + const { dataString } = await encodeFull(action) + assert.strictEqual(dataString, action.data) + }) + }) +}) +describe('E2E-1: Full ACTION-to-PSBT Pipeline', () => { + describe('E2E-1.26: LINK', () => { + it('action indices preserved', async () => { + const action = actions.makeLink('42', '99') + const { dataString } = await encodeFull(action) + assert.strictEqual(dataString, action.data) + assert.ok(dataString.includes('42')) + assert.ok(dataString.includes('99')) + }) + }) + + describe('E2E-1.27: LIST', () => { + it('comma-separated items encoded successfully', async () => { + // Short addresses to fit OP_RETURN + const action = actions.makeList(['mfWxJ45', 'n1BNcx3']) + const { dataString, result } = await encodeFull(action) + assert.strictEqual(result.encoding, 'OP_RETURN') + assert.strictEqual(dataString, action.data) + assert.ok(dataString.includes(',')) + }) + + it('full addresses auto-select P2SH', async () => { + const action = actions.makeList([actions.ADDR_BTC, actions.ADDR_BTC_2]) + const { result } = await encodeFull(action) + assert.strictEqual(result.encoding, 'P2SH') + }) + }) +}) diff --git a/test/e2e/action_pipeline_e2e.test/03_e2e_1_28_batch.test.js b/test/e2e/action_pipeline_e2e.test/03_e2e_1_28_batch.test.js new file mode 100644 index 0000000..774c4be --- /dev/null +++ b/test/e2e/action_pipeline_e2e.test/03_e2e_1_28_batch.test.js @@ -0,0 +1,105 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * E2E-1: Full ACTION-to-PSBT Pipeline + * + * Validates every supported ACTION type through the complete encoding pipeline: + * ACTION config → script compilation → prepareData() → obfuscation → PSBT. + * Each test verifies the returned PSBT is structurally valid and, for OP_RETURN + * payloads, that the deobfuscated data matches the original ACTION string. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const { + extractOpReturnPayload, + decompilePayload, + MAGIC_WORD +} = require('../../integration/helpers/deobfuscate') +const { + TXID_A, + makeUtxo, + makeEncoder, + getTestAddress +} = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') + +const NETWORK = 'dogecoin-regtest' + +function stdUtxo () { + return makeUtxo(NETWORK, TXID_A, 0, 100000000) +} + +/** + * Encode an ACTION and, if it fits OP_RETURN, verify full round-trip fidelity. + * For P2SH payloads, verify structural validity only. + */ +async function encodeFull (actionObj, opts = {}) { + const encoder = makeEncoder(opts.network || NETWORK) + const address = getTestAddress(opts.network || NETWORK) + const utxo = stdUtxo() + + const result = await encoder.createTransaction( + [utxo], address, null, + actionObj.data, actionObj.rawData, 10000, false, opts.encoding || null, address, + null, null, null, true, 0.00001 + ) + + assert.ok(result.psbt instanceof bitcoin.Psbt, 'result.psbt should be a Psbt instance') + assert.ok(['OP_RETURN', 'P2SH', 'P2WSH', 'MULTISIGN'].includes(result.encoding)) + + if (result.encoding === 'OP_RETURN') { + const payload = extractOpReturnPayload(result, TXID_A) + assert.strictEqual(payload.magic, MAGIC_WORD, 'magic word should be XCHN') + const decompiled = decompilePayload(payload.dataBuffer) + return { result, payload, decompiled, dataString: decompiled[0].toString('utf8') } + } + + return { result, payload: null, decompiled: null, dataString: null } +} +describe('E2E-1: Full ACTION-to-PSBT Pipeline', () => { + describe('E2E-1.28: BATCH', () => { + it('semicolon-separated actions preserved', async () => { + const batch = actions.makeBatch([ + actions.makeSend('A', '1', actions.ADDR_BTC), + actions.makeDestroy('B', '2') + ]) + const { dataString } = await encodeFull(batch) + assert.strictEqual(dataString, batch.data) + assert.ok(dataString.includes(';')) + }) + }) + + describe('E2E-1.29: Large BATCH forces P2SH', () => { + it('5+ actions in batch exceeds OP_RETURN and auto-selects P2SH', async () => { + const batch = actions.makeBatch([ + actions.makeSend('A', '1', actions.ADDR_BTC), + actions.makeSend('B', '2', actions.ADDR_BTC), + actions.makeSend('C', '3', actions.ADDR_BTC), + actions.makeSend('D', '4', actions.ADDR_BTC), + actions.makeSend('E', '5', actions.ADDR_BTC) + ]) + const { result } = await encodeFull(batch) + assert.strictEqual(result.encoding, 'P2SH') + }) + }) + + describe('E2E-1.30: TICK by ID reference', () => { + it('caret prefix preserved', async () => { + const action = actions.makeSendByTickId('1234', '100', actions.ADDR_BTC) + const { dataString } = await encodeFull(action) + assert.strictEqual(dataString, action.data) + assert.ok(dataString.includes('^1234')) + }) + }) +}) diff --git a/test/e2e/action_pipeline_e2e.test/04_structural_invariants_across_all_action_types.test.js b/test/e2e/action_pipeline_e2e.test/04_structural_invariants_across_all_action_types.test.js new file mode 100644 index 0000000..dabb5b9 --- /dev/null +++ b/test/e2e/action_pipeline_e2e.test/04_structural_invariants_across_all_action_types.test.js @@ -0,0 +1,120 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * E2E-1: Full ACTION-to-PSBT Pipeline + * + * Validates every supported ACTION type through the complete encoding pipeline: + * ACTION config → script compilation → prepareData() → obfuscation → PSBT. + * Each test verifies the returned PSBT is structurally valid and, for OP_RETURN + * payloads, that the deobfuscated data matches the original ACTION string. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const { + extractOpReturnPayload, + decompilePayload, + MAGIC_WORD +} = require('../../integration/helpers/deobfuscate') +const { + TXID_A, + makeUtxo, + makeEncoder, + getTestAddress +} = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') + +const NETWORK = 'dogecoin-regtest' + +function stdUtxo () { + return makeUtxo(NETWORK, TXID_A, 0, 100000000) +} + +/** + * Encode an ACTION and, if it fits OP_RETURN, verify full round-trip fidelity. + * For P2SH payloads, verify structural validity only. + */ +async function encodeFull (actionObj, opts = {}) { + const encoder = makeEncoder(opts.network || NETWORK) + const address = getTestAddress(opts.network || NETWORK) + const utxo = stdUtxo() + + const result = await encoder.createTransaction( + [utxo], address, null, + actionObj.data, actionObj.rawData, 10000, false, opts.encoding || null, address, + null, null, null, true, 0.00001 + ) + + assert.ok(result.psbt instanceof bitcoin.Psbt, 'result.psbt should be a Psbt instance') + assert.ok(['OP_RETURN', 'P2SH', 'P2WSH', 'MULTISIGN'].includes(result.encoding)) + + if (result.encoding === 'OP_RETURN') { + const payload = extractOpReturnPayload(result, TXID_A) + assert.strictEqual(payload.magic, MAGIC_WORD, 'magic word should be XCHN') + const decompiled = decompilePayload(payload.dataBuffer) + return { result, payload, decompiled, dataString: decompiled[0].toString('utf8') } + } + + return { result, payload: null, decompiled: null, dataString: null } +} +describe('E2E-1: Full ACTION-to-PSBT Pipeline', () => { + describe('Structural invariants across all ACTION types', () => { + const allActions = [ + { name: 'SEND v0', factory: () => actions.makeSend() }, + { name: 'SEND v1', factory: () => actions.makeMultiSendV1() }, + { name: 'SEND v2', factory: () => actions.makeMultiSendV2() }, + { name: 'SEND v3', factory: () => actions.makeMultiSendV3() }, + { name: 'ISSUE minimal', factory: () => actions.makeIssueMinimal() }, + { name: 'ISSUE full', factory: () => actions.makeIssueFull('TK') }, + { name: 'ISSUE v1', factory: () => actions.makeIssueEditDescription() }, + { name: 'MINT', factory: () => actions.makeMint() }, + { name: 'DESTROY', factory: () => actions.makeDestroy() }, + { name: 'CALLBACK', factory: () => actions.makeCallback() }, + { name: 'SLEEP', factory: () => actions.makeSleep() }, + { name: 'SWEEP', factory: () => actions.makeSweep() }, + { name: 'AIRDROP', factory: () => actions.makeAirdrop() }, + { name: 'DIVIDEND', factory: () => actions.makeDividend() }, + { name: 'ORDER', factory: () => actions.makeOrder() }, + { name: 'COINPAY', factory: () => actions.makeCoinpay() }, + { name: 'DISPENSER', factory: () => actions.makeDispenser() }, + { name: 'SWAP', factory: () => actions.makeSwap() }, + { name: 'BROADCAST', factory: () => actions.makeBroadcast() }, + { name: 'MESSAGE', factory: () => actions.makeMessage() }, + { name: 'FILE', factory: () => actions.makeFile() }, + { name: 'ADDRESS', factory: () => actions.makeAddress() }, + { name: 'LINK', factory: () => actions.makeLink() }, + { name: 'LIST', factory: () => actions.makeList() }, + { name: 'BATCH', factory: () => actions.makeBatch([actions.makeSend(), actions.makeMint()]) }, + { name: 'TICK by ID', factory: () => actions.makeSendByTickId() } + ] + + for (const { name, factory } of allActions) { + it(`${name}: produces valid Psbt with >= 1 input and >= 2 outputs`, async () => { + const action = factory() + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const utxo = stdUtxo() + + const result = await encoder.createTransaction( + [utxo], address, null, + action.data, action.rawData, 10000, false, null, address, + null, null, null, true, 0.00001 + ) + + assert.ok(result.psbt instanceof bitcoin.Psbt) + assert.ok(result.psbt.data.inputs.length >= 1, `${name}: should have >= 1 input`) + assert.ok(result.psbt.txOutputs.length >= 2, `${name}: should have >= 2 outputs`) + }) + } + }) +}) From 34d8504014c052e9549f32c8fe011b032342548b Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 08:17:43 -0700 Subject: [PATCH 123/148] test: split the UTXO fee suites into numbered parts --- test/e2e/utxo_fee_change_e2e.test.js | 355 ---------------- ..._e2e_5_2_multiple_utxos_aggregated.test.js | 62 +++ .../02_e2e_5_3_utxo_deduplication.test.js | 62 +++ ...e2e_5_4_unconfirmed_utxos_excluded.test.js | 62 +++ ...e2e_5_5_unconfirmed_utxos_included.test.js | 60 +++ .../05_e2e_5_6_utxo_tracker_fallback.test.js | 84 ++++ .../06_e2e_5_7_fee_floor_enforcement.test.js | 62 +++ .../07_e2e_5_8_fee_cap_enforcement.test.js | 86 ++++ ...08_e2e_5_9_no_change_address_error.test.js | 60 +++ .../09_e2e_5_10_legacy_utxo_handling.test.js | 68 +++ .../10_e2e_5_11_segwit_utxo_handling.test.js | 66 +++ .../11_e2e_5_12_rbf_sequence_flag.test.js | 74 ++++ ...custom_outputs_coexist_with_action.test.js | 71 ++++ ...4_explicit_fee_per_kb_bypasses_rpc.test.js | 62 +++ ..._null_fee_per_kb_triggers_rpc_call.test.js | 64 +++ test/integration/utxo_fee.test.js | 390 ------------------ .../01_d_2_multiple_utxos_needed.test.js | 72 ++++ ...2_d_3_duplicate_utxo_deduplication.test.js | 70 ++++ .../03_d_4_unconfirmed_filtering.test.js | 83 ++++ .../04_d_5_utxo_tracker_fallback.test.js | 89 ++++ .../05_d_6_no_utxos_available.test.js | 87 ++++ ..._7_fee_capped_by_max_fee_per_bytes.test.js | 89 ++++ .../07_d_8_dust_floor_on_fee.test.js | 67 +++ .../08_d_9_no_change_address.test.js | 65 +++ ...10_legacy_non_segwit_utxo_handling.test.js | 98 +++++ .../10_utxo_sorting_largest_first.test.js | 67 +++ .../11_replace_by_fee_sequence.test.js | 79 ++++ .../12_fee_source_selection.test.js | 88 ++++ 28 files changed, 1897 insertions(+), 745 deletions(-) create mode 100644 test/e2e/utxo_fee_change_e2e.test/01_e2e_5_2_multiple_utxos_aggregated.test.js create mode 100644 test/e2e/utxo_fee_change_e2e.test/02_e2e_5_3_utxo_deduplication.test.js create mode 100644 test/e2e/utxo_fee_change_e2e.test/03_e2e_5_4_unconfirmed_utxos_excluded.test.js create mode 100644 test/e2e/utxo_fee_change_e2e.test/04_e2e_5_5_unconfirmed_utxos_included.test.js create mode 100644 test/e2e/utxo_fee_change_e2e.test/05_e2e_5_6_utxo_tracker_fallback.test.js create mode 100644 test/e2e/utxo_fee_change_e2e.test/06_e2e_5_7_fee_floor_enforcement.test.js create mode 100644 test/e2e/utxo_fee_change_e2e.test/07_e2e_5_8_fee_cap_enforcement.test.js create mode 100644 test/e2e/utxo_fee_change_e2e.test/08_e2e_5_9_no_change_address_error.test.js create mode 100644 test/e2e/utxo_fee_change_e2e.test/09_e2e_5_10_legacy_utxo_handling.test.js create mode 100644 test/e2e/utxo_fee_change_e2e.test/10_e2e_5_11_segwit_utxo_handling.test.js create mode 100644 test/e2e/utxo_fee_change_e2e.test/11_e2e_5_12_rbf_sequence_flag.test.js create mode 100644 test/e2e/utxo_fee_change_e2e.test/12_e2e_5_13_custom_outputs_coexist_with_action.test.js create mode 100644 test/e2e/utxo_fee_change_e2e.test/13_e2e_5_14_explicit_fee_per_kb_bypasses_rpc.test.js create mode 100644 test/e2e/utxo_fee_change_e2e.test/14_e2e_5_15_null_fee_per_kb_triggers_rpc_call.test.js create mode 100644 test/integration/utxo_fee.test/01_d_2_multiple_utxos_needed.test.js create mode 100644 test/integration/utxo_fee.test/02_d_3_duplicate_utxo_deduplication.test.js create mode 100644 test/integration/utxo_fee.test/03_d_4_unconfirmed_filtering.test.js create mode 100644 test/integration/utxo_fee.test/04_d_5_utxo_tracker_fallback.test.js create mode 100644 test/integration/utxo_fee.test/05_d_6_no_utxos_available.test.js create mode 100644 test/integration/utxo_fee.test/06_d_7_fee_capped_by_max_fee_per_bytes.test.js create mode 100644 test/integration/utxo_fee.test/07_d_8_dust_floor_on_fee.test.js create mode 100644 test/integration/utxo_fee.test/08_d_9_no_change_address.test.js create mode 100644 test/integration/utxo_fee.test/09_d_10_legacy_non_segwit_utxo_handling.test.js create mode 100644 test/integration/utxo_fee.test/10_utxo_sorting_largest_first.test.js create mode 100644 test/integration/utxo_fee.test/11_replace_by_fee_sequence.test.js create mode 100644 test/integration/utxo_fee.test/12_fee_source_selection.test.js diff --git a/test/e2e/utxo_fee_change_e2e.test.js b/test/e2e/utxo_fee_change_e2e.test.js index 2787da0..05ebfb9 100644 --- a/test/e2e/utxo_fee_change_e2e.test.js +++ b/test/e2e/utxo_fee_change_e2e.test.js @@ -63,359 +63,4 @@ describe('E2E-5: UTXO, Fee, and Change Integration', () => { assert.strictEqual(change[0].value, 100000000 - 10000) }) }) - - describe('E2E-5.2: Multiple UTXOs aggregated', () => { - it('adds UTXOs largest-first until sufficient', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - const utxo1 = makeUtxo(NETWORK, TXID_A, 0, 1000) - const utxo2 = makeUtxo(NETWORK, TXID_B, 0, 1000) - const utxo3 = makeUtxo(NETWORK, TXID_C, 0, 1000) - - const result = await encoder.createTransaction( - [utxo1, utxo2, utxo3], address, null, - action.data, null, 2000, false, null, address, - null, null, null, true, 0.00001 - ) - - assert.ok(result.psbt.data.inputs.length >= 2) - }) - }) - - describe('E2E-5.3: UTXO deduplication', () => { - it('collapses duplicate (txid, vout) pairs', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - const dup1 = makeUtxo(NETWORK, TXID_A, 0, 50000000) - const dup2 = makeUtxo(NETWORK, TXID_A, 0, 50000000) - const unique = makeUtxo(NETWORK, TXID_B, 1, 30000000) - - const result = await encoder.createTransaction( - [dup1, dup2, unique], address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - assert.ok(result.psbt.data.inputs.length <= 2) - }) - }) - - describe('E2E-5.4: Unconfirmed UTXOs excluded', () => { - it('filters mempool UTXOs when unconfirmed=false', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - const confirmed = makeUtxo(NETWORK, TXID_A, 0, 100000000) - confirmed.confirmations = 6 - const mempool = makeMempoolUtxo(TXID_B, 0, 50000000) - - const result = await encoder.createTransaction( - [mempool, confirmed], address, null, - action.data, null, 10000, false, null, address, - null, null, null, false, 0.00001 // unconfirmed=false - ) - - assert.strictEqual(result.psbt.data.inputs.length, 1) - }) - }) - - describe('E2E-5.5: Unconfirmed UTXOs included', () => { - it('uses mempool UTXOs when unconfirmed=true', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - const mempool = makeMempoolUtxo(TXID_A, 0, 100000000) - - const result = await encoder.createTransaction( - [mempool], address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(result.psbt.data.inputs.length, 1) - }) - }) - - describe('E2E-5.6: UtxoTracker fallback', () => { - it('calls UtxoTracker when utxos is null', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - let trackerCalled = false - encoder.utxoTrackerConnector.getUtxosFromAddress = async () => { - trackerCalled = true - return { utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)] } - } - - await encoder.createTransaction( - null, address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(trackerCalled, true) - }) - - it('calls UtxoTracker when utxos is empty array', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - let trackerCalled = false - encoder.utxoTrackerConnector.getUtxosFromAddress = async () => { - trackerCalled = true - return { utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)] } - } - - await encoder.createTransaction( - [], address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(trackerCalled, true) - }) - }) - - describe('E2E-5.7: Fee floor enforcement', () => { - it('floors fee to dustAmount when computed fee is lower', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeSend() - - const result = await encoder.createTransaction( - [utxo], address, null, - action.data, null, null, false, null, address, - null, null, null, true, 0.0000001 // very low rate - ) - - const changeOutput = result.psbt.txOutputs.find(o => o.value > 0) - const impliedFee = 100000000 - changeOutput.value - assert.ok(impliedFee >= encoder.dustAmount, - `fee ${impliedFee} should be >= dustAmount ${encoder.dustAmount}`) - }) - }) - - describe('E2E-5.8: Fee cap enforcement', () => { - it('caps fee when maxFeeRateKb is set', async () => { - const capped = new XChainEncoder( - NETWORK, '127.0.0.1', '8333', 'rpc', 'rpc', '', '', 1000 - ) - capped.connector = { - getFeePerKilobyte: async () => 0.00001, - getTransactionHex: async () => buildRawTxHex(100000000, NETWORK), - isRegtest: async () => true - } - capped.utxoTrackerConnector = { - getUtxosFromAddress: async () => ({ utxos: [] }) - } - - const address = getTestAddress(NETWORK) - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeSend() - - // feePerKb is in base units (sat/litoshi/koinu) per kB. 1_000_000 - // sat/kB is far above the capped encoder's 1000 sat/kB absolute cap, - // so the capped run must clamp its rate while the uncapped run does - // not, leaving the capped transaction with a smaller fee (more change). - const HIGH_FEE_PER_KB = 1000000 - - const resultCapped = await capped.createTransaction( - [utxo], address, null, - action.data, null, null, false, null, address, - null, null, null, true, HIGH_FEE_PER_KB - ) - - const uncapped = makeEncoder(NETWORK) - const resultUncapped = await uncapped.createTransaction( - [utxo], address, null, - action.data, null, null, false, null, address, - null, null, null, true, HIGH_FEE_PER_KB - ) - - const cappedChange = resultCapped.psbt.txOutputs.find(o => o.value > 0) - const uncappedChange = resultUncapped.psbt.txOutputs.find(o => o.value > 0) - assert.ok(cappedChange.value > uncappedChange.value, - 'capped fee should leave more change') - }) - }) - - describe('E2E-5.9: No change address error', () => { - it('throws when change address missing and surplus > dust', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeSend() - - await assert.rejects( - () => encoder.createTransaction( - [utxo], address, null, - action.data, null, 10000, false, null, null, // no change address - null, null, null, true, 0.00001 - ), - /change address/i - ) - }) - }) - - describe('E2E-5.10: Legacy UTXO handling', () => { - it('P2PKH UTXO triggers getTransactionHex and uses nonWitnessUtxo', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - let getHexCalled = false - const rawHex = buildRawTxHex(100000000, NETWORK) - encoder.connector.getTransactionHex = async () => { - getHexCalled = true - return rawHex - } - - const utxo = makeLegacyUtxo(TXID_A, 0, 100000000) - - const result = await encoder.createTransaction( - [utxo], address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(getHexCalled, true) - assert.ok(result.psbt.data.inputs[0].nonWitnessUtxo) - }) - }) - - describe('E2E-5.11: SegWit UTXO handling', () => { - it('P2WPKH UTXO uses witnessUtxo, no raw tx fetch', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - let getHexCalled = false - encoder.connector.getTransactionHex = async () => { - getHexCalled = true - return buildRawTxHex(100000000, NETWORK) - } - - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - - await encoder.createTransaction( - [utxo], address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(getHexCalled, false) - }) - }) - - describe('E2E-5.12: RBF sequence flag', () => { - it('rbf=true sets sequence 0xfffffffd (RBF armed, BIP68 disabled)', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeSend() - - const result = await encoder.createTransaction( - [utxo], address, null, - action.data, null, 10000, true, null, address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(result.psbt.txInputs[0].sequence, 0xfffffffd) - }) - - it('rbf=false sets sequence 0xffffffff', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeSend() - - const result = await encoder.createTransaction( - [utxo], address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(result.psbt.txInputs[0].sequence, 0xffffffff) - }) - }) - - describe('E2E-5.13: Custom outputs coexist with ACTION', () => { - it('custom output value deducted from change', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeSend() - const fee = 10000 - - const resultNone = await encoder.createTransaction( - [utxo], address, null, - action.data, null, fee, false, null, address, - null, null, null, true, 0.00001 - ) - - // Same input on purpose: this compares two builds of one spend, so release - // the first build's reservation rather than double-spend it. - encoder.clearReservations() - const resultCustom = await encoder.createTransaction( - [utxo], address, [{ address, value: '500000' }], - action.data, null, fee, false, null, address, - null, null, null, true, 0.00001 - ) - - const changeNone = resultNone.psbt.txOutputs.reduce((m, o) => o.value > m ? o.value : m, 0) - const changeCustom = resultCustom.psbt.txOutputs.reduce((m, o) => o.value > m ? o.value : m, 0) - assert.strictEqual(changeNone - changeCustom, 500000) - }) - }) - - describe('E2E-5.14: Explicit feePerKb bypasses RPC', () => { - it('does not call getFeePerKilobyte when feePerKb provided', async () => { - const encoder = makeEncoder(NETWORK) - encoder.connector.getFeePerKilobyte = async () => { - throw new Error('should not be called') - } - - const address = getTestAddress(NETWORK) - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeSend() - - const result = await encoder.createTransaction( - [utxo], address, null, - action.data, null, null, false, null, address, - null, null, null, true, 0.00001 - ) - assert.ok(result.psbt) - }) - }) - - describe('E2E-5.15: Null feePerKb triggers RPC call', () => { - it('calls getFeePerKilobyte when feePerKb is null', async () => { - const encoder = makeEncoder(NETWORK) - let called = false - encoder.connector.getFeePerKilobyte = async () => { - called = true - return 0.00001 - } - - const address = getTestAddress(NETWORK) - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeSend() - - await encoder.createTransaction( - [utxo], address, null, - action.data, null, null, false, null, address, - null, null, null, true, null - ) - assert.strictEqual(called, true) - }) - }) }) diff --git a/test/e2e/utxo_fee_change_e2e.test/01_e2e_5_2_multiple_utxos_aggregated.test.js b/test/e2e/utxo_fee_change_e2e.test/01_e2e_5_2_multiple_utxos_aggregated.test.js new file mode 100644 index 0000000..364f91a --- /dev/null +++ b/test/e2e/utxo_fee_change_e2e.test/01_e2e_5_2_multiple_utxos_aggregated.test.js @@ -0,0 +1,62 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * E2E-5: UTXO, Fee, and Change Integration + * + * Validates PSBT input selection, fee calculation, and change outputs + * for realistic encoding scenarios. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') + +// BTC semantics: the change/fee assertions assume the supplied fee (10000) is +// honored. DOGE floors sub-100000 fees, which would skew these. The dust-floor +// tests below read encoder.dustAmount dynamically, so they hold on bitcoin too. +const NETWORK = 'bitcoin-regtest' + +describe('E2E-5: UTXO, Fee, and Change Integration', () => { + + describe('E2E-5.2: Multiple UTXOs aggregated', () => { + it('adds UTXOs largest-first until sufficient', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const action = actions.makeSend() + + const utxo1 = makeUtxo(NETWORK, TXID_A, 0, 1000) + const utxo2 = makeUtxo(NETWORK, TXID_B, 0, 1000) + const utxo3 = makeUtxo(NETWORK, TXID_C, 0, 1000) + + const result = await encoder.createTransaction( + [utxo1, utxo2, utxo3], address, null, + action.data, null, 2000, false, null, address, + null, null, null, true, 0.00001 + ) + + assert.ok(result.psbt.data.inputs.length >= 2) + }) + }) +}) diff --git a/test/e2e/utxo_fee_change_e2e.test/02_e2e_5_3_utxo_deduplication.test.js b/test/e2e/utxo_fee_change_e2e.test/02_e2e_5_3_utxo_deduplication.test.js new file mode 100644 index 0000000..b9c1c6b --- /dev/null +++ b/test/e2e/utxo_fee_change_e2e.test/02_e2e_5_3_utxo_deduplication.test.js @@ -0,0 +1,62 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * E2E-5: UTXO, Fee, and Change Integration + * + * Validates PSBT input selection, fee calculation, and change outputs + * for realistic encoding scenarios. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') + +// BTC semantics: the change/fee assertions assume the supplied fee (10000) is +// honored. DOGE floors sub-100000 fees, which would skew these. The dust-floor +// tests below read encoder.dustAmount dynamically, so they hold on bitcoin too. +const NETWORK = 'bitcoin-regtest' + +describe('E2E-5: UTXO, Fee, and Change Integration', () => { + + describe('E2E-5.3: UTXO deduplication', () => { + it('collapses duplicate (txid, vout) pairs', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const action = actions.makeSend() + + const dup1 = makeUtxo(NETWORK, TXID_A, 0, 50000000) + const dup2 = makeUtxo(NETWORK, TXID_A, 0, 50000000) + const unique = makeUtxo(NETWORK, TXID_B, 1, 30000000) + + const result = await encoder.createTransaction( + [dup1, dup2, unique], address, null, + action.data, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) + + assert.ok(result.psbt.data.inputs.length <= 2) + }) + }) +}) diff --git a/test/e2e/utxo_fee_change_e2e.test/03_e2e_5_4_unconfirmed_utxos_excluded.test.js b/test/e2e/utxo_fee_change_e2e.test/03_e2e_5_4_unconfirmed_utxos_excluded.test.js new file mode 100644 index 0000000..d171e34 --- /dev/null +++ b/test/e2e/utxo_fee_change_e2e.test/03_e2e_5_4_unconfirmed_utxos_excluded.test.js @@ -0,0 +1,62 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * E2E-5: UTXO, Fee, and Change Integration + * + * Validates PSBT input selection, fee calculation, and change outputs + * for realistic encoding scenarios. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') + +// BTC semantics: the change/fee assertions assume the supplied fee (10000) is +// honored. DOGE floors sub-100000 fees, which would skew these. The dust-floor +// tests below read encoder.dustAmount dynamically, so they hold on bitcoin too. +const NETWORK = 'bitcoin-regtest' + +describe('E2E-5: UTXO, Fee, and Change Integration', () => { + + describe('E2E-5.4: Unconfirmed UTXOs excluded', () => { + it('filters mempool UTXOs when unconfirmed=false', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const action = actions.makeSend() + + const confirmed = makeUtxo(NETWORK, TXID_A, 0, 100000000) + confirmed.confirmations = 6 + const mempool = makeMempoolUtxo(TXID_B, 0, 50000000) + + const result = await encoder.createTransaction( + [mempool, confirmed], address, null, + action.data, null, 10000, false, null, address, + null, null, null, false, 0.00001 // unconfirmed=false + ) + + assert.strictEqual(result.psbt.data.inputs.length, 1) + }) + }) +}) diff --git a/test/e2e/utxo_fee_change_e2e.test/04_e2e_5_5_unconfirmed_utxos_included.test.js b/test/e2e/utxo_fee_change_e2e.test/04_e2e_5_5_unconfirmed_utxos_included.test.js new file mode 100644 index 0000000..2b6000a --- /dev/null +++ b/test/e2e/utxo_fee_change_e2e.test/04_e2e_5_5_unconfirmed_utxos_included.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. + * + ********************************************************************** + * E2E-5: UTXO, Fee, and Change Integration + * + * Validates PSBT input selection, fee calculation, and change outputs + * for realistic encoding scenarios. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') + +// BTC semantics: the change/fee assertions assume the supplied fee (10000) is +// honored. DOGE floors sub-100000 fees, which would skew these. The dust-floor +// tests below read encoder.dustAmount dynamically, so they hold on bitcoin too. +const NETWORK = 'bitcoin-regtest' + +describe('E2E-5: UTXO, Fee, and Change Integration', () => { + + describe('E2E-5.5: Unconfirmed UTXOs included', () => { + it('uses mempool UTXOs when unconfirmed=true', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const action = actions.makeSend() + + const mempool = makeMempoolUtxo(TXID_A, 0, 100000000) + + const result = await encoder.createTransaction( + [mempool], address, null, + action.data, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(result.psbt.data.inputs.length, 1) + }) + }) +}) diff --git a/test/e2e/utxo_fee_change_e2e.test/05_e2e_5_6_utxo_tracker_fallback.test.js b/test/e2e/utxo_fee_change_e2e.test/05_e2e_5_6_utxo_tracker_fallback.test.js new file mode 100644 index 0000000..f0da8b6 --- /dev/null +++ b/test/e2e/utxo_fee_change_e2e.test/05_e2e_5_6_utxo_tracker_fallback.test.js @@ -0,0 +1,84 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * E2E-5: UTXO, Fee, and Change Integration + * + * Validates PSBT input selection, fee calculation, and change outputs + * for realistic encoding scenarios. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') + +// BTC semantics: the change/fee assertions assume the supplied fee (10000) is +// honored. DOGE floors sub-100000 fees, which would skew these. The dust-floor +// tests below read encoder.dustAmount dynamically, so they hold on bitcoin too. +const NETWORK = 'bitcoin-regtest' + +describe('E2E-5: UTXO, Fee, and Change Integration', () => { + + describe('E2E-5.6: UtxoTracker fallback', () => { + it('calls UtxoTracker when utxos is null', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const action = actions.makeSend() + + let trackerCalled = false + encoder.utxoTrackerConnector.getUtxosFromAddress = async () => { + trackerCalled = true + return { utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)] } + } + + await encoder.createTransaction( + null, address, null, + action.data, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(trackerCalled, true) + }) + + it('calls UtxoTracker when utxos is empty array', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const action = actions.makeSend() + + let trackerCalled = false + encoder.utxoTrackerConnector.getUtxosFromAddress = async () => { + trackerCalled = true + return { utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)] } + } + + await encoder.createTransaction( + [], address, null, + action.data, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(trackerCalled, true) + }) + }) +}) diff --git a/test/e2e/utxo_fee_change_e2e.test/06_e2e_5_7_fee_floor_enforcement.test.js b/test/e2e/utxo_fee_change_e2e.test/06_e2e_5_7_fee_floor_enforcement.test.js new file mode 100644 index 0000000..d868057 --- /dev/null +++ b/test/e2e/utxo_fee_change_e2e.test/06_e2e_5_7_fee_floor_enforcement.test.js @@ -0,0 +1,62 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * E2E-5: UTXO, Fee, and Change Integration + * + * Validates PSBT input selection, fee calculation, and change outputs + * for realistic encoding scenarios. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') + +// BTC semantics: the change/fee assertions assume the supplied fee (10000) is +// honored. DOGE floors sub-100000 fees, which would skew these. The dust-floor +// tests below read encoder.dustAmount dynamically, so they hold on bitcoin too. +const NETWORK = 'bitcoin-regtest' + +describe('E2E-5: UTXO, Fee, and Change Integration', () => { + + describe('E2E-5.7: Fee floor enforcement', () => { + it('floors fee to dustAmount when computed fee is lower', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) + const action = actions.makeSend() + + const result = await encoder.createTransaction( + [utxo], address, null, + action.data, null, null, false, null, address, + null, null, null, true, 0.0000001 // very low rate + ) + + const changeOutput = result.psbt.txOutputs.find(o => o.value > 0) + const impliedFee = 100000000 - changeOutput.value + assert.ok(impliedFee >= encoder.dustAmount, + `fee ${impliedFee} should be >= dustAmount ${encoder.dustAmount}`) + }) + }) +}) diff --git a/test/e2e/utxo_fee_change_e2e.test/07_e2e_5_8_fee_cap_enforcement.test.js b/test/e2e/utxo_fee_change_e2e.test/07_e2e_5_8_fee_cap_enforcement.test.js new file mode 100644 index 0000000..08882cb --- /dev/null +++ b/test/e2e/utxo_fee_change_e2e.test/07_e2e_5_8_fee_cap_enforcement.test.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. + * + ********************************************************************** + * E2E-5: UTXO, Fee, and Change Integration + * + * Validates PSBT input selection, fee calculation, and change outputs + * for realistic encoding scenarios. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') + +// BTC semantics: the change/fee assertions assume the supplied fee (10000) is +// honored. DOGE floors sub-100000 fees, which would skew these. The dust-floor +// tests below read encoder.dustAmount dynamically, so they hold on bitcoin too. +const NETWORK = 'bitcoin-regtest' + +describe('E2E-5: UTXO, Fee, and Change Integration', () => { + + describe('E2E-5.8: Fee cap enforcement', () => { + it('caps fee when maxFeeRateKb is set', async () => { + const capped = new XChainEncoder( + NETWORK, '127.0.0.1', '8333', 'rpc', 'rpc', '', '', 1000 + ) + capped.connector = { + getFeePerKilobyte: async () => 0.00001, + getTransactionHex: async () => buildRawTxHex(100000000, NETWORK), + isRegtest: async () => true + } + capped.utxoTrackerConnector = { + getUtxosFromAddress: async () => ({ utxos: [] }) + } + + const address = getTestAddress(NETWORK) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) + const action = actions.makeSend() + + // feePerKb is in base units (sat/litoshi/koinu) per kB. 1_000_000 + // sat/kB is far above the capped encoder's 1000 sat/kB absolute cap, + // so the capped run must clamp its rate while the uncapped run does + // not, leaving the capped transaction with a smaller fee (more change). + const HIGH_FEE_PER_KB = 1000000 + + const resultCapped = await capped.createTransaction( + [utxo], address, null, + action.data, null, null, false, null, address, + null, null, null, true, HIGH_FEE_PER_KB + ) + + const uncapped = makeEncoder(NETWORK) + const resultUncapped = await uncapped.createTransaction( + [utxo], address, null, + action.data, null, null, false, null, address, + null, null, null, true, HIGH_FEE_PER_KB + ) + + const cappedChange = resultCapped.psbt.txOutputs.find(o => o.value > 0) + const uncappedChange = resultUncapped.psbt.txOutputs.find(o => o.value > 0) + assert.ok(cappedChange.value > uncappedChange.value, + 'capped fee should leave more change') + }) + }) +}) diff --git a/test/e2e/utxo_fee_change_e2e.test/08_e2e_5_9_no_change_address_error.test.js b/test/e2e/utxo_fee_change_e2e.test/08_e2e_5_9_no_change_address_error.test.js new file mode 100644 index 0000000..70ba7e1 --- /dev/null +++ b/test/e2e/utxo_fee_change_e2e.test/08_e2e_5_9_no_change_address_error.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. + * + ********************************************************************** + * E2E-5: UTXO, Fee, and Change Integration + * + * Validates PSBT input selection, fee calculation, and change outputs + * for realistic encoding scenarios. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') + +// BTC semantics: the change/fee assertions assume the supplied fee (10000) is +// honored. DOGE floors sub-100000 fees, which would skew these. The dust-floor +// tests below read encoder.dustAmount dynamically, so they hold on bitcoin too. +const NETWORK = 'bitcoin-regtest' + +describe('E2E-5: UTXO, Fee, and Change Integration', () => { + + describe('E2E-5.9: No change address error', () => { + it('throws when change address missing and surplus > dust', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) + const action = actions.makeSend() + + await assert.rejects( + () => encoder.createTransaction( + [utxo], address, null, + action.data, null, 10000, false, null, null, // no change address + null, null, null, true, 0.00001 + ), + /change address/i + ) + }) + }) +}) diff --git a/test/e2e/utxo_fee_change_e2e.test/09_e2e_5_10_legacy_utxo_handling.test.js b/test/e2e/utxo_fee_change_e2e.test/09_e2e_5_10_legacy_utxo_handling.test.js new file mode 100644 index 0000000..009da99 --- /dev/null +++ b/test/e2e/utxo_fee_change_e2e.test/09_e2e_5_10_legacy_utxo_handling.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. + * + ********************************************************************** + * E2E-5: UTXO, Fee, and Change Integration + * + * Validates PSBT input selection, fee calculation, and change outputs + * for realistic encoding scenarios. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') + +// BTC semantics: the change/fee assertions assume the supplied fee (10000) is +// honored. DOGE floors sub-100000 fees, which would skew these. The dust-floor +// tests below read encoder.dustAmount dynamically, so they hold on bitcoin too. +const NETWORK = 'bitcoin-regtest' + +describe('E2E-5: UTXO, Fee, and Change Integration', () => { + + describe('E2E-5.10: Legacy UTXO handling', () => { + it('P2PKH UTXO triggers getTransactionHex and uses nonWitnessUtxo', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const action = actions.makeSend() + + let getHexCalled = false + const rawHex = buildRawTxHex(100000000, NETWORK) + encoder.connector.getTransactionHex = async () => { + getHexCalled = true + return rawHex + } + + const utxo = makeLegacyUtxo(TXID_A, 0, 100000000) + + const result = await encoder.createTransaction( + [utxo], address, null, + action.data, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(getHexCalled, true) + assert.ok(result.psbt.data.inputs[0].nonWitnessUtxo) + }) + }) +}) diff --git a/test/e2e/utxo_fee_change_e2e.test/10_e2e_5_11_segwit_utxo_handling.test.js b/test/e2e/utxo_fee_change_e2e.test/10_e2e_5_11_segwit_utxo_handling.test.js new file mode 100644 index 0000000..d4f37f8 --- /dev/null +++ b/test/e2e/utxo_fee_change_e2e.test/10_e2e_5_11_segwit_utxo_handling.test.js @@ -0,0 +1,66 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * E2E-5: UTXO, Fee, and Change Integration + * + * Validates PSBT input selection, fee calculation, and change outputs + * for realistic encoding scenarios. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') + +// BTC semantics: the change/fee assertions assume the supplied fee (10000) is +// honored. DOGE floors sub-100000 fees, which would skew these. The dust-floor +// tests below read encoder.dustAmount dynamically, so they hold on bitcoin too. +const NETWORK = 'bitcoin-regtest' + +describe('E2E-5: UTXO, Fee, and Change Integration', () => { + + describe('E2E-5.11: SegWit UTXO handling', () => { + it('P2WPKH UTXO uses witnessUtxo, no raw tx fetch', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const action = actions.makeSend() + + let getHexCalled = false + encoder.connector.getTransactionHex = async () => { + getHexCalled = true + return buildRawTxHex(100000000, NETWORK) + } + + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) + + await encoder.createTransaction( + [utxo], address, null, + action.data, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(getHexCalled, false) + }) + }) +}) diff --git a/test/e2e/utxo_fee_change_e2e.test/11_e2e_5_12_rbf_sequence_flag.test.js b/test/e2e/utxo_fee_change_e2e.test/11_e2e_5_12_rbf_sequence_flag.test.js new file mode 100644 index 0000000..2877fb0 --- /dev/null +++ b/test/e2e/utxo_fee_change_e2e.test/11_e2e_5_12_rbf_sequence_flag.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. + * + ********************************************************************** + * E2E-5: UTXO, Fee, and Change Integration + * + * Validates PSBT input selection, fee calculation, and change outputs + * for realistic encoding scenarios. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') + +// BTC semantics: the change/fee assertions assume the supplied fee (10000) is +// honored. DOGE floors sub-100000 fees, which would skew these. The dust-floor +// tests below read encoder.dustAmount dynamically, so they hold on bitcoin too. +const NETWORK = 'bitcoin-regtest' + +describe('E2E-5: UTXO, Fee, and Change Integration', () => { + + describe('E2E-5.12: RBF sequence flag', () => { + it('rbf=true sets sequence 0xfffffffd (RBF armed, BIP68 disabled)', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) + const action = actions.makeSend() + + const result = await encoder.createTransaction( + [utxo], address, null, + action.data, null, 10000, true, null, address, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(result.psbt.txInputs[0].sequence, 0xfffffffd) + }) + + it('rbf=false sets sequence 0xffffffff', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) + const action = actions.makeSend() + + const result = await encoder.createTransaction( + [utxo], address, null, + action.data, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(result.psbt.txInputs[0].sequence, 0xffffffff) + }) + }) +}) diff --git a/test/e2e/utxo_fee_change_e2e.test/12_e2e_5_13_custom_outputs_coexist_with_action.test.js b/test/e2e/utxo_fee_change_e2e.test/12_e2e_5_13_custom_outputs_coexist_with_action.test.js new file mode 100644 index 0000000..fee4b96 --- /dev/null +++ b/test/e2e/utxo_fee_change_e2e.test/12_e2e_5_13_custom_outputs_coexist_with_action.test.js @@ -0,0 +1,71 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * E2E-5: UTXO, Fee, and Change Integration + * + * Validates PSBT input selection, fee calculation, and change outputs + * for realistic encoding scenarios. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') + +// BTC semantics: the change/fee assertions assume the supplied fee (10000) is +// honored. DOGE floors sub-100000 fees, which would skew these. The dust-floor +// tests below read encoder.dustAmount dynamically, so they hold on bitcoin too. +const NETWORK = 'bitcoin-regtest' + +describe('E2E-5: UTXO, Fee, and Change Integration', () => { + + describe('E2E-5.13: Custom outputs coexist with ACTION', () => { + it('custom output value deducted from change', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) + const action = actions.makeSend() + const fee = 10000 + + const resultNone = await encoder.createTransaction( + [utxo], address, null, + action.data, null, fee, false, null, address, + null, null, null, true, 0.00001 + ) + + // Same input on purpose: this compares two builds of one spend, so release + // the first build's reservation rather than double-spend it. + encoder.clearReservations() + const resultCustom = await encoder.createTransaction( + [utxo], address, [{ address, value: '500000' }], + action.data, null, fee, false, null, address, + null, null, null, true, 0.00001 + ) + + const changeNone = resultNone.psbt.txOutputs.reduce((m, o) => o.value > m ? o.value : m, 0) + const changeCustom = resultCustom.psbt.txOutputs.reduce((m, o) => o.value > m ? o.value : m, 0) + assert.strictEqual(changeNone - changeCustom, 500000) + }) + }) +}) diff --git a/test/e2e/utxo_fee_change_e2e.test/13_e2e_5_14_explicit_fee_per_kb_bypasses_rpc.test.js b/test/e2e/utxo_fee_change_e2e.test/13_e2e_5_14_explicit_fee_per_kb_bypasses_rpc.test.js new file mode 100644 index 0000000..ee5a5c4 --- /dev/null +++ b/test/e2e/utxo_fee_change_e2e.test/13_e2e_5_14_explicit_fee_per_kb_bypasses_rpc.test.js @@ -0,0 +1,62 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * E2E-5: UTXO, Fee, and Change Integration + * + * Validates PSBT input selection, fee calculation, and change outputs + * for realistic encoding scenarios. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') + +// BTC semantics: the change/fee assertions assume the supplied fee (10000) is +// honored. DOGE floors sub-100000 fees, which would skew these. The dust-floor +// tests below read encoder.dustAmount dynamically, so they hold on bitcoin too. +const NETWORK = 'bitcoin-regtest' + +describe('E2E-5: UTXO, Fee, and Change Integration', () => { + + describe('E2E-5.14: Explicit feePerKb bypasses RPC', () => { + it('does not call getFeePerKilobyte when feePerKb provided', async () => { + const encoder = makeEncoder(NETWORK) + encoder.connector.getFeePerKilobyte = async () => { + throw new Error('should not be called') + } + + const address = getTestAddress(NETWORK) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) + const action = actions.makeSend() + + const result = await encoder.createTransaction( + [utxo], address, null, + action.data, null, null, false, null, address, + null, null, null, true, 0.00001 + ) + assert.ok(result.psbt) + }) + }) +}) diff --git a/test/e2e/utxo_fee_change_e2e.test/14_e2e_5_15_null_fee_per_kb_triggers_rpc_call.test.js b/test/e2e/utxo_fee_change_e2e.test/14_e2e_5_15_null_fee_per_kb_triggers_rpc_call.test.js new file mode 100644 index 0000000..9183f76 --- /dev/null +++ b/test/e2e/utxo_fee_change_e2e.test/14_e2e_5_15_null_fee_per_kb_triggers_rpc_call.test.js @@ -0,0 +1,64 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * E2E-5: UTXO, Fee, and Change Integration + * + * Validates PSBT input selection, fee calculation, and change outputs + * for realistic encoding scenarios. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') + +// BTC semantics: the change/fee assertions assume the supplied fee (10000) is +// honored. DOGE floors sub-100000 fees, which would skew these. The dust-floor +// tests below read encoder.dustAmount dynamically, so they hold on bitcoin too. +const NETWORK = 'bitcoin-regtest' + +describe('E2E-5: UTXO, Fee, and Change Integration', () => { + + describe('E2E-5.15: Null feePerKb triggers RPC call', () => { + it('calls getFeePerKilobyte when feePerKb is null', async () => { + const encoder = makeEncoder(NETWORK) + let called = false + encoder.connector.getFeePerKilobyte = async () => { + called = true + return 0.00001 + } + + const address = getTestAddress(NETWORK) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) + const action = actions.makeSend() + + await encoder.createTransaction( + [utxo], address, null, + action.data, null, null, false, null, address, + null, null, null, true, null + ) + assert.strictEqual(called, true) + }) + }) +}) diff --git a/test/integration/utxo_fee.test.js b/test/integration/utxo_fee.test.js index 5ba6de9..6708569 100644 --- a/test/integration/utxo_fee.test.js +++ b/test/integration/utxo_fee.test.js @@ -69,394 +69,4 @@ describe('Category D: UTXO & Fee Integration', () => { assert.strictEqual(change[0].value, 100000000 - 10000) }) }) - - describe('D-2: Multiple UTXOs needed', () => { - it('adds UTXOs until inputs cover outputs + fee', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - // Three small UTXOs: 1000 + 1000 + 1000 = 3000 sats - // With fee of 2000, first UTXO (sorted largest=1000) won't cover it, - // so encoder must add more - const utxo1 = makeUtxo(NETWORK, TXID_A, 0, 1000) - const utxo2 = makeUtxo(NETWORK, TXID_B, 0, 1000) - const utxo3 = makeUtxo(NETWORK, TXID_C, 0, 1000) - - const result = await encoder.createTransaction( - [utxo1, utxo2, utxo3], address, null, - action.data, null, 2000, false, null, address, - null, null, null, true, 0.00001 - ) - - // Should need multiple inputs to cover the fee - assert.ok(result.psbt.data.inputs.length >= 2, - `expected >= 2 inputs, got ${result.psbt.data.inputs.length}`) - }) - }) - - describe('D-3: Duplicate UTXO deduplication', () => { - it('removes duplicate UTXOs with same txid+vout', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - const dup1 = makeUtxo(NETWORK, TXID_A, 0, 50000000) - const dup2 = makeUtxo(NETWORK, TXID_A, 0, 50000000) - const dup3 = makeUtxo(NETWORK, TXID_A, 0, 50000000) - const unique = makeUtxo(NETWORK, TXID_B, 1, 30000000) - - const result = await encoder.createTransaction( - [dup1, dup2, dup3, unique], address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - // Should have at most 2 unique UTXOs as inputs - assert.ok(result.psbt.data.inputs.length <= 2, - `expected <= 2 inputs after dedup, got ${result.psbt.data.inputs.length}`) - }) - }) - - describe('D-4: Unconfirmed filtering with unconfirmed=false', () => { - it('excludes mempool UTXOs when unconfirmed=false', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - const confirmed = makeUtxo(NETWORK, TXID_A, 0, 100000000) - confirmed.confirmations = 6 - const mempool = makeMempoolUtxo(TXID_B, 0, 50000000) - - const result = await encoder.createTransaction( - [mempool, confirmed], address, null, - action.data, null, 10000, false, null, address, - null, null, null, false, 0.00001 - ) - - assert.strictEqual(result.psbt.data.inputs.length, 1) - }) - - it('includes mempool UTXOs when unconfirmed=true', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - const mempool = makeMempoolUtxo(TXID_A, 0, 100000000) - - const result = await encoder.createTransaction( - [mempool], address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(result.psbt.data.inputs.length, 1) - }) - }) - - describe('D-5: UtxoTracker fallback', () => { - it('calls UtxoTracker when utxos param is null', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - let trackerCalled = false - encoder.utxoTrackerConnector.getUtxosFromAddress = async () => { - trackerCalled = true - return { utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)] } - } - - await encoder.createTransaction( - null, address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(trackerCalled, true) - }) - - it('calls UtxoTracker when utxos param is empty array', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - let trackerCalled = false - encoder.utxoTrackerConnector.getUtxosFromAddress = async () => { - trackerCalled = true - return { utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)] } - } - - await encoder.createTransaction( - [], address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(trackerCalled, true) - }) - }) - - describe('D-6: No UTXOs available', () => { - it('throws when utxos empty and tracker returns empty', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ - utxos: [] - }) - - await assert.rejects( - () => encoder.createTransaction( - [], address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ), - /no utxos/i - ) - }) - - it('throws when utxos null and tracker returns null', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ - utxos: null - }) - - await assert.rejects( - () => encoder.createTransaction( - null, address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ), - /no utxos/i - ) - }) - }) - - describe('D-7: Fee capped by maxFeePerBytes', () => { - it('limits fee when maxFeeRateKb is set', async () => { - // Create encoder WITH fee cap - const capped = new XChainEncoder( - NETWORK, '127.0.0.1', '8333', 'rpc', 'rpc', '', '', 1000 // 1000 sat/kB cap - ) - capped.connector = { - getFeePerKilobyte: async () => 0.00001, - getTransactionHex: async () => ({ hex: buildRawTxHex(100000000, NETWORK) }), - isRegtest: async () => true - } - capped.utxoTrackerConnector = { - getUtxosFromAddress: async () => makeTrackerEnvelope([makeUtxo(NETWORK, TXID_A, 0, 100000000)]) - } - - const address = getTestAddress(NETWORK) - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeSend() - - // Use a very high feePerKb that exceeds the cap - const result = await capped.createTransaction( - [utxo], address, null, - action.data, null, null, false, null, address, - null, null, null, true, 100000000 // very high: 1e8 sat/kB = 100000 sat/byte - ) - - // Create uncapped encoder for comparison - const uncapped = makeEncoder(NETWORK) - const resultUncapped = await uncapped.createTransaction( - [utxo], address, null, - action.data, null, null, false, null, address, - null, null, null, true, 100000000 - ) - - // Capped encoder should produce lower fee (more change) - const cappedChange = result.psbt.txOutputs.find(o => o.value > 0) - const uncappedChange = resultUncapped.psbt.txOutputs.find(o => o.value > 0) - assert.ok(cappedChange.value > uncappedChange.value, - 'capped fee should leave more change than uncapped') - }) - }) - - describe('D-8: Dust floor on fee', () => { - it('floors fee to dustAmount when computed fee is lower', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeSend() - - const result = await encoder.createTransaction( - [utxo], address, null, - action.data, null, null, false, null, address, - null, null, null, true, 0.0000001 // very low fee rate - ) - - const changeOutput = result.psbt.txOutputs.find(o => o.value > 0) - const impliedFee = 100000000 - changeOutput.value - assert.ok(impliedFee >= encoder.dustAmount, - `fee ${impliedFee} should be >= dustAmount ${encoder.dustAmount}`) - }) - }) - - describe('D-9: No change address throws when change > dust', () => { - it('throws error about burning satoshis', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeSend() - - await assert.rejects( - () => encoder.createTransaction( - [utxo], address, null, - action.data, null, 10000, false, null, null, // no change address - null, null, null, true, 0.00001 - ), - /change address/i - ) - }) - }) - - describe('D-10: Legacy (non-segwit) UTXO handling', () => { - it('fetches raw tx hex for nonWitnessUtxo via connector', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - let getHexCalled = false - const rawHex = buildRawTxHex(100000000, NETWORK) - encoder.connector.getTransactionHex = async () => { - getHexCalled = true - return rawHex - } - - const utxo = makeLegacyUtxo(TXID_A, 0, 100000000) - - const result = await encoder.createTransaction( - [utxo], address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(getHexCalled, true, - 'should call getTransactionHex for legacy UTXOs') - assert.ok(result.psbt.data.inputs[0].nonWitnessUtxo, - 'input should have nonWitnessUtxo') - }) - - it('segwit UTXOs do NOT call getTransactionHex', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - let getHexCalled = false - encoder.connector.getTransactionHex = async () => { - getHexCalled = true - return { hex: buildRawTxHex(100000000, NETWORK) } - } - - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - - await encoder.createTransaction( - [utxo], address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(getHexCalled, false, - 'should NOT call getTransactionHex for segwit UTXOs') - }) - }) - - describe('UTXO sorting (largest first)', () => { - it('uses largest UTXO first, needing fewer inputs', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const action = actions.makeSend() - - const small = makeUtxo(NETWORK, TXID_A, 0, 10000000) // 0.1 BTC - const large = makeUtxo(NETWORK, TXID_B, 0, 100000000) // 1 BTC - - const result = await encoder.createTransaction( - [small, large], address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - // The large UTXO alone covers everything, so only 1 input needed - assert.strictEqual(result.psbt.data.inputs.length, 1) - }) - }) - - describe('Replace-by-fee sequence', () => { - it('sets sequence to 0xfffffffd when rbf=true (RBF armed, BIP68 disabled)', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeSend() - - const result = await encoder.createTransaction( - [utxo], address, null, - action.data, null, 10000, true, null, address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(result.psbt.txInputs[0].sequence, 0xfffffffd) - }) - - it('sets sequence to 0xffffffff when rbf=false', async () => { - const encoder = makeEncoder(NETWORK) - const address = getTestAddress(NETWORK) - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeSend() - - const result = await encoder.createTransaction( - [utxo], address, null, - action.data, null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - - assert.strictEqual(result.psbt.txInputs[0].sequence, 0xffffffff) - }) - }) - - describe('Fee source selection', () => { - it('uses feePerKb parameter without RPC call', async () => { - const encoder = makeEncoder(NETWORK) - encoder.connector.getFeePerKilobyte = async () => { - throw new Error('should not be called') - } - - const address = getTestAddress(NETWORK) - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeSend() - - // Should not throw because feePerKb is provided - const result = await encoder.createTransaction( - [utxo], address, null, - action.data, null, null, false, null, address, - null, null, null, true, 0.00001 - ) - assert.ok(result.psbt) - }) - - it('calls connector.getFeePerKilobyte when feePerKb is null', async () => { - const encoder = makeEncoder(NETWORK) - let called = false - encoder.connector.getFeePerKilobyte = async () => { - called = true - return 0.00001 - } - - const address = getTestAddress(NETWORK) - const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) - const action = actions.makeSend() - - await encoder.createTransaction( - [utxo], address, null, - action.data, null, null, false, null, address, - null, null, null, true, null - ) - assert.strictEqual(called, true) - }) - }) }) diff --git a/test/integration/utxo_fee.test/01_d_2_multiple_utxos_needed.test.js b/test/integration/utxo_fee.test/01_d_2_multiple_utxos_needed.test.js new file mode 100644 index 0000000..49484c8 --- /dev/null +++ b/test/integration/utxo_fee.test/01_d_2_multiple_utxos_needed.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. + * + ********************************************************************** + * Category D: UTXO & Fee Integration + * + * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, + * dust floors, and change output logic with realistic ACTION payloads. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + PUBKEY_BUF, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeTrackerEnvelope, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../helpers/utxoFactory') +const actions = require('../helpers/actionFactory') + +// These integration cases encode BITCOIN fee semantics (explicit fees honored +// verbatim, sub-estimate fees, 546 dust). The network was mislabeled +// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the +// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the +// semantics these assertions actually test. (DOGE's high-dust floor is covered +// in test/boundary/fee_calculation_boundaries.test.js.) +const NETWORK = 'bitcoin-regtest' + +describe('Category D: UTXO & Fee Integration', () => { + + describe('D-2: Multiple UTXOs needed', () => { + it('adds UTXOs until inputs cover outputs + fee', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const action = actions.makeSend() + + // Three small UTXOs: 1000 + 1000 + 1000 = 3000 sats + // With fee of 2000, first UTXO (sorted largest=1000) won't cover it, + // so encoder must add more + const utxo1 = makeUtxo(NETWORK, TXID_A, 0, 1000) + const utxo2 = makeUtxo(NETWORK, TXID_B, 0, 1000) + const utxo3 = makeUtxo(NETWORK, TXID_C, 0, 1000) + + const result = await encoder.createTransaction( + [utxo1, utxo2, utxo3], address, null, + action.data, null, 2000, false, null, address, + null, null, null, true, 0.00001 + ) + + // Should need multiple inputs to cover the fee + assert.ok(result.psbt.data.inputs.length >= 2, + `expected >= 2 inputs, got ${result.psbt.data.inputs.length}`) + }) + }) +}) diff --git a/test/integration/utxo_fee.test/02_d_3_duplicate_utxo_deduplication.test.js b/test/integration/utxo_fee.test/02_d_3_duplicate_utxo_deduplication.test.js new file mode 100644 index 0000000..171af17 --- /dev/null +++ b/test/integration/utxo_fee.test/02_d_3_duplicate_utxo_deduplication.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. + * + ********************************************************************** + * Category D: UTXO & Fee Integration + * + * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, + * dust floors, and change output logic with realistic ACTION payloads. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + PUBKEY_BUF, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeTrackerEnvelope, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../helpers/utxoFactory') +const actions = require('../helpers/actionFactory') + +// These integration cases encode BITCOIN fee semantics (explicit fees honored +// verbatim, sub-estimate fees, 546 dust). The network was mislabeled +// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the +// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the +// semantics these assertions actually test. (DOGE's high-dust floor is covered +// in test/boundary/fee_calculation_boundaries.test.js.) +const NETWORK = 'bitcoin-regtest' + +describe('Category D: UTXO & Fee Integration', () => { + + describe('D-3: Duplicate UTXO deduplication', () => { + it('removes duplicate UTXOs with same txid+vout', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const action = actions.makeSend() + + const dup1 = makeUtxo(NETWORK, TXID_A, 0, 50000000) + const dup2 = makeUtxo(NETWORK, TXID_A, 0, 50000000) + const dup3 = makeUtxo(NETWORK, TXID_A, 0, 50000000) + const unique = makeUtxo(NETWORK, TXID_B, 1, 30000000) + + const result = await encoder.createTransaction( + [dup1, dup2, dup3, unique], address, null, + action.data, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) + + // Should have at most 2 unique UTXOs as inputs + assert.ok(result.psbt.data.inputs.length <= 2, + `expected <= 2 inputs after dedup, got ${result.psbt.data.inputs.length}`) + }) + }) +}) diff --git a/test/integration/utxo_fee.test/03_d_4_unconfirmed_filtering.test.js b/test/integration/utxo_fee.test/03_d_4_unconfirmed_filtering.test.js new file mode 100644 index 0000000..15c0f81 --- /dev/null +++ b/test/integration/utxo_fee.test/03_d_4_unconfirmed_filtering.test.js @@ -0,0 +1,83 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Category D: UTXO & Fee Integration + * + * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, + * dust floors, and change output logic with realistic ACTION payloads. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + PUBKEY_BUF, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeTrackerEnvelope, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../helpers/utxoFactory') +const actions = require('../helpers/actionFactory') + +// These integration cases encode BITCOIN fee semantics (explicit fees honored +// verbatim, sub-estimate fees, 546 dust). The network was mislabeled +// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the +// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the +// semantics these assertions actually test. (DOGE's high-dust floor is covered +// in test/boundary/fee_calculation_boundaries.test.js.) +const NETWORK = 'bitcoin-regtest' + +describe('Category D: UTXO & Fee Integration', () => { + + describe('D-4: Unconfirmed filtering with unconfirmed=false', () => { + it('excludes mempool UTXOs when unconfirmed=false', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const action = actions.makeSend() + + const confirmed = makeUtxo(NETWORK, TXID_A, 0, 100000000) + confirmed.confirmations = 6 + const mempool = makeMempoolUtxo(TXID_B, 0, 50000000) + + const result = await encoder.createTransaction( + [mempool, confirmed], address, null, + action.data, null, 10000, false, null, address, + null, null, null, false, 0.00001 + ) + + assert.strictEqual(result.psbt.data.inputs.length, 1) + }) + + it('includes mempool UTXOs when unconfirmed=true', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const action = actions.makeSend() + + const mempool = makeMempoolUtxo(TXID_A, 0, 100000000) + + const result = await encoder.createTransaction( + [mempool], address, null, + action.data, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(result.psbt.data.inputs.length, 1) + }) + }) +}) diff --git a/test/integration/utxo_fee.test/04_d_5_utxo_tracker_fallback.test.js b/test/integration/utxo_fee.test/04_d_5_utxo_tracker_fallback.test.js new file mode 100644 index 0000000..788d4c3 --- /dev/null +++ b/test/integration/utxo_fee.test/04_d_5_utxo_tracker_fallback.test.js @@ -0,0 +1,89 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Category D: UTXO & Fee Integration + * + * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, + * dust floors, and change output logic with realistic ACTION payloads. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + PUBKEY_BUF, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeTrackerEnvelope, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../helpers/utxoFactory') +const actions = require('../helpers/actionFactory') + +// These integration cases encode BITCOIN fee semantics (explicit fees honored +// verbatim, sub-estimate fees, 546 dust). The network was mislabeled +// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the +// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the +// semantics these assertions actually test. (DOGE's high-dust floor is covered +// in test/boundary/fee_calculation_boundaries.test.js.) +const NETWORK = 'bitcoin-regtest' + +describe('Category D: UTXO & Fee Integration', () => { + + describe('D-5: UtxoTracker fallback', () => { + it('calls UtxoTracker when utxos param is null', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const action = actions.makeSend() + + let trackerCalled = false + encoder.utxoTrackerConnector.getUtxosFromAddress = async () => { + trackerCalled = true + return { utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)] } + } + + await encoder.createTransaction( + null, address, null, + action.data, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(trackerCalled, true) + }) + + it('calls UtxoTracker when utxos param is empty array', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const action = actions.makeSend() + + let trackerCalled = false + encoder.utxoTrackerConnector.getUtxosFromAddress = async () => { + trackerCalled = true + return { utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)] } + } + + await encoder.createTransaction( + [], address, null, + action.data, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(trackerCalled, true) + }) + }) +}) diff --git a/test/integration/utxo_fee.test/05_d_6_no_utxos_available.test.js b/test/integration/utxo_fee.test/05_d_6_no_utxos_available.test.js new file mode 100644 index 0000000..c5894ee --- /dev/null +++ b/test/integration/utxo_fee.test/05_d_6_no_utxos_available.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. + * + ********************************************************************** + * Category D: UTXO & Fee Integration + * + * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, + * dust floors, and change output logic with realistic ACTION payloads. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + PUBKEY_BUF, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeTrackerEnvelope, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../helpers/utxoFactory') +const actions = require('../helpers/actionFactory') + +// These integration cases encode BITCOIN fee semantics (explicit fees honored +// verbatim, sub-estimate fees, 546 dust). The network was mislabeled +// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the +// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the +// semantics these assertions actually test. (DOGE's high-dust floor is covered +// in test/boundary/fee_calculation_boundaries.test.js.) +const NETWORK = 'bitcoin-regtest' + +describe('Category D: UTXO & Fee Integration', () => { + + describe('D-6: No UTXOs available', () => { + it('throws when utxos empty and tracker returns empty', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const action = actions.makeSend() + + encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ + utxos: [] + }) + + await assert.rejects( + () => encoder.createTransaction( + [], address, null, + action.data, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ), + /no utxos/i + ) + }) + + it('throws when utxos null and tracker returns null', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const action = actions.makeSend() + + encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ + utxos: null + }) + + await assert.rejects( + () => encoder.createTransaction( + null, address, null, + action.data, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ), + /no utxos/i + ) + }) + }) +}) diff --git a/test/integration/utxo_fee.test/06_d_7_fee_capped_by_max_fee_per_bytes.test.js b/test/integration/utxo_fee.test/06_d_7_fee_capped_by_max_fee_per_bytes.test.js new file mode 100644 index 0000000..07d0558 --- /dev/null +++ b/test/integration/utxo_fee.test/06_d_7_fee_capped_by_max_fee_per_bytes.test.js @@ -0,0 +1,89 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Category D: UTXO & Fee Integration + * + * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, + * dust floors, and change output logic with realistic ACTION payloads. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + PUBKEY_BUF, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeTrackerEnvelope, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../helpers/utxoFactory') +const actions = require('../helpers/actionFactory') + +// These integration cases encode BITCOIN fee semantics (explicit fees honored +// verbatim, sub-estimate fees, 546 dust). The network was mislabeled +// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the +// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the +// semantics these assertions actually test. (DOGE's high-dust floor is covered +// in test/boundary/fee_calculation_boundaries.test.js.) +const NETWORK = 'bitcoin-regtest' + +describe('Category D: UTXO & Fee Integration', () => { + + describe('D-7: Fee capped by maxFeePerBytes', () => { + it('limits fee when maxFeeRateKb is set', async () => { + // Create encoder WITH fee cap + const capped = new XChainEncoder( + NETWORK, '127.0.0.1', '8333', 'rpc', 'rpc', '', '', 1000 // 1000 sat/kB cap + ) + capped.connector = { + getFeePerKilobyte: async () => 0.00001, + getTransactionHex: async () => ({ hex: buildRawTxHex(100000000, NETWORK) }), + isRegtest: async () => true + } + capped.utxoTrackerConnector = { + getUtxosFromAddress: async () => makeTrackerEnvelope([makeUtxo(NETWORK, TXID_A, 0, 100000000)]) + } + + const address = getTestAddress(NETWORK) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) + const action = actions.makeSend() + + // Use a very high feePerKb that exceeds the cap + const result = await capped.createTransaction( + [utxo], address, null, + action.data, null, null, false, null, address, + null, null, null, true, 100000000 // very high: 1e8 sat/kB = 100000 sat/byte + ) + + // Create uncapped encoder for comparison + const uncapped = makeEncoder(NETWORK) + const resultUncapped = await uncapped.createTransaction( + [utxo], address, null, + action.data, null, null, false, null, address, + null, null, null, true, 100000000 + ) + + // Capped encoder should produce lower fee (more change) + const cappedChange = result.psbt.txOutputs.find(o => o.value > 0) + const uncappedChange = resultUncapped.psbt.txOutputs.find(o => o.value > 0) + assert.ok(cappedChange.value > uncappedChange.value, + 'capped fee should leave more change than uncapped') + }) + }) +}) diff --git a/test/integration/utxo_fee.test/07_d_8_dust_floor_on_fee.test.js b/test/integration/utxo_fee.test/07_d_8_dust_floor_on_fee.test.js new file mode 100644 index 0000000..d86ce5e --- /dev/null +++ b/test/integration/utxo_fee.test/07_d_8_dust_floor_on_fee.test.js @@ -0,0 +1,67 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Category D: UTXO & Fee Integration + * + * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, + * dust floors, and change output logic with realistic ACTION payloads. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + PUBKEY_BUF, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeTrackerEnvelope, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../helpers/utxoFactory') +const actions = require('../helpers/actionFactory') + +// These integration cases encode BITCOIN fee semantics (explicit fees honored +// verbatim, sub-estimate fees, 546 dust). The network was mislabeled +// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the +// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the +// semantics these assertions actually test. (DOGE's high-dust floor is covered +// in test/boundary/fee_calculation_boundaries.test.js.) +const NETWORK = 'bitcoin-regtest' + +describe('Category D: UTXO & Fee Integration', () => { + + describe('D-8: Dust floor on fee', () => { + it('floors fee to dustAmount when computed fee is lower', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) + const action = actions.makeSend() + + const result = await encoder.createTransaction( + [utxo], address, null, + action.data, null, null, false, null, address, + null, null, null, true, 0.0000001 // very low fee rate + ) + + const changeOutput = result.psbt.txOutputs.find(o => o.value > 0) + const impliedFee = 100000000 - changeOutput.value + assert.ok(impliedFee >= encoder.dustAmount, + `fee ${impliedFee} should be >= dustAmount ${encoder.dustAmount}`) + }) + }) +}) diff --git a/test/integration/utxo_fee.test/08_d_9_no_change_address.test.js b/test/integration/utxo_fee.test/08_d_9_no_change_address.test.js new file mode 100644 index 0000000..dfb172b --- /dev/null +++ b/test/integration/utxo_fee.test/08_d_9_no_change_address.test.js @@ -0,0 +1,65 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Category D: UTXO & Fee Integration + * + * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, + * dust floors, and change output logic with realistic ACTION payloads. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + PUBKEY_BUF, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeTrackerEnvelope, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../helpers/utxoFactory') +const actions = require('../helpers/actionFactory') + +// These integration cases encode BITCOIN fee semantics (explicit fees honored +// verbatim, sub-estimate fees, 546 dust). The network was mislabeled +// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the +// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the +// semantics these assertions actually test. (DOGE's high-dust floor is covered +// in test/boundary/fee_calculation_boundaries.test.js.) +const NETWORK = 'bitcoin-regtest' + +describe('Category D: UTXO & Fee Integration', () => { + + describe('D-9: No change address throws when change > dust', () => { + it('throws error about burning satoshis', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) + const action = actions.makeSend() + + await assert.rejects( + () => encoder.createTransaction( + [utxo], address, null, + action.data, null, 10000, false, null, null, // no change address + null, null, null, true, 0.00001 + ), + /change address/i + ) + }) + }) +}) diff --git a/test/integration/utxo_fee.test/09_d_10_legacy_non_segwit_utxo_handling.test.js b/test/integration/utxo_fee.test/09_d_10_legacy_non_segwit_utxo_handling.test.js new file mode 100644 index 0000000..a06182f --- /dev/null +++ b/test/integration/utxo_fee.test/09_d_10_legacy_non_segwit_utxo_handling.test.js @@ -0,0 +1,98 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Category D: UTXO & Fee Integration + * + * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, + * dust floors, and change output logic with realistic ACTION payloads. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + PUBKEY_BUF, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeTrackerEnvelope, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../helpers/utxoFactory') +const actions = require('../helpers/actionFactory') + +// These integration cases encode BITCOIN fee semantics (explicit fees honored +// verbatim, sub-estimate fees, 546 dust). The network was mislabeled +// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the +// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the +// semantics these assertions actually test. (DOGE's high-dust floor is covered +// in test/boundary/fee_calculation_boundaries.test.js.) +const NETWORK = 'bitcoin-regtest' + +describe('Category D: UTXO & Fee Integration', () => { + + describe('D-10: Legacy (non-segwit) UTXO handling', () => { + it('fetches raw tx hex for nonWitnessUtxo via connector', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const action = actions.makeSend() + + let getHexCalled = false + const rawHex = buildRawTxHex(100000000, NETWORK) + encoder.connector.getTransactionHex = async () => { + getHexCalled = true + return rawHex + } + + const utxo = makeLegacyUtxo(TXID_A, 0, 100000000) + + const result = await encoder.createTransaction( + [utxo], address, null, + action.data, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(getHexCalled, true, + 'should call getTransactionHex for legacy UTXOs') + assert.ok(result.psbt.data.inputs[0].nonWitnessUtxo, + 'input should have nonWitnessUtxo') + }) + + it('segwit UTXOs do NOT call getTransactionHex', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const action = actions.makeSend() + + let getHexCalled = false + encoder.connector.getTransactionHex = async () => { + getHexCalled = true + return { hex: buildRawTxHex(100000000, NETWORK) } + } + + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) + + await encoder.createTransaction( + [utxo], address, null, + action.data, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(getHexCalled, false, + 'should NOT call getTransactionHex for segwit UTXOs') + }) + }) +}) diff --git a/test/integration/utxo_fee.test/10_utxo_sorting_largest_first.test.js b/test/integration/utxo_fee.test/10_utxo_sorting_largest_first.test.js new file mode 100644 index 0000000..6b2ed54 --- /dev/null +++ b/test/integration/utxo_fee.test/10_utxo_sorting_largest_first.test.js @@ -0,0 +1,67 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Category D: UTXO & Fee Integration + * + * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, + * dust floors, and change output logic with realistic ACTION payloads. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + PUBKEY_BUF, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeTrackerEnvelope, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../helpers/utxoFactory') +const actions = require('../helpers/actionFactory') + +// These integration cases encode BITCOIN fee semantics (explicit fees honored +// verbatim, sub-estimate fees, 546 dust). The network was mislabeled +// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the +// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the +// semantics these assertions actually test. (DOGE's high-dust floor is covered +// in test/boundary/fee_calculation_boundaries.test.js.) +const NETWORK = 'bitcoin-regtest' + +describe('Category D: UTXO & Fee Integration', () => { + + describe('UTXO sorting (largest first)', () => { + it('uses largest UTXO first, needing fewer inputs', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const action = actions.makeSend() + + const small = makeUtxo(NETWORK, TXID_A, 0, 10000000) // 0.1 BTC + const large = makeUtxo(NETWORK, TXID_B, 0, 100000000) // 1 BTC + + const result = await encoder.createTransaction( + [small, large], address, null, + action.data, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) + + // The large UTXO alone covers everything, so only 1 input needed + assert.strictEqual(result.psbt.data.inputs.length, 1) + }) + }) +}) diff --git a/test/integration/utxo_fee.test/11_replace_by_fee_sequence.test.js b/test/integration/utxo_fee.test/11_replace_by_fee_sequence.test.js new file mode 100644 index 0000000..afe9b8c --- /dev/null +++ b/test/integration/utxo_fee.test/11_replace_by_fee_sequence.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. + * + ********************************************************************** + * Category D: UTXO & Fee Integration + * + * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, + * dust floors, and change output logic with realistic ACTION payloads. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + PUBKEY_BUF, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeTrackerEnvelope, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../helpers/utxoFactory') +const actions = require('../helpers/actionFactory') + +// These integration cases encode BITCOIN fee semantics (explicit fees honored +// verbatim, sub-estimate fees, 546 dust). The network was mislabeled +// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the +// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the +// semantics these assertions actually test. (DOGE's high-dust floor is covered +// in test/boundary/fee_calculation_boundaries.test.js.) +const NETWORK = 'bitcoin-regtest' + +describe('Category D: UTXO & Fee Integration', () => { + + describe('Replace-by-fee sequence', () => { + it('sets sequence to 0xfffffffd when rbf=true (RBF armed, BIP68 disabled)', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) + const action = actions.makeSend() + + const result = await encoder.createTransaction( + [utxo], address, null, + action.data, null, 10000, true, null, address, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(result.psbt.txInputs[0].sequence, 0xfffffffd) + }) + + it('sets sequence to 0xffffffff when rbf=false', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) + const action = actions.makeSend() + + const result = await encoder.createTransaction( + [utxo], address, null, + action.data, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(result.psbt.txInputs[0].sequence, 0xffffffff) + }) + }) +}) diff --git a/test/integration/utxo_fee.test/12_fee_source_selection.test.js b/test/integration/utxo_fee.test/12_fee_source_selection.test.js new file mode 100644 index 0000000..9777764 --- /dev/null +++ b/test/integration/utxo_fee.test/12_fee_source_selection.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. + * + ********************************************************************** + * Category D: UTXO & Fee Integration + * + * Verifies UTXO deduplication, sorting, filtering, fee estimation, fee caps, + * dust floors, and change output logic with realistic ACTION payloads. + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') +const { + TXID_A, + TXID_B, + TXID_C, + PUBKEY_BUF, + makeUtxo, + makeLegacyUtxo, + makeMempoolUtxo, + makeTrackerEnvelope, + makeEncoder, + getTestAddress, + buildRawTxHex +} = require('../helpers/utxoFactory') +const actions = require('../helpers/actionFactory') + +// These integration cases encode BITCOIN fee semantics (explicit fees honored +// verbatim, sub-estimate fees, 546 dust). The network was mislabeled +// 'dogecoin-regtest' (dust 100000), so any fee below 100000 was floored and the +// "verbatim"/cap-comparison assertions failed. Bitcoin-regtest matches the +// semantics these assertions actually test. (DOGE's high-dust floor is covered +// in test/boundary/fee_calculation_boundaries.test.js.) +const NETWORK = 'bitcoin-regtest' + +describe('Category D: UTXO & Fee Integration', () => { + + describe('Fee source selection', () => { + it('uses feePerKb parameter without RPC call', async () => { + const encoder = makeEncoder(NETWORK) + encoder.connector.getFeePerKilobyte = async () => { + throw new Error('should not be called') + } + + const address = getTestAddress(NETWORK) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) + const action = actions.makeSend() + + // Should not throw because feePerKb is provided + const result = await encoder.createTransaction( + [utxo], address, null, + action.data, null, null, false, null, address, + null, null, null, true, 0.00001 + ) + assert.ok(result.psbt) + }) + + it('calls connector.getFeePerKilobyte when feePerKb is null', async () => { + const encoder = makeEncoder(NETWORK) + let called = false + encoder.connector.getFeePerKilobyte = async () => { + called = true + return 0.00001 + } + + const address = getTestAddress(NETWORK) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) + const action = actions.makeSend() + + await encoder.createTransaction( + [utxo], address, null, + action.data, null, null, false, null, address, + null, null, null, true, null + ) + assert.strictEqual(called, true) + }) + }) +}) From dbe0904c8abd88c6cbee67f0996d05a19a97e253 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 08:19:28 -0700 Subject: [PATCH 124/148] test: split validator and concurrency suites into numbered parts --- test/regression/reg_04_validator.test.js | 363 +----------------- .../01_validate_utxo_entry.test.js | 120 ++++++ .../02_validate_utxo_array.test.js | 66 ++++ .../03_validate_custom_outputs.test.js | 72 ++++ .../04_validate_p2sh_params.test.js | 75 ++++ .../05_validate_compressed_pub_key.test.js | 60 +++ .../06_validate_change.test.js | 47 +++ .../07_validate_all_integration.test.js | 121 ++++++ test/security/concurrency_gate.test.js | 126 +++--- .../01_resolve_limit.test.js | 47 +++ .../02_api_js_wiring.test.js | 53 +++ 11 files changed, 734 insertions(+), 416 deletions(-) create mode 100644 test/regression/reg_04_validator.test/01_validate_utxo_entry.test.js create mode 100644 test/regression/reg_04_validator.test/02_validate_utxo_array.test.js create mode 100644 test/regression/reg_04_validator.test/03_validate_custom_outputs.test.js create mode 100644 test/regression/reg_04_validator.test/04_validate_p2sh_params.test.js create mode 100644 test/regression/reg_04_validator.test/05_validate_compressed_pub_key.test.js create mode 100644 test/regression/reg_04_validator.test/06_validate_change.test.js create mode 100644 test/regression/reg_04_validator.test/07_validate_all_integration.test.js create mode 100644 test/security/concurrency_gate.test/01_resolve_limit.test.js create mode 100644 test/security/concurrency_gate.test/02_api_js_wiring.test.js diff --git a/test/regression/reg_04_validator.test.js b/test/regression/reg_04_validator.test.js index c5f1768..89206e5 100644 --- a/test/regression/reg_04_validator.test.js +++ b/test/regression/reg_04_validator.test.js @@ -28,34 +28,11 @@ const { validateFee, validateFeePerKb, validateDust, - validateUtxoArray, - validateUtxoEntry, - validateCustomOutputs, - validateP2shParams, - validateCompressedPubKey, - validateChange, - validateAll, MAX_DATA_BYTES, - MAX_UTXO_COUNT, - MAX_CUSTOM_OUTPUTS, MAX_FEE_SATOSHIS, VALID_ENCODINGS } = require('../../src/common/validator') -const VALID_TXID = 'a'.repeat(64) -const VALID_COMPRESSED_02 = '02' + 'a'.repeat(64) -const VALID_COMPRESSED_03 = '03' + 'b'.repeat(64) - -function makeValidUtxoEntry () { - return { - txid: VALID_TXID, - vout: 0, - value: 100000000, - scriptPubKey: '0014' + 'aa'.repeat(20), - confirmations: 6 - } -} - describe('REG-04: Validator Functions', function () { describe('REG-04.1: validatePubkey()', function () { @@ -88,7 +65,9 @@ describe('REG-04: Validator Functions', function () { assert.strictEqual(validatePubkey(key), key) }) }) +}) +describe('REG-04: Validator Functions', function () { describe('REG-04.2: validateDataParam()', function () { it('returns null for null', function () { assert.strictEqual(validateDataParam(null, 'data'), null) @@ -106,7 +85,9 @@ describe('REG-04: Validator Functions', function () { assert.throws(() => validateDataParam(true, 'data'), { name: 'TypeError' }) }) }) +}) +describe('REG-04: Validator Functions', function () { describe('REG-04.3: validateCombinedDataLength()', function () { it('does nothing for null data', function () { assert.doesNotThrow(() => validateCombinedDataLength(null, null)) @@ -135,7 +116,9 @@ describe('REG-04: Validator Functions', function () { assert.doesNotThrow(() => validateCombinedDataLength('x'.repeat(MAX_DATA_BYTES), null)) }) }) +}) +describe('REG-04: Validator Functions', function () { describe('REG-04.4: validateEncoding()', function () { it('returns null for null', function () { assert.strictEqual(validateEncoding(null), null) @@ -164,7 +147,9 @@ describe('REG-04: Validator Functions', function () { assert.throws(() => validateEncoding('p2sh'), { name: 'TypeError' }) }) }) +}) +describe('REG-04: Validator Functions', function () { describe('REG-04.5: validateFee()', function () { it('returns null for null', function () { assert.strictEqual(validateFee(null), null) @@ -202,7 +187,9 @@ describe('REG-04: Validator Functions', function () { assert.strictEqual(validateFee(MAX_FEE_SATOSHIS), MAX_FEE_SATOSHIS) }) }) +}) +describe('REG-04: Validator Functions', function () { describe('REG-04.6: validateFeePerKb()', function () { it('returns null for null', function () { assert.strictEqual(validateFeePerKb(null), null) @@ -232,7 +219,9 @@ describe('REG-04: Validator Functions', function () { assert.throws(() => validateFeePerKb(Infinity), { name: 'TypeError' }) }) }) +}) +describe('REG-04: Validator Functions', function () { describe('REG-04.7: validateDust()', function () { it('returns null for null', function () { assert.strictEqual(validateDust(null), null) @@ -258,332 +247,4 @@ describe('REG-04: Validator Functions', function () { assert.throws(() => validateDust(-1), { name: 'RangeError' }) }) }) - - describe('REG-04.8: validateUtxoEntry()', function () { - it('validates a complete valid entry and coerces value/vout', function () { - const entry = { - txid: VALID_TXID, - vout: '0', - value: '100000000', - scriptPubKey: '0014aabb' - } - const result = validateUtxoEntry(entry, 0) - assert.strictEqual(result.vout, 0) - assert.strictEqual(result.value, 100000000) - assert.strictEqual(result.confirmations, 0) // default - }) - - it('throws TypeError for non-object', function () { - assert.throws(() => validateUtxoEntry('string', 0), { name: 'TypeError' }) - }) - - it('throws TypeError for array', function () { - assert.throws(() => validateUtxoEntry([1, 2, 3], 0), { name: 'TypeError' }) - }) - - it('throws TypeError for txid that is not 64-char hex', function () { - assert.throws( - () => validateUtxoEntry({ txid: 'short', vout: 0, value: 100, scriptPubKey: 'aa' }, 0), - { name: 'TypeError' } - ) - }) - - it('throws TypeError for txid with non-hex chars', function () { - assert.throws( - () => validateUtxoEntry({ txid: 'g'.repeat(64), vout: 0, value: 100, scriptPubKey: 'aa' }, 0), - { name: 'TypeError' } - ) - }) - - it('throws TypeError for negative vout', function () { - assert.throws( - () => validateUtxoEntry({ txid: VALID_TXID, vout: -1, value: 100, scriptPubKey: 'aa' }, 0), - { name: 'TypeError' } - ) - }) - - it('throws RangeError for negative value', function () { - assert.throws( - () => validateUtxoEntry({ txid: VALID_TXID, vout: 0, value: -1, scriptPubKey: 'aa' }, 0), - { name: 'RangeError' } - ) - }) - - it('throws TypeError for missing scriptPubKey', function () { - assert.throws( - () => validateUtxoEntry({ txid: VALID_TXID, vout: 0, value: 100 }, 0), - { name: 'TypeError' } - ) - }) - - it('throws TypeError for empty scriptPubKey', function () { - assert.throws( - () => validateUtxoEntry({ txid: VALID_TXID, vout: 0, value: 100, scriptPubKey: '' }, 0), - { name: 'TypeError' } - ) - }) - - it('sets confirmations=0 when absent', function () { - const entry = makeValidUtxoEntry() - delete entry.confirmations - validateUtxoEntry(entry, 0) - assert.strictEqual(entry.confirmations, 0) - }) - - it('preserves existing confirmations', function () { - const entry = makeValidUtxoEntry() - entry.confirmations = 10 - validateUtxoEntry(entry, 0) - assert.strictEqual(entry.confirmations, 10) - }) - }) - - describe('REG-04.9: validateUtxoArray()', function () { - it('returns null for null input', function () { - assert.strictEqual(validateUtxoArray(null), null) - }) - - it('accepts valid array', function () { - const result = validateUtxoArray([makeValidUtxoEntry()]) - assert.ok(Array.isArray(result)) - assert.strictEqual(result.length, 1) - }) - - it('throws TypeError for non-array', function () { - assert.throws(() => validateUtxoArray('not-array'), { name: 'TypeError' }) - }) - - it('throws RangeError for array > MAX_UTXO_COUNT entries', function () { - const arr = Array.from({ length: MAX_UTXO_COUNT + 1 }, () => makeValidUtxoEntry()) - assert.throws(() => validateUtxoArray(arr), { name: 'RangeError' }) - }) - - it('accepts array at exactly MAX_UTXO_COUNT', function () { - const arr = Array.from({ length: MAX_UTXO_COUNT }, () => makeValidUtxoEntry()) - assert.doesNotThrow(() => validateUtxoArray(arr)) - }) - }) - - describe('REG-04.10: validateCustomOutputs()', function () { - it('returns null for null input', function () { - assert.strictEqual(validateCustomOutputs(null), null) - }) - - it('validates address and value fields', function () { - const outputs = [{ address: '1ABC', value: '10000' }] - const result = validateCustomOutputs(outputs) - assert.strictEqual(result[0].value, 10000) // coerced to int - }) - - it('throws TypeError for non-array', function () { - assert.throws(() => validateCustomOutputs('nope'), { name: 'TypeError' }) - }) - - it('throws RangeError for array > MAX_CUSTOM_OUTPUTS', function () { - const arr = Array.from({ length: MAX_CUSTOM_OUTPUTS + 1 }, () => ({ - address: '1ABC', value: 1000 - })) - assert.throws(() => validateCustomOutputs(arr), { name: 'RangeError' }) - }) - - it('throws RangeError for negative value', function () { - assert.throws( - () => validateCustomOutputs([{ address: '1ABC', value: -1 }]), - { name: 'RangeError' } - ) - }) - - it('throws TypeError for empty address string', function () { - assert.throws( - () => validateCustomOutputs([{ address: '', value: 1000 }]), - { name: 'TypeError' } - ) - }) - - it('throws TypeError for address > 100 chars', function () { - assert.throws( - () => validateCustomOutputs([{ address: 'x'.repeat(101), value: 1000 }]), - { name: 'TypeError' } - ) - }) - }) - - describe('REG-04.11: validateP2shParams()', function () { - it('returns nulls when both absent', function () { - const result = validateP2shParams(null, null) - assert.strictEqual(result.p2shHash, null) - assert.strictEqual(result.p2shHex, null) - }) - - it('returns nulls when both false', function () { - const result = validateP2shParams(false, false) - assert.strictEqual(result.p2shHash, null) - assert.strictEqual(result.p2shHex, null) - }) - - it('throws TypeError when only p2shHash is provided', function () { - assert.throws( - () => validateP2shParams(VALID_TXID, null), - { name: 'TypeError' } - ) - }) - - it('throws TypeError when only p2shHex is provided', function () { - assert.throws( - () => validateP2shParams(null, 'aabb'), - { name: 'TypeError' } - ) - }) - - it('throws TypeError for non-hex-64 p2shHash', function () { - assert.throws( - () => validateP2shParams('tooshort', 'aabb'), - { name: 'TypeError' } - ) - }) - - it('throws TypeError for empty p2shHex string', function () { - assert.throws( - () => validateP2shParams(VALID_TXID, ''), - { name: 'TypeError' } - ) - }) - - it('accepts valid pair', function () { - const result = validateP2shParams(VALID_TXID, 'aabbccdd') - assert.strictEqual(result.p2shHash, VALID_TXID) - assert.strictEqual(result.p2shHex, 'aabbccdd') - }) - }) - - describe('REG-04.12: validateCompressedPubKey()', function () { - it('returns null for null', function () { - assert.strictEqual(validateCompressedPubKey(null), null) - }) - - it('accepts 02-prefixed 66-char hex key', function () { - assert.strictEqual(validateCompressedPubKey(VALID_COMPRESSED_02), VALID_COMPRESSED_02) - }) - - it('accepts 03-prefixed 66-char hex key', function () { - assert.strictEqual(validateCompressedPubKey(VALID_COMPRESSED_03), VALID_COMPRESSED_03) - }) - - it('throws TypeError for 04-prefixed (uncompressed)', function () { - assert.throws( - () => validateCompressedPubKey('04' + 'a'.repeat(128)), - { name: 'TypeError' } - ) - }) - - it('throws TypeError for wrong length (too short)', function () { - assert.throws( - () => validateCompressedPubKey('02' + 'a'.repeat(10)), - { name: 'TypeError' } - ) - }) - - it('throws TypeError for non-string', function () { - assert.throws(() => validateCompressedPubKey(12345), { name: 'TypeError' }) - }) - }) - - describe('REG-04.13: validateChange()', function () { - it('returns null for null', function () { - assert.strictEqual(validateChange(null), null) - }) - - it('accepts non-empty string <= 100 chars', function () { - assert.strictEqual(validateChange('1JDogZS6tQcSxwfxhv6XKKjcyicYA4Feev'), '1JDogZS6tQcSxwfxhv6XKKjcyicYA4Feev') - }) - - it('throws TypeError for empty string', function () { - assert.throws(() => validateChange(''), { name: 'TypeError' }) - }) - - it('throws TypeError for string > 100 chars', function () { - assert.throws(() => validateChange('x'.repeat(101)), { name: 'TypeError' }) - }) - - it('throws TypeError for non-string', function () { - assert.throws(() => validateChange(42), { name: 'TypeError' }) - }) - }) - - describe('REG-04.14: validateAll() integration', function () { - it('returns sanitized object with all fields for valid input', function () { - const params = { - data: 'SEND|0|JDOG|1|addr', - rawData: null, - pubkey: '1JDogZS6tQcSxwfxhv6XKKjcyicYA4Feev', - encoding: 'OP_RETURN', - fee: '10000', - feePerKb: null, - dust: null, - utxos: [makeValidUtxoEntry()], - customOutputs: null, - p2shHash: null, - p2shHex: null, - compressedPubKey: null, - change: '1JDogZS6tQcSxwfxhv6XKKjcyicYA4Feev', - rbf: false, - unconfirmed: true - } - - const result = validateAll(params) - assert.strictEqual(result.data, 'SEND|0|JDOG|1|addr') - assert.strictEqual(result.encoding, 'OP_RETURN') - assert.strictEqual(result.fee, 10000) // coerced from string - assert.strictEqual(result.rbf, false) - assert.strictEqual(result.unconfirmed, true) - }) - - it('throws TypeError for null params', function () { - assert.throws(() => validateAll(null), { name: 'TypeError' }) - }) - - it('throws TypeError for string params', function () { - assert.throws(() => validateAll('not-an-object'), { name: 'TypeError' }) - }) - - it('throws a SHAPE error for positional array params, not a missing-field one', function () { - // typeof [] is 'object', so before the Array.isArray clause a JSON-RPC call - // with positional params cleared this gate and died on 'pubkey is required'. - assert.throws(() => validateAll([]), { - name: 'TypeError', - message: 'Request params must be an object' - }) - assert.throws(() => validateAll(['SEND|0|JDOG|1|addr']), { - name: 'TypeError', - message: 'Request params must be an object' - }) - }) - - it('throws RangeError for combined data > 65536 bytes', function () { - assert.throws( - () => validateAll({ data: 'x'.repeat(MAX_DATA_BYTES + 1) }), - { name: 'RangeError' } - ) - }) - - it('rejects non-boolean rbf and unconfirmed, passes real booleans through', function () { - // Hardened behavior: a non-boolean rbf/unconfirmed (e.g. the string - // "false", which is truthy) would silently flip RBF signaling or unconfirmed-UTXO - // selection on a money protocol, so validateAll rejects anything but a real JSON - // boolean instead of coercing; undefined/null still fall through to the defaults. - const base = { data: 'SEND|0|X|1|addr', pubkey: '1JDogZS6tQcSxwfxhv6XKKjcyicYA4Feev' } - assert.throws(() => validateAll({ ...base, rbf: 'truthy-string' }), { name: 'TypeError' }) - assert.throws(() => validateAll({ ...base, unconfirmed: 0 }), { name: 'TypeError' }) - const result = validateAll({ ...base, rbf: true, unconfirmed: false }) - assert.strictEqual(result.rbf, true) - assert.strictEqual(result.unconfirmed, false) - }) - - it('validates P2SH pair atomically (rejects partial pair)', function () { - assert.throws( - () => validateAll({ data: 'SEND|0|X|1|addr', p2shHash: VALID_TXID }), - { name: 'TypeError' } - ) - }) - }) }) diff --git a/test/regression/reg_04_validator.test/01_validate_utxo_entry.test.js b/test/regression/reg_04_validator.test/01_validate_utxo_entry.test.js new file mode 100644 index 0000000..f772cf9 --- /dev/null +++ b/test/regression/reg_04_validator.test/01_validate_utxo_entry.test.js @@ -0,0 +1,120 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * REG-04: Validator Functions + * + * Primary dedicated coverage for src/common/validator.js, filling the identified + * gap where no existing test file imports the validator directly. Tests each + * exported validate* function individually plus validateAll() as integration. + * Purely synchronous (no encoder instantiation needed). + */ + +const assert = require('assert') +const { validateUtxoEntry } = require('../../../src/common/validator') + +const VALID_TXID = 'a'.repeat(64) + +function makeValidUtxoEntry () { + return { + txid: VALID_TXID, + vout: 0, + value: 100000000, + scriptPubKey: '0014' + 'aa'.repeat(20), + confirmations: 6 + } +} + +describe('REG-04: Validator Functions', function () { + describe('REG-04.8: validateUtxoEntry()', function () { + it('validates a complete valid entry and coerces value/vout', function () { + const entry = { + txid: VALID_TXID, + vout: '0', + value: '100000000', + scriptPubKey: '0014aabb' + } + const result = validateUtxoEntry(entry, 0) + assert.strictEqual(result.vout, 0) + assert.strictEqual(result.value, 100000000) + assert.strictEqual(result.confirmations, 0) // default + }) + + it('throws TypeError for non-object', function () { + assert.throws(() => validateUtxoEntry('string', 0), { name: 'TypeError' }) + }) + + it('throws TypeError for array', function () { + assert.throws(() => validateUtxoEntry([1, 2, 3], 0), { name: 'TypeError' }) + }) + + it('throws TypeError for txid that is not 64-char hex', function () { + assert.throws( + () => validateUtxoEntry({ txid: 'short', vout: 0, value: 100, scriptPubKey: 'aa' }, 0), + { name: 'TypeError' } + ) + }) + + it('throws TypeError for txid with non-hex chars', function () { + assert.throws( + () => validateUtxoEntry({ txid: 'g'.repeat(64), vout: 0, value: 100, scriptPubKey: 'aa' }, 0), + { name: 'TypeError' } + ) + }) + + it('throws TypeError for negative vout', function () { + assert.throws( + () => validateUtxoEntry({ txid: VALID_TXID, vout: -1, value: 100, scriptPubKey: 'aa' }, 0), + { name: 'TypeError' } + ) + }) + }) +}) + +describe('REG-04: Validator Functions', function () { + describe('REG-04.8: validateUtxoEntry()', function () { + it('throws RangeError for negative value', function () { + assert.throws( + () => validateUtxoEntry({ txid: VALID_TXID, vout: 0, value: -1, scriptPubKey: 'aa' }, 0), + { name: 'RangeError' } + ) + }) + + it('throws TypeError for missing scriptPubKey', function () { + assert.throws( + () => validateUtxoEntry({ txid: VALID_TXID, vout: 0, value: 100 }, 0), + { name: 'TypeError' } + ) + }) + + it('throws TypeError for empty scriptPubKey', function () { + assert.throws( + () => validateUtxoEntry({ txid: VALID_TXID, vout: 0, value: 100, scriptPubKey: '' }, 0), + { name: 'TypeError' } + ) + }) + + it('sets confirmations=0 when absent', function () { + const entry = makeValidUtxoEntry() + delete entry.confirmations + validateUtxoEntry(entry, 0) + assert.strictEqual(entry.confirmations, 0) + }) + + it('preserves existing confirmations', function () { + const entry = makeValidUtxoEntry() + entry.confirmations = 10 + validateUtxoEntry(entry, 0) + assert.strictEqual(entry.confirmations, 10) + }) + }) +}) diff --git a/test/regression/reg_04_validator.test/02_validate_utxo_array.test.js b/test/regression/reg_04_validator.test/02_validate_utxo_array.test.js new file mode 100644 index 0000000..2007cd4 --- /dev/null +++ b/test/regression/reg_04_validator.test/02_validate_utxo_array.test.js @@ -0,0 +1,66 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * REG-04: Validator Functions + * + * Primary dedicated coverage for src/common/validator.js, filling the identified + * gap where no existing test file imports the validator directly. Tests each + * exported validate* function individually plus validateAll() as integration. + * Purely synchronous (no encoder instantiation needed). + */ + +const assert = require('assert') +const { + validateUtxoArray, + MAX_UTXO_COUNT +} = require('../../../src/common/validator') + +const VALID_TXID = 'a'.repeat(64) + +function makeValidUtxoEntry () { + return { + txid: VALID_TXID, + vout: 0, + value: 100000000, + scriptPubKey: '0014' + 'aa'.repeat(20), + confirmations: 6 + } +} + +describe('REG-04: Validator Functions', function () { + describe('REG-04.9: validateUtxoArray()', function () { + it('returns null for null input', function () { + assert.strictEqual(validateUtxoArray(null), null) + }) + + it('accepts valid array', function () { + const result = validateUtxoArray([makeValidUtxoEntry()]) + assert.ok(Array.isArray(result)) + assert.strictEqual(result.length, 1) + }) + + it('throws TypeError for non-array', function () { + assert.throws(() => validateUtxoArray('not-array'), { name: 'TypeError' }) + }) + + it('throws RangeError for array > MAX_UTXO_COUNT entries', function () { + const arr = Array.from({ length: MAX_UTXO_COUNT + 1 }, () => makeValidUtxoEntry()) + assert.throws(() => validateUtxoArray(arr), { name: 'RangeError' }) + }) + + it('accepts array at exactly MAX_UTXO_COUNT', function () { + const arr = Array.from({ length: MAX_UTXO_COUNT }, () => makeValidUtxoEntry()) + assert.doesNotThrow(() => validateUtxoArray(arr)) + }) + }) +}) diff --git a/test/regression/reg_04_validator.test/03_validate_custom_outputs.test.js b/test/regression/reg_04_validator.test/03_validate_custom_outputs.test.js new file mode 100644 index 0000000..3114a9d --- /dev/null +++ b/test/regression/reg_04_validator.test/03_validate_custom_outputs.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. + * + ********************************************************************** + * REG-04: Validator Functions + * + * Primary dedicated coverage for src/common/validator.js, filling the identified + * gap where no existing test file imports the validator directly. Tests each + * exported validate* function individually plus validateAll() as integration. + * Purely synchronous (no encoder instantiation needed). + */ + +const assert = require('assert') +const { + validateCustomOutputs, + MAX_CUSTOM_OUTPUTS +} = require('../../../src/common/validator') + +describe('REG-04: Validator Functions', function () { + describe('REG-04.10: validateCustomOutputs()', function () { + it('returns null for null input', function () { + assert.strictEqual(validateCustomOutputs(null), null) + }) + + it('validates address and value fields', function () { + const outputs = [{ address: '1ABC', value: '10000' }] + const result = validateCustomOutputs(outputs) + assert.strictEqual(result[0].value, 10000) // coerced to int + }) + + it('throws TypeError for non-array', function () { + assert.throws(() => validateCustomOutputs('nope'), { name: 'TypeError' }) + }) + + it('throws RangeError for array > MAX_CUSTOM_OUTPUTS', function () { + const arr = Array.from({ length: MAX_CUSTOM_OUTPUTS + 1 }, () => ({ + address: '1ABC', value: 1000 + })) + assert.throws(() => validateCustomOutputs(arr), { name: 'RangeError' }) + }) + + it('throws RangeError for negative value', function () { + assert.throws( + () => validateCustomOutputs([{ address: '1ABC', value: -1 }]), + { name: 'RangeError' } + ) + }) + + it('throws TypeError for empty address string', function () { + assert.throws( + () => validateCustomOutputs([{ address: '', value: 1000 }]), + { name: 'TypeError' } + ) + }) + + it('throws TypeError for address > 100 chars', function () { + assert.throws( + () => validateCustomOutputs([{ address: 'x'.repeat(101), value: 1000 }]), + { name: 'TypeError' } + ) + }) + }) +}) diff --git a/test/regression/reg_04_validator.test/04_validate_p2sh_params.test.js b/test/regression/reg_04_validator.test/04_validate_p2sh_params.test.js new file mode 100644 index 0000000..9dda846 --- /dev/null +++ b/test/regression/reg_04_validator.test/04_validate_p2sh_params.test.js @@ -0,0 +1,75 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * REG-04: Validator Functions + * + * Primary dedicated coverage for src/common/validator.js, filling the identified + * gap where no existing test file imports the validator directly. Tests each + * exported validate* function individually plus validateAll() as integration. + * Purely synchronous (no encoder instantiation needed). + */ + +const assert = require('assert') +const { validateP2shParams } = require('../../../src/common/validator') + +const VALID_TXID = 'a'.repeat(64) + +describe('REG-04: Validator Functions', function () { + describe('REG-04.11: validateP2shParams()', function () { + it('returns nulls when both absent', function () { + const result = validateP2shParams(null, null) + assert.strictEqual(result.p2shHash, null) + assert.strictEqual(result.p2shHex, null) + }) + + it('returns nulls when both false', function () { + const result = validateP2shParams(false, false) + assert.strictEqual(result.p2shHash, null) + assert.strictEqual(result.p2shHex, null) + }) + + it('throws TypeError when only p2shHash is provided', function () { + assert.throws( + () => validateP2shParams(VALID_TXID, null), + { name: 'TypeError' } + ) + }) + + it('throws TypeError when only p2shHex is provided', function () { + assert.throws( + () => validateP2shParams(null, 'aabb'), + { name: 'TypeError' } + ) + }) + + it('throws TypeError for non-hex-64 p2shHash', function () { + assert.throws( + () => validateP2shParams('tooshort', 'aabb'), + { name: 'TypeError' } + ) + }) + + it('throws TypeError for empty p2shHex string', function () { + assert.throws( + () => validateP2shParams(VALID_TXID, ''), + { name: 'TypeError' } + ) + }) + + it('accepts valid pair', function () { + const result = validateP2shParams(VALID_TXID, 'aabbccdd') + assert.strictEqual(result.p2shHash, VALID_TXID) + assert.strictEqual(result.p2shHex, 'aabbccdd') + }) + }) +}) diff --git a/test/regression/reg_04_validator.test/05_validate_compressed_pub_key.test.js b/test/regression/reg_04_validator.test/05_validate_compressed_pub_key.test.js new file mode 100644 index 0000000..b27c8f8 --- /dev/null +++ b/test/regression/reg_04_validator.test/05_validate_compressed_pub_key.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. + * + ********************************************************************** + * REG-04: Validator Functions + * + * Primary dedicated coverage for src/common/validator.js, filling the identified + * gap where no existing test file imports the validator directly. Tests each + * exported validate* function individually plus validateAll() as integration. + * Purely synchronous (no encoder instantiation needed). + */ + +const assert = require('assert') +const { validateCompressedPubKey } = require('../../../src/common/validator') + +const VALID_COMPRESSED_02 = '02' + 'a'.repeat(64) +const VALID_COMPRESSED_03 = '03' + 'b'.repeat(64) + +describe('REG-04: Validator Functions', function () { + describe('REG-04.12: validateCompressedPubKey()', function () { + it('returns null for null', function () { + assert.strictEqual(validateCompressedPubKey(null), null) + }) + + it('accepts 02-prefixed 66-char hex key', function () { + assert.strictEqual(validateCompressedPubKey(VALID_COMPRESSED_02), VALID_COMPRESSED_02) + }) + + it('accepts 03-prefixed 66-char hex key', function () { + assert.strictEqual(validateCompressedPubKey(VALID_COMPRESSED_03), VALID_COMPRESSED_03) + }) + + it('throws TypeError for 04-prefixed (uncompressed)', function () { + assert.throws( + () => validateCompressedPubKey('04' + 'a'.repeat(128)), + { name: 'TypeError' } + ) + }) + + it('throws TypeError for wrong length (too short)', function () { + assert.throws( + () => validateCompressedPubKey('02' + 'a'.repeat(10)), + { name: 'TypeError' } + ) + }) + + it('throws TypeError for non-string', function () { + assert.throws(() => validateCompressedPubKey(12345), { name: 'TypeError' }) + }) + }) +}) diff --git a/test/regression/reg_04_validator.test/06_validate_change.test.js b/test/regression/reg_04_validator.test/06_validate_change.test.js new file mode 100644 index 0000000..744e21d --- /dev/null +++ b/test/regression/reg_04_validator.test/06_validate_change.test.js @@ -0,0 +1,47 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * REG-04: Validator Functions + * + * Primary dedicated coverage for src/common/validator.js, filling the identified + * gap where no existing test file imports the validator directly. Tests each + * exported validate* function individually plus validateAll() as integration. + * Purely synchronous (no encoder instantiation needed). + */ + +const assert = require('assert') +const { validateChange } = require('../../../src/common/validator') + +describe('REG-04: Validator Functions', function () { + describe('REG-04.13: validateChange()', function () { + it('returns null for null', function () { + assert.strictEqual(validateChange(null), null) + }) + + it('accepts non-empty string <= 100 chars', function () { + assert.strictEqual(validateChange('1JDogZS6tQcSxwfxhv6XKKjcyicYA4Feev'), '1JDogZS6tQcSxwfxhv6XKKjcyicYA4Feev') + }) + + it('throws TypeError for empty string', function () { + assert.throws(() => validateChange(''), { name: 'TypeError' }) + }) + + it('throws TypeError for string > 100 chars', function () { + assert.throws(() => validateChange('x'.repeat(101)), { name: 'TypeError' }) + }) + + it('throws TypeError for non-string', function () { + assert.throws(() => validateChange(42), { name: 'TypeError' }) + }) + }) +}) diff --git a/test/regression/reg_04_validator.test/07_validate_all_integration.test.js b/test/regression/reg_04_validator.test/07_validate_all_integration.test.js new file mode 100644 index 0000000..01a30a1 --- /dev/null +++ b/test/regression/reg_04_validator.test/07_validate_all_integration.test.js @@ -0,0 +1,121 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * REG-04: Validator Functions + * + * Primary dedicated coverage for src/common/validator.js, filling the identified + * gap where no existing test file imports the validator directly. Tests each + * exported validate* function individually plus validateAll() as integration. + * Purely synchronous (no encoder instantiation needed). + */ + +const assert = require('assert') +const { + validateAll, + MAX_DATA_BYTES +} = require('../../../src/common/validator') + +const VALID_TXID = 'a'.repeat(64) + +function makeValidUtxoEntry () { + return { + txid: VALID_TXID, + vout: 0, + value: 100000000, + scriptPubKey: '0014' + 'aa'.repeat(20), + confirmations: 6 + } +} + +describe('REG-04: Validator Functions', function () { + describe('REG-04.14: validateAll() integration', function () { + it('returns sanitized object with all fields for valid input', function () { + const params = { + data: 'SEND|0|JDOG|1|addr', + rawData: null, + pubkey: '1JDogZS6tQcSxwfxhv6XKKjcyicYA4Feev', + encoding: 'OP_RETURN', + fee: '10000', + feePerKb: null, + dust: null, + utxos: [makeValidUtxoEntry()], + customOutputs: null, + p2shHash: null, + p2shHex: null, + compressedPubKey: null, + change: '1JDogZS6tQcSxwfxhv6XKKjcyicYA4Feev', + rbf: false, + unconfirmed: true + } + + const result = validateAll(params) + assert.strictEqual(result.data, 'SEND|0|JDOG|1|addr') + assert.strictEqual(result.encoding, 'OP_RETURN') + assert.strictEqual(result.fee, 10000) // coerced from string + assert.strictEqual(result.rbf, false) + assert.strictEqual(result.unconfirmed, true) + }) + + it('throws TypeError for null params', function () { + assert.throws(() => validateAll(null), { name: 'TypeError' }) + }) + + it('throws TypeError for string params', function () { + assert.throws(() => validateAll('not-an-object'), { name: 'TypeError' }) + }) + + it('throws a SHAPE error for positional array params, not a missing-field one', function () { + // typeof [] is 'object', so before the Array.isArray clause a JSON-RPC call + // with positional params cleared this gate and died on 'pubkey is required'. + assert.throws(() => validateAll([]), { + name: 'TypeError', + message: 'Request params must be an object' + }) + assert.throws(() => validateAll(['SEND|0|JDOG|1|addr']), { + name: 'TypeError', + message: 'Request params must be an object' + }) + }) + }) +}) + +describe('REG-04: Validator Functions', function () { + describe('REG-04.14: validateAll() integration', function () { + it('throws RangeError for combined data > 65536 bytes', function () { + assert.throws( + () => validateAll({ data: 'x'.repeat(MAX_DATA_BYTES + 1) }), + { name: 'RangeError' } + ) + }) + + it('rejects non-boolean rbf and unconfirmed, passes real booleans through', function () { + // Hardened behavior: a non-boolean rbf/unconfirmed (e.g. the string + // "false", which is truthy) would silently flip RBF signaling or unconfirmed-UTXO + // selection on a money protocol, so validateAll rejects anything but a real JSON + // boolean instead of coercing; undefined/null still fall through to the defaults. + const base = { data: 'SEND|0|X|1|addr', pubkey: '1JDogZS6tQcSxwfxhv6XKKjcyicYA4Feev' } + assert.throws(() => validateAll({ ...base, rbf: 'truthy-string' }), { name: 'TypeError' }) + assert.throws(() => validateAll({ ...base, unconfirmed: 0 }), { name: 'TypeError' }) + const result = validateAll({ ...base, rbf: true, unconfirmed: false }) + assert.strictEqual(result.rbf, true) + assert.strictEqual(result.unconfirmed, false) + }) + + it('validates P2SH pair atomically (rejects partial pair)', function () { + assert.throws( + () => validateAll({ data: 'SEND|0|X|1|addr', p2shHash: VALID_TXID }), + { name: 'TypeError' } + ) + }) + }) +}) diff --git a/test/security/concurrency_gate.test.js b/test/security/concurrency_gate.test.js index 3753bb9..9085112 100644 --- a/test/security/concurrency_gate.test.js +++ b/test/security/concurrency_gate.test.js @@ -29,9 +29,7 @@ const assert = require('assert') const express = require('express') const http = require('http') const rateLimit = require('express-rate-limit') -const { createConcurrencyGate, resolveLimit } = require('../../src/server/concurrency_gate.js') -const fs = require('fs'); -const path = require('path'); +const { createConcurrencyGate } = require('../../src/server/concurrency_gate.js') // The 429 body the gate serves in production (src/api.js). -32029 is the // encoder's "too many requests" JSON-RPC code, shared with the per-IP limiter, @@ -42,16 +40,7 @@ const isProbe = (req) => req.method === 'GET' && (req.path === '/status' || re // Servers opened by a test, torn down in afterEach. let openServers = [] -/** - * Stand up a miniature encoder with api.js's exact middleware order: the - * production per-IP limiter, the probe reserve, the main gate, then handlers - * that park until the test releases them. Parking is what makes "concurrent" - * deterministic - requests stay in flight until we say so. - */ -function buildServer(options){ - options = options || {} - - const app = express() +function configureApp(app){ // Same trust-proxy default api.js uses (ENCODER_TRUST_PROXY), so an // X-Forwarded-For hop from the loopback peer becomes req.ip. app.set('trust proxy', 'loopback, uniquelocal') @@ -67,7 +56,9 @@ function buildServer(options){ legacyHeaders: false, message: { jsonrpc: '2.0', id: null, error: { code: -32029, message: 'Too many requests' } } })) +} +function createGates(app, options){ const probeGate = createConcurrencyGate({ limit: options.probeLimit !== undefined ? options.probeLimit : 16, retryAfter: 1, @@ -84,6 +75,10 @@ function buildServer(options){ }) app.use(gate) + return { probeGate, gate } +} + +function mountRoutes(app, gate, options){ let releaseHeld, releaseProbe const held = new Promise(resolve => { releaseHeld = resolve }) const heldProbe = new Promise(resolve => { releaseProbe = resolve }) @@ -119,15 +114,28 @@ function buildServer(options){ res.json({ status: 'healthy' }) }) + return { + arrivals: () => expensiveArrivals, enteredHeld: () => heldEntered, + release: () => releaseHeld(), releaseProbe: () => releaseProbe() } +} + +/** + * Stand up a miniature encoder with api.js's exact middleware order: the + * production per-IP limiter, the probe reserve, the main gate, then handlers + * that park until the test releases them. Parking is what makes "concurrent" + * deterministic - requests stay in flight until we say so. + */ +function buildServer(options){ + options = options || {} + + const app = express() + configureApp(app) + const { probeGate, gate } = createGates(app, options) + const routes = mountRoutes(app, gate, options) const server = http.createServer(app) openServers.push(server) - return { - app, gate, probeGate, server, - arrivals: () => expensiveArrivals, - enteredHeld: () => heldEntered, - release: () => releaseHeld(), releaseProbe: () => releaseProbe() - } + return { app, gate, probeGate, server, ...routes } } function listen(server){ @@ -150,16 +158,17 @@ async function waitFor(predicate, label){ throw new Error('timed out waiting for: ' + label) } -describe('Security: global in-flight concurrency cap', function () { +function closeServers(){ + for(const server of openServers){ + // fetch keeps its sockets alive, so close() alone would hang. + if(typeof server.closeAllConnections === 'function') server.closeAllConnections() + server.close() + } + openServers = [] +} - afterEach(function () { - for(const server of openServers){ - // fetch keeps its sockets alive, so close() alone would hang. - if(typeof server.closeAllConnections === 'function') server.closeAllConnections() - server.close() - } - openServers = [] - }) +describe('Security: global in-flight concurrency cap', function () { + afterEach(closeServers) it('refuses the (cap+1)th concurrent request with 429, though every request has a distinct IP', async function () { const CAP = 3 @@ -191,6 +200,10 @@ describe('Security: global in-flight concurrency cap', function () { } assert.strictEqual(new Set(ips).size, CAP) }) +}) + +describe('Security: global in-flight concurrency cap', function () { + afterEach(closeServers) it('frees a slot when a request completes, so the next caller is served', async function () { const { server, gate, release } = buildServer({ limit: 1 }) @@ -223,6 +236,10 @@ describe('Security: global in-flight concurrency cap', function () { await aborted.catch(() => {}) await waitFor(() => gate.getStats().in_flight === 0, 'aborted slot to be released') }) +}) + +describe('Security: global in-flight concurrency cap', function () { + afterEach(closeServers) it('keeps a held slot while the upstream work runs on after the client aborts', async function () { // Express never awaits an async handler, so releasing on the socket's @@ -261,6 +278,10 @@ describe('Security: global in-flight concurrency cap', function () { await waitFor(() => gate.getStats().in_flight === 0, 'slot to come back once the work settled') assert.strictEqual((await get(server, '/held', 3)).status, 200) }) +}) + +describe('Security: global in-flight concurrency cap', function () { + afterEach(closeServers) it('negative control: with hold() stubbed out, the aborted slot is handed to the next caller', async function () { // The same scenario against a pass-through wrapper, which is exactly the @@ -299,6 +320,10 @@ describe('Security: global in-flight concurrency cap', function () { release() assert.strictEqual((await parked).status, 200) }) +}) + +describe('Security: global in-flight concurrency cap', function () { + afterEach(closeServers) it('api.js mounts the JSON-RPC router and /status inside hold()', function () { // The wrapper is the whole fix, and it lives at the mount site rather @@ -325,6 +350,10 @@ describe('Security: global in-flight concurrency cap', function () { assert.strictEqual((await get(server, '/status', 2)).status, 200) assert.strictEqual((await get(server, '/expensive', 3)).status, 429) }) +}) + +describe('Security: global in-flight concurrency cap', function () { + afterEach(closeServers) it('bounds the probe reserve too, so /status is not an uncapped bypass', async function () { // /status is exempt from the MAIN cap, not from every cap: each call @@ -349,6 +378,10 @@ describe('Security: global in-flight concurrency cap', function () { releaseProbe() for(const response of await Promise.all(parkedProbes)) assert.strictEqual(response.status, 200) }) +}) + +describe('Security: global in-flight concurrency cap', function () { + afterEach(closeServers) it('is disabled by a cap of 0 (operator escape hatch)', async function () { const { server, gate, release, arrivals } = buildServer({ limit: 0 }) @@ -364,41 +397,4 @@ describe('Security: global in-flight concurrency cap', function () { release() for(const response of await Promise.all(parked)) assert.strictEqual(response.status, 200) }) - - describe('resolveLimit', function () { - - it('keeps the caller default when the env var is unset or unparseable', function () { - // A typo must not silently remove the cap. - assert.strictEqual(resolveLimit(undefined, 50), 50) - assert.strictEqual(resolveLimit('', 50), 50) - assert.strictEqual(resolveLimit('lots', 50), 50) - }) - - it('honours an explicit value and treats <= 0 as disabled', function () { - assert.strictEqual(resolveLimit('25', 50), 25) - assert.strictEqual(resolveLimit('0', 50), 0) - assert.strictEqual(resolveLimit('-5', 50), 0) - }) - }) - - describe('api.js wiring', function () { - - const apiSource = fs.readFileSync(path.join(__dirname, '../../src/api.js'), 'utf8') - - it('mounts the gate on the app with an env-overridable cap', function () { - assert.ok(apiSource.includes('concurrencyGate.createConcurrencyGate')) - assert.ok(apiSource.includes('ENCODER_MAX_CONCURRENT_REQUESTS')) - assert.ok(/app\.use\(requestGate\)/.test(apiSource)) - }) - - it('mounts a bounded reserve for the exempt readiness probes', function () { - assert.ok(apiSource.includes('ENCODER_MAX_CONCURRENT_PROBES')) - assert.ok(/app\.use\(probeGate\)/.test(apiSource)) - }) - - it('reports the gate stats so a stampede is visible to operators', function () { - assert.ok(apiSource.includes('request_gate: requestGate.getStats()')) - assert.ok(apiSource.includes('probe_gate: probeGate.getStats()')) - }) - }) }) diff --git a/test/security/concurrency_gate.test/01_resolve_limit.test.js b/test/security/concurrency_gate.test/01_resolve_limit.test.js new file mode 100644 index 0000000..6c55001 --- /dev/null +++ b/test/security/concurrency_gate.test/01_resolve_limit.test.js @@ -0,0 +1,47 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Security: global in-flight concurrency cap + * + * The encoder's per-IP rate limiter cannot see a stampede spread across many + * source IPs: every bucket stays under its own limit while every create_tx + * still fans out into coin-node and utxo-tracker RPCs on a shared upstream. + * These tests drive the real gate over real HTTP with a DISTINCT forged client + * IP per request, so a shed can only come from the global cap - the per-IP + * limiter is mounted alongside at its production default and never fires. + * + * Run: mocha test/security/concurrency_gate.test.js --timeout 30000 + */ + +'use strict' + +const assert = require('assert') +const { resolveLimit } = require('../../../src/server/concurrency_gate.js') + +describe('Security: global in-flight concurrency cap', function () { + describe('resolveLimit', function () { + + it('keeps the caller default when the env var is unset or unparseable', function () { + // A typo must not silently remove the cap. + assert.strictEqual(resolveLimit(undefined, 50), 50) + assert.strictEqual(resolveLimit('', 50), 50) + assert.strictEqual(resolveLimit('lots', 50), 50) + }) + + it('honours an explicit value and treats <= 0 as disabled', function () { + assert.strictEqual(resolveLimit('25', 50), 25) + assert.strictEqual(resolveLimit('0', 50), 0) + assert.strictEqual(resolveLimit('-5', 50), 0) + }) + }) +}) diff --git a/test/security/concurrency_gate.test/02_api_js_wiring.test.js b/test/security/concurrency_gate.test/02_api_js_wiring.test.js new file mode 100644 index 0000000..0f72000 --- /dev/null +++ b/test/security/concurrency_gate.test/02_api_js_wiring.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. + * + ********************************************************************** + * Security: global in-flight concurrency cap + * + * The encoder's per-IP rate limiter cannot see a stampede spread across many + * source IPs: every bucket stays under its own limit while every create_tx + * still fans out into coin-node and utxo-tracker RPCs on a shared upstream. + * These tests drive the real gate over real HTTP with a DISTINCT forged client + * IP per request, so a shed can only come from the global cap - the per-IP + * limiter is mounted alongside at its production default and never fires. + * + * Run: mocha test/security/concurrency_gate.test.js --timeout 30000 + */ + +'use strict' + +const assert = require('assert') +const fs = require('fs'); +const path = require('path'); + +describe('Security: global in-flight concurrency cap', function () { + describe('api.js wiring', function () { + + const apiSource = fs.readFileSync(path.join(__dirname, '../../../src/api.js'), 'utf8') + + it('mounts the gate on the app with an env-overridable cap', function () { + assert.ok(apiSource.includes('concurrencyGate.createConcurrencyGate')) + assert.ok(apiSource.includes('ENCODER_MAX_CONCURRENT_REQUESTS')) + assert.ok(/app\.use\(requestGate\)/.test(apiSource)) + }) + + it('mounts a bounded reserve for the exempt readiness probes', function () { + assert.ok(apiSource.includes('ENCODER_MAX_CONCURRENT_PROBES')) + assert.ok(/app\.use\(probeGate\)/.test(apiSource)) + }) + + it('reports the gate stats so a stampede is visible to operators', function () { + assert.ok(apiSource.includes('request_gate: requestGate.getStats()')) + assert.ok(apiSource.includes('probe_gate: probeGate.getStats()')) + }) + }) +}) From c1415ea57a5d892c53b55cc39e206e31bf2ef13e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 08:20:35 -0700 Subject: [PATCH 125/148] test: split the smoke suite into numbered parts by behavior --- test/smoke/smoke.test.js | 238 ++---------------- .../smoke.test/01_tx_size_estimator.test.js | 97 +++++++ .../02_segwit_utxo_detection.test.js | 80 ++++++ .../smoke.test/03_data_to_pubkey.test.js | 56 +++++ .../smoke.test/04_api_server_startup.test.js | 85 +++++++ 5 files changed, 334 insertions(+), 222 deletions(-) create mode 100644 test/smoke/smoke.test/01_tx_size_estimator.test.js create mode 100644 test/smoke/smoke.test/02_segwit_utxo_detection.test.js create mode 100644 test/smoke/smoke.test/03_data_to_pubkey.test.js create mode 100644 test/smoke/smoke.test/04_api_server_startup.test.js diff --git a/test/smoke/smoke.test.js b/test/smoke/smoke.test.js index 41d2f9f..538b4fa 100644 --- a/test/smoke/smoke.test.js +++ b/test/smoke/smoke.test.js @@ -28,12 +28,6 @@ const ecc = require('tiny-secp256k1'); const XChainEncoder = require('../../src/XChainEncoder'); const CryptoNetworks = require('../../src/build/crypto_networks'); const TxSizeEstimator = require('../../src/build/tx_size_estimator'); -const express = require('express'); -const bodyParser = require('body-parser'); -const helmet = require('helmet'); -const cors = require('cors'); -const jsonRouter = require('express-json-rpc-router'); -const http = require('http'); describe('S1: Module Loading', () => { it('loads XChainEncoder', () => { @@ -221,6 +215,22 @@ describe('S5: prepareData', () => { assert.ok(result.dataBufferArray.length >= 1) assert.ok(Buffer.isBuffer(result.dataBufferArray[0])) }) +}) + +describe('S5: prepareData', () => { + + // Use a regtest P2PKH address for P2SH/P2WSH encoding paths + const PUBKEY_BUF = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex' + ) + const network = require('../../src/build/crypto_networks').getBitcoinJsNetwork('bitcoin-regtest') + const testAddress = bitcoin.payments.p2pkh({ pubkey: PUBKEY_BUF, network }).address + + let encoder + beforeEach(() => { + encoder = new XChainEncoder('bitcoin-regtest', '127.0.0.1', '8332', 'u', 'p', '', '') + }) it('P2WSH: produces script buffers', () => { const data = Buffer.alloc(5000, 0x43) @@ -291,219 +301,3 @@ describe('S6: Obfuscation Round-Trip', () => { assert.strictEqual(encrypted.length, 0) }) }) - -describe('S7: TxSizeEstimator', () => { - - it('estimateOpReturnOutput returns positive integer', () => { - const data = Buffer.from('XCHN test data') - const size = TxSizeEstimator.estimateOpReturnOutput(data) - assert.ok(Number.isInteger(size)) - assert.ok(size > 0) - }) - - it('estimateP2shOutput returns positive integer', () => { - const size = TxSizeEstimator.estimateP2shOutput() - assert.ok(Number.isInteger(size)) - assert.strictEqual(size, 32) - }) - - it('estimateP2wshOutput returns positive integer', () => { - const size = TxSizeEstimator.estimateP2wshOutput() - assert.ok(Number.isInteger(size)) - assert.strictEqual(size, 43) - }) - - it('estimateMultisignOutput returns positive integer', () => { - // 114 = 8 (value) + 1 (script-length varint) + 105 (compiled 1-of-3 bare - // multisig). The old 111 here came from a 102-byte script figure that - // omitted the three push opcodes; 99ebc78 corrected the estimator and this - // smoke assertion was left on the undercount. The derivation lives in - // test/unit/tx_size_estimator.test.js, which builds a real p2ms script. - const size = TxSizeEstimator.estimateMultisignOutput() - assert.ok(Number.isInteger(size)) - assert.strictEqual(size, 114) - }) - - it('estimateInputSize: P2WPKH (segwit) is smaller than P2PKH (legacy)', () => { - const PUBKEY_BUF = Buffer.from( - '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', - 'hex' - ) - - const p2wpkh = bitcoin.payments.p2wpkh({ - pubkey: PUBKEY_BUF, - network: bitcoin.networks.regtest - }) - const segwitInput = { - hash: 'a'.repeat(64), - index: 0, - sequence: 0xffffffff, - witnessUtxo: { script: p2wpkh.output, value: 100000 } - } - const segwitSize = TxSizeEstimator.estimateInputSize(segwitInput) - - const p2pkh = bitcoin.payments.p2pkh({ - pubkey: PUBKEY_BUF, - network: bitcoin.networks.regtest - }) - const tx = new bitcoin.Transaction() - tx.addInput(Buffer.alloc(32, 0x11), 0) - tx.addOutput(p2pkh.output, 100000) - const legacyInput = { - hash: 'a'.repeat(64), - index: 0, - sequence: 0xffffffff, - nonWitnessUtxo: Buffer.from(tx.toHex(), 'hex') - } - const legacySize = TxSizeEstimator.estimateInputSize(legacyInput) - - assert.ok(segwitSize < legacySize, - `Segwit (${segwitSize}) should be smaller than legacy (${legacySize})`) - }) -}) - -describe('S8: Segwit UTXO Detection', () => { - - const PUBKEY_BUF = Buffer.from( - '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', - 'hex' - ) - - let encoder - before(() => { - encoder = new XChainEncoder('bitcoin-regtest', '127.0.0.1', '8332', 'u', 'p', '', '') - }) - - it('P2WPKH scriptPubKey → true', () => { - const p2wpkh = bitcoin.payments.p2wpkh({ - pubkey: PUBKEY_BUF, - network: bitcoin.networks.regtest - }) - assert.strictEqual( - encoder.isSegwitUTXO({ scriptPubKey: p2wpkh.output.toString('hex') }), - true - ) - }) - - it('P2PKH scriptPubKey → false', () => { - const p2pkh = bitcoin.payments.p2pkh({ - pubkey: PUBKEY_BUF, - network: bitcoin.networks.regtest - }) - assert.strictEqual( - encoder.isSegwitUTXO({ scriptPubKey: p2pkh.output.toString('hex') }), - false - ) - }) - - it('P2SH scriptPubKey → false', () => { - const p2sh = bitcoin.payments.p2sh({ - redeem: bitcoin.payments.p2wpkh({ - pubkey: PUBKEY_BUF, - network: bitcoin.networks.regtest - }), - network: bitcoin.networks.regtest - }) - assert.strictEqual( - encoder.isSegwitUTXO({ scriptPubKey: p2sh.output.toString('hex') }), - false - ) - }) - - it('invalid scriptPubKey → false (no throw)', () => { - assert.strictEqual( - encoder.isSegwitUTXO({ scriptPubKey: 'zzzz' }), - false - ) - }) -}) - -describe('S9: dataToPubkey', () => { - - let encoder - before(() => { - encoder = new XChainEncoder('bitcoin-regtest', '127.0.0.1', '8332', 'u', 'p', '', '') - }) - - it('produces 33-byte buffer starting with 0x02', async () => { - const data = Buffer.alloc(31, 0xab) - const result = await encoder.dataToPubkey(data) - assert.strictEqual(result.length, 33) - assert.strictEqual(result[0], 0x02) - }) - - it('pads short data to 33 bytes', async () => { - const data = Buffer.from([0x01, 0x02, 0x03]) - const result = await encoder.dataToPubkey(data) - assert.strictEqual(result.length, 33) - assert.strictEqual(result[0], 0x02) - // Original data follows the 0x02 prefix - assert.strictEqual(result[1], 0x01) - assert.strictEqual(result[2], 0x02) - assert.strictEqual(result[3], 0x03) - }) - - it('handles exactly 32 bytes of data', async () => { - const data = Buffer.alloc(32, 0xff) - const result = await encoder.dataToPubkey(data) - assert.strictEqual(result.length, 33) - assert.strictEqual(result[0], 0x02) - }) -}) - -describe('S10: API Server Startup', () => { - - let server - - afterEach((done) => { - if (server) { - server.close(done) - server = null - } else { - done() - } - }) - - it('Express + JSON-RPC stack starts and responds to ping', (done) => { - const app = express() - app.use(helmet()) - app.use(bodyParser.json()) - app.use(cors()) - - const jsonRpcController = { - async ping () { return { status: 'success' } } - } - app.use(jsonRouter({ methods: jsonRpcController })) - - // Listen on port 0 to let the OS pick a free port - server = app.listen(0, () => { - const port = server.address().port - const payload = JSON.stringify({ jsonrpc: '2.0', method: 'ping', id: 1 }) - - const req = http.request({ - hostname: '127.0.0.1', - port, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(payload) - } - }, (res) => { - let body = '' - res.on('data', (chunk) => { body += chunk }) - res.on('end', () => { - try { - const parsed = JSON.parse(body) - assert.deepStrictEqual(parsed.result, { status: 'success' }) - done() - } catch (err) { - done(err) - } - }) - }) - req.on('error', done) - req.write(payload) - req.end() - }) - }) -}) diff --git a/test/smoke/smoke.test/01_tx_size_estimator.test.js b/test/smoke/smoke.test/01_tx_size_estimator.test.js new file mode 100644 index 0000000..c5fccc0 --- /dev/null +++ b/test/smoke/smoke.test/01_tx_size_estimator.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. + * + ********************************************************************** + * Smoke Tests: xchain-encoder + * + * Fast health-check suite that verifies the encoder's core building blocks + * are operational. No coin node, no network calls, no external services. + * + * Run: npm run smoke-test + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib'); +const TxSizeEstimator = require('../../../src/build/tx_size_estimator'); + +describe('S7: TxSizeEstimator', () => { + + it('estimateOpReturnOutput returns positive integer', () => { + const data = Buffer.from('XCHN test data') + const size = TxSizeEstimator.estimateOpReturnOutput(data) + assert.ok(Number.isInteger(size)) + assert.ok(size > 0) + }) + + it('estimateP2shOutput returns positive integer', () => { + const size = TxSizeEstimator.estimateP2shOutput() + assert.ok(Number.isInteger(size)) + assert.strictEqual(size, 32) + }) + + it('estimateP2wshOutput returns positive integer', () => { + const size = TxSizeEstimator.estimateP2wshOutput() + assert.ok(Number.isInteger(size)) + assert.strictEqual(size, 43) + }) + + it('estimateMultisignOutput returns positive integer', () => { + // 114 = 8 (value) + 1 (script-length varint) + 105 (compiled 1-of-3 bare + // multisig). The old 111 here came from a 102-byte script figure that + // omitted the three push opcodes; 99ebc78 corrected the estimator and this + // smoke assertion was left on the undercount. The derivation lives in + // test/unit/tx_size_estimator.test.js, which builds a real p2ms script. + const size = TxSizeEstimator.estimateMultisignOutput() + assert.ok(Number.isInteger(size)) + assert.strictEqual(size, 114) + }) +}) + +describe('S7: TxSizeEstimator', () => { + + it('estimateInputSize: P2WPKH (segwit) is smaller than P2PKH (legacy)', () => { + const PUBKEY_BUF = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex' + ) + + const p2wpkh = bitcoin.payments.p2wpkh({ + pubkey: PUBKEY_BUF, + network: bitcoin.networks.regtest + }) + const segwitInput = { + hash: 'a'.repeat(64), + index: 0, + sequence: 0xffffffff, + witnessUtxo: { script: p2wpkh.output, value: 100000 } + } + const segwitSize = TxSizeEstimator.estimateInputSize(segwitInput) + + const p2pkh = bitcoin.payments.p2pkh({ + pubkey: PUBKEY_BUF, + network: bitcoin.networks.regtest + }) + const tx = new bitcoin.Transaction() + tx.addInput(Buffer.alloc(32, 0x11), 0) + tx.addOutput(p2pkh.output, 100000) + const legacyInput = { + hash: 'a'.repeat(64), + index: 0, + sequence: 0xffffffff, + nonWitnessUtxo: Buffer.from(tx.toHex(), 'hex') + } + const legacySize = TxSizeEstimator.estimateInputSize(legacyInput) + + assert.ok(segwitSize < legacySize, + `Segwit (${segwitSize}) should be smaller than legacy (${legacySize})`) + }) +}) diff --git a/test/smoke/smoke.test/02_segwit_utxo_detection.test.js b/test/smoke/smoke.test/02_segwit_utxo_detection.test.js new file mode 100644 index 0000000..56f596c --- /dev/null +++ b/test/smoke/smoke.test/02_segwit_utxo_detection.test.js @@ -0,0 +1,80 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Smoke Tests: xchain-encoder + * + * Fast health-check suite that verifies the encoder's core building blocks + * are operational. No coin node, no network calls, no external services. + * + * Run: npm run smoke-test + */ + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib'); +const XChainEncoder = require('../../../src/XChainEncoder'); + +describe('S8: Segwit UTXO Detection', () => { + + const PUBKEY_BUF = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex' + ) + + let encoder + before(() => { + encoder = new XChainEncoder('bitcoin-regtest', '127.0.0.1', '8332', 'u', 'p', '', '') + }) + + it('P2WPKH scriptPubKey → true', () => { + const p2wpkh = bitcoin.payments.p2wpkh({ + pubkey: PUBKEY_BUF, + network: bitcoin.networks.regtest + }) + assert.strictEqual( + encoder.isSegwitUTXO({ scriptPubKey: p2wpkh.output.toString('hex') }), + true + ) + }) + + it('P2PKH scriptPubKey → false', () => { + const p2pkh = bitcoin.payments.p2pkh({ + pubkey: PUBKEY_BUF, + network: bitcoin.networks.regtest + }) + assert.strictEqual( + encoder.isSegwitUTXO({ scriptPubKey: p2pkh.output.toString('hex') }), + false + ) + }) + + it('P2SH scriptPubKey → false', () => { + const p2sh = bitcoin.payments.p2sh({ + redeem: bitcoin.payments.p2wpkh({ + pubkey: PUBKEY_BUF, + network: bitcoin.networks.regtest + }), + network: bitcoin.networks.regtest + }) + assert.strictEqual( + encoder.isSegwitUTXO({ scriptPubKey: p2sh.output.toString('hex') }), + false + ) + }) + + it('invalid scriptPubKey → false (no throw)', () => { + assert.strictEqual( + encoder.isSegwitUTXO({ scriptPubKey: 'zzzz' }), + false + ) + }) +}) diff --git a/test/smoke/smoke.test/03_data_to_pubkey.test.js b/test/smoke/smoke.test/03_data_to_pubkey.test.js new file mode 100644 index 0000000..5d3ff57 --- /dev/null +++ b/test/smoke/smoke.test/03_data_to_pubkey.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. + * + ********************************************************************** + * Smoke Tests: xchain-encoder + * + * Fast health-check suite that verifies the encoder's core building blocks + * are operational. No coin node, no network calls, no external services. + * + * Run: npm run smoke-test + */ + +const assert = require('assert') +const XChainEncoder = require('../../../src/XChainEncoder'); + +describe('S9: dataToPubkey', () => { + + let encoder + before(() => { + encoder = new XChainEncoder('bitcoin-regtest', '127.0.0.1', '8332', 'u', 'p', '', '') + }) + + it('produces 33-byte buffer starting with 0x02', async () => { + const data = Buffer.alloc(31, 0xab) + const result = await encoder.dataToPubkey(data) + assert.strictEqual(result.length, 33) + assert.strictEqual(result[0], 0x02) + }) + + it('pads short data to 33 bytes', async () => { + const data = Buffer.from([0x01, 0x02, 0x03]) + const result = await encoder.dataToPubkey(data) + assert.strictEqual(result.length, 33) + assert.strictEqual(result[0], 0x02) + // Original data follows the 0x02 prefix + assert.strictEqual(result[1], 0x01) + assert.strictEqual(result[2], 0x02) + assert.strictEqual(result[3], 0x03) + }) + + it('handles exactly 32 bytes of data', async () => { + const data = Buffer.alloc(32, 0xff) + const result = await encoder.dataToPubkey(data) + assert.strictEqual(result.length, 33) + assert.strictEqual(result[0], 0x02) + }) +}) diff --git a/test/smoke/smoke.test/04_api_server_startup.test.js b/test/smoke/smoke.test/04_api_server_startup.test.js new file mode 100644 index 0000000..95809f6 --- /dev/null +++ b/test/smoke/smoke.test/04_api_server_startup.test.js @@ -0,0 +1,85 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Smoke Tests: xchain-encoder + * + * Fast health-check suite that verifies the encoder's core building blocks + * are operational. No coin node, no network calls, no external services. + * + * Run: npm run smoke-test + */ + +const assert = require('assert') +const express = require('express'); +const bodyParser = require('body-parser'); +const helmet = require('helmet'); +const cors = require('cors'); +const jsonRouter = require('express-json-rpc-router'); +const http = require('http'); + +describe('S10: API Server Startup', () => { + + let server + + afterEach((done) => { + if (server) { + server.close(done) + server = null + } else { + done() + } + }) + + it('Express + JSON-RPC stack starts and responds to ping', (done) => { + const app = express() + app.use(helmet()) + app.use(bodyParser.json()) + app.use(cors()) + + const jsonRpcController = { + async ping () { return { status: 'success' } } + } + app.use(jsonRouter({ methods: jsonRpcController })) + + // Listen on port 0 to let the OS pick a free port + server = app.listen(0, () => { + const port = server.address().port + const payload = JSON.stringify({ jsonrpc: '2.0', method: 'ping', id: 1 }) + + const req = http.request({ + hostname: '127.0.0.1', + port, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload) + } + }, (res) => { + let body = '' + res.on('data', (chunk) => { body += chunk }) + res.on('end', () => { + try { + const parsed = JSON.parse(body) + assert.deepStrictEqual(parsed.result, { status: 'success' }) + done() + } catch (err) { + done(err) + } + }) + }) + req.on('error', done) + req.write(payload) + req.end() + }) + }) +}) From 4ac788cc976b7a4198af4f96e3071e33974e4de4 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 08:22:58 -0700 Subject: [PATCH 126/148] test: split validator suite into numbered parts by behavior --- test/unit/validator.test.js | 541 ------------------ .../01_validate_data_param.test.js | 62 ++ ...tion_name_encoder_action_name_gate.test.js | 63 ++ .../03_validate_combined_data_length.test.js | 140 +++++ ..._expansion_at_the_compiled_ceiling.test.js | 71 +++ .../05_validate_encoding.test.js | 33 ++ .../06_max_fee_satoshis.test.js | 33 ++ .../validator.test/07_validate_fee.test.js | 36 ++ .../08_validate_fee_per_kb.test.js | 43 ++ .../validator.test/09_validate_dust.test.js | 34 ++ ...ate_utxo_array_validate_utxo_entry.test.js | 83 +++ ...validate_custom_output_via_wrapper.test.js | 46 ++ .../12_validate_p2sh_params.test.js | 49 ++ .../13_validate_raw_tx_hex.test.js | 51 ++ .../14_validate_compressed_pub_key.test.js | 35 ++ .../validator.test/15_validate_change.test.js | 33 ++ .../16_validate_address.test.js | 37 ++ .../validator.test/17_validate_all.test.js | 112 ++++ 18 files changed, 961 insertions(+), 541 deletions(-) create mode 100644 test/unit/validator.test/01_validate_data_param.test.js create mode 100644 test/unit/validator.test/02_validate_action_name_encoder_action_name_gate.test.js create mode 100644 test/unit/validator.test/03_validate_combined_data_length.test.js create mode 100644 test/unit/validator.test/04_alias_expansion_at_the_compiled_ceiling.test.js create mode 100644 test/unit/validator.test/05_validate_encoding.test.js create mode 100644 test/unit/validator.test/06_max_fee_satoshis.test.js create mode 100644 test/unit/validator.test/07_validate_fee.test.js create mode 100644 test/unit/validator.test/08_validate_fee_per_kb.test.js create mode 100644 test/unit/validator.test/09_validate_dust.test.js create mode 100644 test/unit/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js create mode 100644 test/unit/validator.test/11_validate_custom_outputs_validate_custom_output_via_wrapper.test.js create mode 100644 test/unit/validator.test/12_validate_p2sh_params.test.js create mode 100644 test/unit/validator.test/13_validate_raw_tx_hex.test.js create mode 100644 test/unit/validator.test/14_validate_compressed_pub_key.test.js create mode 100644 test/unit/validator.test/15_validate_change.test.js create mode 100644 test/unit/validator.test/16_validate_address.test.js create mode 100644 test/unit/validator.test/17_validate_all.test.js diff --git a/test/unit/validator.test.js b/test/unit/validator.test.js index 0d00b37..4890a37 100644 --- a/test/unit/validator.test.js +++ b/test/unit/validator.test.js @@ -19,9 +19,6 @@ const assert = require('assert'); const v = require('../../src/common/validator.js'); -const bitcoin = require('bitcoinjs-lib'); - -const HEX64 = 'a'.repeat(64); describe('Encoder input validator', function () { @@ -35,542 +32,4 @@ describe('Encoder input validator', function () { assert.throws(() => v.validatePubkey('x'.repeat(101)), /maximum length/); }); }); - - describe('validateDataParam', function () { - it('null passes, string passes, non-string throws with field name', function () { - assert.strictEqual(v.validateDataParam(null, 'data'), null); - assert.strictEqual(v.validateDataParam('hello', 'data'), 'hello'); - assert.throws(() => v.validateDataParam(5, 'rawData'), /rawData must be a string/); - }); - - // The emitter converts data with Buffer.from(...,'utf8') and rawData - // with Buffer.from(...,'binary'), and neither is reversible for every JS string. - // A validated value that the wire encoding mutates can never be reconstructed by - // the decoder, so it must be refused here rather than silently corrupted. - // Built with fromCharCode so this source file stays plain ASCII. - const LONE_SURROGATE = String.fromCharCode(0xD800); - const ABOVE_LATIN1 = String.fromCharCode(0x0100); - const BINARY_BYTES = String.fromCharCode(0x00, 0x01, 0xFF, 0x80, 0x7F); - - it('rejects a data string that is not well-formed Unicode (utf8 would emit U+FFFD)', function () { - assert.throws(() => v.validateDataParam(`ISSUE|0|TICK${LONE_SURROGATE}`, 'data'), RangeError); - assert.throws(() => v.validateDataParam(LONE_SURROGATE, 'data'), /not well-formed Unicode/); - }); - - it('rejects a rawData code point above U+00FF (latin-1 would truncate it)', function () { - assert.throws(() => v.validateDataParam(ABOVE_LATIN1, 'rawData'), RangeError); - assert.throws(() => v.validateDataParam(`meta${ABOVE_LATIN1}`, 'rawData'), /above U\+00FF/); - }); - - it('still accepts every value the wire encodings carry losslessly', function () { - // Full-range binary rawData (control bytes and 0x80-0xFF) is the normal shape. - assert.strictEqual(v.validateDataParam(BINARY_BYTES, 'rawData'), BINARY_BYTES); - // Well-formed non-ASCII text in `data` round-trips through utf8 and stays legal. - const emoji = `MESSAGE|0|${String.fromCodePoint(0x1F600)}`; - assert.strictEqual(v.validateDataParam(emoji, 'data'), emoji); - // A surrogate PAIR is well-formed; only an unpaired half is refused. - assert.strictEqual(v.validateDataParam(LONE_SURROGATE + String.fromCharCode(0xDC00), 'data'), - LONE_SURROGATE + String.fromCharCode(0xDC00)); - }); - }); - - describe('validateActionName (encoder ACTION-name gate)', function () { - it('accepts every canonical ACTION name from VALID_ACTION_NAMES', function () { - for (const name of v.VALID_ACTION_NAMES) { - assert.doesNotThrow(() => v.validateActionName(`${name}|0|X|1|a`), name); - } - }); - it('accepts a bare canonical name with no pipe-delimited payload', function () { - assert.doesNotThrow(() => v.validateActionName('SEND')); - }); - it('accepts every known alias and expands it the same way the decoder does', function () { - for (const alias of Object.keys(v.ACTION_ALIASES)) { - assert.doesNotThrow(() => v.validateActionName(`${alias}|0|X|1|a`), alias); - } - }); - it('rejects a typoed ACTION name with a RangeError', function () { - assert.throws(() => v.validateActionName('TRANSFRE|0|X|1|a'), RangeError); - assert.throws(() => v.validateActionName('TRANSFRE|0|X|1|a'), /unknown ACTION name/); - }); - it('rejects an action newer than the deployed decoder', function () { - assert.throws(() => v.validateActionName('FUTURE_ACTION|1'), RangeError); - }); - it('is case-sensitive: a lowercase canonical name is not recognized', function () { - assert.throws(() => v.validateActionName('send|0|X|1|a'), RangeError); - }); - it('does not reject the intentionally-supported empty/absent data shapes', function () { - assert.doesNotThrow(() => v.validateActionName(null)); - assert.doesNotThrow(() => v.validateActionName('')); - }); - it('is wired into validateAll and fires before feeQuote/utxo checks', function () { - assert.throws( - () => v.validateAll({ data: 'TRANSFRE|0|X|1|a', pubkey: '02ab' }), - /unknown ACTION name/ - ); - }); - it('validateAll still accepts a valid canonical action end-to-end', function () { - assert.doesNotThrow(() => v.validateAll({ data: 'SEND|0|X|1|a', pubkey: '02ab' })); - }); - }); - - describe('validateCombinedDataLength', function () { - it('returns early for null data and accepts within-limit payloads', function () { - assert.strictEqual(v.validateCombinedDataLength(null, null), undefined); - assert.doesNotThrow(() => v.validateCombinedDataLength('abc', null)); - assert.doesNotThrow(() => v.validateCombinedDataLength('abc', 'rawbytes')); - }); - it('counts data + rawData and throws over the limit', function () { - const big = 'x'.repeat(v.MAX_DATA_BYTES); - assert.doesNotThrow(() => v.validateCombinedDataLength(big, null)); - assert.throws(() => v.validateCombinedDataLength(big, 'y'), RangeError); - }); - it('accepts a single-push payload at the compiled ceiling (backwards-compatible)', function () { - // 8189 raw bytes compile to 8189 + 3 (OP_PUSHDATA2) == 8192 == ceiling. - const single = 'x'.repeat(v.MAX_DATA_BYTES); - assert.doesNotThrow(() => v.validateCombinedDataLength(single, null)); - }); - it('accounts for per-push overhead on dual-push payloads at the boundary', function () { - // Two pushes of 4093 bytes each compile to (4093 + 3) * 2 == 8192 == ceiling. - const okData = 'x'.repeat(4093); - const okRaw = 'y'.repeat(4093); - assert.doesNotThrow(() => v.validateCombinedDataLength(okData, okRaw)); - }); - it('rejects dual-push payloads whose compiled size exceeds the ceiling', function () { - // Two pushes of 4094 bytes each compile to (4094 + 3) * 2 == 8194 > 8192. - const bigData = 'x'.repeat(4094); - const bigRaw = 'y'.repeat(4094); - assert.throws(() => v.validateCombinedDataLength(bigData, bigRaw), RangeError); - }); - it('rejects the documented dual-push undercount case (raw sum within old limit)', function () { - // 4094 + 4095 == 8189 raw bytes passed the old sum-based check, but the - // two pushes compile to 4097 + 4098 == 8195 > 8192. - const data = 'x'.repeat(4094); - const rawData = 'y'.repeat(4095); - assert.throws(() => v.validateCombinedDataLength(data, rawData), RangeError); - }); - // Explicit encoding:"OP_RETURN" gets the tighter 76-byte ceiling - // pre-compile, so an oversize request is rejected as invalid-params before - // any UTXO reservation instead of failing post-compile as -32603 internal. - it('rejects an explicit OP_RETURN payload above the 76-byte ceiling', function () { - // 200 raw bytes compile to 200 + 2 (OP_PUSHDATA1) = 202 > 76. - const data = 'x'.repeat(200); - assert.throws(() => v.validateCombinedDataLength(data, null, 'OP_RETURN'), - /OP_RETURN encoding requires compiled payload <= 76 bytes/); - }); - it('accepts an explicit OP_RETURN payload at/under the 76-byte ceiling', function () { - // 75 raw bytes compile to 75 + 1 (direct push) = 76 == ceiling. - assert.doesNotThrow(() => v.validateCombinedDataLength('x'.repeat(75), null, 'OP_RETURN')); - }); - it('does NOT apply the OP_RETURN ceiling when encoding is omitted (P2SH auto-fallback)', function () { - // Same 200-byte payload passes when no explicit encoding is given, so - // prepareData can auto-select P2SH for the larger payload. - assert.doesNotThrow(() => v.validateCombinedDataLength('x'.repeat(200), null)); - assert.doesNotThrow(() => v.validateCombinedDataLength('x'.repeat(200), null, undefined)); - }); - - // An AUTO request can resolve to TAPROOT in selectEncoding, so the - // pre-flight check must use the envelope ceiling for it; measuring AUTO - // against the 8,192-byte legacy ceiling rejected payloads the builder - // would have accepted. An OMITTED encoding is a different request and - // keeps the legacy ceiling. - it('applies the envelope ceiling to AUTO, like TAPROOT', function () { - const big = 'x'.repeat(50000); - assert.doesNotThrow(() => v.validateCombinedDataLength(big, null, 'TAPROOT')); - assert.doesNotThrow(() => v.validateCombinedDataLength(big, null, 'AUTO')); - assert.throws(() => v.validateCombinedDataLength(big, null), RangeError); - assert.throws(() => v.validateCombinedDataLength(big, null, 'P2WSH'), RangeError); - // The envelope ceiling still binds for AUTO. - assert.throws(() => v.validateCombinedDataLength('x'.repeat(400000), null, 'AUTO'), - /exceeds maximum \(390000, the TAPROOT envelope payload ceiling\)/); - }); - - // bitcoin.script.compile frames a push of >= 65,536 bytes with - // OP_PUSHDATA4 (+5), a band compiledPushSize does not model because it is - // pinned byte-for-byte against the decoder's copy. Only the envelope - // ceiling reaches that band, and _buildTransaction refuses on the REAL - // compiled buffer, so under-counting here by 2 per large push moved the - // boundary payload from this -32602 pre-check to a -32603 builder error. - it('counts the OP_PUSHDATA4 band on a push past 65,535 bytes', function () { - // The widest rawData whose real compiled size is exactly the ceiling: - // OP_0 (1 byte, the empty data push) + rawLen + 5. - const atCeiling = v.ENVELOPE_MAX_PAYLOAD - 1 - 5; - assert.strictEqual( - bitcoin.script.compile([Buffer.alloc(0), Buffer.alloc(atCeiling)]).length, - v.ENVELOPE_MAX_PAYLOAD, - 'the fixture must sit exactly on the ceiling or the boundary is untested'); - assert.doesNotThrow(() => v.validateCombinedDataLength(null, 'y'.repeat(atCeiling), 'TAPROOT')); - // One byte more is one byte over, and must be refused HERE rather - // than surviving to _buildTransaction. - assert.strictEqual( - bitcoin.script.compile([Buffer.alloc(0), Buffer.alloc(atCeiling + 1)]).length, - v.ENVELOPE_MAX_PAYLOAD + 1); - assert.throws(() => v.validateCombinedDataLength(null, 'y'.repeat(atCeiling + 1), 'TAPROOT'), - RangeError); - }); - - it('leaves the OP_PUSHDATA2 band alone at its upper edge', function () { - // 65,535 is still +3, so the correction must not start a byte early, - // and compiledPushSize itself must stay unforked for the decoder pin. - assert.strictEqual(bitcoin.script.compile([Buffer.alloc(65535)]).length, 65538); - assert.strictEqual(v.compiledPushSize(65535), 65538); - assert.strictEqual(v.compiledPushSize(65536), 65539); - assert.strictEqual(bitcoin.script.compile([Buffer.alloc(65536)]).length, 65541); - }); - - it('measures rawData-only payloads (data defaults to an OP_0 push)', function () { - // createTransaction compiles [emptyBuffer, rawDataBuffer]: OP_0 (1 byte) - // + rawLen + 3 (OP_PUSHDATA2). 8188 -> 8192 == ceiling; 8189 -> 8193. - assert.doesNotThrow(() => v.validateCombinedDataLength(null, 'y'.repeat(8188))); - assert.throws(() => v.validateCombinedDataLength(null, 'y'.repeat(8189)), RangeError); - }); - }); - - describe('alias expansion at the compiled ceiling', function () { - // MAX_COMPILED_ACTION_DATA_LENGTH bounds the COMPILED on-chain push - i.e. - // the alias/wire spelling - measured BEFORE the decoder canonicalizes the - // name. Several aliases EXPAND during that canonicalization (ADDR->ADDRESS - // +3, DROP->AIRDROP +3, MSG->MESSAGE +4, CAST->BROADCAST +5), and nothing - // re-measures afterward, so a CAST payload accepted right at the ceiling is - // stored as a BROADCAST record a few bytes past the numeric cap. That is - // intentional: the cap is a wire-bytes cap, not a stored-record cap. This - // pins the boundary on the encoder half; the decoder half is pinned by - // xchain-decoder/test/unit/alias_expansion_boundary.test.js. - const PREFIX = 'CAST|0|'; - // MAX_DATA_BYTES == MAX_COMPILED_ACTION_DATA_LENGTH - OP_PUSHDATA2 overhead: - // the widest single raw push whose compiled form is exactly the ceiling. - const atCeiling = PREFIX + 'a'.repeat(v.MAX_DATA_BYTES - PREFIX.length); - const overCeiling = PREFIX + 'a'.repeat(v.MAX_DATA_BYTES + 1 - PREFIX.length); - - it('the at-ceiling CAST push measures exactly the cap', function () { - assert.strictEqual(atCeiling.length, v.MAX_DATA_BYTES); - assert.strictEqual(v.compiledPushSize(atCeiling.length), v.MAX_COMPILED_ACTION_DATA_LENGTH); - }); - - it('accepts the CAST payload sitting exactly at the compiled ceiling', function () { - assert.doesNotThrow(() => v.validateCombinedDataLength(atCeiling, null)); - // The size gate is the ONLY gate that fires here: the ACTION-name gate - // accepts CAST because it resolves to the canonical BROADCAST. - assert.doesNotThrow(() => v.validateActionName(atCeiling)); - assert.strictEqual(v.ACTION_ALIASES['CAST'], 'BROADCAST'); - }); - - it('rejects the CAST payload one byte over the ceiling with a RangeError', function () { - // RangeError is what api.js maps to a JSON-RPC -32602 invalid-params. - assert.strictEqual(v.compiledPushSize(overCeiling.length), v.MAX_COMPILED_ACTION_DATA_LENGTH + 1); - assert.throws(() => v.validateCombinedDataLength(overCeiling, null), RangeError); - }); - - it('the accepted at-ceiling CAST is stored as a record past the cap', function () { - // CAST -> BROADCAST adds 5 bytes to the leading name and nothing - // re-measures, so the stored ACTION record exceeds the numeric cap. - const grow = 'BROADCAST'.length - 'CAST'.length; - const storedLen = atCeiling.length + grow; - assert.strictEqual(grow, 5); - assert.strictEqual(storedLen, v.MAX_DATA_BYTES + 5); - assert.ok(storedLen > v.MAX_COMPILED_ACTION_DATA_LENGTH, - 'the stored record is intentionally allowed a few bytes past the wire cap'); - }); - }); - - describe('validateEncoding', function () { - it('accepts the valid set, null passes, unknown throws', function () { - for (const e of v.VALID_ENCODINGS) assert.strictEqual(v.validateEncoding(e), e); - assert.strictEqual(v.validateEncoding(null), null); - assert.throws(() => v.validateEncoding('BOGUS'), /Invalid encoding/); - assert.throws(() => v.validateEncoding(7), /Invalid encoding/); - }); - }); - - describe('MAX_FEE_SATOSHIS', function () { - it('is pinned at 21,000 BTC in satoshis, not 21M BTC', function () { - // The comment used to read '21M BTC', inviting a 1000x 'repair' that - // would loosen validateFee/validateDust/validateFeeQuote together. - assert.strictEqual(v.MAX_FEE_SATOSHIS, 2_100_000_000_000); - assert.strictEqual(v.MAX_FEE_SATOSHIS / 100_000_000, 21_000); - }); - }); - - describe('validateFee', function () { - it('null/false pass; coerces; rejects NaN, negative, over-max', function () { - assert.strictEqual(v.validateFee(null), null); - assert.strictEqual(v.validateFee(false), null); - assert.strictEqual(v.validateFee('1000'), 1000); - assert.throws(() => v.validateFee('abc'), TypeError); - assert.throws(() => v.validateFee({}), /got: object/); - assert.throws(() => v.validateFee(-1), RangeError); - assert.throws(() => v.validateFee(v.MAX_FEE_SATOSHIS + 1), /exceeds maximum/); - }); - }); - - describe('validateFeePerKb', function () { - it('null/false pass; rejects non-finite and non-positive', function () { - assert.strictEqual(v.validateFeePerKb(null), null); - assert.strictEqual(v.validateFeePerKb(false), null); - assert.strictEqual(v.validateFeePerKb('2.5'), 2.5); - assert.throws(() => v.validateFeePerKb('nope'), TypeError); - assert.throws(() => v.validateFeePerKb(Infinity), TypeError); - assert.throws(() => v.validateFeePerKb(0), RangeError); - assert.throws(() => v.validateFeePerKb(-3), RangeError); - }); - it('rejects hex, scientific, boolean, array, and object forms Number() would coerce', function () { - assert.throws(() => v.validateFeePerKb('0x20'), TypeError); // hex string -> 32 - assert.throws(() => v.validateFeePerKb('1e3'), TypeError); // scientific -> 1000 - assert.throws(() => v.validateFeePerKb(true), TypeError); // boolean -> 1 - assert.throws(() => v.validateFeePerKb([50]), TypeError); // array -> 50 - assert.throws(() => v.validateFeePerKb({}), TypeError); // object -> NaN - }); - }); - - describe('validateDust', function () { - it('null/false pass; coerces; rejects NaN and negative', function () { - assert.strictEqual(v.validateDust(null), null); - assert.strictEqual(v.validateDust(false), null); - assert.strictEqual(v.validateDust('546'), 546); - assert.throws(() => v.validateDust('xx'), TypeError); - assert.throws(() => v.validateDust(-5), RangeError); - }); - }); - - describe('validateUtxoArray / validateUtxoEntry', function () { - const goodUtxo = () => ({ txid: HEX64, vout: 0, value: 1000, scriptPubKey: '76a914' }); - - it('null passes; non-array and over-cap throw', function () { - assert.strictEqual(v.validateUtxoArray(null), null); - assert.throws(() => v.validateUtxoArray('no'), /must be an array/); - const tooMany = Array.from({ length: v.MAX_UTXO_COUNT + 1 }, goodUtxo); - assert.throws(() => v.validateUtxoArray(tooMany), /exceeds maximum/); - }); - - it('coerces vout/value and defaults confirmations', function () { - const arr = [{ txid: HEX64, vout: '2', value: '500', scriptPubKey: 'aa' }]; - const out = v.validateUtxoArray(arr); - assert.strictEqual(out[0].vout, 2); - assert.strictEqual(out[0].value, 500); - assert.strictEqual(out[0].confirmations, 0); - }); - - it('rejects each malformed field', function () { - assert.throws(() => v.validateUtxoEntry(null, 0), /must be an object/); - assert.throws(() => v.validateUtxoEntry([], 0), /must be an object/); - assert.throws(() => v.validateUtxoEntry({ ...goodUtxo(), txid: 'short' }, 0), /64-character hex/); - assert.throws(() => v.validateUtxoEntry({ ...goodUtxo(), vout: -1 }, 1), /non-negative integer/); - assert.throws(() => v.validateUtxoEntry({ ...goodUtxo(), value: -5 }, 2), RangeError); - assert.throws(() => v.validateUtxoEntry({ ...goodUtxo(), scriptPubKey: '' }, 3), /scriptPubKey/); - }); - - it('rejects vout values bare Number() would coerce to a plausible index (uuid:4555d78c)', function () { - // null/''/false/[] all Number()-coerce to 0 and once validated as - // vout 0 the encoder would spend a different outpoint (txid:0). - for (const bad of [null, '', false, true, [], [7], '2.0', '0x2']) { - assert.throws( - () => v.validateUtxoEntry({ ...goodUtxo(), vout: bad }, 0), - /vout must be a non-negative integer/, - `vout ${JSON.stringify(bad)} must be rejected` - ); - } - // integers and integer strings still pass - assert.strictEqual(v.validateUtxoEntry({ ...goodUtxo(), vout: 5 }, 0).vout, 5); - assert.strictEqual(v.validateUtxoEntry({ ...goodUtxo(), vout: '5' }, 0).vout, 5); - }); - - it('preserves an explicit confirmations value', function () { - const out = v.validateUtxoArray([{ ...goodUtxo(), confirmations: 6 }]); - assert.strictEqual(out[0].confirmations, 6); - }); - - it('coerces numeric-string confirmations and rejects untyped values', function () { - const out = v.validateUtxoArray([{ ...goodUtxo(), confirmations: '3' }]); - assert.strictEqual(out[0].confirmations, 3); - assert.throws(() => v.validateUtxoEntry({ ...goodUtxo(), confirmations: -1 }, 0), /confirmations must be a non-negative integer/); - assert.throws(() => v.validateUtxoEntry({ ...goodUtxo(), confirmations: 1.5 }, 0), /confirmations must be a non-negative integer/); - assert.throws(() => v.validateUtxoEntry({ ...goodUtxo(), confirmations: 'abc' }, 0), /confirmations must be a non-negative integer/); - assert.throws(() => v.validateUtxoEntry({ ...goodUtxo(), confirmations: {} }, 0), /confirmations must be a non-negative integer/); - }); - }); - - describe('validateCustomOutputs (+ validateCustomOutput via wrapper)', function () { - it('null passes; non-array and over-cap throw', function () { - assert.strictEqual(v.validateCustomOutputs(null), null); - assert.throws(() => v.validateCustomOutputs('no'), /must be an array/); - const tooMany = Array.from({ length: v.MAX_CUSTOM_OUTPUTS + 1 }, () => ({ address: 'a', value: 1 })); - assert.throws(() => v.validateCustomOutputs(tooMany), /exceeds maximum/); - }); - it('coerces value and rejects malformed entries', function () { - const out = v.validateCustomOutputs([{ address: 'addr', value: '100' }]); - assert.strictEqual(out[0].value, 100); - assert.throws(() => v.validateCustomOutputs([null]), /must be an object/); - assert.throws(() => v.validateCustomOutputs([{ address: '', value: 1 }]), /non-empty/); - assert.throws(() => v.validateCustomOutputs([{ address: 'x'.repeat(101), value: 1 }]), /maximum length/); - assert.throws(() => v.validateCustomOutputs([{ address: 'a', value: -1 }]), RangeError); - // Interim safe dust rule: a 0-sat caller output is relay-rejected as - // dust, so it is rejected at the boundary (matching validateFeeQuote). - assert.throws(() => v.validateCustomOutputs([{ address: 'a', value: 0 }]), /must be a positive integer/); - // A positive sub-dust value still passes the boundary (no dust floor yet). - assert.strictEqual(v.validateCustomOutputs([{ address: 'a', value: 1 }])[0].value, 1); - }); - }); - - describe('validateP2shParams', function () { - it('both-omitted yields nulls; mismatch throws', function () { - assert.deepStrictEqual(v.validateP2shParams(null, false), { p2shHash: null, p2shHex: null }); - assert.throws(() => v.validateP2shParams(HEX64, null), /both be provided or both omitted/); - }); - it('validates hash hex and non-empty hex; returns the pair', function () { - assert.throws(() => v.validateP2shParams('bad', 'deadbeef'), /64-character hex/); - assert.throws(() => v.validateP2shParams(HEX64, ''), /non-empty hex/); - assert.deepStrictEqual(v.validateP2shParams(HEX64, 'deadbeef'), { p2shHash: HEX64, p2shHex: 'deadbeef' }); - }); - it('enforces hex shape and the raw-tx length cap on p2shHex', function () { - assert.throws(() => v.validateP2shParams(HEX64, 'not-hex!'), /even-length hex/); - assert.throws(() => v.validateP2shParams(HEX64, 'abc'), /even-length hex/); - assert.throws( - () => v.validateP2shParams(HEX64, 'ab'.repeat(v.MAX_RAW_TX_HEX_LENGTH / 2 + 1)), - /exceeds maximum length/ - ); - // Exactly at the cap is accepted (shape-valid hex) - const atCap = 'ab'.repeat(v.MAX_RAW_TX_HEX_LENGTH / 2); - assert.strictEqual(v.validateP2shParams(HEX64, atCap).p2shHex, atCap); - }); - }); - - describe('validateRawTxHex', function () { - it('accepts well-formed hex and returns it', function () { - assert.strictEqual(v.validateRawTxHex('deadbeef'), 'deadbeef'); - }); - it('rejects non-strings, empty, odd-length and non-hex input', function () { - assert.throws(() => v.validateRawTxHex(null), /non-empty hex/); - assert.throws(() => v.validateRawTxHex(42), /non-empty hex/); - assert.throws(() => v.validateRawTxHex(''), /non-empty hex/); - assert.throws(() => v.validateRawTxHex('abc'), /even-length hex/); - assert.throws(() => v.validateRawTxHex('zz00'), /even-length hex/); - }); - it('accepts hex between the p2shHex cap and the broadcast cap (an envelope reveal)', function () { - // A signed TAPROOT envelope reveal is ~810,000 hex chars, - // above MAX_RAW_TX_HEX_LENGTH (which still bounds p2shHex) but - // within the broadcast_tx ceiling. - const hex = 'ab'.repeat(v.MAX_RAW_TX_HEX_LENGTH / 2 + 1); - assert.strictEqual(v.validateRawTxHex(hex), hex); - }); - it('rejects hex above MAX_BROADCAST_TX_HEX_LENGTH', function () { - assert.ok(v.MAX_BROADCAST_TX_HEX_LENGTH > v.MAX_RAW_TX_HEX_LENGTH); - assert.throws( - () => v.validateRawTxHex('ab'.repeat(v.MAX_BROADCAST_TX_HEX_LENGTH / 2 + 1)), - /exceeds maximum length/ - ); - }); - }); - - describe('validateCompressedPubKey', function () { - it('null passes; enforces the 02/03 + 64-hex shape', function () { - assert.strictEqual(v.validateCompressedPubKey(null), null); - assert.strictEqual(v.validateCompressedPubKey('02' + HEX64), '02' + HEX64); - assert.throws(() => v.validateCompressedPubKey('04' + HEX64), /02 or 03/); - assert.throws(() => v.validateCompressedPubKey('nope'), TypeError); - }); - }); - - describe('validateChange', function () { - it('null passes; rejects empty and over-long', function () { - assert.strictEqual(v.validateChange(null), null); - assert.strictEqual(v.validateChange('addr'), 'addr'); - assert.throws(() => v.validateChange(''), /non-empty/); - assert.throws(() => v.validateChange('x'.repeat(101)), /maximum length/); - }); - }); - - describe('validateAddress', function () { - it('accepts a valid string (incl. exactly 100 chars); rejects empty, non-string, and over-length', function () { - assert.strictEqual(v.validateAddress('addr'), 'addr'); - assert.strictEqual(v.validateAddress('x'.repeat(100)), 'x'.repeat(100)); - assert.throws(() => v.validateAddress(''), /non-empty string/); - assert.throws(() => v.validateAddress({}), /non-empty string/); - assert.throws(() => v.validateAddress([]), /non-empty string/); - assert.throws(() => v.validateAddress(123), /non-empty string/); - assert.throws(() => v.validateAddress(null), /non-empty string/); - assert.throws(() => v.validateAddress('x'.repeat(101)), /maximum length/); - }); - }); - - describe('validateAll', function () { - it('throws when params is not an object', function () { - assert.throws(() => v.validateAll(null), /must be an object/); - assert.throws(() => v.validateAll('x'), /must be an object/); - }); - - it('returns a fully-coerced, normalized param set', function () { - const result = v.validateAll({ - data: 'SEND', - rawData: 'world', - pubkey: '02ab', - encoding: 'OP_RETURN', - fee: '1000', - feePerKb: '2', - dust: '546', - utxos: [{ txid: HEX64, vout: '0', value: '5000', scriptPubKey: 'aa' }], - customOutputs: [{ address: 'addr', value: '10' }], - feeQuote: { address: 'fqaddr', amount: '999' }, - p2shHash: HEX64, - p2shHex: 'deadbeef', - compressedPubKey: '03' + HEX64, - change: 'changeaddr', - rbf: true, - unconfirmed: false, - }); - assert.strictEqual(result.fee, 1000); - assert.strictEqual(result.utxos[0].value, 5000); - assert.strictEqual(result.customOutputs[0].value, 10); - assert.strictEqual(result.feeQuote.amount, 999); - assert.strictEqual(result.rbf, true); - assert.strictEqual(result.unconfirmed, false); - }); - - it('rejects non-boolean rbf/unconfirmed instead of truthiness-coercing (string "false" must not flip policy to true)', function () { - const base = { data: 'SEND', pubkey: '02ab' }; - assert.throws(() => v.validateAll({ ...base, unconfirmed: 'false' }), TypeError); - assert.throws(() => v.validateAll({ ...base, unconfirmed: 'true' }), /unconfirmed must be a boolean/); - assert.throws(() => v.validateAll({ ...base, rbf: 'false' }), /rbf must be a boolean/); - assert.throws(() => v.validateAll({ ...base, rbf: 1 }), TypeError); - assert.throws(() => v.validateAll({ ...base, unconfirmed: 0 }), TypeError); - }); - - it('rbf/unconfirmed absent or null stay undefined so downstream defaults apply', function () { - const base = { data: 'SEND', pubkey: '02ab' }; - const absent = v.validateAll(base); - assert.strictEqual(absent.rbf, undefined); - assert.strictEqual(absent.unconfirmed, undefined); - const nulled = v.validateAll({ ...base, rbf: null, unconfirmed: null }); - assert.strictEqual(nulled.rbf, undefined); - assert.strictEqual(nulled.unconfirmed, undefined); - }); - - it('exercises validateFeeQuote validation paths through validateAll', function () { - const base = { data: 'SEND', pubkey: '02ab' }; - assert.throws(() => v.validateAll({ ...base, feeQuote: 'no' }), /feeQuote must be an object/); - // An array is typeof 'object'; it must fail the SHAPE check, not the - // address check one line later. - assert.throws(() => v.validateAll({ ...base, feeQuote: [] }), /feeQuote must be an object/); - assert.throws(() => v.validateAll({ ...base, feeQuote: ['1Addr', 1000] }), /feeQuote must be an object/); - assert.throws(() => v.validateAll({ ...base, feeQuote: { address: '', amount: 1 } }), /address must be a non-empty/); - assert.throws(() => v.validateAll({ ...base, feeQuote: { address: 'x'.repeat(101), amount: 1 } }), /maximum length/); - assert.throws(() => v.validateAll({ ...base, feeQuote: { address: 'a', amount: 0 } }), /positive integer/); - assert.throws(() => v.validateAll({ ...base, feeQuote: { address: 'a', amount: v.MAX_FEE_SATOSHIS + 1 } }), /exceeds maximum/); - assert.strictEqual(v.validateAll({ ...base, feeQuote: null }).feeQuote, null); - }); - - it('runs the combined-length pre-check on rawData-only requests', function () { - const result = v.validateAll({ data: null, rawData: 'x'.repeat(100), pubkey: '02ab' }); - assert.strictEqual(result.data, null); - assert.strictEqual(result.rawData, 'x'.repeat(100)); - // Oversize rawData-only is rejected here (invalid params), not left - // to createTransaction's compiled-size ceiling (internal error). - assert.throws(() => v.validateAll({ data: null, rawData: 'x'.repeat(8189), pubkey: '02ab' }), RangeError); - }); - - it('rejects an absent or null pubkey (openrpc marks it required)', function () { - // pubkey omitted / null must fail up front (RangeError -> -32602) rather - // than reaching fromBase58Check(null) deep in createTransaction. - assert.throws(() => v.validateAll({ data: 'SEND' }), /pubkey is required/); - assert.throws(() => v.validateAll({ data: 'SEND', pubkey: null }), /pubkey is required/); - }); - }); }); diff --git a/test/unit/validator.test/01_validate_data_param.test.js b/test/unit/validator.test/01_validate_data_param.test.js new file mode 100644 index 0000000..df27268 --- /dev/null +++ b/test/unit/validator.test/01_validate_data_param.test.js @@ -0,0 +1,62 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * Unit tests for src/common/validator.js: centralized createTransaction input + * validation. Every validator returns the coerced value or throws + * TypeError/RangeError. validateCustomOutput / validateFeeQuote are exercised + * through their array/validateAll wrappers (not individually exported). + ********************************************************************/ + +const assert = require('assert'); +const v = require('../../../src/common/validator.js'); + +describe('Encoder input validator', function () { + + describe('validateDataParam', function () { + it('null passes, string passes, non-string throws with field name', function () { + assert.strictEqual(v.validateDataParam(null, 'data'), null); + assert.strictEqual(v.validateDataParam('hello', 'data'), 'hello'); + assert.throws(() => v.validateDataParam(5, 'rawData'), /rawData must be a string/); + }); + + // The emitter converts data with Buffer.from(...,'utf8') and rawData + // with Buffer.from(...,'binary'), and neither is reversible for every JS string. + // A validated value that the wire encoding mutates can never be reconstructed by + // the decoder, so it must be refused here rather than silently corrupted. + // Built with fromCharCode so this source file stays plain ASCII. + const LONE_SURROGATE = String.fromCharCode(0xD800); + const ABOVE_LATIN1 = String.fromCharCode(0x0100); + const BINARY_BYTES = String.fromCharCode(0x00, 0x01, 0xFF, 0x80, 0x7F); + + it('rejects a data string that is not well-formed Unicode (utf8 would emit U+FFFD)', function () { + assert.throws(() => v.validateDataParam(`ISSUE|0|TICK${LONE_SURROGATE}`, 'data'), RangeError); + assert.throws(() => v.validateDataParam(LONE_SURROGATE, 'data'), /not well-formed Unicode/); + }); + + it('rejects a rawData code point above U+00FF (latin-1 would truncate it)', function () { + assert.throws(() => v.validateDataParam(ABOVE_LATIN1, 'rawData'), RangeError); + assert.throws(() => v.validateDataParam(`meta${ABOVE_LATIN1}`, 'rawData'), /above U\+00FF/); + }); + + it('still accepts every value the wire encodings carry losslessly', function () { + // Full-range binary rawData (control bytes and 0x80-0xFF) is the normal shape. + assert.strictEqual(v.validateDataParam(BINARY_BYTES, 'rawData'), BINARY_BYTES); + // Well-formed non-ASCII text in `data` round-trips through utf8 and stays legal. + const emoji = `MESSAGE|0|${String.fromCodePoint(0x1F600)}`; + assert.strictEqual(v.validateDataParam(emoji, 'data'), emoji); + // A surrogate PAIR is well-formed; only an unpaired half is refused. + assert.strictEqual(v.validateDataParam(LONE_SURROGATE + String.fromCharCode(0xDC00), 'data'), + LONE_SURROGATE + String.fromCharCode(0xDC00)); + }); + }); +}); diff --git a/test/unit/validator.test/02_validate_action_name_encoder_action_name_gate.test.js b/test/unit/validator.test/02_validate_action_name_encoder_action_name_gate.test.js new file mode 100644 index 0000000..df7f504 --- /dev/null +++ b/test/unit/validator.test/02_validate_action_name_encoder_action_name_gate.test.js @@ -0,0 +1,63 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Unit tests for src/common/validator.js: centralized createTransaction input + * validation. Every validator returns the coerced value or throws + * TypeError/RangeError. validateCustomOutput / validateFeeQuote are exercised + * through their array/validateAll wrappers (not individually exported). + ********************************************************************/ + +const assert = require('assert'); +const v = require('../../../src/common/validator.js'); + +describe('Encoder input validator', function () { + + describe('validateActionName (encoder ACTION-name gate)', function () { + it('accepts every canonical ACTION name from VALID_ACTION_NAMES', function () { + for (const name of v.VALID_ACTION_NAMES) { + assert.doesNotThrow(() => v.validateActionName(`${name}|0|X|1|a`), name); + } + }); + it('accepts a bare canonical name with no pipe-delimited payload', function () { + assert.doesNotThrow(() => v.validateActionName('SEND')); + }); + it('accepts every known alias and expands it the same way the decoder does', function () { + for (const alias of Object.keys(v.ACTION_ALIASES)) { + assert.doesNotThrow(() => v.validateActionName(`${alias}|0|X|1|a`), alias); + } + }); + it('rejects a typoed ACTION name with a RangeError', function () { + assert.throws(() => v.validateActionName('TRANSFRE|0|X|1|a'), RangeError); + assert.throws(() => v.validateActionName('TRANSFRE|0|X|1|a'), /unknown ACTION name/); + }); + it('rejects an action newer than the deployed decoder', function () { + assert.throws(() => v.validateActionName('FUTURE_ACTION|1'), RangeError); + }); + it('is case-sensitive: a lowercase canonical name is not recognized', function () { + assert.throws(() => v.validateActionName('send|0|X|1|a'), RangeError); + }); + it('does not reject the intentionally-supported empty/absent data shapes', function () { + assert.doesNotThrow(() => v.validateActionName(null)); + assert.doesNotThrow(() => v.validateActionName('')); + }); + it('is wired into validateAll and fires before feeQuote/utxo checks', function () { + assert.throws( + () => v.validateAll({ data: 'TRANSFRE|0|X|1|a', pubkey: '02ab' }), + /unknown ACTION name/ + ); + }); + it('validateAll still accepts a valid canonical action end-to-end', function () { + assert.doesNotThrow(() => v.validateAll({ data: 'SEND|0|X|1|a', pubkey: '02ab' })); + }); + }); +}); diff --git a/test/unit/validator.test/03_validate_combined_data_length.test.js b/test/unit/validator.test/03_validate_combined_data_length.test.js new file mode 100644 index 0000000..8f0220a --- /dev/null +++ b/test/unit/validator.test/03_validate_combined_data_length.test.js @@ -0,0 +1,140 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * Unit tests for src/common/validator.js: centralized createTransaction input + * validation. Every validator returns the coerced value or throws + * TypeError/RangeError. validateCustomOutput / validateFeeQuote are exercised + * through their array/validateAll wrappers (not individually exported). + ********************************************************************/ + +const assert = require('assert'); +const v = require('../../../src/common/validator.js'); +const bitcoin = require('bitcoinjs-lib'); + +describe('Encoder input validator', function () { + + describe('validateCombinedDataLength', function () { + it('returns early for null data and accepts within-limit payloads', function () { + assert.strictEqual(v.validateCombinedDataLength(null, null), undefined); + assert.doesNotThrow(() => v.validateCombinedDataLength('abc', null)); + assert.doesNotThrow(() => v.validateCombinedDataLength('abc', 'rawbytes')); + }); + it('counts data + rawData and throws over the limit', function () { + const big = 'x'.repeat(v.MAX_DATA_BYTES); + assert.doesNotThrow(() => v.validateCombinedDataLength(big, null)); + assert.throws(() => v.validateCombinedDataLength(big, 'y'), RangeError); + }); + it('accepts a single-push payload at the compiled ceiling (backwards-compatible)', function () { + // 8189 raw bytes compile to 8189 + 3 (OP_PUSHDATA2) == 8192 == ceiling. + const single = 'x'.repeat(v.MAX_DATA_BYTES); + assert.doesNotThrow(() => v.validateCombinedDataLength(single, null)); + }); + it('accounts for per-push overhead on dual-push payloads at the boundary', function () { + // Two pushes of 4093 bytes each compile to (4093 + 3) * 2 == 8192 == ceiling. + const okData = 'x'.repeat(4093); + const okRaw = 'y'.repeat(4093); + assert.doesNotThrow(() => v.validateCombinedDataLength(okData, okRaw)); + }); + it('rejects dual-push payloads whose compiled size exceeds the ceiling', function () { + // Two pushes of 4094 bytes each compile to (4094 + 3) * 2 == 8194 > 8192. + const bigData = 'x'.repeat(4094); + const bigRaw = 'y'.repeat(4094); + assert.throws(() => v.validateCombinedDataLength(bigData, bigRaw), RangeError); + }); + it('rejects the documented dual-push undercount case (raw sum within old limit)', function () { + // 4094 + 4095 == 8189 raw bytes passed the old sum-based check, but the + // two pushes compile to 4097 + 4098 == 8195 > 8192. + const data = 'x'.repeat(4094); + const rawData = 'y'.repeat(4095); + assert.throws(() => v.validateCombinedDataLength(data, rawData), RangeError); + }); + // Explicit encoding:"OP_RETURN" gets the tighter 76-byte ceiling + // pre-compile, so an oversize request is rejected as invalid-params before + // any UTXO reservation instead of failing post-compile as -32603 internal. + it('rejects an explicit OP_RETURN payload above the 76-byte ceiling', function () { + // 200 raw bytes compile to 200 + 2 (OP_PUSHDATA1) = 202 > 76. + const data = 'x'.repeat(200); + assert.throws(() => v.validateCombinedDataLength(data, null, 'OP_RETURN'), + /OP_RETURN encoding requires compiled payload <= 76 bytes/); + }); + it('accepts an explicit OP_RETURN payload at/under the 76-byte ceiling', function () { + // 75 raw bytes compile to 75 + 1 (direct push) = 76 == ceiling. + assert.doesNotThrow(() => v.validateCombinedDataLength('x'.repeat(75), null, 'OP_RETURN')); + }); + it('does NOT apply the OP_RETURN ceiling when encoding is omitted (P2SH auto-fallback)', function () { + // Same 200-byte payload passes when no explicit encoding is given, so + // prepareData can auto-select P2SH for the larger payload. + assert.doesNotThrow(() => v.validateCombinedDataLength('x'.repeat(200), null)); + assert.doesNotThrow(() => v.validateCombinedDataLength('x'.repeat(200), null, undefined)); + }); + + }); + + describe('validateCombinedDataLength', function () { + // An AUTO request can resolve to TAPROOT in selectEncoding, so the + // pre-flight check must use the envelope ceiling for it; measuring AUTO + // against the 8,192-byte legacy ceiling rejected payloads the builder + // would have accepted. An OMITTED encoding is a different request and + // keeps the legacy ceiling. + it('applies the envelope ceiling to AUTO, like TAPROOT', function () { + const big = 'x'.repeat(50000); + assert.doesNotThrow(() => v.validateCombinedDataLength(big, null, 'TAPROOT')); + assert.doesNotThrow(() => v.validateCombinedDataLength(big, null, 'AUTO')); + assert.throws(() => v.validateCombinedDataLength(big, null), RangeError); + assert.throws(() => v.validateCombinedDataLength(big, null, 'P2WSH'), RangeError); + // The envelope ceiling still binds for AUTO. + assert.throws(() => v.validateCombinedDataLength('x'.repeat(400000), null, 'AUTO'), + /exceeds maximum \(390000, the TAPROOT envelope payload ceiling\)/); + }); + + // bitcoin.script.compile frames a push of >= 65,536 bytes with + // OP_PUSHDATA4 (+5), a band compiledPushSize does not model because it is + // pinned byte-for-byte against the decoder's copy. Only the envelope + // ceiling reaches that band, and _buildTransaction refuses on the REAL + // compiled buffer, so under-counting here by 2 per large push moved the + // boundary payload from this -32602 pre-check to a -32603 builder error. + it('counts the OP_PUSHDATA4 band on a push past 65,535 bytes', function () { + // The widest rawData whose real compiled size is exactly the ceiling: + // OP_0 (1 byte, the empty data push) + rawLen + 5. + const atCeiling = v.ENVELOPE_MAX_PAYLOAD - 1 - 5; + assert.strictEqual( + bitcoin.script.compile([Buffer.alloc(0), Buffer.alloc(atCeiling)]).length, + v.ENVELOPE_MAX_PAYLOAD, + 'the fixture must sit exactly on the ceiling or the boundary is untested'); + assert.doesNotThrow(() => v.validateCombinedDataLength(null, 'y'.repeat(atCeiling), 'TAPROOT')); + // One byte more is one byte over, and must be refused HERE rather + // than surviving to _buildTransaction. + assert.strictEqual( + bitcoin.script.compile([Buffer.alloc(0), Buffer.alloc(atCeiling + 1)]).length, + v.ENVELOPE_MAX_PAYLOAD + 1); + assert.throws(() => v.validateCombinedDataLength(null, 'y'.repeat(atCeiling + 1), 'TAPROOT'), + RangeError); + }); + + it('leaves the OP_PUSHDATA2 band alone at its upper edge', function () { + // 65,535 is still +3, so the correction must not start a byte early, + // and compiledPushSize itself must stay unforked for the decoder pin. + assert.strictEqual(bitcoin.script.compile([Buffer.alloc(65535)]).length, 65538); + assert.strictEqual(v.compiledPushSize(65535), 65538); + assert.strictEqual(v.compiledPushSize(65536), 65539); + assert.strictEqual(bitcoin.script.compile([Buffer.alloc(65536)]).length, 65541); + }); + + it('measures rawData-only payloads (data defaults to an OP_0 push)', function () { + // createTransaction compiles [emptyBuffer, rawDataBuffer]: OP_0 (1 byte) + // + rawLen + 3 (OP_PUSHDATA2). 8188 -> 8192 == ceiling; 8189 -> 8193. + assert.doesNotThrow(() => v.validateCombinedDataLength(null, 'y'.repeat(8188))); + assert.throws(() => v.validateCombinedDataLength(null, 'y'.repeat(8189)), RangeError); + }); + }); +}); diff --git a/test/unit/validator.test/04_alias_expansion_at_the_compiled_ceiling.test.js b/test/unit/validator.test/04_alias_expansion_at_the_compiled_ceiling.test.js new file mode 100644 index 0000000..949edfb --- /dev/null +++ b/test/unit/validator.test/04_alias_expansion_at_the_compiled_ceiling.test.js @@ -0,0 +1,71 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Unit tests for src/common/validator.js: centralized createTransaction input + * validation. Every validator returns the coerced value or throws + * TypeError/RangeError. validateCustomOutput / validateFeeQuote are exercised + * through their array/validateAll wrappers (not individually exported). + ********************************************************************/ + +const assert = require('assert'); +const v = require('../../../src/common/validator.js'); + +describe('Encoder input validator', function () { + + describe('alias expansion at the compiled ceiling', function () { + // MAX_COMPILED_ACTION_DATA_LENGTH bounds the COMPILED on-chain push - i.e. + // the alias/wire spelling - measured BEFORE the decoder canonicalizes the + // name. Several aliases EXPAND during that canonicalization (ADDR->ADDRESS + // +3, DROP->AIRDROP +3, MSG->MESSAGE +4, CAST->BROADCAST +5), and nothing + // re-measures afterward, so a CAST payload accepted right at the ceiling is + // stored as a BROADCAST record a few bytes past the numeric cap. That is + // intentional: the cap is a wire-bytes cap, not a stored-record cap. This + // pins the boundary on the encoder half; the decoder half is pinned by + // xchain-decoder/test/unit/alias_expansion_boundary.test.js. + const PREFIX = 'CAST|0|'; + // MAX_DATA_BYTES == MAX_COMPILED_ACTION_DATA_LENGTH - OP_PUSHDATA2 overhead: + // the widest single raw push whose compiled form is exactly the ceiling. + const atCeiling = PREFIX + 'a'.repeat(v.MAX_DATA_BYTES - PREFIX.length); + const overCeiling = PREFIX + 'a'.repeat(v.MAX_DATA_BYTES + 1 - PREFIX.length); + + it('the at-ceiling CAST push measures exactly the cap', function () { + assert.strictEqual(atCeiling.length, v.MAX_DATA_BYTES); + assert.strictEqual(v.compiledPushSize(atCeiling.length), v.MAX_COMPILED_ACTION_DATA_LENGTH); + }); + + it('accepts the CAST payload sitting exactly at the compiled ceiling', function () { + assert.doesNotThrow(() => v.validateCombinedDataLength(atCeiling, null)); + // The size gate is the ONLY gate that fires here: the ACTION-name gate + // accepts CAST because it resolves to the canonical BROADCAST. + assert.doesNotThrow(() => v.validateActionName(atCeiling)); + assert.strictEqual(v.ACTION_ALIASES['CAST'], 'BROADCAST'); + }); + + it('rejects the CAST payload one byte over the ceiling with a RangeError', function () { + // RangeError is what api.js maps to a JSON-RPC -32602 invalid-params. + assert.strictEqual(v.compiledPushSize(overCeiling.length), v.MAX_COMPILED_ACTION_DATA_LENGTH + 1); + assert.throws(() => v.validateCombinedDataLength(overCeiling, null), RangeError); + }); + + it('the accepted at-ceiling CAST is stored as a record past the cap', function () { + // CAST -> BROADCAST adds 5 bytes to the leading name and nothing + // re-measures, so the stored ACTION record exceeds the numeric cap. + const grow = 'BROADCAST'.length - 'CAST'.length; + const storedLen = atCeiling.length + grow; + assert.strictEqual(grow, 5); + assert.strictEqual(storedLen, v.MAX_DATA_BYTES + 5); + assert.ok(storedLen > v.MAX_COMPILED_ACTION_DATA_LENGTH, + 'the stored record is intentionally allowed a few bytes past the wire cap'); + }); + }); +}); diff --git a/test/unit/validator.test/05_validate_encoding.test.js b/test/unit/validator.test/05_validate_encoding.test.js new file mode 100644 index 0000000..f1e0f07 --- /dev/null +++ b/test/unit/validator.test/05_validate_encoding.test.js @@ -0,0 +1,33 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * Unit tests for src/common/validator.js: centralized createTransaction input + * validation. Every validator returns the coerced value or throws + * TypeError/RangeError. validateCustomOutput / validateFeeQuote are exercised + * through their array/validateAll wrappers (not individually exported). + ********************************************************************/ + +const assert = require('assert'); +const v = require('../../../src/common/validator.js'); + +describe('Encoder input validator', function () { + + describe('validateEncoding', function () { + it('accepts the valid set, null passes, unknown throws', function () { + for (const e of v.VALID_ENCODINGS) assert.strictEqual(v.validateEncoding(e), e); + assert.strictEqual(v.validateEncoding(null), null); + assert.throws(() => v.validateEncoding('BOGUS'), /Invalid encoding/); + assert.throws(() => v.validateEncoding(7), /Invalid encoding/); + }); + }); +}); diff --git a/test/unit/validator.test/06_max_fee_satoshis.test.js b/test/unit/validator.test/06_max_fee_satoshis.test.js new file mode 100644 index 0000000..ef0aacc --- /dev/null +++ b/test/unit/validator.test/06_max_fee_satoshis.test.js @@ -0,0 +1,33 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * Unit tests for src/common/validator.js: centralized createTransaction input + * validation. Every validator returns the coerced value or throws + * TypeError/RangeError. validateCustomOutput / validateFeeQuote are exercised + * through their array/validateAll wrappers (not individually exported). + ********************************************************************/ + +const assert = require('assert'); +const v = require('../../../src/common/validator.js'); + +describe('Encoder input validator', function () { + + describe('MAX_FEE_SATOSHIS', function () { + it('is pinned at 21,000 BTC in satoshis, not 21M BTC', function () { + // A misread of '21M BTC' here would invite a 1000x 'repair' that + // would loosen validateFee/validateDust/validateFeeQuote together. + assert.strictEqual(v.MAX_FEE_SATOSHIS, 2_100_000_000_000); + assert.strictEqual(v.MAX_FEE_SATOSHIS / 100_000_000, 21_000); + }); + }); +}); diff --git a/test/unit/validator.test/07_validate_fee.test.js b/test/unit/validator.test/07_validate_fee.test.js new file mode 100644 index 0000000..4a40ff1 --- /dev/null +++ b/test/unit/validator.test/07_validate_fee.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. + * + ********************************************************************** + * Unit tests for src/common/validator.js: centralized createTransaction input + * validation. Every validator returns the coerced value or throws + * TypeError/RangeError. validateCustomOutput / validateFeeQuote are exercised + * through their array/validateAll wrappers (not individually exported). + ********************************************************************/ + +const assert = require('assert'); +const v = require('../../../src/common/validator.js'); + +describe('Encoder input validator', function () { + + describe('validateFee', function () { + it('null/false pass; coerces; rejects NaN, negative, over-max', function () { + assert.strictEqual(v.validateFee(null), null); + assert.strictEqual(v.validateFee(false), null); + assert.strictEqual(v.validateFee('1000'), 1000); + assert.throws(() => v.validateFee('abc'), TypeError); + assert.throws(() => v.validateFee({}), /got: object/); + assert.throws(() => v.validateFee(-1), RangeError); + assert.throws(() => v.validateFee(v.MAX_FEE_SATOSHIS + 1), /exceeds maximum/); + }); + }); +}); diff --git a/test/unit/validator.test/08_validate_fee_per_kb.test.js b/test/unit/validator.test/08_validate_fee_per_kb.test.js new file mode 100644 index 0000000..6492268 --- /dev/null +++ b/test/unit/validator.test/08_validate_fee_per_kb.test.js @@ -0,0 +1,43 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * Unit tests for src/common/validator.js: centralized createTransaction input + * validation. Every validator returns the coerced value or throws + * TypeError/RangeError. validateCustomOutput / validateFeeQuote are exercised + * through their array/validateAll wrappers (not individually exported). + ********************************************************************/ + +const assert = require('assert'); +const v = require('../../../src/common/validator.js'); + +describe('Encoder input validator', function () { + + describe('validateFeePerKb', function () { + it('null/false pass; rejects non-finite and non-positive', function () { + assert.strictEqual(v.validateFeePerKb(null), null); + assert.strictEqual(v.validateFeePerKb(false), null); + assert.strictEqual(v.validateFeePerKb('2.5'), 2.5); + assert.throws(() => v.validateFeePerKb('nope'), TypeError); + assert.throws(() => v.validateFeePerKb(Infinity), TypeError); + assert.throws(() => v.validateFeePerKb(0), RangeError); + assert.throws(() => v.validateFeePerKb(-3), RangeError); + }); + it('rejects hex, scientific, boolean, array, and object forms Number() would coerce', function () { + assert.throws(() => v.validateFeePerKb('0x20'), TypeError); // hex string -> 32 + assert.throws(() => v.validateFeePerKb('1e3'), TypeError); // scientific -> 1000 + assert.throws(() => v.validateFeePerKb(true), TypeError); // boolean -> 1 + assert.throws(() => v.validateFeePerKb([50]), TypeError); // array -> 50 + assert.throws(() => v.validateFeePerKb({}), TypeError); // object -> NaN + }); + }); +}); diff --git a/test/unit/validator.test/09_validate_dust.test.js b/test/unit/validator.test/09_validate_dust.test.js new file mode 100644 index 0000000..aec8e59 --- /dev/null +++ b/test/unit/validator.test/09_validate_dust.test.js @@ -0,0 +1,34 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * Unit tests for src/common/validator.js: centralized createTransaction input + * validation. Every validator returns the coerced value or throws + * TypeError/RangeError. validateCustomOutput / validateFeeQuote are exercised + * through their array/validateAll wrappers (not individually exported). + ********************************************************************/ + +const assert = require('assert'); +const v = require('../../../src/common/validator.js'); + +describe('Encoder input validator', function () { + + describe('validateDust', function () { + it('null/false pass; coerces; rejects NaN and negative', function () { + assert.strictEqual(v.validateDust(null), null); + assert.strictEqual(v.validateDust(false), null); + assert.strictEqual(v.validateDust('546'), 546); + assert.throws(() => v.validateDust('xx'), TypeError); + assert.throws(() => v.validateDust(-5), RangeError); + }); + }); +}); diff --git a/test/unit/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js b/test/unit/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js new file mode 100644 index 0000000..00f2144 --- /dev/null +++ b/test/unit/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js @@ -0,0 +1,83 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Unit tests for src/common/validator.js: centralized createTransaction input + * validation. Every validator returns the coerced value or throws + * TypeError/RangeError. validateCustomOutput / validateFeeQuote are exercised + * through their array/validateAll wrappers (not individually exported). + ********************************************************************/ + +const assert = require('assert'); +const v = require('../../../src/common/validator.js'); + +const HEX64 = 'a'.repeat(64); + +describe('Encoder input validator', function () { + + describe('validateUtxoArray / validateUtxoEntry', function () { + const goodUtxo = () => ({ txid: HEX64, vout: 0, value: 1000, scriptPubKey: '76a914' }); + + it('null passes; non-array and over-cap throw', function () { + assert.strictEqual(v.validateUtxoArray(null), null); + assert.throws(() => v.validateUtxoArray('no'), /must be an array/); + const tooMany = Array.from({ length: v.MAX_UTXO_COUNT + 1 }, goodUtxo); + assert.throws(() => v.validateUtxoArray(tooMany), /exceeds maximum/); + }); + + it('coerces vout/value and defaults confirmations', function () { + const arr = [{ txid: HEX64, vout: '2', value: '500', scriptPubKey: 'aa' }]; + const out = v.validateUtxoArray(arr); + assert.strictEqual(out[0].vout, 2); + assert.strictEqual(out[0].value, 500); + assert.strictEqual(out[0].confirmations, 0); + }); + + it('rejects each malformed field', function () { + assert.throws(() => v.validateUtxoEntry(null, 0), /must be an object/); + assert.throws(() => v.validateUtxoEntry([], 0), /must be an object/); + assert.throws(() => v.validateUtxoEntry({ ...goodUtxo(), txid: 'short' }, 0), /64-character hex/); + assert.throws(() => v.validateUtxoEntry({ ...goodUtxo(), vout: -1 }, 1), /non-negative integer/); + assert.throws(() => v.validateUtxoEntry({ ...goodUtxo(), value: -5 }, 2), RangeError); + assert.throws(() => v.validateUtxoEntry({ ...goodUtxo(), scriptPubKey: '' }, 3), /scriptPubKey/); + }); + + it('rejects vout values bare Number() would coerce to a plausible index', function () { + // null/''/false/[] all Number()-coerce to 0 and once validated as + // vout 0 the encoder would spend a different outpoint (txid:0). + for (const bad of [null, '', false, true, [], [7], '2.0', '0x2']) { + assert.throws( + () => v.validateUtxoEntry({ ...goodUtxo(), vout: bad }, 0), + /vout must be a non-negative integer/, + `vout ${JSON.stringify(bad)} must be rejected` + ); + } + // integers and integer strings still pass + assert.strictEqual(v.validateUtxoEntry({ ...goodUtxo(), vout: 5 }, 0).vout, 5); + assert.strictEqual(v.validateUtxoEntry({ ...goodUtxo(), vout: '5' }, 0).vout, 5); + }); + + it('preserves an explicit confirmations value', function () { + const out = v.validateUtxoArray([{ ...goodUtxo(), confirmations: 6 }]); + assert.strictEqual(out[0].confirmations, 6); + }); + + it('coerces numeric-string confirmations and rejects untyped values', function () { + const out = v.validateUtxoArray([{ ...goodUtxo(), confirmations: '3' }]); + assert.strictEqual(out[0].confirmations, 3); + assert.throws(() => v.validateUtxoEntry({ ...goodUtxo(), confirmations: -1 }, 0), /confirmations must be a non-negative integer/); + assert.throws(() => v.validateUtxoEntry({ ...goodUtxo(), confirmations: 1.5 }, 0), /confirmations must be a non-negative integer/); + assert.throws(() => v.validateUtxoEntry({ ...goodUtxo(), confirmations: 'abc' }, 0), /confirmations must be a non-negative integer/); + assert.throws(() => v.validateUtxoEntry({ ...goodUtxo(), confirmations: {} }, 0), /confirmations must be a non-negative integer/); + }); + }); +}); diff --git a/test/unit/validator.test/11_validate_custom_outputs_validate_custom_output_via_wrapper.test.js b/test/unit/validator.test/11_validate_custom_outputs_validate_custom_output_via_wrapper.test.js new file mode 100644 index 0000000..4730190 --- /dev/null +++ b/test/unit/validator.test/11_validate_custom_outputs_validate_custom_output_via_wrapper.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. + * + ********************************************************************** + * Unit tests for src/common/validator.js: centralized createTransaction input + * validation. Every validator returns the coerced value or throws + * TypeError/RangeError. validateCustomOutput / validateFeeQuote are exercised + * through their array/validateAll wrappers (not individually exported). + ********************************************************************/ + +const assert = require('assert'); +const v = require('../../../src/common/validator.js'); + +describe('Encoder input validator', function () { + + describe('validateCustomOutputs (+ validateCustomOutput via wrapper)', function () { + it('null passes; non-array and over-cap throw', function () { + assert.strictEqual(v.validateCustomOutputs(null), null); + assert.throws(() => v.validateCustomOutputs('no'), /must be an array/); + const tooMany = Array.from({ length: v.MAX_CUSTOM_OUTPUTS + 1 }, () => ({ address: 'a', value: 1 })); + assert.throws(() => v.validateCustomOutputs(tooMany), /exceeds maximum/); + }); + it('coerces value and rejects malformed entries', function () { + const out = v.validateCustomOutputs([{ address: 'addr', value: '100' }]); + assert.strictEqual(out[0].value, 100); + assert.throws(() => v.validateCustomOutputs([null]), /must be an object/); + assert.throws(() => v.validateCustomOutputs([{ address: '', value: 1 }]), /non-empty/); + assert.throws(() => v.validateCustomOutputs([{ address: 'x'.repeat(101), value: 1 }]), /maximum length/); + assert.throws(() => v.validateCustomOutputs([{ address: 'a', value: -1 }]), RangeError); + // Interim safe dust rule: a 0-sat caller output is relay-rejected as + // dust, so it is rejected at the boundary (matching validateFeeQuote). + assert.throws(() => v.validateCustomOutputs([{ address: 'a', value: 0 }]), /must be a positive integer/); + // A positive sub-dust value still passes the boundary (no dust floor yet). + assert.strictEqual(v.validateCustomOutputs([{ address: 'a', value: 1 }])[0].value, 1); + }); + }); +}); diff --git a/test/unit/validator.test/12_validate_p2sh_params.test.js b/test/unit/validator.test/12_validate_p2sh_params.test.js new file mode 100644 index 0000000..5430889 --- /dev/null +++ b/test/unit/validator.test/12_validate_p2sh_params.test.js @@ -0,0 +1,49 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * Unit tests for src/common/validator.js: centralized createTransaction input + * validation. Every validator returns the coerced value or throws + * TypeError/RangeError. validateCustomOutput / validateFeeQuote are exercised + * through their array/validateAll wrappers (not individually exported). + ********************************************************************/ + +const assert = require('assert'); +const v = require('../../../src/common/validator.js'); + +const HEX64 = 'a'.repeat(64); + +describe('Encoder input validator', function () { + + describe('validateP2shParams', function () { + it('both-omitted yields nulls; mismatch throws', function () { + assert.deepStrictEqual(v.validateP2shParams(null, false), { p2shHash: null, p2shHex: null }); + assert.throws(() => v.validateP2shParams(HEX64, null), /both be provided or both omitted/); + }); + it('validates hash hex and non-empty hex; returns the pair', function () { + assert.throws(() => v.validateP2shParams('bad', 'deadbeef'), /64-character hex/); + assert.throws(() => v.validateP2shParams(HEX64, ''), /non-empty hex/); + assert.deepStrictEqual(v.validateP2shParams(HEX64, 'deadbeef'), { p2shHash: HEX64, p2shHex: 'deadbeef' }); + }); + it('enforces hex shape and the raw-tx length cap on p2shHex', function () { + assert.throws(() => v.validateP2shParams(HEX64, 'not-hex!'), /even-length hex/); + assert.throws(() => v.validateP2shParams(HEX64, 'abc'), /even-length hex/); + assert.throws( + () => v.validateP2shParams(HEX64, 'ab'.repeat(v.MAX_RAW_TX_HEX_LENGTH / 2 + 1)), + /exceeds maximum length/ + ); + // Exactly at the cap is accepted (shape-valid hex) + const atCap = 'ab'.repeat(v.MAX_RAW_TX_HEX_LENGTH / 2); + assert.strictEqual(v.validateP2shParams(HEX64, atCap).p2shHex, atCap); + }); + }); +}); diff --git a/test/unit/validator.test/13_validate_raw_tx_hex.test.js b/test/unit/validator.test/13_validate_raw_tx_hex.test.js new file mode 100644 index 0000000..fe19982 --- /dev/null +++ b/test/unit/validator.test/13_validate_raw_tx_hex.test.js @@ -0,0 +1,51 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * Unit tests for src/common/validator.js: centralized createTransaction input + * validation. Every validator returns the coerced value or throws + * TypeError/RangeError. validateCustomOutput / validateFeeQuote are exercised + * through their array/validateAll wrappers (not individually exported). + ********************************************************************/ + +const assert = require('assert'); +const v = require('../../../src/common/validator.js'); + +describe('Encoder input validator', function () { + + describe('validateRawTxHex', function () { + it('accepts well-formed hex and returns it', function () { + assert.strictEqual(v.validateRawTxHex('deadbeef'), 'deadbeef'); + }); + it('rejects non-strings, empty, odd-length and non-hex input', function () { + assert.throws(() => v.validateRawTxHex(null), /non-empty hex/); + assert.throws(() => v.validateRawTxHex(42), /non-empty hex/); + assert.throws(() => v.validateRawTxHex(''), /non-empty hex/); + assert.throws(() => v.validateRawTxHex('abc'), /even-length hex/); + assert.throws(() => v.validateRawTxHex('zz00'), /even-length hex/); + }); + it('accepts hex between the p2shHex cap and the broadcast cap (an envelope reveal)', function () { + // A signed TAPROOT envelope reveal is ~810,000 hex chars, + // above MAX_RAW_TX_HEX_LENGTH (which still bounds p2shHex) but + // within the broadcast_tx ceiling. + const hex = 'ab'.repeat(v.MAX_RAW_TX_HEX_LENGTH / 2 + 1); + assert.strictEqual(v.validateRawTxHex(hex), hex); + }); + it('rejects hex above MAX_BROADCAST_TX_HEX_LENGTH', function () { + assert.ok(v.MAX_BROADCAST_TX_HEX_LENGTH > v.MAX_RAW_TX_HEX_LENGTH); + assert.throws( + () => v.validateRawTxHex('ab'.repeat(v.MAX_BROADCAST_TX_HEX_LENGTH / 2 + 1)), + /exceeds maximum length/ + ); + }); + }); +}); diff --git a/test/unit/validator.test/14_validate_compressed_pub_key.test.js b/test/unit/validator.test/14_validate_compressed_pub_key.test.js new file mode 100644 index 0000000..dc60941 --- /dev/null +++ b/test/unit/validator.test/14_validate_compressed_pub_key.test.js @@ -0,0 +1,35 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * Unit tests for src/common/validator.js: centralized createTransaction input + * validation. Every validator returns the coerced value or throws + * TypeError/RangeError. validateCustomOutput / validateFeeQuote are exercised + * through their array/validateAll wrappers (not individually exported). + ********************************************************************/ + +const assert = require('assert'); +const v = require('../../../src/common/validator.js'); + +const HEX64 = 'a'.repeat(64); + +describe('Encoder input validator', function () { + + describe('validateCompressedPubKey', function () { + it('null passes; enforces the 02/03 + 64-hex shape', function () { + assert.strictEqual(v.validateCompressedPubKey(null), null); + assert.strictEqual(v.validateCompressedPubKey('02' + HEX64), '02' + HEX64); + assert.throws(() => v.validateCompressedPubKey('04' + HEX64), /02 or 03/); + assert.throws(() => v.validateCompressedPubKey('nope'), TypeError); + }); + }); +}); diff --git a/test/unit/validator.test/15_validate_change.test.js b/test/unit/validator.test/15_validate_change.test.js new file mode 100644 index 0000000..d0d1a35 --- /dev/null +++ b/test/unit/validator.test/15_validate_change.test.js @@ -0,0 +1,33 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * Unit tests for src/common/validator.js: centralized createTransaction input + * validation. Every validator returns the coerced value or throws + * TypeError/RangeError. validateCustomOutput / validateFeeQuote are exercised + * through their array/validateAll wrappers (not individually exported). + ********************************************************************/ + +const assert = require('assert'); +const v = require('../../../src/common/validator.js'); + +describe('Encoder input validator', function () { + + describe('validateChange', function () { + it('null passes; rejects empty and over-long', function () { + assert.strictEqual(v.validateChange(null), null); + assert.strictEqual(v.validateChange('addr'), 'addr'); + assert.throws(() => v.validateChange(''), /non-empty/); + assert.throws(() => v.validateChange('x'.repeat(101)), /maximum length/); + }); + }); +}); diff --git a/test/unit/validator.test/16_validate_address.test.js b/test/unit/validator.test/16_validate_address.test.js new file mode 100644 index 0000000..ef1a9e8 --- /dev/null +++ b/test/unit/validator.test/16_validate_address.test.js @@ -0,0 +1,37 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Unit tests for src/common/validator.js: centralized createTransaction input + * validation. Every validator returns the coerced value or throws + * TypeError/RangeError. validateCustomOutput / validateFeeQuote are exercised + * through their array/validateAll wrappers (not individually exported). + ********************************************************************/ + +const assert = require('assert'); +const v = require('../../../src/common/validator.js'); + +describe('Encoder input validator', function () { + + describe('validateAddress', function () { + it('accepts a valid string (incl. exactly 100 chars); rejects empty, non-string, and over-length', function () { + assert.strictEqual(v.validateAddress('addr'), 'addr'); + assert.strictEqual(v.validateAddress('x'.repeat(100)), 'x'.repeat(100)); + assert.throws(() => v.validateAddress(''), /non-empty string/); + assert.throws(() => v.validateAddress({}), /non-empty string/); + assert.throws(() => v.validateAddress([]), /non-empty string/); + assert.throws(() => v.validateAddress(123), /non-empty string/); + assert.throws(() => v.validateAddress(null), /non-empty string/); + assert.throws(() => v.validateAddress('x'.repeat(101)), /maximum length/); + }); + }); +}); diff --git a/test/unit/validator.test/17_validate_all.test.js b/test/unit/validator.test/17_validate_all.test.js new file mode 100644 index 0000000..f02ca78 --- /dev/null +++ b/test/unit/validator.test/17_validate_all.test.js @@ -0,0 +1,112 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Unit tests for src/common/validator.js: centralized createTransaction input + * validation. Every validator returns the coerced value or throws + * TypeError/RangeError. validateCustomOutput / validateFeeQuote are exercised + * through their array/validateAll wrappers (not individually exported). + ********************************************************************/ + +const assert = require('assert'); +const v = require('../../../src/common/validator.js'); + +const HEX64 = 'a'.repeat(64); + +describe('Encoder input validator', function () { + + describe('validateAll', function () { + it('throws when params is not an object', function () { + assert.throws(() => v.validateAll(null), /must be an object/); + assert.throws(() => v.validateAll('x'), /must be an object/); + }); + + it('returns a fully-coerced, normalized param set', function () { + const result = v.validateAll({ + data: 'SEND', + rawData: 'world', + pubkey: '02ab', + encoding: 'OP_RETURN', + fee: '1000', + feePerKb: '2', + dust: '546', + utxos: [{ txid: HEX64, vout: '0', value: '5000', scriptPubKey: 'aa' }], + customOutputs: [{ address: 'addr', value: '10' }], + feeQuote: { address: 'fqaddr', amount: '999' }, + p2shHash: HEX64, + p2shHex: 'deadbeef', + compressedPubKey: '03' + HEX64, + change: 'changeaddr', + rbf: true, + unconfirmed: false, + }); + assert.strictEqual(result.fee, 1000); + assert.strictEqual(result.utxos[0].value, 5000); + assert.strictEqual(result.customOutputs[0].value, 10); + assert.strictEqual(result.feeQuote.amount, 999); + assert.strictEqual(result.rbf, true); + assert.strictEqual(result.unconfirmed, false); + }); + + it('rejects non-boolean rbf/unconfirmed instead of truthiness-coercing (string "false" must not flip policy to true)', function () { + const base = { data: 'SEND', pubkey: '02ab' }; + assert.throws(() => v.validateAll({ ...base, unconfirmed: 'false' }), TypeError); + assert.throws(() => v.validateAll({ ...base, unconfirmed: 'true' }), /unconfirmed must be a boolean/); + assert.throws(() => v.validateAll({ ...base, rbf: 'false' }), /rbf must be a boolean/); + assert.throws(() => v.validateAll({ ...base, rbf: 1 }), TypeError); + assert.throws(() => v.validateAll({ ...base, unconfirmed: 0 }), TypeError); + }); + + it('rbf/unconfirmed absent or null stay undefined so downstream defaults apply', function () { + const base = { data: 'SEND', pubkey: '02ab' }; + const absent = v.validateAll(base); + assert.strictEqual(absent.rbf, undefined); + assert.strictEqual(absent.unconfirmed, undefined); + const nulled = v.validateAll({ ...base, rbf: null, unconfirmed: null }); + assert.strictEqual(nulled.rbf, undefined); + assert.strictEqual(nulled.unconfirmed, undefined); + }); + + }); + + describe('validateAll', function () { + it('exercises validateFeeQuote validation paths through validateAll', function () { + const base = { data: 'SEND', pubkey: '02ab' }; + assert.throws(() => v.validateAll({ ...base, feeQuote: 'no' }), /feeQuote must be an object/); + // An array is typeof 'object'; it must fail the SHAPE check, not the + // address check one line later. + assert.throws(() => v.validateAll({ ...base, feeQuote: [] }), /feeQuote must be an object/); + assert.throws(() => v.validateAll({ ...base, feeQuote: ['1Addr', 1000] }), /feeQuote must be an object/); + assert.throws(() => v.validateAll({ ...base, feeQuote: { address: '', amount: 1 } }), /address must be a non-empty/); + assert.throws(() => v.validateAll({ ...base, feeQuote: { address: 'x'.repeat(101), amount: 1 } }), /maximum length/); + assert.throws(() => v.validateAll({ ...base, feeQuote: { address: 'a', amount: 0 } }), /positive integer/); + assert.throws(() => v.validateAll({ ...base, feeQuote: { address: 'a', amount: v.MAX_FEE_SATOSHIS + 1 } }), /exceeds maximum/); + assert.strictEqual(v.validateAll({ ...base, feeQuote: null }).feeQuote, null); + }); + + it('runs the combined-length pre-check on rawData-only requests', function () { + const result = v.validateAll({ data: null, rawData: 'x'.repeat(100), pubkey: '02ab' }); + assert.strictEqual(result.data, null); + assert.strictEqual(result.rawData, 'x'.repeat(100)); + // Oversize rawData-only is rejected here (invalid params), not left + // to createTransaction's compiled-size ceiling (internal error). + assert.throws(() => v.validateAll({ data: null, rawData: 'x'.repeat(8189), pubkey: '02ab' }), RangeError); + }); + + it('rejects an absent or null pubkey (openrpc marks it required)', function () { + // pubkey omitted / null must fail up front (RangeError -> -32602) rather + // than reaching fromBase58Check(null) deep in createTransaction. + assert.throws(() => v.validateAll({ data: 'SEND' }), /pubkey is required/); + assert.throws(() => v.validateAll({ data: 'SEND', pubkey: null }), /pubkey is required/); + }); + }); +}); From 478c2cc5b91230eab7de6bfd13cf803b4edea566 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 08:27:14 -0700 Subject: [PATCH 127/148] chore(pins): declare the 8 test-file splits in suite-title-splits.json --- bin/pins/suite-title-splits.json | 93 ++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/bin/pins/suite-title-splits.json b/bin/pins/suite-title-splits.json index 044a1e2..5066b74 100644 --- a/bin/pins/suite-title-splits.json +++ b/bin/pins/suite-title-splits.json @@ -94,6 +94,99 @@ "test/xchain_encoder.test/01_should_create_a_p2sh_tx.test.js", "test/xchain_encoder.test/02_should_create_a_multisign_tx.test.js", "test/xchain_encoder.test/03_should_create_a_p2wsh_tx.test.js" + ], + "test/boundary/encoding_chunk_boundaries.test.js": [ + "test/boundary/encoding_chunk_boundaries.test.js", + "test/boundary/encoding_chunk_boundaries.test/01_op_return_chunk_split_threshold_forced_encoding.test.js", + "test/boundary/encoding_chunk_boundaries.test/02_p2sh_chunk_split_threshold.test.js", + "test/boundary/encoding_chunk_boundaries.test/03_p2wsh_real_world_boundary_bitcoin_regtest_only.test.js", + "test/boundary/encoding_chunk_boundaries.test/04_p2wsh_on_dogecoin_no_bech32_in_network_config.test.js", + "test/boundary/encoding_chunk_boundaries.test/05_multisign_encoding_boundaries.test.js", + "test/boundary/encoding_chunk_boundaries.test/06_data_to_pubkey_unit_boundaries.test.js", + "test/boundary/encoding_chunk_boundaries.test/07_forced_encoding_override.test.js", + "test/boundary/encoding_chunk_boundaries.test/08_single_op_return_policy_enforcement_always_fail_closed.test.js", + "test/boundary/encoding_chunk_boundaries.test/09_p2sh_p2wsh_compiled_chunk_size_ceiling.test.js" + ], + "test/e2e/action_pipeline_e2e.test.js": [ + "test/e2e/action_pipeline_e2e.test.js", + "test/e2e/action_pipeline_e2e.test/01_e2e_1_14_airdrop.test.js", + "test/e2e/action_pipeline_e2e.test/02_e2e_1_21_broadcast.test.js", + "test/e2e/action_pipeline_e2e.test/03_e2e_1_28_batch.test.js", + "test/e2e/action_pipeline_e2e.test/04_structural_invariants_across_all_action_types.test.js" + ], + "test/e2e/utxo_fee_change_e2e.test.js": [ + "test/e2e/utxo_fee_change_e2e.test.js", + "test/e2e/utxo_fee_change_e2e.test/01_e2e_5_2_multiple_utxos_aggregated.test.js", + "test/e2e/utxo_fee_change_e2e.test/02_e2e_5_3_utxo_deduplication.test.js", + "test/e2e/utxo_fee_change_e2e.test/03_e2e_5_4_unconfirmed_utxos_excluded.test.js", + "test/e2e/utxo_fee_change_e2e.test/04_e2e_5_5_unconfirmed_utxos_included.test.js", + "test/e2e/utxo_fee_change_e2e.test/05_e2e_5_6_utxo_tracker_fallback.test.js", + "test/e2e/utxo_fee_change_e2e.test/06_e2e_5_7_fee_floor_enforcement.test.js", + "test/e2e/utxo_fee_change_e2e.test/07_e2e_5_8_fee_cap_enforcement.test.js", + "test/e2e/utxo_fee_change_e2e.test/08_e2e_5_9_no_change_address_error.test.js", + "test/e2e/utxo_fee_change_e2e.test/09_e2e_5_10_legacy_utxo_handling.test.js", + "test/e2e/utxo_fee_change_e2e.test/10_e2e_5_11_segwit_utxo_handling.test.js", + "test/e2e/utxo_fee_change_e2e.test/11_e2e_5_12_rbf_sequence_flag.test.js", + "test/e2e/utxo_fee_change_e2e.test/12_e2e_5_13_custom_outputs_coexist_with_action.test.js", + "test/e2e/utxo_fee_change_e2e.test/13_e2e_5_14_explicit_fee_per_kb_bypasses_rpc.test.js", + "test/e2e/utxo_fee_change_e2e.test/14_e2e_5_15_null_fee_per_kb_triggers_rpc_call.test.js" + ], + "test/integration/utxo_fee.test.js": [ + "test/integration/utxo_fee.test.js", + "test/integration/utxo_fee.test/01_d_2_multiple_utxos_needed.test.js", + "test/integration/utxo_fee.test/02_d_3_duplicate_utxo_deduplication.test.js", + "test/integration/utxo_fee.test/03_d_4_unconfirmed_filtering.test.js", + "test/integration/utxo_fee.test/04_d_5_utxo_tracker_fallback.test.js", + "test/integration/utxo_fee.test/05_d_6_no_utxos_available.test.js", + "test/integration/utxo_fee.test/06_d_7_fee_capped_by_max_fee_per_bytes.test.js", + "test/integration/utxo_fee.test/07_d_8_dust_floor_on_fee.test.js", + "test/integration/utxo_fee.test/08_d_9_no_change_address.test.js", + "test/integration/utxo_fee.test/09_d_10_legacy_non_segwit_utxo_handling.test.js", + "test/integration/utxo_fee.test/10_utxo_sorting_largest_first.test.js", + "test/integration/utxo_fee.test/11_replace_by_fee_sequence.test.js", + "test/integration/utxo_fee.test/12_fee_source_selection.test.js" + ], + "test/regression/reg_04_validator.test.js": [ + "test/regression/reg_04_validator.test.js", + "test/regression/reg_04_validator.test/01_validate_utxo_entry.test.js", + "test/regression/reg_04_validator.test/02_validate_utxo_array.test.js", + "test/regression/reg_04_validator.test/03_validate_custom_outputs.test.js", + "test/regression/reg_04_validator.test/04_validate_p2sh_params.test.js", + "test/regression/reg_04_validator.test/05_validate_compressed_pub_key.test.js", + "test/regression/reg_04_validator.test/06_validate_change.test.js", + "test/regression/reg_04_validator.test/07_validate_all_integration.test.js" + ], + "test/security/concurrency_gate.test.js": [ + "test/security/concurrency_gate.test.js", + "test/security/concurrency_gate.test/01_resolve_limit.test.js", + "test/security/concurrency_gate.test/02_api_js_wiring.test.js" + ], + "test/smoke/smoke.test.js": [ + "test/smoke/smoke.test.js", + "test/smoke/smoke.test/01_tx_size_estimator.test.js", + "test/smoke/smoke.test/02_segwit_utxo_detection.test.js", + "test/smoke/smoke.test/03_data_to_pubkey.test.js", + "test/smoke/smoke.test/04_api_server_startup.test.js" + ], + "test/unit/validator.test.js": [ + "test/unit/validator.test.js", + "test/unit/validator.test/01_validate_data_param.test.js", + "test/unit/validator.test/02_validate_action_name_encoder_action_name_gate.test.js", + "test/unit/validator.test/03_validate_combined_data_length.test.js", + "test/unit/validator.test/04_alias_expansion_at_the_compiled_ceiling.test.js", + "test/unit/validator.test/05_validate_encoding.test.js", + "test/unit/validator.test/06_max_fee_satoshis.test.js", + "test/unit/validator.test/07_validate_fee.test.js", + "test/unit/validator.test/08_validate_fee_per_kb.test.js", + "test/unit/validator.test/09_validate_dust.test.js", + "test/unit/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js", + "test/unit/validator.test/11_validate_custom_outputs_validate_custom_output_via_wrapper.test.js", + "test/unit/validator.test/12_validate_p2sh_params.test.js", + "test/unit/validator.test/13_validate_raw_tx_hex.test.js", + "test/unit/validator.test/14_validate_compressed_pub_key.test.js", + "test/unit/validator.test/15_validate_change.test.js", + "test/unit/validator.test/16_validate_address.test.js", + "test/unit/validator.test/17_validate_all.test.js" ] } } From 37600686f2bcce1628e26cdc2f977cce04223f9f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 08:52:53 -0700 Subject: [PATCH 128/148] refactor(encoder): split the compression guards, the input size classifier and the gate slot wiring into named steps --- src/build/compression.js | 100 +++++++++++++++++-------------- src/build/tx_size_estimator.js | 77 +++++++++++++----------- src/server/concurrency_gate.js | 105 ++++++++++++++++++--------------- 3 files changed, 155 insertions(+), 127 deletions(-) diff --git a/src/build/compression.js b/src/build/compression.js index e92b845..20a39b3 100644 --- a/src/build/compression.js +++ b/src/build/compression.js @@ -76,7 +76,7 @@ function isFileV0Action(actionString){ } // Is this FILE token-gated? Load-bearing, see the carve-out in -// compressPayloadForAction below. +// guardCompressible below. function isGatedFileAction(actionString){ if (!isFileV0Action(actionString)) return false const parts = actionString.split('|') @@ -104,6 +104,56 @@ function withCompressionField(actionString, value){ return parts.join('|') } +// The four conditions under which the encoder must not compress a payload, +// checked in order. Returns null when compression may proceed, otherwise the +// caller's refusal (which throws or hands the payload back raw). +function guardCompressible(actionString, rawDataBuffer, maxInputBytes, refuse){ + // GUARD 1: COMPRESSION is a FILE v0 field. Compressing another action's + // payload would produce bytes no reader can reconstruct, because there is + // nowhere to record that they were compressed. + if (!isFileV0Action(actionString)) + return refuse(TypeError, 'not-a-file-action', + 'Compression requested, but COMPRESSION is a FILE v0 field: ' + + 'refusing to compress a payload whose action cannot carry the marker.') + + // GUARD 2: gated FILEs are carved out, and this is the subtle one. + // + // On a gated FILE, COMPRESSION=1 means "inflate AFTER decrypt": + // the field describes the PLAINTEXT, compressed by the client before + // encryption. If the encoder also compressed the ciphertext and set the + // same field, the marker would be ambiguous and a reader would try to + // inflate ciphertext. So a gated FILE's COMPRESSION field belongs solely + // to whoever performed the compress-then-encrypt, and the encoder passes + // it through untouched. + // + // (In practice GCM ciphertext is incompressible, so try-and-keep would + // almost always emit raw anyway. Almost is not a guarantee, and the + // failure mode is permanently unreadable published data.) + if (isGatedFileAction(actionString)) + return refuse(TypeError, 'gated-file', + 'Compression requested for a token-gated FILE. On a gated FILE the ' + + 'COMPRESSION field means inflate-after-decrypt and belongs to the client that ' + + 'performed compress-then-encrypt; the encoder must not set it. ' + + 'Compress the plaintext before encrypting and pass the field in the action string.') + + // GUARD 3: never overwrite a marker the caller already set. If a caller + // asserts a codec, those bytes are theirs; silently re-compressing them + // would double-encode. + const declared = compressionFieldOf(actionString) + if (declared.length > 0) + return refuse(TypeError, 'codec-already-declared', + `Compression requested, but the action already declares COMPRESSION='${declared}'. ` + + 'Refusing to re-compress bytes whose codec the caller already asserted.') + + // GUARD 4: pre-compression input cap. + if (rawDataBuffer.length > maxInputBytes) + return refuse(RangeError, 'over-input-cap', + `Payload of ${rawDataBuffer.length} bytes exceeds the ` + + `${maxInputBytes}-byte compression input cap.`) + + return null +} + // SIZE CEILINGS ARE NOT THIS PASS'S JOB (AML #5308). validator.js measures the // caller's PRE-compression bytes and refuses an oversize payload before this // runs, so compression never rescues one; it only shrinks what is already @@ -120,7 +170,7 @@ function withCompressionField(actionString, value){ * @param {number} [options.maxRatio] * @param {number} [options.maxInputBytes] * @param {boolean} [options.explicit=true] - did the CALLER ask for compression, - * or is this the encoder's default pass? It decides what the guards below do + * or is this the encoder's default pass? It decides what guardCompressible does * when a payload cannot be compressed safely. An explicit request that cannot * be honoured is an error the caller must see (they asked for something this * payload cannot have). The DEFAULT pass runs over every action, most of which @@ -131,7 +181,7 @@ function withCompressionField(actionString, value){ * rawLength: number, storedLength: number, reason: string|null}>} * `data` and `rawData` are what the transaction must actually carry. * @throws TypeError when compression was explicitly requested but cannot be - * applied safely (see the guards below: they fail CLOSED, because emitting + * applied safely (see guardCompressible: its guards fail CLOSED, because emitting * compressed bytes without a correct marker publishes permanently * unreadable data, and the money is already spent by the time anyone finds * out). @@ -162,48 +212,8 @@ async function compressPayloadForAction(actionString, rawDataBuffer, options = { return unchanged(reason) } - // GUARD 1: COMPRESSION is a FILE v0 field. Compressing another action's - // payload would produce bytes no reader can reconstruct, because there is - // nowhere to record that they were compressed. - if (!isFileV0Action(actionString)) - return refuse(TypeError, 'not-a-file-action', - 'Compression requested, but COMPRESSION is a FILE v0 field: ' + - 'refusing to compress a payload whose action cannot carry the marker.') - - // GUARD 2: gated FILEs are carved out, and this is the subtle one. - // - // On a gated FILE, COMPRESSION=1 means "inflate AFTER decrypt": - // the field describes the PLAINTEXT, compressed by the client before - // encryption. If the encoder also compressed the ciphertext and set the - // same field, the marker would be ambiguous and a reader would try to - // inflate ciphertext. So a gated FILE's COMPRESSION field belongs solely - // to whoever performed the compress-then-encrypt, and the encoder passes - // it through untouched. - // - // (In practice GCM ciphertext is incompressible, so try-and-keep would - // almost always emit raw anyway. Almost is not a guarantee, and the - // failure mode is permanently unreadable published data.) - if (isGatedFileAction(actionString)) - return refuse(TypeError, 'gated-file', - 'Compression requested for a token-gated FILE. On a gated FILE the ' + - 'COMPRESSION field means inflate-after-decrypt and belongs to the client that ' + - 'performed compress-then-encrypt; the encoder must not set it. ' + - 'Compress the plaintext before encrypting and pass the field in the action string.') - - // GUARD 3: never overwrite a marker the caller already set. If a caller - // asserts a codec, those bytes are theirs; silently re-compressing them - // would double-encode. - const declared = compressionFieldOf(actionString) - if (declared.length > 0) - return refuse(TypeError, 'codec-already-declared', - `Compression requested, but the action already declares COMPRESSION='${declared}'. ` + - 'Refusing to re-compress bytes whose codec the caller already asserted.') - - // GUARD 4: pre-compression input cap. - if (rawDataBuffer.length > maxInputBytes) - return refuse(RangeError, 'over-input-cap', - `Payload of ${rawDataBuffer.length} bytes exceeds the ` + - `${maxInputBytes}-byte compression input cap.`) + const refused = guardCompressible(actionString, rawDataBuffer, maxInputBytes, refuse) + if (refused) return refused let deflated try { diff --git a/src/build/tx_size_estimator.js b/src/build/tx_size_estimator.js index 896af44..f40be6c 100644 --- a/src/build/tx_size_estimator.js +++ b/src/build/tx_size_estimator.js @@ -37,6 +37,47 @@ function compactSizeLen(n) { return 9 } +// Input vSize by scriptPubKey pattern, once the script is in hand: the segwit +// shapes first (only reachable from a witnessUtxo), then the legacy ones, and +// the conservative 350 fallback for anything unrecognized. +function inputSizeForScript(scriptPubKey, isSegwit) { + // Convert to hex string to detect patterns + const scriptHex = scriptPubKey.toString('hex') + + if (isSegwit) { + if (scriptHex.startsWith('0014')) { + // P2WPKH: 0014{20-byte hash} + return 68 + } + if (scriptHex.startsWith('0020')) { + // P2WSH: 0020{32-byte hash} - Assuming 2-3 Multisig + return 105 + } + 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 + // fell through to the 350 fallback and wildly over-estimated fees. + return 58 + } + } + + // P2PKH (Legacy): 76a914{20-byte hash}88ac + if (scriptHex.startsWith('76a914') && scriptHex.endsWith('88ac')) { + return 180 + } + + // P2SH (Legacy): a914{20-byte hash}87 + if (scriptHex.startsWith('a914') && scriptHex.endsWith('87')) { + // **Conservative Estimate for P2SH:** + // Assuming 2-3 Legacy Multisig (The most expensive option, ~289 bytes). + // If the input is a nested P2SH-P2WPKH, the transaction will be like segwit + // and this path cannot be taken, so if it gets here and it's a P2SH, most probably is it's Legacy. + return 289 + } + + return 350 +} + class TxSizeEstimator { // OP_RETURN output = 8 (value) + script-length compactSize + scriptPubKey, // where scriptPubKey = OP_RETURN (1) + the compiled data push. Push framing @@ -172,41 +213,7 @@ class TxSizeEstimator { return 350 } - // Convert to hex string to detect patterns - const scriptHex = scriptPubKey.toString('hex') - - if (isSegwit) { - if (scriptHex.startsWith('0014')) { - // P2WPKH: 0014{20-byte hash} - return 68 - } - if (scriptHex.startsWith('0020')) { - // P2WSH: 0020{32-byte hash} - Assuming 2-3 Multisig - return 105 - } - 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 - // fell through to the 350 fallback and wildly over-estimated fees. - return 58 - } - } - - // P2PKH (Legacy): 76a914{20-byte hash}88ac - if (scriptHex.startsWith('76a914') && scriptHex.endsWith('88ac')) { - return 180 - } - - // P2SH (Legacy): a914{20-byte hash}87 - if (scriptHex.startsWith('a914') && scriptHex.endsWith('87')) { - // **Conservative Estimate for P2SH:** - // Assuming 2-3 Legacy Multisig (The most expensive option, ~289 bytes). - // If the input is a nested P2SH-P2WPKH, the transaction will be like segwit - // and this path cannot be taken, so if it gets here and it's a P2SH, most probably is it's Legacy. - return 289 - } - - return 350 + return inputSizeForScript(scriptPubKey, isSegwit) } } diff --git a/src/server/concurrency_gate.js b/src/server/concurrency_gate.js index e441275..8dfb8ed 100644 --- a/src/server/concurrency_gate.js +++ b/src/server/concurrency_gate.js @@ -42,6 +42,62 @@ function resolveLimit(rawValue, defaultLimit){ return limit > 0 ? limit : 0; } +// Record an admitted request's slot on `req` under the gate's SLOT key and +// free it when the socket ends, unless a hold() wrapper has claimed it. +function attachSlot(req, res, SLOT, release){ + const slot = { release, claimed: false }; + req[SLOT] = slot; + + // 'finish' fires on a fully-sent response; 'close' on a client abort or + // a handler that never answers. Whichever lands first frees the slot, + // and the guard makes the pair idempotent (both fire on a normal + // response). Without the 'close' leg an aborted request would leak its + // slot permanently and the gate would ratchet shut on a live service. + // + // The socket lifetime is NOT the work lifetime, though. Express never + // awaits an async handler, so a client that hangs up mid-build frees its + // slot here while the create_tx behind it is still fanning out into node + // and tracker RPCs: abort-spam then admits work past the cap while + // in_flight reads zero, defeating the gate with the exact stampede shape + // it exists to shed. A handler wrapped in hold() claims its slot and + // answers for it itself, which leaves these legs as the anti-leak path + // for whatever nobody wrapped. + const releaseIfUnclaimed = () => { if(!slot.claimed) release(); }; + res.on('finish', releaseIfUnclaimed); + res.on('close', releaseIfUnclaimed); +} + +/** + * Bind a handler's slot to the WORK instead of to the socket. + * + * The wrapped handler owns its slot from entry until its promise settles, so + * an aborted request keeps counting against the cap for exactly as long as + * its upstream work is still running. A request with no slot (gate disabled + * by a cap <= 0, or exempted by `skip`) is passed straight through, so + * wrapping is safe on every route the gate may or may not have admitted. + * + * This changes only WHEN the counter is decremented. Nothing is cancelled: + * create_tx, broadcast_tx and the outpoint reservations they take are money + * paths where an aborted call could strand transaction state, so every + * request still runs to completion exactly as it did before. + * + * @param {symbol} SLOT The gate's per-request slot key. + * @param {function} handler Express handler or middleware. + * @returns {function} The handler, holding its slot until it settles. + */ +function holdSlot(SLOT, handler){ + return async function heldHandler(req, res, next){ + const slot = req[SLOT]; + if(!slot) return handler(req, res, next); + slot.claimed = true; + try { + return await handler(req, res, next); + } finally { + slot.release(); + } + }; +} + /** * Build the gate middleware. * @@ -84,57 +140,12 @@ function createConcurrencyGate(options){ released = true; inFlight--; }; - const slot = { release, claimed: false }; - req[SLOT] = slot; - - // 'finish' fires on a fully-sent response; 'close' on a client abort or - // a handler that never answers. Whichever lands first frees the slot, - // and the guard makes the pair idempotent (both fire on a normal - // response). Without the 'close' leg an aborted request would leak its - // slot permanently and the gate would ratchet shut on a live service. - // - // The socket lifetime is NOT the work lifetime, though. Express never - // awaits an async handler, so a client that hangs up mid-build frees its - // slot here while the create_tx behind it is still fanning out into node - // and tracker RPCs: abort-spam then admits work past the cap while - // in_flight reads zero, defeating the gate with the exact stampede shape - // it exists to shed. A handler wrapped in hold() claims its slot and - // answers for it itself, which leaves these legs as the anti-leak path - // for whatever nobody wrapped. - const releaseIfUnclaimed = () => { if(!slot.claimed) release(); }; - res.on('finish', releaseIfUnclaimed); - res.on('close', releaseIfUnclaimed); + attachSlot(req, res, SLOT, release); next(); }; - /** - * Bind a handler's slot to the WORK instead of to the socket. - * - * The wrapped handler owns its slot from entry until its promise settles, so - * an aborted request keeps counting against the cap for exactly as long as - * its upstream work is still running. A request with no slot (gate disabled - * by a cap <= 0, or exempted by `skip`) is passed straight through, so - * wrapping is safe on every route the gate may or may not have admitted. - * - * This changes only WHEN the counter is decremented. Nothing is cancelled: - * create_tx, broadcast_tx and the outpoint reservations they take are money - * paths where an aborted call could strand transaction state, so every - * request still runs to completion exactly as it did before. - * - * @param {function} handler Express handler or middleware. - * @returns {function} The handler, holding its slot until it settles. - */ - middleware.hold = (handler) => async function heldHandler(req, res, next){ - const slot = req[SLOT]; - if(!slot) return handler(req, res, next); - slot.claimed = true; - try { - return await handler(req, res, next); - } finally { - slot.release(); - } - }; + middleware.hold = (handler) => holdSlot(SLOT, handler); middleware.limit = limit; // Operational surface: a climbing `shed` is the signal that a stampede is From dbb2f0826e4631d1a18e59564a26d7ab5ed6e7ea Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 08:52:57 -0700 Subject: [PATCH 129/148] refactor(encoder): split the utxo page walk and the instance lock acquisition into named steps --- src/build/utxo_tracker.js | 313 +++++++++++++++------------- src/server/single_instance_guard.js | 264 ++++++++++++----------- 2 files changed, 312 insertions(+), 265 deletions(-) diff --git a/src/build/utxo_tracker.js b/src/build/utxo_tracker.js index 9ee190d..dff47b9 100644 --- a/src/build/utxo_tracker.js +++ b/src/build/utxo_tracker.js @@ -65,6 +65,158 @@ function snapshotDivergence(first, later){ return null } +// Pre-flight: refuse to fetch UTXOs while the tracker is lagging the +// chain tip. A stale UTXO view can select already-spent outputs and +// produce transactions the network rejects, which would otherwise fail +// silently. Converting this into an explicit, catchable error lets +// callers (e.g. the oracle PRICE submission path) surface or retry. +async function assertTrackerReady(tracker){ + try { + const syncStatus = await tracker.getSyncStatus() + const lag = syncStatus.lag + if (lag === null || typeof lag === 'undefined') { + throw new Error('utxo-tracker has not indexed any blocks yet; refusing to fetch UTXOs') + } + // Halt is a verdict the tracker publishes independently of `synced`: it + // has stopped polling on an unrecoverable reorg and its store is frozen, + // possibly mid-rollback. A frozen height whose lag still looked acceptable + // sailed past both checks below, so gate on it first. + if (syncStatus.halted === true) { + throw new Error(`utxo-tracker is halted (${syncStatus.halt_reason || 'unrecoverable reorg'}); refusing to fetch UTXOs`) + } + // A negative lag means our committed tip is ABOVE the node's, i.e. the node + // reset or reindexed below us and those outputs sit in blocks it no longer + // recognizes. Checked here as well as at the tracker so an un-upgraded + // tracker still reporting synced:true cannot authorize selection over + // orphaned UTXOs (the fail-open boundary's own guard). + if (lag < 0) { + throw new Error(`utxo-tracker is ${-lag} blocks ahead of the node (node reset or reorged below its tip); refusing to fetch UTXOs`) + } + // Delegate the sync verdict to the tracker, which computes `synced` + // against its own authoritative threshold. Avoids drift from keeping + // a local copy of the threshold here. + if (!syncStatus.synced) { + throw new Error(`utxo-tracker is lagging by ${lag} blocks; refusing to fetch UTXOs`) + } + // Block sync flips true before the first mempool rebuild finishes, and until + // it does the empty mempool index cannot filter a confirmed output already + // spent in the node's mempool, so the fetch hands back unspendable inputs. + // Same refusal create_tx makes as UTXO_TRACKER_NOT_READY; strict === false + // keeps a tracker predating the field on the fail-open path. + // Ordered LAST of the three, most specific cause first, exactly as + // XChainEncoder.js orders its own gates: get_sync_status derives + // mempool_ready as `synced && isMempoolReconverged()`, so a merely + // lagging tracker de-asserts it too, and checking it first blamed + // mempool reconvergence for a fault that is really block lag. + if (syncStatus.mempool_ready === false) { + throw new Error('utxo-tracker has not reconverged its mempool yet, so an already-spent confirmed output cannot be filtered; refusing to fetch UTXOs') + } + } catch (error) { + logger.error(util.format('Error checking UTXO tracker sync status:', error)); + throw error; + } +} + +// One get_utxos page: `cursor` is the previous page's nextCursor, absent on +// the first page. Returns the JSON-RPC response body as the tracker sent it. +async function fetchUtxoPage(tracker, address, limit, cursor){ + const params = { address, limit } + if (cursor !== undefined) { + params.after = cursor + } + + const data = { + jsonrpc: '2.0', + method: 'get_utxos', + params, + id: 1 + }; + + const response = await axios.post(tracker.url, data, { + timeout: TRACKER_TIMEOUT + }); + + return response.data; +} + +// Unwrap one page's result, or throw the error the tracker sent in its place. +function readUtxoResult(responseData){ + // Verify structure or surface the tracker's structured error. + if (responseData.result && typeof responseData.result === 'object' && responseData.result !== null) { + const result = responseData.result + if (!Array.isArray(result.utxos)) { + throw new TypeError('UTXO tracker result missing utxos array') + } + return result + } + // Surface the structured error the tracker returned (e.g. ADDRESS_TOO_LARGE, + // INVALID_CURSOR) rather than discarding it. The tracker maps these to + // meaningful error codes/messages at the JSON-RPC layer; losing them here + // makes the operator see the same opaque message for every failure mode. + // Prefer err.data.code (string code forwarded by the tracker) over err.code. + const rpcError = responseData.error + if (rpcError && (rpcError.message || rpcError.code || rpcError.data?.code)) { + const code = rpcError.data?.code || rpcError.code + throw new Error(`Error getting utxos: ${code ? `[${code}] ` : ''}${rpcError.message || 'unknown error'}`) + } + throw new Error('Error getting utxos: empty result') +} + +// Throw when a later page's `sync` sibling describes a different chain state +// from page 1's (see snapshotDivergence). +function assertSameSnapshot(firstPageSync, pageSync, address){ + const divergence = snapshotDivergence(firstPageSync, pageSync) + if (divergence) { + throw new Error(`utxo-tracker chain state changed while paginating utxos for ${address}: ` + + `${divergence}; refusing to merge pages read at different chain states (retry the request)`) + } +} + +// Validate one page's rows and append them to the running set. +function appendPageUtxos(utxos, allUtxos){ + // pageOffset is the count before this page so globalIdx + // across pages matches what a single-page caller would see. + const pageOffset = allUtxos.length + for (let i = 0; i < utxos.length; i++) { + const u = utxos[i] + const globalIdx = pageOffset + i + if (typeof u !== 'object' || u === null || + typeof u.txid !== 'string' || + typeof u.vout === 'undefined' || + typeof u.value === 'undefined') { + throw new TypeError(`UTXO tracker returned malformed utxo at index ${globalIdx}`) + } + if (!HEX_64_RE.test(u.txid)) { + throw new TypeError(`UTXO tracker returned malformed utxo at index ${globalIdx}: txid must be a 64-character hex string`) + } + if (typeof u.scriptPubKey !== 'string' || u.scriptPubKey.length === 0) { + throw new TypeError(`UTXO tracker returned malformed utxo at index ${globalIdx}: scriptPubKey must be a non-empty string`) + } + if (u.confirmations == null) { + u.confirmations = 0 + } else { + // Mirror validateUtxoEntry: the encoder's unconfirmed + // filter compares `confirmations == 0` loosely, so + // coerce and range-check tracker-supplied values too. + const confirmations = Number(u.confirmations) + if (!Number.isInteger(confirmations) || confirmations < 0) { + throw new TypeError(`UTXO tracker returned malformed utxo at index ${globalIdx}: confirmations must be a non-negative integer`) + } + u.confirmations = confirmations + } + allUtxos.push(u) + } +} + +// A repeated cursor means the tracker is cycling/stalled: abort rather +// than loop forever accumulating the same pages. +function assertFreshCursor(nextCursor, seenCursors, address){ + if (seenCursors.has(nextCursor)) { + throw new Error(`utxo-tracker returned a repeated pagination cursor for ${address}; aborting (stalled or hostile tracker)`) + } + seenCursors.add(nextCursor) +} + class UtxoTracker { constructor(url, port) { this.url = "http://"+url+":"+port @@ -99,55 +251,7 @@ class UtxoTracker { } async getUtxosFromAddress(address) { - // Pre-flight: refuse to fetch UTXOs while the tracker is lagging the - // chain tip. A stale UTXO view can select already-spent outputs and - // produce transactions the network rejects, which would otherwise fail - // silently. Converting this into an explicit, catchable error lets - // callers (e.g. the oracle PRICE submission path) surface or retry. - try { - const syncStatus = await this.getSyncStatus() - const lag = syncStatus.lag - if (lag === null || typeof lag === 'undefined') { - throw new Error('utxo-tracker has not indexed any blocks yet; refusing to fetch UTXOs') - } - // Halt is a verdict the tracker publishes independently of `synced`: it - // has stopped polling on an unrecoverable reorg and its store is frozen, - // possibly mid-rollback. A frozen height whose lag still looked acceptable - // sailed past both checks below, so gate on it first. - if (syncStatus.halted === true) { - throw new Error(`utxo-tracker is halted (${syncStatus.halt_reason || 'unrecoverable reorg'}); refusing to fetch UTXOs`) - } - // A negative lag means our committed tip is ABOVE the node's, i.e. the node - // reset or reindexed below us and those outputs sit in blocks it no longer - // recognizes. Checked here as well as at the tracker so an un-upgraded - // tracker still reporting synced:true cannot authorize selection over - // orphaned UTXOs (the fail-open boundary's own guard). - if (lag < 0) { - throw new Error(`utxo-tracker is ${-lag} blocks ahead of the node (node reset or reorged below its tip); refusing to fetch UTXOs`) - } - // Delegate the sync verdict to the tracker, which computes `synced` - // against its own authoritative threshold. Avoids drift from keeping - // a local copy of the threshold here. - if (!syncStatus.synced) { - throw new Error(`utxo-tracker is lagging by ${lag} blocks; refusing to fetch UTXOs`) - } - // Block sync flips true before the first mempool rebuild finishes, and until - // it does the empty mempool index cannot filter a confirmed output already - // spent in the node's mempool, so the fetch hands back unspendable inputs. - // Same refusal create_tx makes as UTXO_TRACKER_NOT_READY; strict === false - // keeps a tracker predating the field on the fail-open path. - // Ordered LAST of the three, most specific cause first, exactly as - // XChainEncoder.js orders its own gates: get_sync_status derives - // mempool_ready as `synced && isMempoolReconverged()`, so a merely - // lagging tracker de-asserts it too, and checking it first blamed - // mempool reconvergence for a fault that is really block lag. - if (syncStatus.mempool_ready === false) { - throw new Error('utxo-tracker has not reconverged its mempool yet, so an already-spent confirmed output cannot be filtered; refusing to fetch UTXOs') - } - } catch (error) { - logger.error(util.format('Error checking UTXO tracker sync status:', error)); - throw error; - } + await assertTrackerReady(this) // Paginate to handle addresses with more UTXOs than the tracker's // MAX_ADDRESS_OUTPUTS limit. Each page passes limit=10000 and threads @@ -174,103 +278,28 @@ class UtxoTracker { if (++pageCount > MAX_PAGES) { throw new Error(`utxo-tracker returned more than ${MAX_PAGES} pages for ${address}; aborting (buggy or hostile tracker)`) } - const params = { address, limit: PAGE_LIMIT } - if (cursor !== undefined) { - params.after = cursor - } + const responseData = await fetchUtxoPage(this, address, PAGE_LIMIT, cursor) + const result = readUtxoResult(responseData) - const data = { - jsonrpc: '2.0', - method: 'get_utxos', - params, - id: 1 - }; - - const response = await axios.post(this.url, data, { - timeout: TRACKER_TIMEOUT - }); - - const responseData = response.data; - - // Verify structure or surface the tracker's structured error. - if (responseData.result && typeof responseData.result === 'object' && responseData.result !== null) { - const result = responseData.result - if (!Array.isArray(result.utxos)) { - throw new TypeError('UTXO tracker result missing utxos array') - } - - // Refuse a merged set that spans two chain states. Checked BEFORE the - // page's rows are appended, so a diverged page contributes nothing. - const pageSync = (result.sync && typeof result.sync === 'object') ? result.sync : null - if (pageCount === 1) { - firstPageSync = pageSync - } else { - const divergence = snapshotDivergence(firstPageSync, pageSync) - if (divergence) { - throw new Error(`utxo-tracker chain state changed while paginating utxos for ${address}: ` + - `${divergence}; refusing to merge pages read at different chain states (retry the request)`) - } - } + // Refuse a merged set that spans two chain states. Checked BEFORE the + // page's rows are appended, so a diverged page contributes nothing. + const pageSync = (result.sync && typeof result.sync === 'object') ? result.sync : null + if (pageCount === 1) { + firstPageSync = pageSync + } else { + assertSameSnapshot(firstPageSync, pageSync, address) + } - // pageOffset is the count before this page so globalIdx - // across pages matches what a single-page caller would see. - const pageOffset = allUtxos.length - for (let i = 0; i < result.utxos.length; i++) { - const u = result.utxos[i] - const globalIdx = pageOffset + i - if (typeof u !== 'object' || u === null || - typeof u.txid !== 'string' || - typeof u.vout === 'undefined' || - typeof u.value === 'undefined') { - throw new TypeError(`UTXO tracker returned malformed utxo at index ${globalIdx}`) - } - if (!HEX_64_RE.test(u.txid)) { - throw new TypeError(`UTXO tracker returned malformed utxo at index ${globalIdx}: txid must be a 64-character hex string`) - } - if (typeof u.scriptPubKey !== 'string' || u.scriptPubKey.length === 0) { - throw new TypeError(`UTXO tracker returned malformed utxo at index ${globalIdx}: scriptPubKey must be a non-empty string`) - } - if (u.confirmations == null) { - u.confirmations = 0 - } else { - // Mirror validateUtxoEntry: the encoder's unconfirmed - // filter compares `confirmations == 0` loosely, so - // coerce and range-check tracker-supplied values too. - const confirmations = Number(u.confirmations) - if (!Number.isInteger(confirmations) || confirmations < 0) { - throw new TypeError(`UTXO tracker returned malformed utxo at index ${globalIdx}: confirmations must be a non-negative integer`) - } - u.confirmations = confirmations - } - allUtxos.push(u) - } + appendPageUtxos(result.utxos, allUtxos) - // Continue only if the tracker signals another page. - const nextCursor = result.nextCursor - if (nextCursor) { - // A repeated cursor means the tracker is cycling/stalled: abort rather - // than loop forever accumulating the same pages. - if (seenCursors.has(nextCursor)) { - throw new Error(`utxo-tracker returned a repeated pagination cursor for ${address}; aborting (stalled or hostile tracker)`) - } - seenCursors.add(nextCursor) - cursor = nextCursor - } else { - // No more pages; return accumulated result with utxos merged. - return { ...result, utxos: allUtxos } - } + // Continue only if the tracker signals another page. + const nextCursor = result.nextCursor + if (nextCursor) { + assertFreshCursor(nextCursor, seenCursors, address) + cursor = nextCursor } else { - // Surface the structured error the tracker returned (e.g. ADDRESS_TOO_LARGE, - // INVALID_CURSOR) rather than discarding it. The tracker maps these to - // meaningful error codes/messages at the JSON-RPC layer; losing them here - // makes the operator see the same opaque message for every failure mode. - // Prefer err.data.code (string code forwarded by the tracker) over err.code. - const rpcError = responseData.error - if (rpcError && (rpcError.message || rpcError.code || rpcError.data?.code)) { - const code = rpcError.data?.code || rpcError.code - throw new Error(`Error getting utxos: ${code ? `[${code}] ` : ''}${rpcError.message || 'unknown error'}`) - } - throw new Error('Error getting utxos: empty result') + // No more pages; return accumulated result with utxos merged. + return { ...result, utxos: allUtxos } } } } catch (error) { diff --git a/src/server/single_instance_guard.js b/src/server/single_instance_guard.js index 32c9fde..ba0f032 100644 --- a/src/server/single_instance_guard.js +++ b/src/server/single_instance_guard.js @@ -135,6 +135,142 @@ function selfDescription() { return describePid(process.pid) || [process.argv0].concat(process.argv.slice(1)).join(' ') } +// Publish ownership ATOMICALLY: write the complete record to a sibling temp +// file, then link it into place. link() either creates the name or throws +// EEXIST, and it publishes the finished record in one step, so the lock name +// is never observable as a zero-byte file that a contender reads as stale. +// openSync('wx') + a separate write cannot promise that, and the window is +// wide: self() reads /proc between the two. +// `token` is the acquisition's ownership token, `self` describes this process. +function publishLock(file, token, self) { + const record = JSON.stringify({ pid: process.pid, cmd: self(), token }) + const tmp = file + '.' + process.pid + '.' + token.slice(0, 8) + '.tmp' + let tmpWritten = false + try { + const fd = fs.openSync(tmp, 'wx') + try { fs.writeSync(fd, record) } finally { fs.closeSync(fd) } + tmpWritten = true + fs.linkSync(tmp, file) + } catch (err) { + if (tmpWritten && NO_HARDLINK_CODES.has(err.code)) { + // No hard links on this mount. Fall back to the original + // non-atomic publication, which is why the reader below still + // treats an illegible record as an initializing owner. + const fd = fs.openSync(file, 'wx') + try { fs.writeSync(fd, record) } finally { fs.closeSync(fd) } + return + } + throw err + } finally { + if (tmpWritten) { try { fs.unlinkSync(tmp) } catch (e) { /* already gone */ } } + } +} + +// Read the record at the lock path. Accepts three shapes: the JSON written +// above, the tokenless JSON written by encoders that predate the token, and +// the bare integer written by those that predate the record (a lock can +// outlive an upgrade). `legible` separates "no readable record yet" from "a +// record that names no usable pid". +function readLockHolder(file) { + let text + try { text = fs.readFileSync(file, 'utf8') } catch (e) { return { pid: NaN, cmd: null, token: null, legible: false } } + if (text.trim() === '') return { pid: NaN, cmd: null, token: null, legible: false } + // NB: a bare pid is itself valid JSON ('1' parses to the number 1), so the + // object check is what keeps a legacy lock on the legacy path instead of + // reading it as a record with no pid and breaking a live holder's lock. + try { + const parsed = JSON.parse(text) + if (parsed && typeof parsed === 'object') { + const pid = parseInt(parsed.pid, 10) + return { pid, cmd: parsed.cmd || null, token: parsed.token || null, legible: Number.isInteger(pid) } + } + } catch (e) { /* not JSON at all: fall through to the bare-pid reading */ } + const pid = parseInt(text, 10) + return { pid, cmd: null, token: null, legible: Number.isInteger(pid) } +} + +// Re-read an illegible lock a bounded number of times before judging it. +// An owner publishing through the non-atomic fallback above is mid-write, +// not dead, and breaking its lock is how two processes both came to hold one. +function readLockHolderSettled(file) { + let holder = readLockHolder(file) + for (let pass = 0; !holder.legible && pass < INITIALIZING_RECHECK_PASSES; pass++) { + sleepSync(INITIALIZING_RECHECK_MS) + holder = readLockHolder(file) + } + return holder +} + +// The holder is alive, and not us. Only a matching identity makes it a real +// conflict, which throws; a reused pid number is logged and left for the +// caller to break. `describe` reads a live pid's command line. +function refuseLiveEncoder(holder, file, describe) { + const holderPid = holder.pid + const liveCmd = describe(holderPid) + // Recorded identity: the strong test, an exact mismatch means reuse. + let reused = holder.cmd !== null && liveCmd !== null && liveCmd !== holder.cmd + // A lock written before this change carries no identity to compare, and + // refusing on it would reproduce the very wedge this fixes on the first + // restart after the upgrade (the lock outlives the process that wrote + // it, so an old bare-pid file is exactly what a rolling deploy meets). + // Weaker but sufficient test for that case: the holder cannot be an + // encoder if its command line is not running one. + if (!reused && holder.cmd === null && liveCmd !== null && !looksLikeEncoder(liveCmd)) { + reused = true + } + if (!reused) { + throw new Error( + 'Another xchain-encoder instance (pid ' + holderPid + ') holds the instance lock ' + + file + '. The outpoint-reservation and recent-build stores are in-process; ' + + 'running two encoder instances against one UTXO set risks conflicting ' + + 'double-spend PSBTs, and lets one transaction be built twice and journaled ' + + 'as two successes. Stop the other instance, or set ENCODER_INSTANCE_LOCK_FILE ' + + 'to isolate intentionally separate deployments.' + ) + } + logger.warn('singleInstanceGuard: breaking a stale lock on ' + file + ': pid ' + + holderPid + ' is alive but is running "' + liveCmd + '", ' + + (holder.cmd === null + ? 'which is not an encoder (the lock predates identity recording)' + : 'not the "' + holder.cmd + '" that took the lock') + + '. This is pid reuse after an unclean shutdown, not a second instance.') +} + +// Stale (dead holder, garbage contents, or a reused pid): break and +// re-take. A second process can reach this same verdict on the same lock, +// so the re-take is not guaranteed to win. Losing it is a normal outcome: +// go round again and re-evaluate whoever now holds the lock, and only +// when the passes run out does the conflict error below stand in place of +// a raw EEXIST escaping acquireInstanceLock and killing boot. +function breakStaleLock(file, pass) { + try { fs.unlinkSync(file) } catch (e) { /* somebody else broke it first */ } + if (pass >= BREAK_PASSES) { + throw new Error( + 'Another xchain-encoder instance keeps re-taking the instance lock ' + file + + ' faster than this one can. The outpoint-reservation and recent-build stores ' + + 'are in-process; running two encoder instances against one UTXO set risks ' + + 'conflicting double-spend PSBTs. Stop the other instance, or set ' + + 'ENCODER_INSTANCE_LOCK_FILE to isolate intentionally separate deployments.' + ) + } +} + +// The release function handed to the acquirer, idempotent. +function lockReleaser(file, token) { + let released = false + return function release() { + if (released) return + released = true + // Unlink only OUR record. The lock may legitimately have changed hands + // (an instance ruled stale on the pid-reuse path is still running and + // still owns a release), and deleting by path alone is how one owner's + // exit freed another owner's lock. + try { + if (readLockHolder(file).token === token) fs.unlinkSync(file) + } catch (e) { /* already gone, or unreadable: leave it for the stale path */ } + } +} + // Same-host duplicate-process guard: takes an exclusive PID lockfile so two // encoder processes accidentally started on one host (each with its own // reservation and recent-build Maps) fail fast instead of racing UTXO @@ -173,140 +309,22 @@ function acquireInstanceLock(lockPath, env = config, deps = {}) { // happened to sit at that name, including a successor's. const token = crypto.randomBytes(16).toString('hex') - // Publish ownership ATOMICALLY: write the complete record to a sibling temp - // file, then link it into place. link() either creates the name or throws - // EEXIST, and it publishes the finished record in one step, so the lock name - // is never observable as a zero-byte file that a contender reads as stale. - // openSync('wx') + a separate write cannot promise that, and the window is - // wide: self() reads /proc between the two. - const tryTake = () => { - const record = JSON.stringify({ pid: process.pid, cmd: self(), token }) - const tmp = file + '.' + process.pid + '.' + token.slice(0, 8) + '.tmp' - let tmpWritten = false - try { - const fd = fs.openSync(tmp, 'wx') - try { fs.writeSync(fd, record) } finally { fs.closeSync(fd) } - tmpWritten = true - fs.linkSync(tmp, file) - } catch (err) { - if (tmpWritten && NO_HARDLINK_CODES.has(err.code)) { - // No hard links on this mount. Fall back to the original - // non-atomic publication, which is why the reader below still - // treats an illegible record as an initializing owner. - const fd = fs.openSync(file, 'wx') - try { fs.writeSync(fd, record) } finally { fs.closeSync(fd) } - return - } - throw err - } finally { - if (tmpWritten) { try { fs.unlinkSync(tmp) } catch (e) { /* already gone */ } } - } - } - - // Accepts three shapes: the JSON written above, the tokenless JSON written by - // encoders that predate the token, and the bare integer written by those that - // predate the record (a lock can outlive an upgrade). `legible` separates "no - // readable record yet" from "a record that names no usable pid". - const readHolder = () => { - let text - try { text = fs.readFileSync(file, 'utf8') } catch (e) { return { pid: NaN, cmd: null, token: null, legible: false } } - if (text.trim() === '') return { pid: NaN, cmd: null, token: null, legible: false } - // NB: a bare pid is itself valid JSON ('1' parses to the number 1), so the - // object check is what keeps a legacy lock on the legacy path instead of - // reading it as a record with no pid and breaking a live holder's lock. - try { - const parsed = JSON.parse(text) - if (parsed && typeof parsed === 'object') { - const pid = parseInt(parsed.pid, 10) - return { pid, cmd: parsed.cmd || null, token: parsed.token || null, legible: Number.isInteger(pid) } - } - } catch (e) { /* not JSON at all: fall through to the bare-pid reading */ } - const pid = parseInt(text, 10) - return { pid, cmd: null, token: null, legible: Number.isInteger(pid) } - } - - // Re-read an illegible lock a bounded number of times before judging it. - // An owner publishing through the non-atomic fallback above is mid-write, - // not dead, and breaking its lock is how two processes both came to hold one. - const readHolderSettled = () => { - let holder = readHolder() - for (let pass = 0; !holder.legible && pass < INITIALIZING_RECHECK_PASSES; pass++) { - sleepSync(INITIALIZING_RECHECK_MS) - holder = readHolder() - } - return holder - } - for (let pass = 0; ; pass++) { try { - tryTake() + publishLock(file, token, self) break } catch (err) { if (err.code !== 'EEXIST') throw err - const holder = readHolderSettled() + const holder = readLockHolderSettled(file) const holderPid = holder.pid if (Number.isInteger(holderPid) && holderPid > 0 && alive(holderPid) && holderPid !== process.pid) { - // Alive, and not us. Only a matching identity makes it a real conflict. - const liveCmd = describe(holderPid) - // Recorded identity: the strong test, an exact mismatch means reuse. - let reused = holder.cmd !== null && liveCmd !== null && liveCmd !== holder.cmd - // A lock written before this change carries no identity to compare, and - // refusing on it would reproduce the very wedge this fixes on the first - // restart after the upgrade (the lock outlives the process that wrote - // it, so an old bare-pid file is exactly what a rolling deploy meets). - // Weaker but sufficient test for that case: the holder cannot be an - // encoder if its command line is not running one. - if (!reused && holder.cmd === null && liveCmd !== null && !looksLikeEncoder(liveCmd)) { - reused = true - } - if (!reused) { - throw new Error( - 'Another xchain-encoder instance (pid ' + holderPid + ') holds the instance lock ' + - file + '. The outpoint-reservation and recent-build stores are in-process; ' + - 'running two encoder instances against one UTXO set risks conflicting ' + - 'double-spend PSBTs, and lets one transaction be built twice and journaled ' + - 'as two successes. Stop the other instance, or set ENCODER_INSTANCE_LOCK_FILE ' + - 'to isolate intentionally separate deployments.' - ) - } - logger.warn('singleInstanceGuard: breaking a stale lock on ' + file + ': pid ' + - holderPid + ' is alive but is running "' + liveCmd + '", ' + - (holder.cmd === null - ? 'which is not an encoder (the lock predates identity recording)' - : 'not the "' + holder.cmd + '" that took the lock') + - '. This is pid reuse after an unclean shutdown, not a second instance.') - } - // Stale (dead holder, garbage contents, or a reused pid): break and - // re-take. A second process can reach this same verdict on the same lock, - // so the re-take is not guaranteed to win. Losing it is a normal outcome: - // go round again and re-evaluate whoever now holds the lock, and only - // when the passes run out does the conflict error below stand in place of - // a raw EEXIST escaping this function and killing boot. - try { fs.unlinkSync(file) } catch (e) { /* somebody else broke it first */ } - if (pass >= BREAK_PASSES) { - throw new Error( - 'Another xchain-encoder instance keeps re-taking the instance lock ' + file + - ' faster than this one can. The outpoint-reservation and recent-build stores ' + - 'are in-process; running two encoder instances against one UTXO set risks ' + - 'conflicting double-spend PSBTs. Stop the other instance, or set ' + - 'ENCODER_INSTANCE_LOCK_FILE to isolate intentionally separate deployments.' - ) + refuseLiveEncoder(holder, file, describe) } + breakStaleLock(file, pass) } } - let released = false - return function release() { - if (released) return - released = true - // Unlink only OUR record. The lock may legitimately have changed hands - // (an instance ruled stale on the pid-reuse path is still running and - // still owns a release), and deleting by path alone is how one owner's - // exit freed another owner's lock. - try { - if (readHolder().token === token) fs.unlinkSync(file) - } catch (e) { /* already gone, or unreadable: leave it for the stale path */ } - } + return lockReleaser(file, token) } // Exit status a shell (and Docker) reports for a death by each signal. From 5fc26fafc284d0a674abf7144d078f0428ea45e9 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 09:01:40 -0700 Subject: [PATCH 130/148] refactor(api): move the JSON-RPC controller into src/api/json_rpc_methods.js as per-method-group builders The serve-readiness probe and the eight JSON-RPC methods leave src/api.js for a part beside it, built by createJsonRpcController({ encoder, NETWORK }) from seven small per-group builders composed in dispatch order. The six error lines the methods emit go through the service logger like every other module, same level and message text. GET /status reads the readiness fields from the controller's health() so the two endpoints cannot drift apart, and the openrpc drift guard extracts method names from the builder literals. --- src/api.js | 289 +---------------------- src/api/json_rpc_methods.js | 364 +++++++++++++++++++++++++++++ test/unit/openrpc_coverage.test.js | 13 +- 3 files changed, 379 insertions(+), 287 deletions(-) create mode 100644 src/api/json_rpc_methods.js diff --git a/src/api.js b/src/api.js index 04ce77a..86c354e 100644 --- a/src/api.js +++ b/src/api.js @@ -58,14 +58,11 @@ function makeRpcBatchGuard(maxBatch){ next() } } -const validator = require('./common/validator') const { assertSingleInstance, acquireInstanceLock, releaseLockOnSignals } = require('./server/single_instance_guard') -const { upstreamErrorMessage } = require('./common/error_sanitize') 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 { readMaintenanceWindow } = require('./server/maintenance_window') // operator-declared scheduled outage, reported beside readiness const NETWORK = process.env.NETWORK @@ -251,288 +248,18 @@ installObservability(app, { network: NETWORK || '' }); -// 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. -// -// 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. -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. - const maintenance = await readMaintenanceWindow() - let tracker_reachable = false - let tracker_synced = false - let tracker_lag = null - let tracker_halted = false - let tracker_mempool_ready = false - 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. - const freshness = XChainEncoder.classifyTrackerFreshness(status, encoder.maxUtxoTrackerLagBlocks) - tracker_halted = freshness.halted - tracker_mempool_ready = freshness.mempoolReady - tracker_synced = freshness.syncedClaimed && freshness.code === null - tracker_lag = freshness.lag - } catch (_err) { - // tracker unreachable; fields stay at defaults - } - return { tracker_reachable, tracker_synced, tracker_lag, tracker_halted, tracker_mempool_ready, maintenance } -} - -const jsonRpcController = { - 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. - async health() { - return getServeReadiness() - }, - // Suggested fee tiers (base-unit per vByte: sat/koinu/litoshi) from the node's - // estimatesmartfee at decreasing confirmation targets: low=slow/cheap (6 - // blocks) … high=next block (1). getFeePerKilobyte returns coin/kB; *1e5 - // converts to base-unit/byte (1e8 base-units per coin ÷ 1000 bytes per kB). - // Floored at 1 so a tier is never 0. Read-only; no params. - async estimate_fee() { - const targets = { low: 6, medium: 3, high: 1 }; - const out = {}; - // 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); - const capPerVbyte = capPerByte == null ? null : Math.max(1, Math.round(capPerByte * 100000000)); - try { - for (const tier of Object.keys(targets)) { - 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; - out[tier] = perVbyte; - } - } catch (err) { - console.error('Fee estimation error:', err) - const e = new Error('Fee estimation failed') - e.code = -32603 - throw e - } - return out; - }, - async create_tx(rawParams) { - let params - try { - params = validator.validateAll(rawParams) - } catch (err) { - const e = new Error(err.message) - e.code = -32602 - throw e - } - - let psbt - try { - psbt = await encoder.createTransaction( - params.utxos, params.pubkey, params.customOutputs, - params.data, params.rawData, params.fee, params.rbf, - params.encoding, params.change, params.p2shHash, params.p2shHex, - params.compressedPubKey, params.unconfirmed, params.feePerKb, params.dust, - params.feeQuote, params.attachPrevTx, params.compress, params.options) - } catch (err) { - // Typed operational errors (no UTXOs, insufficient funds, missing - // change address, tracker unavailable) are expected, caller-actionable - // conditions with an encoder-authored, credential-free message and a - // stable machine-readable code. Forward those so the wallet/SDK can - // branch on them: message passes through and `xchainCode` (plus any - // details payload) rides in the JSON-RPC error `data.reason`. - if (err && err.operational === true) { - const e = new Error(err.message) - e.code = -32010 - e.data = Object.assign({ reason: err.xchainCode }, err.details || {}) - throw e - } - // Validation errors (TypeError/RangeError) carry safe messages from - // our own validation. Everything else is an unexpected internal and is - // collapsed to a generic message to prevent leaking internals - // (host:port, stack, RPC credentials). - const isKnown = err instanceof TypeError || err instanceof RangeError - if (!isKnown) { - console.error('Encoder error:', err) - } - const e = new Error(isKnown ? err.message : 'Internal encoder error') - e.code = -32603 - throw e - } - - psbt["psbt"] = psbt["psbt"].toHex() - // TAPROOT envelope: one call returns the {commit, reveal} - // pair; the caller signs both and broadcasts commit then reveal. - if (psbt["revealPsbt"]) { - psbt["revealPsbt"] = psbt["revealPsbt"].toHex() - } - return psbt; - }, - // Key-path cancel of an unrevealed TAPROOT envelope commit: rebuilds the - // sweep PSBT from the wallet's persisted recovery - // record alone. Validation lives in the encoder method (typed - // TypeError/RangeError -> -32602, OperationalError -> -32010). - async create_envelope_cancel_tx(rawParams) { - // Array.isArray for the same reason validator.validateAll carries it: - // positional params otherwise clear the gate and destructure to undefined. - if (typeof rawParams !== 'object' || rawParams === null || Array.isArray(rawParams)) { - const e = new Error('Request params must be an object') - e.code = -32602 - throw e - } - let result - try { - result = await encoder.createEnvelopeCancelTransaction(rawParams) - } catch (err) { - if (err && err.operational === true) { - const e = new Error(err.message) - e.code = -32010 - e.data = Object.assign({ reason: err.xchainCode }, err.details || {}) - throw e - } - const isKnown = err instanceof TypeError || err instanceof RangeError - if (!isKnown) { - console.error('Encoder error:', err) - } - const e = new Error(isKnown ? err.message : 'Internal encoder error') - e.code = isKnown ? -32602 : -32603 - throw e - } - result.psbt = result.psbt.toHex() - return result - }, - // Hand back the input reservations ONE earlier build took, named by the - // ticket id that build's result carried. The wallet composes when its send - // modal opens and abandons most of those builds, and each abandoned one held - // the address's inputs for the full reservation window, so the next compose - // on a few-UTXO address reported insufficient funds against its own money. - // Ownership lives in the ticket (unguessable id plus a per-claim stamp - // re-checked at release time), so this method can never free another - // caller's inputs; see XChainEncoder.releaseReservation. - // - // Deliberately never an error for an unknown, expired or already-released - // ticket: a modal-close handler cannot act on a failure, and reporting one - // would push clients to retry a call that has nothing left to do. found - // tells them which it was. - async release_inputs(rawParams) { - let reservationId - try { - if (typeof rawParams !== 'object' || rawParams === null || Array.isArray(rawParams)) { - throw new TypeError('Request params must be an object') - } - reservationId = validator.validateReservationId(rawParams.reservationId) - } catch (err) { - const e = new Error(err.message) - e.code = -32602 - throw e - } - try { - return encoder.releaseReservation(reservationId) - } catch (err) { - const isKnown = err instanceof TypeError || err instanceof RangeError - if (!isKnown) { - console.error('Encoder error:', err) - } - const e = new Error(isKnown ? err.message : 'Internal encoder error') - e.code = isKnown ? -32602 : -32603 - throw e - } - }, - async broadcast_tx(rawParams) { - let tx_hex = rawParams && rawParams.tx_hex - if (!tx_hex) { - const e = new Error('Missing required parameter: tx_hex') - e.code = -32602 - throw e - } - // Shed malformed/oversized payloads before the node round-trip; the - // node would reject them anyway, this just answers with a precise - // invalid-params reason instead of a node-side parse error. - try { - validator.validateRawTxHex(tx_hex) - } catch (err) { - const e = new Error(err.message) - e.code = -32602 - throw e - } - - try { - let txid = await encoder.connector.sendRawTransaction(tx_hex) - return { txid: txid } - } catch (err) { - console.error('Broadcast error:', err) - const e = new Error(upstreamErrorMessage(err, 'Transaction broadcast failed')) - e.code = -32603 - throw e - } - }, - async get_utxos(rawParams) { - let address = rawParams && rawParams.address - if (!address) { - const e = new Error('Missing required parameter: address') - e.code = -32602 - throw e - } - // Shed non-string / oversized addresses with a precise invalid-params - // reason before handing the value to the UTXO tracker. - try { - validator.validateAddress(address) - } catch (err) { - const e = new Error(err.message) - e.code = -32602 - throw e - } - - try { - let result = await encoder.utxoTrackerConnector.getUtxosFromAddress(address) - return result - } catch (err) { - console.error('UTXO fetch error:', err) - const e = new Error(upstreamErrorMessage(err, 'UTXO fetch failed')) - e.code = -32603 - throw e - } - } -} +// The readiness probe and every JSON-RPC method live beside this file; they +// 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 }) // 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 // `health` method so load-balancer / uptime monitors can rely on the HTTP status // code directly (the JSON-RPC catch-all routes all GETs to 200 today). Shares -// the readiness gate with health() via getServeReadiness() above so -// the two endpoints cannot drift apart. +// the readiness gate with health() by calling that very method (its +// getServeReadiness() lives in src/api/json_rpc_methods.js) so the two +// endpoints cannot drift apart. // probeGate.hold, not requestGate.hold: /status is exempted from the main cap by // `skip`, so its slot lives in the probe reserve and only that gate's hold() can // find it. This route awaits an HTTP round-trip to the utxo-tracker, so without @@ -541,7 +268,7 @@ const jsonRpcController = { 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 getServeReadiness() + const { tracker_reachable, tracker_synced, tracker_lag, tracker_halted, tracker_mempool_ready, maintenance } = await jsonRpcController.health() const healthy = tracker_reachable && tracker_synced // 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 diff --git a/src/api/json_rpc_methods.js b/src/api/json_rpc_methods.js new file mode 100644 index 0000000..5f23251 --- /dev/null +++ b/src/api/json_rpc_methods.js @@ -0,0 +1,364 @@ +/********************************************************************* + * + * 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 - JSON-RPC methods + * + * The serve-readiness probe and every JSON-RPC method the API dispatches, + * built by a factory over the encoder instance and network the entry owns. + * + ********************************************************************/ + +const util = require('util') +const XChainEncoder = require('../XChainEncoder'); +const validator = require('../common/validator') +const { upstreamErrorMessage } = require('../common/error_sanitize') +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 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. + +// 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. +// +// 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. +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. + const maintenance = await readMaintenanceWindow() + let tracker_reachable = false + let tracker_synced = false + let tracker_lag = null + let tracker_halted = false + let tracker_mempool_ready = false + 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. + const freshness = XChainEncoder.classifyTrackerFreshness(status, encoder.maxUtxoTrackerLagBlocks) + tracker_halted = freshness.halted + tracker_mempool_ready = freshness.mempoolReady + tracker_synced = freshness.syncedClaimed && freshness.code === null + tracker_lag = freshness.lag + } catch (_err) { + // tracker unreachable; fields stay at defaults + } + return { tracker_reachable, tracker_synced, tracker_lag, tracker_halted, tracker_mempool_ready, maintenance } +} + +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. + async health() { + return getServeReadiness() + }, +} +} + +// Fee quoting needs the network name for the test-chain ceiling. +function buildFeeMethods({ encoder, NETWORK }) { +return { + // Suggested fee tiers (base-unit per vByte: sat/koinu/litoshi) from the node's + // estimatesmartfee at decreasing confirmation targets: low=slow/cheap (6 + // blocks) … high=next block (1). getFeePerKilobyte returns coin/kB; *1e5 + // converts to base-unit/byte (1e8 base-units per coin ÷ 1000 bytes per kB). + // Floored at 1 so a tier is never 0. Read-only; no params. + async estimate_fee() { + const targets = { low: 6, medium: 3, high: 1 }; + const out = {}; + // 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); + const capPerVbyte = capPerByte == null ? null : Math.max(1, Math.round(capPerByte * 100000000)); + try { + for (const tier of Object.keys(targets)) { + 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; + out[tier] = perVbyte; + } + } catch (err) { + logger.error(util.format('Fee estimation error:', err)) + const e = new Error('Fee estimation failed') + e.code = -32603 + throw e + } + return out; + }, +} +} + +// The build itself; every error class it can raise maps to one JSON-RPC code. +function buildTransactionMethods({ encoder }) { +return { + async create_tx(rawParams) { + let params + try { + params = validator.validateAll(rawParams) + } catch (err) { + const e = new Error(err.message) + e.code = -32602 + throw e + } + + let psbt + try { + psbt = await encoder.createTransaction( + params.utxos, params.pubkey, params.customOutputs, + params.data, params.rawData, params.fee, params.rbf, + params.encoding, params.change, params.p2shHash, params.p2shHex, + params.compressedPubKey, params.unconfirmed, params.feePerKb, params.dust, + params.feeQuote, params.attachPrevTx, params.compress, params.options) + } catch (err) { + // Typed operational errors (no UTXOs, insufficient funds, missing + // change address, tracker unavailable) are expected, caller-actionable + // conditions with an encoder-authored, credential-free message and a + // stable machine-readable code. Forward those so the wallet/SDK can + // branch on them: message passes through and `xchainCode` (plus any + // details payload) rides in the JSON-RPC error `data.reason`. + if (err && err.operational === true) { + const e = new Error(err.message) + e.code = -32010 + e.data = Object.assign({ reason: err.xchainCode }, err.details || {}) + throw e + } + // Validation errors (TypeError/RangeError) carry safe messages from + // our own validation. Everything else is an unexpected internal and is + // collapsed to a generic message to prevent leaking internals + // (host:port, stack, RPC credentials). + const isKnown = err instanceof TypeError || err instanceof RangeError + if (!isKnown) { + logger.error(util.format('Encoder error:', err)) + } + const e = new Error(isKnown ? err.message : 'Internal encoder error') + e.code = -32603 + throw e + } + + psbt["psbt"] = psbt["psbt"].toHex() + // TAPROOT envelope: one call returns the {commit, reveal} + // pair; the caller signs both and broadcasts commit then reveal. + if (psbt["revealPsbt"]) { + psbt["revealPsbt"] = psbt["revealPsbt"].toHex() + } + return psbt; + }, +} +} + + +function buildCancelMethods({ encoder }) { +return { + // Key-path cancel of an unrevealed TAPROOT envelope commit: rebuilds the + // sweep PSBT from the wallet's persisted recovery + // record alone. Validation lives in the encoder method (typed + // TypeError/RangeError -> -32602, OperationalError -> -32010). + async create_envelope_cancel_tx(rawParams) { + // Array.isArray for the same reason validator.validateAll carries it: + // positional params otherwise clear the gate and destructure to undefined. + if (typeof rawParams !== 'object' || rawParams === null || Array.isArray(rawParams)) { + const e = new Error('Request params must be an object') + e.code = -32602 + throw e + } + let result + try { + result = await encoder.createEnvelopeCancelTransaction(rawParams) + } catch (err) { + if (err && err.operational === true) { + const e = new Error(err.message) + e.code = -32010 + e.data = Object.assign({ reason: err.xchainCode }, err.details || {}) + throw e + } + const isKnown = err instanceof TypeError || err instanceof RangeError + if (!isKnown) { + logger.error(util.format('Encoder error:', err)) + } + const e = new Error(isKnown ? err.message : 'Internal encoder error') + e.code = isKnown ? -32602 : -32603 + throw e + } + result.psbt = result.psbt.toHex() + return result + }, +} +} + + +function buildReleaseMethods({ encoder }) { +return { + // Hand back the input reservations ONE earlier build took, named by the + // ticket id that build's result carried. The wallet composes when its send + // modal opens and abandons most of those builds, and each abandoned one held + // the address's inputs for the full reservation window, so the next compose + // on a few-UTXO address reported insufficient funds against its own money. + // Ownership lives in the ticket (unguessable id plus a per-claim stamp + // re-checked at release time), so this method can never free another + // caller's inputs; see XChainEncoder.releaseReservation. + // + // Deliberately never an error for an unknown, expired or already-released + // ticket: a modal-close handler cannot act on a failure, and reporting one + // would push clients to retry a call that has nothing left to do. found + // tells them which it was. + async release_inputs(rawParams) { + let reservationId + try { + if (typeof rawParams !== 'object' || rawParams === null || Array.isArray(rawParams)) { + throw new TypeError('Request params must be an object') + } + reservationId = validator.validateReservationId(rawParams.reservationId) + } catch (err) { + const e = new Error(err.message) + e.code = -32602 + throw e + } + try { + return encoder.releaseReservation(reservationId) + } catch (err) { + const isKnown = err instanceof TypeError || err instanceof RangeError + if (!isKnown) { + logger.error(util.format('Encoder error:', err)) + } + const e = new Error(isKnown ? err.message : 'Internal encoder error') + e.code = isKnown ? -32602 : -32603 + throw e + } + }, +} +} + +// Node-facing broadcast; upstream error text is sanitized before it leaves. +function buildBroadcastMethods({ encoder }) { +return { + async broadcast_tx(rawParams) { + let tx_hex = rawParams && rawParams.tx_hex + if (!tx_hex) { + const e = new Error('Missing required parameter: tx_hex') + e.code = -32602 + throw e + } + // Shed malformed/oversized payloads before the node round-trip; the + // node would reject them anyway, this just answers with a precise + // invalid-params reason instead of a node-side parse error. + try { + validator.validateRawTxHex(tx_hex) + } catch (err) { + const e = new Error(err.message) + e.code = -32602 + throw e + } + + try { + let txid = await encoder.connector.sendRawTransaction(tx_hex) + return { txid: txid } + } catch (err) { + logger.error(util.format('Broadcast error:', err)) + const e = new Error(upstreamErrorMessage(err, 'Transaction broadcast failed')) + e.code = -32603 + throw e + } + }, +} +} + +// Tracker-facing UTXO lookup; upstream error text is sanitized before it leaves. +function buildUtxoMethods({ encoder }) { +return { + async get_utxos(rawParams) { + let address = rawParams && rawParams.address + if (!address) { + const e = new Error('Missing required parameter: address') + e.code = -32602 + throw e + } + // Shed non-string / oversized addresses with a precise invalid-params + // reason before handing the value to the UTXO tracker. + try { + validator.validateAddress(address) + } catch (err) { + const e = new Error(err.message) + e.code = -32602 + throw e + } + + try { + let result = await encoder.utxoTrackerConnector.getUtxosFromAddress(address) + return result + } catch (err) { + logger.error(util.format('UTXO fetch error:', err)) + const e = new Error(upstreamErrorMessage(err, 'UTXO fetch failed')) + e.code = -32603 + throw e + } + } +} +} + +// 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 }) { + return Object.assign( + buildReadinessMethods({ encoder }), + buildFeeMethods({ encoder, NETWORK }), + buildTransactionMethods({ encoder }), + buildCancelMethods({ encoder }), + buildReleaseMethods({ encoder }), + buildBroadcastMethods({ encoder }), + buildUtxoMethods({ encoder }) + ) +} + +module.exports = { createJsonRpcController } diff --git a/test/unit/openrpc_coverage.test.js b/test/unit/openrpc_coverage.test.js index 5bf5181..7ee92d8 100644 --- a/test/unit/openrpc_coverage.test.js +++ b/test/unit/openrpc_coverage.test.js @@ -12,9 +12,9 @@ * ********************************************************************** * Drift guard: docs/openrpc.json must list exactly the methods exposed by - * the jsonRpcController in src/api.js. If a method is added/removed/renamed, - * regenerate the spec (node docs/openrpc.build.js); this fails until both - * sides match. + * the controller built in src/api/json_rpc_methods.js. If a method is + * added/removed/renamed, regenerate the spec (node docs/openrpc.build.js); + * this fails until both sides match. */ 'use strict'; @@ -24,11 +24,12 @@ const path = require('path'); const assert = require('assert'); { - const src = fs.readFileSync(path.join(__dirname, '../../src/api.js'), 'utf8'); + const src = fs.readFileSync(path.join(__dirname, '../../src/api/json_rpc_methods.js'), 'utf8'); const spec = JSON.parse(fs.readFileSync(path.join(__dirname, '../../docs/openrpc.json'), 'utf8')); - // Method names inside the jsonRpcController object literal. - const block = src.slice(src.indexOf('jsonRpcController = {'), src.indexOf('jsonRouter(')); + // Method names across the per-group builder literals, which is every + // method the factory composes into the controller. + const block = src.slice(0, src.indexOf('function createJsonRpcController')); const controllerMethods = [...block.matchAll(/^\s{4}async\s+([a-z][a-z0-9_]*)\s*\(/gm)].map((m) => m[1]); describe('openrpc.json method coverage', () => { From 8bb8fdd7273663608db077f52ee15f3e2a46210f Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 09:31:35 -0700 Subject: [PATCH 131/148] test: split the validator combined-length and validate-all suites into same-title blocks by behaviour (cherry picked from commit 3186d2896aa7e42d78a6696e2bf4d77b81fc3db2) --- .../validator.test/03_validate_combined_data_length.test.js | 2 ++ test/unit/validator.test/17_validate_all.test.js | 2 ++ 2 files changed, 4 insertions(+) diff --git a/test/unit/validator.test/03_validate_combined_data_length.test.js b/test/unit/validator.test/03_validate_combined_data_length.test.js index 8f0220a..cb0e9d6 100644 --- a/test/unit/validator.test/03_validate_combined_data_length.test.js +++ b/test/unit/validator.test/03_validate_combined_data_length.test.js @@ -79,7 +79,9 @@ describe('Encoder input validator', function () { }); }); +}); +describe('Encoder input validator', function () { describe('validateCombinedDataLength', function () { // An AUTO request can resolve to TAPROOT in selectEncoding, so the // pre-flight check must use the envelope ceiling for it; measuring AUTO diff --git a/test/unit/validator.test/17_validate_all.test.js b/test/unit/validator.test/17_validate_all.test.js index f02ca78..0701c9e 100644 --- a/test/unit/validator.test/17_validate_all.test.js +++ b/test/unit/validator.test/17_validate_all.test.js @@ -77,7 +77,9 @@ describe('Encoder input validator', function () { }); }); +}); +describe('Encoder input validator', function () { describe('validateAll', function () { it('exercises validateFeeQuote validation paths through validateAll', function () { const base = { data: 'SEND', pubkey: '02ab' }; From 1fb228f030a2310a566713bf6c26c501c2a13ba6 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 10:05:48 -0700 Subject: [PATCH 132/148] refactor(encoder): move the encoder's constants, script helpers, fee policy, request resolution, reservations, payload preparation, sizing and envelope cancel into src/XChainEncoder/ --- src/XChainEncoder.js | 1393 +------------------- src/XChainEncoder/constants.js | 116 ++ src/XChainEncoder/envelope_cancel.js | 229 ++++ src/XChainEncoder/fee_policy.js | 86 ++ src/XChainEncoder/outpoint_reservations.js | 297 +++++ src/XChainEncoder/payload_preparation.js | 367 ++++++ src/XChainEncoder/request_resolution.js | 203 +++ src/XChainEncoder/script_amount_helpers.js | 92 ++ src/XChainEncoder/size_estimation.js | 210 +++ 9 files changed, 1607 insertions(+), 1386 deletions(-) create mode 100644 src/XChainEncoder/constants.js create mode 100644 src/XChainEncoder/envelope_cancel.js create mode 100644 src/XChainEncoder/fee_policy.js create mode 100644 src/XChainEncoder/outpoint_reservations.js create mode 100644 src/XChainEncoder/payload_preparation.js create mode 100644 src/XChainEncoder/request_resolution.js create mode 100644 src/XChainEncoder/script_amount_helpers.js create mode 100644 src/XChainEncoder/size_estimation.js diff --git a/src/XChainEncoder.js b/src/XChainEncoder.js index 41b2ccf..ddf41f7 100644 --- a/src/XChainEncoder.js +++ b/src/XChainEncoder.js @@ -36,112 +36,13 @@ const { compressPayloadForAction } = require('./build/compression') const { OperationalError } = require('./build/errors') const { upstreamErrorMessage } = require('./common/error_sanitize') const util = require('node:util'); -const { getLogger } = require('./observability'); const config = require('./common/config'); -const logger = getLogger(); - -const OP_RETURN_SIZE = 80 -const P2SH_SIZE = 520 -// Each data chunk is pushed as a SINGLE script element inside the witness -// script, so it is bound by consensus MAX_SCRIPT_ELEMENT_SIZE (520 bytes), -// the same limit that caps the P2SH chunk. It is NOT bound by the 3600-byte -// total witness-script policy limit. A larger chunk (e.g. the former 3571) -// builds a witness script the node rejects at spend time with "Push value -// size limit exceeded". 520 - 44 overhead = 476-byte max chunk, identical to P2SH. -const PW2SH_SIZE = 520 -const MULTISIGN_SIZE = 69 // 9 bytes overhead (1 OP_CHECKMULTISIG + 1 m + 1 n + 2 key-length bytes + 4 magic) + 60 raw data bytes = 69 total bytes per chunk -const MAGIC_WORD = "XCHN" - -// Taproot envelope: the payload rides as 520-byte pushes inside -// a single tapscript leaf, bounded per element by consensus -// MAX_SCRIPT_ELEMENT_SIZE exactly like the chunk lanes; tapscript has no -// 10,000-byte script cap, so ONE leaf carries the whole payload. -const TAPROOT_ENVELOPE_CHUNK_SIZE = 520 -// BIP342 tapscript leaf version. Also the first control-block byte (even -// output-key parity keeps it 0xc0 verbatim). -const TAPROOT_LEAF_VERSION = 0xc0 -// Envelope format byte 0x00 = this version. Any other value is unrecognized -// by design (invisible, not invalid): future formats activate via their own -// recognition flag heights. -const TAPROOT_ENVELOPE_FORMAT_V0 = 0x00 - -const SATOSHI_UNIT = 100000000 - -const MAX_SAFE_SATOSHI_BIG = BigInt(Number.MAX_SAFE_INTEGER) - -// Relay-policy floor per coin for every value output this encoder authors, distinct -// from the consensus-pinned network.dustThreshold the decoder reads. Dogecoin relays an -// output under its 0.01 DOGE soft dust limit only if the whole limit is added to the fee. -const SOFT_DUST_FLOOR_BY_COIN = { - DOGE: 1000000 -} - -// 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] - return (tick && SOFT_DUST_FLOOR_BY_COIN[tick]) || 0 -} -// 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 -// only INSIDE a script. Witness-stack items are not script pushes; each is -// framed by a compactSize varint, which switches width at 253, not at 76/256. -function compactSizeLen(n) { - if (n < 253) return 1 - if (n <= 0xffff) return 3 - if (n <= 0xffffffff) return 5 - return 9 -} +const { logger, MAGIC_WORD, TAPROOT_LEAF_VERSION, SATOSHI_UNIT, DEFAULT_SUGGESTED_FEE_MAX_PER_VBYTE, DEFAULT_MAX_CPFP_UPLIFT_SAT, RESERVATION_TTL_MS, Encoding } = require('./XChainEncoder/constants.js') +const { softDustFloorFor, envelopeTapLeafHash, ensureEccLib, asSatValue, jsonSafeSat } = require('./XChainEncoder/script_amount_helpers.js') +const { isTestNetworkKey, suggestedFeeCeilingPerByte, suggestedFeeCeilingFloorPerByte, maxCpfpUpliftSat, packageFeeUpliftSatoshis } = require('./XChainEncoder/fee_policy.js') +const { classifyTrackerFreshness, resolveCallerAddress, defaultCompressionEnabled, assertRevealFundingTxMatches } = require('./XChainEncoder/request_resolution.js') -// Serialized compactSize varint for `n`, the wire form whose width -// compactSizeLen models. Needed for the BIP341 tapleaf hash, whose preimage -// length-prefixes the script with a compactSize (not a script push). -function compactSizeBuffer(n) { - if (n < 253) return Buffer.from([n]) - if (n <= 0xffff) { const b = Buffer.alloc(3); b[0] = 0xfd; b.writeUInt16LE(n, 1); return b } - const b = Buffer.alloc(5); b[0] = 0xfe; b.writeUInt32LE(n, 1); return b -} - -// BIP341 tapleaf hash of the envelope script: -// taggedHash("TapLeaf", leaf_version || compactSize(len(script)) || script). -// For the single-leaf envelope tree this doubles as the taproot merkle root, -// which is exactly the third element of the wallet's cancel-recovery record -// {commit outpoint, internal key derivation path, tapleaf hash}: the key-path -// tweak cannot be reconstructed without it. -function envelopeTapLeafHash(script) { - return bitcoin.crypto.taggedHash('TapLeaf', - Buffer.concat([Buffer.from([TAPROOT_LEAF_VERSION]), compactSizeBuffer(script.length), script])) -} - -// bitcoinjs-lib refuses any P2TR construction until an ECC backend is -// registered. Registered lazily on the first envelope build rather than at -// module load so the non-envelope lanes (and the browserify bundle) never pay -// for the wasm-backed tiny-secp256k1 at startup. -let eccLibReady = false -function ensureEccLib() { - if (eccLibReady) return - bitcoin.initEccLib(require('tiny-secp256k1')) - eccLibReady = true -} - -// Narrow a satoshi amount computed in BigInt back to a Number when it is -// exactly representable, so consumers predating BigInt support (tests, PSBT -// inspectors) keep seeing Number for every value they could handle; only a -// genuinely >2^53-1 amount stays BigInt (the patched bitcoinjs/bip174 -// serializers accept both). -function asSatValue(v) { - return (typeof v === 'bigint' && v <= MAX_SAFE_SATOSHI_BIG) ? Number(v) : v -} - -// JSON-safe form of a satoshi amount for OperationalError metadata: -// JSON.stringify throws on BigInt, so a >2^53-1 amount is emitted as its -// exact decimal string instead. -function jsonSafeSat(v) { - if (typeof v !== 'bigint') return v - return v <= MAX_SAFE_SATOSHI_BIG ? Number(v) : v.toString() -} // Default ceiling on any caller-supplied fee, expressed as a multiple of the // node's own estimatesmartfee(1) estimate. Without a cap, a malicious or buggy @@ -154,101 +55,7 @@ function jsonSafeSat(v) { // multiples above the market rate. const DEFAULT_MAX_FEE_RATE_MULTIPLIER = 100 -// 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 -// every confirmation target, which prices an ordinary action above the balance -// funding it and fails the build outright. Mainnet is deliberately unclamped: -// there the estimate is real and a ceiling would underpay a genuine fee spike. -// This bounds only the rate CHOSEN on the caller's behalf, never a rate the -// caller supplied and never the anchor the fee-drain caps derive from. -const DEFAULT_SUGGESTED_FEE_MAX_PER_VBYTE = 20 - -// Test chains by network key suffix; everything else is treated as mainnet. -const TEST_NETWORK_SUFFIXES = ['-testnet', '-testnet4', '-regtest', '-signet'] - -function isTestNetworkKey(networkKey){ - const key = String(networkKey || '').toLowerCase() - return TEST_NETWORK_SUFFIXES.some(s => key.endsWith(s)) -} - -// The suggested-rate ceiling in BTC/byte for `networkKey`, or null when this -// chain is unclamped. SUGGESTED_FEE_MAX_PER_VBYTE overrides the default; 0 -// disables the clamp entirely. -function suggestedFeeCeilingPerByte(networkKey, satoshiUnit){ - const raw = parseFloat(config.SUGGESTED_FEE_MAX_PER_VBYTE) - const perVbyte = Number.isFinite(raw) ? raw - : (isTestNetworkKey(networkKey) ? DEFAULT_SUGGESTED_FEE_MAX_PER_VBYTE : 0) - if (!(perVbyte > 0)) return null - return perVbyte / satoshiUnit -} - -// The ceiling above is denominated in base units per vByte, and 20 is a -// Bitcoin-scale number: on Dogecoin the node's relay floor is already 100 -// koinu/byte (0.001 DOGE/kB) and 1.14 rejects anything at or under the floor -// through its free-transaction priority gate ("66: insufficient priority"), -// so a ceiling of 20 turns every clamped build into a guaranteed rejection. -// Lift the ceiling to a coin-correct minimum derived from the node's own -// relayfee (BTC-or-coin per kB): ten times the floor, Dogecoin's published -// recommended rate (0.01 DOGE/kB) and 10 sat/vB on BTC/LTC test chains. -const SUGGESTED_FEE_CEILING_RELAY_MULTIPLIER = 10 - -function suggestedFeeCeilingFloorPerByte(relayfeePerKb){ - const relayfee = Number(relayfeePerKb) - if (!(relayfee > 0)) return null - return (relayfee / 1000) * SUGGESTED_FEE_CEILING_RELAY_MULTIPLIER -} - -// Hard ceiling (base units) on what CPFP package sizing may ADD to one -// transaction's fee, on top of every rate cap the build already enforces. The -// rate caps bound a fee against THIS transaction's size; a package uplift is -// paid for someone else's bytes, so a mempool ancestor chain that is large, -// cheap, or misreported could otherwise buy an arbitrarily large fee with the -// caller's coin. 10,000,000 base units is 0.1 DOGE (ten times the block- -// inclusion floor for a 10 kB package, so it clears any realistic real chain) -// and stays well inside the rate caps on BTC/LTC, where they bind first anyway. -// MAX_CPFP_UPLIFT_SAT overrides it; 0 turns package sizing off. -const DEFAULT_MAX_CPFP_UPLIFT_SAT = 10000000 - -function maxCpfpUpliftSat(){ - const raw = parseFloat(config.MAX_CPFP_UPLIFT_SAT) - const bound = Number.isFinite(raw) ? raw : DEFAULT_MAX_CPFP_UPLIFT_SAT - return (bound > 0) ? Math.floor(bound) : 0 -} -// Extra fee (base units) this transaction must pay for its whole mempool -// package to reach `targetFeePerBytes`, given the unconfirmed ancestors it -// inherits. Dogecoin Core 1.14 and every modern Core select for a block by -// ANCESTOR fee rate, so a child paying the target on its own bytes still sits -// unmined behind cheap parents: the live case was a batch paying 0.01017 -// DOGE/kB whose 0.00313 DOGE/kB funding ancestors dragged the package to -// 0.00896, under the 0.01 inclusion floor. Returns 0 whenever the package -// already clears the target, so this only ever raises a fee. -// `ancestorFees` is the package's fee in COIN units (what a node's mempool RPC -// reports). `ancestorFeeSatoshis` is the same quantity already in base units and -// takes precedence when supplied: the two-phase prefund below knows the commit's -// fee exactly, as an integer, and routing it through a coin-unit float would -// re-introduce the representation error the epsilon shave exists to absorb. -function packageFeeUpliftSatoshis({ currentFee, txSize, ancestorSize, ancestorFees, ancestorFeeSatoshis, targetFeePerBytes, satoshiUnit }){ - if (!(targetFeePerBytes > 0)) return 0 - if (!Number.isFinite(txSize) || txSize <= 0) return 0 - const packageSize = Number(ancestorSize) - if (!Number.isFinite(packageSize) || packageSize <= 0) return 0 - const ancestorFeeSat = (ancestorFeeSatoshis != null) - ? Math.round(Number(ancestorFeeSatoshis)) - : Math.round(Number(ancestorFees) * satoshiUnit) - if (!Number.isFinite(ancestorFeeSat) || ancestorFeeSat < 0) return 0 - // Rounding up a float product costs a whole base unit whenever the rate - // carries binary representation error (0.01/1000 is not exact, so a 3000-byte - // package at that rate lands on 3000000.0000000005). Shave a relative - // epsilon, twelve orders of magnitude above the double's own error and far - // below one base unit, so the ceiling reflects the arithmetic rather than the - // representation. - const packageFeeExact = (packageSize + txSize) * targetFeePerBytes * satoshiUnit - const packageFeeNeeded = Math.ceil(packageFeeExact - Math.abs(packageFeeExact) * 1e-12) - const uplift = (packageFeeNeeded - ancestorFeeSat) - currentFee - return (uplift > 0) ? uplift : 0 -} // Ceiling (in blocks) on how far the utxo-tracker's committed view may lag // the chain tip before a tracker-fetched UTXO set is refused rather than @@ -263,205 +70,7 @@ function packageFeeUpliftSatoshis({ currentFee, txSize, ancestorSize, ancestorFe // that without false-positiving on routine operation. const DEFAULT_MAX_UTXO_TRACKER_LAG_BLOCKS = 2 -// THE tracker-freshness classifier. Pure: it reads a `sync` object and a lag -// ceiling and returns a verdict; it never throws, logs, or touches a connector. -// -// It exists because the same verdict was written twice against two different -// endpoints and drifted: _buildTransaction read the `sync` sibling that get_utxos -// carries, api.js getServeReadiness() read getSyncStatus(), and each re-derived -// halted / over-lag / behind-node / mempool-ready with its own strict-equality -// chain. Both surfaces now call this, so a create_tx refusal and an unhealthy -// /status can no longer disagree about the same tracker. -// -// Fail-open is deliberate and narrow, NOT an oversight: a field that is absent -// (an older tracker predating the ce16bdd freshness surface, or one predating -// the halt/mempool markers) must not refuse every build in the fleet, so only an -// EXPLICIT negative refuses. `halted` refuses on === true; `synced` refuses on -// === false; `mempool_ready` refuses on === false; a non-numeric lag is unknown -// and bounds nothing. An entirely missing `sync` yields present:false and no code. -// -// Returns: -// present - a usable sync object was supplied -// lag - numeric lag, or null when the tracker did not report one -// overLag - lag is above maxLagBlocks -// behindNode - lag is negative, i.e. the tracker's committed tip sits ABOVE -// the node's, so its outputs live in orphaned blocks -// halted - tracker stopped polling on an unrecoverable reorg -// mempoolReady - mempool index has reconverged (true unless explicitly false) -// syncedClaimed - the tracker's own positive `synced` assertion, truthy-tested. -// Readiness probes need this POSITIVE form (an omitted field is -// not an assertion of health), while the create_tx gate refuses -// only on the explicit negative; that asymmetry is the one real -// difference between the two surfaces and is now named instead -// of being buried in two `===` chains. -// code - null when nothing refuses, else the OperationalError code -// message - operator-facing refusal text, null when code is null -// details - error details payload, null when code is null -// -// Order matters: halted first (most specific physical cause), then staleness, -// then mempool readiness. An orphaned or lagging view also de-asserts -// mempool_ready (the tracker floors that field on the same negative lag), so -// checking readiness first would name mempool reconvergence for what is really a -// node reset. -function classifyTrackerFreshness(sync, maxLagBlocks){ - if (!sync || typeof sync !== 'object'){ - return { - present: false, lag: null, overLag: false, behindNode: false, - halted: false, mempoolReady: true, syncedClaimed: false, - code: null, message: null, details: null - } - } - // NaN is excluded explicitly: it is `typeof number`, so it passed the old check - // and was then reported verbatim as the lag while answering every comparison - // below with a silent false. Unknown is the honest verdict for it. Infinity is - // NOT excluded: it compares, and it compares as unboundedly stale, which is - // exactly the refusal a garbage lag should get. - const lag = (typeof sync.lag === 'number' && !Number.isNaN(sync.lag)) ? sync.lag : null - const overLag = (lag !== null) && (lag > maxLagBlocks) - const behindNode = (lag !== null) && (lag < 0) - const halted = sync.halted === true - const mempoolReady = sync.mempool_ready !== false - const syncedClaimed = !!sync.synced - const heights = { lag, tracker_height: sync.tracker_height, node_height: sync.node_height } - const verdict = { - present: true, lag, overLag, behindNode, halted, mempoolReady, syncedClaimed, - code: null, message: null, details: null - } - if (halted){ - verdict.code = 'UTXO_TRACKER_HALTED' - verdict.message = `utxo-tracker is halted (${sync.halt_reason || 'unrecoverable reorg'}); ` + - 'refusing to select utxos from it' - verdict.details = Object.assign({}, heights, { halt_reason: sync.halt_reason || null }) - return verdict - } - if (sync.synced === false || overLag || behindNode){ - verdict.code = 'UTXO_TRACKER_STALE' - verdict.message = `utxo-tracker view is stale (lag ${lag === null ? 'unknown' : lag} blocks` + - `${overLag ? `, exceeds ${maxLagBlocks}-block threshold` : ''}` + - `${behindNode ? `, tracker is ahead of the node so its view is orphaned` : ''}); ` + - 'refusing to select utxos from it' - verdict.details = heights - return verdict - } - if (!mempoolReady){ - verdict.code = 'UTXO_TRACKER_NOT_READY' - verdict.message = 'utxo-tracker has not reconverged its mempool yet, so an already-spent ' + - 'confirmed output cannot be filtered; refusing to select utxos from it' - verdict.details = heights - return verdict - } - return verdict -} - -// How long a selected outpoint stays reserved against concurrent selection. -// Long enough for a caller to sign and broadcast, short enough that an -// abandoned selection auto-releases without operator intervention. In-memory -// and best-effort only (the encoder is a single stateless process): this -// narrows, but cannot fully close, a same-address double-spend race. See the -// reservation helpers and the selection loop. -const RESERVATION_TTL_MS = 5 * 60 * 1000 - -// Resolve the 20-byte caller HASH160 that gates a P2SH/P2WSH chunk-lane reveal, -// from ANY caller identity form, not just a base58 legacy address. The reveal tx -// that spends a chunk output must satisfy an ordinary P2PKH gate (OP_DUP -// OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG) with the SOURCE key, so the -// returned hash MUST equal HASH160(that pubkey). It is the same 20 bytes whether -// the caller sends a base58 address, a raw pubkey hex, or a v0 bech32 P2WPKH -// address (whose witness program already IS that HASH160). The decoder reads -// ONLY the leading data chunk (redeemScript[0]) and never this trailer hash, so -// this is compose-side only with NO consensus/wire surface. Base58-only parsing -// used to throw "Non-base58 character" here, which broke every wallet flow (they -// send a raw compressed pubkey) and every bech32-only source. -function resolveCallerHash160(pubKey) { - // Raw compressed (02/03 + 64 hex) or uncompressed (04 + 128 hex) pubkey first: - // a base58 address can never match this shape (base58 excludes 0/O/I/l and - // 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')) - } - // Base58 P2PKH/P2SH address: byte-identical to the original behavior. - try { return bitcoin.address.fromBase58Check(pubKey).hash } catch (_) { /* not base58 */ } - // v0 bech32 P2WPKH: the 20-byte witness program already equals HASH160(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)`) - } 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)`) -} - -// Sibling of resolveCallerHash160, same "any identity in" contract, but for -// call sites that need an address STRING rather than a HASH160 (the UTXO -// tracker's getUtxosFromAddress, and the dust-padding fallback below). Wallet -// flows send their source as a raw compressed pubkey hex in this `pubkey` -// param, which is neither base58 nor bech32, so handing it straight to -// address.toOutputScript threw " has no matching Script" and failed every -// compose that did not pre-supply `utxos`. -// Address-type choice for a bare pubkey: this network's default (P2WPKH when -// segwit-capable, else legacy P2PKH), matching the wallet's own default address -// type per coin. A caller spending from a different address type (P2SH-P2WPKH, -// taproot) must pre-supply `utxos` or pass an explicit address, because a bare -// pubkey is inherently address-type-ambiguous. -function resolveCallerAddress(pubKey, network) { - if (typeof pubKey !== 'string' || pubKey.length === 0) return pubKey - // Already a valid address on this network: pass through unchanged. - 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') - 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 -} - -const Encoding = { - OP_RETURN: "OP_RETURN", - P2SH: "P2SH", - MULTISIGN: "MULTISIGN", - P2WSH: "P2WSH", - TAPROOT: "TAPROOT", - // Not a carrier: the caller's explicit request that the encoder pick the - // smallest-footprint carrier this network and signer can actually use. - // Resolved to one of the above before anything is built, - // so no downstream code ever sees it. - AUTO: "AUTO" -} -// Deployment default for transparent FILE compression. ON unless -// the operator turns it off for a staged rollout; read per call rather than -// cached so a restart is not required to change it. -function defaultCompressionEnabled(){ - const raw = config.XCHAIN_COMPRESSION_DEFAULT - if (raw === undefined || raw === null || raw === '') return true - return !(raw === '0' || raw.toLowerCase() === 'false' || raw.toLowerCase() === 'off') -} - -// Bind the reveal's marker key to the outpoint it actually spends. -// The P2SH/P2WSH reveal obfuscates its OP_RETURN marker with the id of the -// funding tx parsed from p2shHex, but builds every input outpoint from the -// separately supplied p2shHash, and nothing checked the two agree. A caller -// mixing the id of one funding tx with the hex of an equivalent other one still -// produced a signable, broadcastable reveal, while the decoder derives its key -// from the input's real txid, fails the magic-word check, and silently drops the -// paid ACTION. Called right after the hex is parsed, so it fires before the -// marker is obfuscated and before any input is added. -function assertRevealFundingTxMatches(p2shHash, fundingTxid){ - if (!p2shHash) return - if (String(p2shHash).toLowerCase() !== String(fundingTxid).toLowerCase()){ - throw new TypeError( - `p2shHash (${p2shHash}) does not match the txid of the supplied p2shHex transaction (${fundingTxid}); ` + - `the reveal would spend one funding transaction while keying its marker to another` - ) - } -} class XChainEncoder { @@ -544,615 +153,6 @@ class XChainEncoder { this.reservationTickets = new Map() } - // Sweep the recent-build map the same way evictExpiredReservations sweeps - // the outpoint map, so neither grows unbounded in a long-lived process. - evictExpiredRecentBuilds(now) { - for (const [txid, expiry] of this.recentBuilds) { - if (expiry <= now) this.recentBuilds.delete(txid) - } - } - - // Refuse to hand back a transaction identical to one built within the - // reservation window, then record this one. Identity is the UNSIGNED txid - // (inputs, outputs, version, locktime): a rebuild that changes any of those - // (an RBF bump, a different amount, a different input) is a different - // transaction and passes. The outpoint reservations normally stop an - // identical rebuild one layer earlier (every input is still reserved), so - // this only fires when those were bypassed or cleared; it exists so the - // "same txid twice" failure can never present as two successes again. - refuseDuplicateBuild(psbt, now) { - this.evictExpiredRecentBuilds(now) - const unsignedTx = bitcoin.Transaction.fromBuffer(psbt.data.globalMap.unsignedTx.toBuffer()) - const txid = unsignedTx.getId() - if (this.recentBuilds.has(txid)) { - throw new OperationalError( - 'DUPLICATE_TRANSACTION', - 'refusing to rebuild a transaction identical to one built in the last ' + - Math.round(RESERVATION_TTL_MS / 60000) + ' minutes (same inputs and outputs, same txid ' + - txid + '); broadcast the one you already have, or change the inputs or outputs', - { txid } - ) - } - this.recentBuilds.set(txid, now + RESERVATION_TTL_MS) - // Returned so the caller can put the txid (and therefore this record) on - // the build's reservation ticket: an explicit release has to retire the - // duplicate refusal alongside the outpoints, or a wallet that composes, - // releases and recomposes the SAME transaction is refused for the rest of - // the window by the guard instead of the reservation map. - return txid - } - - // A reserved outpoint is one an in-flight selection has claimed and not yet - // released. Expired entries are treated as free and lazily evicted here. - isOutpointReserved(key, now) { - const expiry = this.outpointReservations.get(key) - if (expiry == null) return false - if (expiry <= now) { - this.outpointReservations.delete(key) - return false - } - return true - } - - // Returns the expiry it wrote, which doubles as this claim's ownership stamp - // (see releaseCallReservations). - reserveOutpoint(key, now) { - const expiry = now + RESERVATION_TTL_MS - this.outpointReservations.set(key, expiry) - return expiry - } - - // Reserve an outpoint AND record the claim on the calling createTransaction's - // own ledger, so a later throw can hand it back. - claimOutpoint(callReservations, key, now) { - callReservations.push({ key, expiry: this.reserveOutpoint(key, now) }) - } - - // Release the reservations a single createTransaction call took, and only - // those. The stored expiry is the ownership stamp: while a claim is live no - // other call can take that outpoint (the selection loop skips reserved keys), - // so the only way the map value can differ from what this call wrote is that - // its claim already lapsed and somebody else re-reserved the outpoint against - // a later clock. A mismatch therefore means the entry is foreign, and dropping - // a foreign entry would reopen the same-address double-spend window the - // reservation map exists to close. Leave it. - releaseCallReservations(callReservations) { - for (const claim of callReservations) { - if (this.outpointReservations.get(claim.key) === claim.expiry){ - this.outpointReservations.delete(claim.key) - } - } - } - - // The highest expiry any STILL-LIVE cancel owner holds on this outpoint, or - // null when no live owner remains. This is the value outpointReservations must - // carry while the cancel path holds an outpoint, and comparing it against the - // reservation is how the build guard tells "held only by this path" from - // "held by a foreign createTransaction claim". - maxLiveCancelOwnerExpiry(key, now) { - const owners = this.envelopeCancelClaims.get(key) - if (!owners) return null - let max = null - for (const expiry of owners.values()) { - if (expiry > now && (max === null || expiry > max)) max = expiry - } - return max - } - - // Drop ONE cancel build's owner token and restate what the outpoint is now - // worth. While another live owner remains, outpointReservations is rewritten - // to that owner's expiry and the key stays reserved: this is the whole point - // of the owner set, because the releasing build's stamp is then no longer the - // map's value and every stamp-checked delete below leaves the entry alone. - // Returns true when the outpoint is still held by some other cancel owner. - dropCancelOwner(key, ownerId, now) { - const owners = this.envelopeCancelClaims.get(key) - if (!owners) return false - owners.delete(ownerId) - const remaining = this.maxLiveCancelOwnerExpiry(key, now) - if (remaining === null) { - this.envelopeCancelClaims.delete(key) - return false - } - this.outpointReservations.set(key, remaining) - return true - } - - // Release an envelope-cancel claim and its ownership token together, so a - // failed cancel build hands the commit outpoint back instead of squatting it - // for the whole TTL - but only once no other cancel build still owns it. A - // retry of a lost-response cancel legitimately re-claims an outpoint the first - // build is still outstanding on, and deleting the reservation outright on the - // retry's failure freed that outpoint to createTransaction while the earlier - // unsigned cancel spent it. Ownership-checked exactly like - // releaseCallReservations: a stamp that moved belongs to somebody else. - releaseEnvelopeCancelClaims(callReservations) { - const now = Date.now() - const handBack = [] - for (const claim of callReservations) { - // A claim another cancel owner still holds is withheld from the stamped - // release below, and deliberately not left to that check: two builds a - // millisecond apart share an expiry, so the stamp alone cannot tell the - // surviving owner's reservation from this one's. - if (claim.cancelOwnerId && this.dropCancelOwner(claim.key, claim.cancelOwnerId, now)) continue - handBack.push(claim) - } - this.releaseCallReservations(handBack) - } - - // Sweep the cancel ownership tokens alongside the reservation map, so neither - // grows unbounded in a long-lived process. An outpoint whose last owner token - // has lapsed goes with it. - evictExpiredEnvelopeCancelClaims(now) { - for (const [key, owners] of this.envelopeCancelClaims) { - for (const [ownerId, expiry] of owners) { - if (expiry <= now) owners.delete(ownerId) - } - if (owners.size === 0) this.envelopeCancelClaims.delete(key) - } - } - - // Sweep expired reservations so the map cannot grow unbounded across a - // long-lived process. Called opportunistically at the start of selection. - evictExpiredReservations(now) { - for (const [key, expiry] of this.outpointReservations) { - if (expiry <= now) this.outpointReservations.delete(key) - } - } - - // Sweep expired tickets so the ticket map cannot outgrow the reservation map - // it describes. A ticket expires with the LAST of its claims. - evictExpiredReservationTickets(now) { - for (const [id, ticket] of this.reservationTickets) { - if (ticket.expiry <= now) this.reservationTickets.delete(id) - } - } - - // Mint the receipt for the claims a SUCCESSFUL build kept, so the caller can - // hand them back the moment it knows it will not broadcast (the wallet closes - // its compose modal) instead of stranding a funded address for the rest of - // RESERVATION_TTL_MS. Returns null when the build claimed nothing still live, - // and the result then simply carries no reservation block: a release RPC that - // has nothing to release must not mint a ticket that pretends otherwise. - // - // Only claims whose stamp is still the map's value go on the ticket. Anything - // else already lapsed and was re-taken by another call, and putting it on this - // ticket would hand this caller a lever over a foreign claim. - mintReservationTicket(callReservations, now) { - this.evictExpiredReservationTickets(now) - const live = [] - for (const claim of callReservations) { - // cancelOwnerId rides onto the ticket because releaseReservation has to - // retire this build's owner token as well as its reservation; leaving it - // behind would hold the outpoint until the token's own TTL lapsed. - if (this.outpointReservations.get(claim.key) === claim.expiry) { - live.push({ key: claim.key, expiry: claim.expiry, cancelOwnerId: claim.cancelOwnerId || null }) - } - } - if (live.length === 0) return null - const id = crypto.randomBytes(16).toString('hex') - let expiry = 0 - for (const claim of live) if (claim.expiry > expiry) expiry = claim.expiry - const txid = callReservations.buildTxid || null - this.reservationTickets.set(id, { - claims: live, - expiry, - txid, - // The recent-build record's own ownership stamp, read back rather than - // recomputed so a rebuild that re-registered the txid under a later - // clock is left alone by this ticket's release. - txidExpiry: txid ? (this.recentBuilds.get(txid) || null) : null - }) - return { id, outpoints: live.map(c => c.key), expiresAt: expiry } - } - - // Explicit, ownership-stamped release of ONE build's reservations, the - // counterpart of the receipt mintReservationTicket put on that build's - // result. The wallet composes when its send modal opens and most of those - // builds are never broadcast, so without this every abandoned compose held a - // few-UTXO address's whole balance for five minutes and the next compose read - // as insufficient funds. Idempotent and never an error: an unknown, already - // released or lapsed ticket returns found:false rather than throwing, because - // a wallet firing this from a modal-close handler cannot usefully react to a - // failure and must not be taught to retry one. - // - // Ownership is enforced twice: the ticket id is 16 unguessable bytes handed - // only to the build that took the claims, and each claim is dropped only while - // the map still holds the exact expiry that build wrote. A claim whose stamp - // moved lapsed and was re-taken by somebody else, so it is REPORTED (retained) - // and left in place; dropping it would reopen the same-address double-spend - // window the reservation map exists to close. - releaseReservation(reservationId) { - if (typeof reservationId !== 'string' || !/^[0-9a-f]{32}$/.test(reservationId)) { - throw new TypeError('reservationId must be a 32-character lowercase hex string, as returned in create_tx result.reservation.id') - } - const now = Date.now() - this.evictExpiredReservations(now) - this.evictExpiredEnvelopeCancelClaims(now) - this.evictExpiredRecentBuilds(now) - this.evictExpiredReservationTickets(now) - const ticket = this.reservationTickets.get(reservationId) - if (!ticket) return { reservationId, found: false, released: [], retained: [] } - // A ticket is single-use: the claims it names are either freed now or were - // already taken over by another call, and either way it has no further use. - this.reservationTickets.delete(reservationId) - const released = [] - const retained = [] - for (const claim of ticket.claims) { - // Retire this build's cancel owner token first, whatever the stamp says. - // A ticket whose stamp has moved still has to give the token up, or the - // outpoint would stay reserved at this build's expiry long after the - // build that took it over released. dropCancelOwner restates the - // reservation at the highest expiry a surviving owner holds, which is - // also what makes the stamp check below leave that owner's entry alone. - const stillHeldByAnotherCancel = claim.cancelOwnerId - ? this.dropCancelOwner(claim.key, claim.cancelOwnerId, now) - : false - if (!stillHeldByAnotherCancel && this.outpointReservations.get(claim.key) === claim.expiry) { - this.outpointReservations.delete(claim.key) - released.push(claim.key) - } else { - retained.push(claim.key) - } - } - // Retire the duplicate-build refusal for this build too, under the same - // stamp check. Releasing the inputs without it just moves the five-minute - // wall: the recompose that follows a release is byte-identical by - // construction and would be refused DUPLICATE_TRANSACTION instead. - if (ticket.txid && ticket.txidExpiry != null && this.recentBuilds.get(ticket.txid) === ticket.txidExpiry) { - this.recentBuilds.delete(ticket.txid) - } - return { reservationId, found: true, released, retained } - } - - // Explicit release of all reservations and recent-build records. The encoder - // holds no durable state, so this is primarily a test seam; production - // relies on the TTL. - clearReservations() { - this.outpointReservations.clear() - this.recentBuilds.clear() - this.envelopeCancelClaims.clear() - this.reservationTickets.clear() - } - - isSegwitUTXO(utxo) { - try { - const script = bitcoin.script.decompile(Buffer.from(utxo.scriptPubKey, 'hex')); - // A witness program is exactly [version opcode][2..40 byte push]: OP_0 - // (0x00) for v0 (P2WPKH/P2WSH), OP_1..OP_16 (0x51..0x60) for v1..v16. - // Taproot is v1, so the old v0-only check (script[0] === 0x00) treated - // P2TR UTXOs as legacy and set nonWitnessUtxo instead of witnessUtxo - - // but Taproot signing (and the MuSig2 co-signer) require witnessUtxo. - if (!script || script.length !== 2 || !Buffer.isBuffer(script[1])) return false; - const version = script[0]; - const isWitnessVersion = version === bitcoin.opcodes.OP_0 || - (version >= bitcoin.opcodes.OP_1 && version <= bitcoin.opcodes.OP_16); - return isWitnessVersion && script[1].length >= 2 && script[1].length <= 40; - } catch (error) { - return false; - } - } - - prepareData(data, encoding, pubKey, compressedPubKey){ - let magicWordBuffer = Buffer.from(MAGIC_WORD,'utf8') - - if (!encoding){ - if (data.length + magicWordBuffer.length <= OP_RETURN_SIZE) { - encoding = Encoding.OP_RETURN - } else { - encoding = Encoding.P2SH - } - } - let chunksSize = 0 - let dataBufferArray = [] - let i = 0 - let nextDataChunk = null - switch (encoding){ - case Encoding.OP_RETURN: - chunksSize = OP_RETURN_SIZE - magicWordBuffer.length //Single OP_RETURN output: 80-byte limit minus 4-byte magic word = 76 bytes of payload - - // All supported chains (BTC, LTC, DOGE) inherit Bitcoin Core's - // IsStandardTx rule that rejects any transaction with more than one - // nulldata/OP_RETURN output as non-standard ("multi-op-return"). A split - // payload builds a structurally valid PSBT that fails silently at broadcast - // and burns the fee UTXOs. The auto-selection path avoids this by falling - // back to P2SH; the explicit-encoding path must reject loudly here instead. - // - // The single-output 80-byte ceiling is enforced unconditionally. - // singleOpReturnPolicy:false used to skip this throw and fall - // through to a multi-chunk split loop instead: no shipped decoder - // can reassemble a payload split across multiple OP_RETURN pushes - // (it only ever reads a single OP_RETURN output), and no shipped - // coin relays a multi-OP_RETURN transaction as standard, so that - // path was a fee-burning, undecodable-transaction trap armed by one - // config flag rather than a real opt-out. Fail-closed - // unconditionally now, the same as when the flag is absent. - if (data.length > chunksSize) { - throw new RangeError( - `OP_RETURN encoding requires compiled payload <= ${chunksSize} bytes; ` + - `got ${data.length}. Use P2SH for larger payloads.` - ) - } - - // data.length <= chunksSize is now guaranteed above, so the - // payload always compiles to exactly one OP_RETURN chunk. - dataBufferArray.push(Buffer.concat([magicWordBuffer,data])) - - return {"dataBufferArray":dataBufferArray, "encoding": encoding} - case Encoding.P2SH: - case Encoding.P2WSH: - /* REDEEM SCRIPT (exactly as compiled below) - // the on-wire data push (max chunksSize bytes) - + - OP_DROP // 1 byte - drops the data so it never gates the spend - + - OP_DUP // 1 byte - + - OP_HASH160 // 1 byte - + - // 20-byte HASH160 of the caller key - // (resolveCallerHash160(pubKey): base58 addr, - // raw pubkey hex, or v0 bech32 P2WPKH all yield - // the same HASH160(pubkey)), NOT a 33-byte pubkey - + - OP_EQUALVERIFY // 1 byte - + - OP_CHECKSIG // 1 byte - // - // The trailing five ops form an ordinary P2PKH gate; the data rides - // in the leading push and is discarded at spend time by OP_DROP. - // There is no n / OP_DEPTH / 0 / OP_EQUAL tail (that older sketch - // never matched the compiled script). - */ - - chunksSize = (encoding == Encoding.P2SH?P2SH_SIZE:PW2SH_SIZE) - 44 // 44 is a conservative per-chunk overhead reservation that leaves headroom under the 520-byte consensus MAX_SCRIPT_ELEMENT_SIZE (P2SH_SIZE/PW2SH_SIZE) for the OP_DROP/OP_DUP/OP_HASH160//OP_EQUALVERIFY/OP_CHECKSIG trailer plus the leading data-push prefix. Each chunk becomes one P2SH/P2WSH output; the input spending it carries the data inside its redeem/witness script. - - let pubkeyFromBase58 = resolveCallerHash160(pubKey) - - // Slice the chunk boundaries first so a degenerate 1-byte final - // chunk can be rebalanced before compile. bitcoin.script.compile - // applies asMinimalOP: a lone data byte of 0x01-0x10 or 0x81 - // canonicalizes to a bare opcode, and the decoder's redeem-script - // Buffer gate (XChainDecoder.js: !Buffer.isBuffer(decodedRedeemScript[0])) - // then silently skips that output, corrupting reassembly of an - // otherwise-valid multi-byte payload. Shifting one byte from the - // penultimate chunk keeps every chunk >= 2 bytes; the decoder - // reassembles by concatenation so moving a byte across the - // boundary is transparent end-to-end. - let p2shChunks = [] - i = 0 - while (i < data.length){ - nextDataChunk = data.subarray(i,i+chunksSize) - p2shChunks.push(nextDataChunk) - i = i + nextDataChunk.length - } - if (p2shChunks.length >= 2){ - let last = p2shChunks[p2shChunks.length - 1] - if (last.length === 1 && ((last[0] >= 0x01 && last[0] <= 0x10) || last[0] === 0x81)){ - // Repartition the final two chunks: penultimate drops its - // last byte, final grows to 2 bytes. Both are re-sliced - // from `data` so the concatenation is byte-identical. - let prevStart = data.length - 1 - p2shChunks[p2shChunks.length - 2].length - p2shChunks[p2shChunks.length - 2] = data.subarray(prevStart, data.length - 2) - p2shChunks[p2shChunks.length - 1] = data.subarray(data.length - 2) - } - } - - for (const chunk of p2shChunks){ - let nextDataBuffer = bitcoin.script.compile([ - chunk, - bitcoin.opcodes.OP_DROP, - bitcoin.opcodes.OP_DUP, - bitcoin.opcodes.OP_HASH160, - pubkeyFromBase58, - bitcoin.opcodes.OP_EQUALVERIFY, - bitcoin.opcodes.OP_CHECKSIG, - ]) - - dataBufferArray.push(nextDataBuffer) - } - - return {"dataBufferArray":dataBufferArray, "encoding": encoding} - case Encoding.MULTISIGN: - chunksSize = MULTISIGN_SIZE - - magicWordBuffer.length - - 1 //1 byte for the OP_CHECKMULTISIG - - 1 //1 byte for the m signatures to pop - - 1 //1 byte for the n addresses to pop - - 1 //1 byte for the first address length - - 1 //1 byte for the second address length - - // Each MULTISIGN output carries its data across two 32-byte - // pubkey halves (64 data bytes total). A full chunk is already - // magic(4) + 60 = 64 bytes, but the final chunk is shorter. - // Zero-pad every chunk up to the full 64-byte slot so BOTH - // pubkey halves are always complete 32-byte values. Without - // this, a short final chunk leaves the second half empty (or - // near-empty); dataToPubkey() then produces an all-zero / - // low-entropy EC point that bitcoinjs-lib rejects as not a - // valid point. The reader strips this trailing pad using the - // payload's own self-describing compiled-script length, so the - // padding is invisible end-to-end. - let multisignSlotSize = 64 - - i = 0 - while (i < data.length){ - nextDataChunk = data.subarray(i,i+chunksSize) - let nextChunk = Buffer.concat([magicWordBuffer,nextDataChunk]) - if (nextChunk.length < multisignSlotSize){ - nextChunk = Buffer.concat([nextChunk, Buffer.alloc(multisignSlotSize - nextChunk.length, 0)]) - } - dataBufferArray.push(nextChunk) - i = i + nextDataChunk.length - } - - return {"dataBufferArray":dataBufferArray, "encoding": encoding} - case Encoding.TAPROOT: { - /* TAPROOT ENVELOPE LEAF (grammar frozen at review) - OP_FALSE OP_IF - <"XCHN"> // 4-byte magic, CLEARTEXT (same constant as every lane) - <0x00> // format byte v0, cleartext - // raw payload, 520-byte elements, in order - OP_ENDIF - OP_CHECKSIG - // - // The payload is the compiled action stream (`data` here: the - // action-string push plus the rawData push), byte-identical to - // what the chunk lanes carry, with the magic excluded because - // the envelope header already carries it. Raw by design: the - // shipped large-payload (P2WSH) precedent is unobfuscated, and - // keying on the commit txid is circular by construction. - */ - // The leaf tail and the key-path cancel both need the caller's - // real pubkey; `pubKey` may be a bare address, so the explicit - // compressedPubKey is required, mirroring MULTISIGN. - if (compressedPubKey == null) { - throw new TypeError('compressedPubKey is required for TAPROOT encoding (it becomes the envelope internal key)') - } - if (typeof compressedPubKey !== 'string' || !/^(02|03)[0-9a-fA-F]{64}$/.test(compressedPubKey)) { - throw new TypeError('compressedPubKey must be a 66-character hex string starting with 02 or 03') - } - const internalPubkey = Buffer.from(compressedPubKey, 'hex').subarray(1) - - chunksSize = TAPROOT_ENVELOPE_CHUNK_SIZE - let envelopeChunks = [] - i = 0 - while (i < data.length){ - nextDataChunk = data.subarray(i, i + chunksSize) - envelopeChunks.push(nextDataChunk) - i = i + nextDataChunk.length - } - // Same degenerate-final-chunk rebalance as the P2SH/P2WSH lane: - // bitcoin.script.compile canonicalizes a lone 0x01-0x10/0x81 byte - // to a bare opcode (asMinimalOP), which would corrupt the - // concatenation the decoder reassembles. Shift one byte across - // the final boundary so every push stays >= 2 bytes. (A 1-byte - // TOTAL payload cannot occur: the compiled stream always leads - // with a push opcode, so any non-empty payload is >= 2 bytes.) - if (envelopeChunks.length >= 2){ - let last = envelopeChunks[envelopeChunks.length - 1] - if (last.length === 1 && ((last[0] >= 0x01 && last[0] <= 0x10) || last[0] === 0x81)){ - let prevStart = data.length - 1 - envelopeChunks[envelopeChunks.length - 2].length - envelopeChunks[envelopeChunks.length - 2] = data.subarray(prevStart, data.length - 2) - envelopeChunks[envelopeChunks.length - 1] = data.subarray(data.length - 2) - } - } - - const envelopeScript = bitcoin.script.compile([ - bitcoin.opcodes.OP_0, // OP_FALSE - bitcoin.opcodes.OP_IF, - magicWordBuffer, - Buffer.from([TAPROOT_ENVELOPE_FORMAT_V0]), - ...envelopeChunks, - bitcoin.opcodes.OP_ENDIF, - internalPubkey, - bitcoin.opcodes.OP_CHECKSIG, - ]) - - // dataBufferArray carries exactly ONE element: the whole - // envelope tapscript. The emission loop turns it into one - // commit output; there is no per-chunk output fan-out. - return {"dataBufferArray":[envelopeScript], "encoding": encoding, "internalPubkey": internalPubkey} - } - default: - throw new TypeError(`Unknown encoding: "${encoding}". Valid values: OP_RETURN, P2SH, MULTISIGN, P2WSH, TAPROOT`) - } - } - - async obfuscate(data, key){ - var cipherKey = key.substr(0,16) - var iv = key.substr(16,16) - - var cipher = crypto.createCipheriv('aes-128-ctr', cipherKey, iv); - var encryptedData = cipher.update(data) - encryptedData = Buffer.concat([encryptedData,cipher.final()]) - return encryptedData - } - - async dataToPubkey(data){ - let bufferArray = [Buffer.from("02","hex"),data] - let bufferFill = null - if (data.length < 32){ - bufferFill = Buffer.allocUnsafe(32 - data.length) - bufferFill.fill("00", 0, bufferFill.length, "hex") - bufferArray.push(bufferFill) - } - - return Buffer.concat(bufferArray) - } - - /** - * Refuse to build a Taproot envelope that decoders would ignore. - * - * Envelope recognition activates at a per-network height. Below it every decoder - * treats the reveal as an ordinary P2TR spend, so the caller would pay a real - * miner fee, write a real payload on chain, and own an action that does not - * exist. That refusal is silent and correct by design and nothing downstream can - * detect the loss, which is exactly why the check has to live here. Fail-closed - * on an unknown height too: a node that cannot answer getblockcount leaves us - * unable to prove recognition is active, and guessing wrong costs the caller - * real money. - * - * `null` means the network never recognizes envelopes (DOGE: no segwit). That is - * already refused by the supportsSegwit gate; this repeats it as a safety net for - * any future non-segwit chain whose definition is added without one. - */ - async assertEnvelopeRecognized(){ - const height = CryptoNetworks.getEnvelopeRecognitionHeight(this.networkKey) - if (height === null || height === undefined) { - throw new TypeError('TAPROOT encoding is not recognized on this network; ' + - 'no envelope recognition height is defined for it') - } - if (height === 0) return // genesis-active (testnet/regtest) - - const tip = this.connector && typeof this.connector.getBlockCount === 'function' - ? await this.connector.getBlockCount() - : null - if (!Number.isFinite(tip)) { - const e = new Error('Cannot confirm Taproot envelope recognition is active on this network ' + - `(recognition height ${height}); the node did not return a chain height. Refusing to build ` + - 'an envelope that decoders may ignore.') - e.operational = true - e.xchainCode = 'ENVELOPE_RECOGNITION_UNKNOWN' - throw e - } - if (tip < height) { - const e = new Error(`Taproot envelope recognition is not active on this network until block ${height} ` + - `(chain tip ${tip}, ${height - tip} block(s) to go). An envelope broadcast now would cost a real ` + - 'fee and be ignored by every decoder, so it is refused. Use P2WSH until the activation height.') - e.operational = true - e.xchainCode = 'ENVELOPE_NOT_YET_ACTIVE' - e.details = { recognitionHeight: height, chainTip: tip, blocksRemaining: height - tip } - throw e - } - } - - // Public entry point. It owns the per-call reservation ledger: every outpoint - // the build claims is recorded there, and if the build throws for ANY reason - // (INPUT_SELECTION_RACE, INSUFFICIENT_FUNDS, a fee-cap RangeError, an upstream - // node error) those claims are handed back immediately instead of squatting - // until RESERVATION_TTL_MS. Without that, the retry an INPUT_SELECTION_RACE - // error explicitly asks for hit its OWN dead reservations and came back - // INSUFFICIENT_FUNDS for up to five minutes. The release is ownership-stamped - // so a concurrent call's entries are never dropped; see - // releaseCallReservations. The success path keeps its reservations on purpose: - // the caller is about to sign and broadcast those inputs. - // A successful build's kept claims come back as a `reservation` receipt on the - // result, which the caller hands to releaseReservation the moment it knows it - // will not broadcast; see mintReservationTicket. - async createTransaction(...args){ - const callReservations = [] - let result - try { - result = await this._buildTransaction(callReservations, ...args) - } catch (err) { - this.releaseCallReservations(callReservations) - throw err - } - const reservation = this.mintReservationTicket(callReservations, Date.now()) - if (reservation && result && typeof result === 'object') result.reservation = reservation - return result - } async _buildTransaction(callReservations, utxos, pubkey, customOutputs, data, rawData, fee, replacebyfee, encoding, change, p2shHash=null, p2shHex=null, compressedPubKey=null, @@ -3082,389 +2082,10 @@ class XChainEncoder { return result } - /* - * Size-aware encoding selection: "smallest footprint by default" as the - * platform's behaviour rather than its option. Only reached when the caller - * explicitly asked for Encoding.AUTO. - * - * @param {number} compiledLength the compiled ACTION stream, AFTER compression - * @param {string|null} compressedPubKey required for the envelope's internal key - * @param {object|null} options { signerSupportsTapscript } - * @returns {string} a concrete Encoding - */ - selectEncoding(compiledLength, compressedPubKey, options){ - const magicBytes = Buffer.from(MAGIC_WORD, 'utf8').length - // Small payloads: one output, no reveal, nothing cheaper exists. - if (compiledLength + magicBytes <= OP_RETURN_SIZE) return Encoding.OP_RETURN - - const segwit = this.network.supportsSegwit !== false - - // The envelope is ~2x cheaper per byte than P2WSH and replaces ~820 - // chunk outputs per 390 KB, so it wins whenever it is available. Two - // conditions, both fail-closed: - // - // - the network must have Taproot at all (DOGE never does); - // - the SIGNER must be able to produce a tapscript script-path - // signature. This defaults to NO and must be affirmed by the caller. - // The reveal has to be signable BEFORE the commit is broadcast; - // picking the envelope for a signer that cannot spend it does not - // produce an error message, it produces stranded funds. Hardware - // signers are the live case: the wallet's Trezor integration cannot - // sign the leaf today, so those accounts must land on P2WSH. - const tapscriptSigner = !!(options && options.signerSupportsTapscript) - if (segwit && tapscriptSigner && compressedPubKey) return Encoding.TAPROOT - - // P2WSH carries 476 bytes per chunk against MULTISIGN's 60, so it is the - // segwit fallback. On a non-segwit chain P2SH is the same 476-byte lane - // and is therefore preferred over MULTISIGN there too: AUTO never - // selects the worst-density carrier, which is what makes MULTISIGN an - // explicit-request-only lane from here on. - if (segwit) return Encoding.P2WSH - return Encoding.P2SH - } - - // Stripped (non-witness) serialized byte count of a PSBT's underlying tx. - // This is the size a node measures against its MIN_STANDARD_TX_NONWITNESS_SIZE - // relay floor. P2WSH reveal inputs carry an empty scriptSig (all data lives in - // the witness), so each contributes a fixed 41 non-witness bytes: 36-byte - // outpoint + 1-byte empty-scriptSig length + 4-byte sequence. - strippedTxSize(psbt){ - const varIntSize = (n) => n < 0xfd ? 1 : n <= 0xffff ? 3 : n <= 0xffffffff ? 5 : 9 - let size = 4 + 4 // version + locktime - size = size + varIntSize(psbt.txInputs.length) + (41 * psbt.txInputs.length) - size = size + varIntSize(psbt.txOutputs.length) - for (let out of psbt.txOutputs){ - size = size + 8 + varIntSize(out.script.length) + out.script.length - } - return size - } - - // Raise an already-emitted output's value in place. - // - // bitcoinjs exposes no setter for an output's value (updateOutput only - // touches PSBT-level fields), and re-emitting the output is not an option: - // the commit output's index is load-bearing (the envelope reveal spends it by - // vout, the decoder reads the chunk legs in order), so it has to stay put. - // The PSBT is unsigned at this point and every serialization path reads the - // same unsigned transaction object, so the write is seen by psbt.txOutputs, - // psbt.toHex() and the reveal builder alike. Asserted rather than assumed: - // a bitcoinjs release that reshapes this would otherwise underfund a reveal - // silently, which is the exact failure this whole pass exists to prevent. - raiseOutputValue(psbt, outputIndex, delta){ - if (!Number.isInteger(delta) || delta <= 0){ - throw new RangeError('output uplift must be a positive integer') - } - const outs = psbt.data.globalMap.unsignedTx - && psbt.data.globalMap.unsignedTx.tx - && psbt.data.globalMap.unsignedTx.tx.outs - if (!Array.isArray(outs) || !outs[outputIndex]){ - throw new RangeError(`no output at index ${outputIndex} to raise`) - } - const raised = outs[outputIndex].value + delta - outs[outputIndex].value = raised - if (psbt.txOutputs[outputIndex].value !== raised){ - throw new RangeError('output value uplift did not reach the transaction bitcoinjs will serialize') - } - } - - estimateSpendingP2shTx(redeemData){ - // Per-chunk embedded value sized to cover the spending tx's worst - // case at 1 sat/vbyte. Includes tx overhead, the OP_RETURN marker - // output, the P2SH input bringing this chunk's redeem script (sig + - // compressed pubkey + redeem script push; see estimateP2shInputWithRedeem), - // plus a small safety margin to absorb DER signature length jitter so the - // broadcast never lands fractionally under the node's min relay fee floor. - let sizeEstimated = - 10 // 4 version + 1 inputs count + 1 outputs count + 4 locktime - + TxSizeEstimator.estimateP2shInputWithRedeem(redeemData) - + TxSizeEstimator.estimateOpReturnOutput(Buffer.concat([ - Buffer.from(MAGIC_WORD,'utf8'), - Buffer.from("p2sh",'utf8') - ])) - + 8 // safety margin for DER-sig length jitter (sig push assumes 72B) - - return sizeEstimated - } - - // Whole-tx size of the P2SH reveal (phase 2): every chunk input carrying its - // redeem script, the OP_RETURN marker output, plus extraOutputsBytes for the - // value outputs the reveal emits (the change sweep, reveal-side - // customOutputs). estimateSpendingP2shTx above is the PER-CHUNK share that - // sizes each funding leg; this is the fee base for the ONE transaction - // that spends all of them, so the funding side can top the first leg up to - // what the reveal will actually need. Both phases MUST - // price the reveal with this function: the reveal path's generic - // TxSizeEstimator.estimateInputSize() models a P2SH input as 2-of-3 - // multisig (289 bytes flat) and disagrees with the funding side's - // redeem-aware estimate, and that drift is exactly what would leave the - // funded change headroom a few satoshis short of emittable. The per-input - // +8 is the same DER-signature-length-jitter margin estimateSpendingP2shTx - // carries. - estimateP2shRevealTx(dataBufferArray, extraOutputsBytes = 0){ - let inputsBytes = 0 - for (const redeemData of dataBufferArray){ - inputsBytes = inputsBytes + TxSizeEstimator.estimateP2shInputWithRedeem(redeemData) + 8 - } - return 10 // 4 version + 1 inputs count + 1 outputs count + 4 locktime - + inputsBytes - + TxSizeEstimator.estimateOpReturnOutput(Buffer.concat([ - Buffer.from(MAGIC_WORD,'utf8'), - Buffer.from("p2sh",'utf8') - ])) - + extraOutputsBytes - } - - estimateSpendingP2wshTx(witnessData){ - // 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 - // script) in the witness, which is weight-1 (i.e. ÷4 toward vbytes). - // That makes a P2WSH reveal materially cheaper than the equivalent P2SH - // reveal: sizing it with estimateSpendingP2shTx would over-fund ~4x. - // compactSize, not compiledPushSize: a witness stack item is length- - // prefixed by a varint on the wire, it is not a push inside a script. - // The two disagree in two bands and the old script-push model was wrong - // in both: 76..252 it over-funded by a byte, and 253..255 it UNDER-funded - // by a byte (OP_PUSHDATA1 is 2 bytes there, the varint is 3). - let witnessScriptPrefix = compactSizeLen(witnessData.length) - // Witness stack (weight 1): item count + sig item (1+72) + pubkey item - // (1+33) + witness-script item prefix + script bytes, plus the segwit - // marker+flag (2) which are also weight 1. The 72- and 33-byte items are - // both under 253, so their compactSize prefix is the literal 1 below. - let witnessBytes = 2 // marker + flag - + 1 // witness stack item count - + (1 + 72) // sig item - + (1 + 33) // compressed pubkey item - + (witnessScriptPrefix + witnessData.length) // witness script prefix + bytes - - // Non-witness (weight 4) bytes: tx overhead + the empty-scriptSig input - // outpoint + the OP_RETURN marker output. - let nonWitnessBytes = - 10 // 4 version + 1 inputs count + 1 outputs count + 4 locktime - + (36 + 1 + 4) // outpoint(36) + empty scriptSig len(1) + sequence(4) - + TxSizeEstimator.estimateOpReturnOutput(Buffer.concat([ - Buffer.from(MAGIC_WORD,'utf8'), - 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.) - let strippedFloor = this.network.minStandardTxNonWitnessSize - if (strippedFloor && nonWitnessBytes < strippedFloor){ - nonWitnessBytes = strippedFloor - } - - // vsize = ceil(total weight / 4) = nonWitnessBytes + ceil(witnessBytes/4) - let sizeEstimated = nonWitnessBytes - + Math.ceil(witnessBytes / 4) - + 8 // safety margin for DER-sig length jitter (sig push assumes 72B) - - return sizeEstimated - } - - // Key-path cancel of an UNREVEALED envelope commit: - // sweeps the commit output back to the caller before any reveal exists. By - // contract this must be buildable from the wallet's PERSISTED RECOVERY - // RECORD ALONE ({commit outpoint, internal key derivation path, tapleaf - // hash} plus the commit value), surviving a crash between commit and - // reveal: nothing here re-derives from the envelope payload, and the P2TR - // scriptPubKey is reconstructed from internal key + merkle root (the - // tapleaf hash IS the merkle root of the single-leaf tree). The returned - // PSBT carries tapInternalKey + tapMerkleRoot so the signer can compute - // the BIP341 tweak; it conflicts with the reveal by construction (same - // outpoint) and the wallet treats it as a replacement of the reveal. - // - // Public entry point. Like createTransaction it owns the per-call reservation - // ledger: a build that throws for any reason hands the commit outpoint back at - // once instead of squatting it until RESERVATION_TTL_MS. - // A successful cancel keeps its commit-outpoint claim and gets the same - // `reservation` receipt create_tx does, so an abandoned cancel can hand the - // commit outpoint back instead of blocking every rebuild for the whole TTL. - async createEnvelopeCancelTransaction(params = {}){ - const callReservations = [] - let result - try { - result = await this.buildEnvelopeCancelTransaction(callReservations, params) - } catch (err) { - this.releaseEnvelopeCancelClaims(callReservations) - throw err - } - const reservation = this.mintReservationTicket(callReservations, Date.now()) - if (reservation && result && typeof result === 'object') result.reservation = reservation - return result - } - - async buildEnvelopeCancelTransaction(callReservations, { commitTxid, commitVout, commitValue, internalPubkey, tapleafHash, destination, feePerKb = null, replacebyfee = false } = {}){ - if (typeof commitTxid !== 'string' || !/^[0-9a-fA-F]{64}$/.test(commitTxid)) { - throw new TypeError('commitTxid must be a 64-character hex string') - } - if (!Number.isInteger(commitVout) || commitVout < 0) { - throw new TypeError('commitVout must be a non-negative integer') - } - const value = parseSatoshiAmount(commitValue, 'commitValue') - // Accept the 33-byte compressed form (what create_tx took) or the - // 32-byte x-only form (what the recovery record may hold). - let internalKeyBuf - if (typeof internalPubkey === 'string' && /^(02|03)[0-9a-fA-F]{64}$/.test(internalPubkey)) { - internalKeyBuf = Buffer.from(internalPubkey, 'hex').subarray(1) - } else if (typeof internalPubkey === 'string' && /^[0-9a-fA-F]{64}$/.test(internalPubkey)) { - internalKeyBuf = Buffer.from(internalPubkey, 'hex') - } else { - throw new TypeError('internalPubkey must be a 66-character compressed or 64-character x-only pubkey hex string') - } - if (typeof tapleafHash !== 'string' || !/^[0-9a-fA-F]{64}$/.test(tapleafHash)) { - throw new TypeError('tapleafHash must be a 64-character hex string') - } - // validateAddress, not a local non-empty check: this is the one create - // path api.js does not route through validateAll, so without it the - // 100-char cap every other address field gets is missing here and an - // unbounded string reaches bs58check (quadratic) and psbt.addOutput. - validateAddress(destination, 'destination') - if (this.network.supportsSegwit === false) { - throw new TypeError('TAPROOT encoding is not supported on this network (no segwit support)') - } - // Same money/boolean guards create_tx gets through validateAll, so both - // create paths classify identical bad input identically (-32602). Left - // uncoerced, a non-numeric feePerKb becomes NaN, slips past both dust - // comparisons (every NaN comparison is false) and surfaces as an opaque - // -32603; and the JSON string "false" is truthy, silently arming RBF. - const feeRatePerKb = validateFeePerKb(feePerKb) - const rbfArmed = validateOptionalBoolean(replacebyfee, 'replacebyfee') === true - ensureEccLib() - const tapleafHashBuf = Buffer.from(tapleafHash, 'hex') - - // Cross-path double-spend guard. This is the one build path outside - // _buildTransaction, so without a claim here a concurrent create_tx whose - // fetched set still carries the commit output selects and reserves it while - // an unsigned cancel of that same output is outstanding. Lowercased because - // commitTxid is accepted in either case above while create_tx keys are - // canonicalized in validator.validateUtxoEntry, and an uppercase key can - // never collide with the reservation it is meant to see. Claimed - // synchronously, before the first await below, so a concurrent call - // observes it. - const outpointKey = commitTxid.toLowerCase() + ':' + commitVout - const nowClaim = Date.now() - this.evictExpiredReservations(nowClaim) - this.evictExpiredEnvelopeCancelClaims(nowClaim) - // Reserved AND not held by this path: the reservation is worth the highest - // live cancel-owner expiry whenever a cancel build holds it, so any other - // value means a foreign createTransaction claim owns the outpoint. - if (this.isOutpointReserved(outpointKey, nowClaim) && - this.maxLiveCancelOwnerExpiry(outpointKey, nowClaim) !== this.outpointReservations.get(outpointKey)){ - throw new OperationalError( - 'ENVELOPE_CANCEL_OUTPOINT_RESERVED', - `commit outpoint ${outpointKey} is reserved by a transaction built in the last ` + - `${Math.round(RESERVATION_TTL_MS / 60000)} minutes; broadcast that transaction and rebuild ` + - 'from the resulting view, or wait for the reservation to lapse', - { outpoint: outpointKey } - ) - } - // Re-taking this path's OWN live claim is allowed, and refuseDuplicateBuild - // is deliberately not wired in here, for the same reason: the header - // contract above is that a cancel rebuilds from the persisted recovery - // record alone, so a lost-response retry must not be refused for five - // minutes for producing the byte-identical transaction it is supposed to. - // - // Each build registers its own owner token rather than overwriting a single - // stamp, so an overlapping build's failure releases only what it took. A - // single stamp, overwritten and then deleted, hands the commit outpoint back - // to createTransaction while the first build's unsigned cancel still spends it. - this.claimOutpoint(callReservations, outpointKey, nowClaim) - const cancelClaim = callReservations[callReservations.length - 1] - const cancelOwnerId = crypto.randomBytes(16).toString('hex') - cancelClaim.cancelOwnerId = cancelOwnerId - let cancelOwners = this.envelopeCancelClaims.get(outpointKey) - if (!cancelOwners) { - cancelOwners = new Map() - this.envelopeCancelClaims.set(outpointKey, cancelOwners) - } - cancelOwners.set(cancelOwnerId, cancelClaim.expiry) - - // Fee-rate resolution with the same drain guards as createTransaction, - // in miniature: the caller rate is clamped to the tighter of the - // absolute MAX_FEE_RATE_KB cap and the relative multiplier x the node's - // own estimate. A cancel sweeps a prefund that scales with payload size - // and fee rate, so an unbounded rate here is a real burn surface. - let feePerBytes - let nodeFeePerBytes = null - if (feeRatePerKb){ - feePerBytes = feeRatePerKb / 1000 / SATOSHI_UNIT - try { - nodeFeePerBytes = await this.connector.getFeePerKilobyte(1) / 1000 - } catch (err) { - logger.warn(util.format('Envelope-cancel relative fee cap skipped: node fee estimate unavailable:', err.message)) - } - } else { - feePerBytes = await this.connector.getFeePerKilobyte(1) / 1000 - nodeFeePerBytes = feePerBytes - } - let capFeePerBytes = this.maxFeePerBytes - if (this.maxFeeRateMultiplier && nodeFeePerBytes != null){ - const relativeCap = nodeFeePerBytes * this.maxFeeRateMultiplier - capFeePerBytes = (capFeePerBytes != null) ? Math.min(capFeePerBytes, relativeCap) : relativeCap - } - if (capFeePerBytes != null && feePerBytes > capFeePerBytes){ - feePerBytes = capFeePerBytes - } - - const p2trPayment = bitcoin.payments.p2tr({ - internalPubkey: internalKeyBuf, - hash: tapleafHashBuf, - network: this.network - }) - - // vsize: 10 tx overhead + 58 key-path input (41 stripped + witness) + - // destination output + 2 rounding slack; padded to the chain's - // stripped-size relay floor exactly like the reveal (the key-path - // witness does not count toward stripped size either). - const destOutBytes = TxSizeEstimator.estimateOutputSizeForAddress(destination, this.network) - const cancelStrippedFloor = this.network.minStandardTxNonWitnessSize - const padNeeded = !!(cancelStrippedFloor && (10 + 41 + destOutBytes) < cancelStrippedFloor) - let strippedBytes = 10 + 41 + destOutBytes + (padNeeded ? destOutBytes : 0) - if (cancelStrippedFloor && strippedBytes < cancelStrippedFloor){ - strippedBytes = cancelStrippedFloor - } - const cancelVsize = strippedBytes + Math.ceil((2 + 1 + 1 + 65) / 4) + 2 - let cancelFee = Math.trunc(cancelVsize * feePerBytes * SATOSHI_UNIT) - if (cancelFee < this.dustAmount){ - cancelFee = this.dustAmount - } - - const sweepValue = value - cancelFee - (padNeeded ? this.dustAmount : 0) - if (sweepValue < this.dustAmount){ - throw new OperationalError( - 'ENVELOPE_CANCEL_BELOW_DUST', - `cancel would sweep ${sweepValue} satoshis (commit value ${value} minus fee ${cancelFee}${padNeeded ? ' minus floor pad' : ''}), below the dust floor (${this.dustAmount}); spend it via the reveal or CPFP instead`, - { commitValue: value, fee: cancelFee, sweepValue } - ) - } +} - const psbt = new bitcoin.Psbt({ network: this.network }) - psbt.addInput({ - hash: commitTxid, - index: commitVout, - // Same BIP68 trap as the funding path above: 0xfffffffd signals RBF - // without enabling relative locktime. - sequence: (rbfArmed ? 0xfffffffd : 0xffffffff), - witnessUtxo: { - script: p2trPayment.output, - value: value - }, - tapInternalKey: internalKeyBuf, - tapMerkleRoot: tapleafHashBuf - }) - psbt.addOutput({ address: destination, value: sweepValue }) - if (padNeeded){ - psbt.addOutput({ address: destination, value: this.dustAmount }) - } +Object.assign(XChainEncoder.prototype, require('./XChainEncoder/outpoint_reservations.js'), require('./XChainEncoder/payload_preparation.js'), require('./XChainEncoder/size_estimation.js'), require('./XChainEncoder/envelope_cancel.js')) - return { psbt, encoding: Encoding.TAPROOT, cancel: true, fee: cancelFee } - } -} // The suggested-rate ceiling is exported so the estimate_fee endpoint quotes the // same rate createTx would charge; a quote the builder then ignores is worse than @@ -3480,4 +2101,4 @@ XChainEncoder.packageFeeUpliftSatoshis = packageFeeUpliftSatoshis XChainEncoder.maxCpfpUpliftSat = maxCpfpUpliftSat XChainEncoder.DEFAULT_MAX_CPFP_UPLIFT_SAT = DEFAULT_MAX_CPFP_UPLIFT_SAT -module.exports = XChainEncoder \ No newline at end of file +module.exports = XChainEncoder diff --git a/src/XChainEncoder/constants.js b/src/XChainEncoder/constants.js new file mode 100644 index 0000000..e41827e --- /dev/null +++ b/src/XChainEncoder/constants.js @@ -0,0 +1,116 @@ +/********************************************************************* + * + * 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 handles starting the encoder and generating transactions + * + ********************************************************************/ + +const { getLogger } = require('../observability'); + +const logger = getLogger(); + +const OP_RETURN_SIZE = 80 +const P2SH_SIZE = 520 +// Each data chunk is pushed as a SINGLE script element inside the witness +// script, so it is bound by consensus MAX_SCRIPT_ELEMENT_SIZE (520 bytes), +// the same limit that caps the P2SH chunk. It is NOT bound by the 3600-byte +// total witness-script policy limit. A larger chunk (e.g. the former 3571) +// builds a witness script the node rejects at spend time with "Push value +// size limit exceeded". 520 - 44 overhead = 476-byte max chunk, identical to P2SH. +const PW2SH_SIZE = 520 +const MULTISIGN_SIZE = 69 // 9 bytes overhead (1 OP_CHECKMULTISIG + 1 m + 1 n + 2 key-length bytes + 4 magic) + 60 raw data bytes = 69 total bytes per chunk +const MAGIC_WORD = "XCHN" + +// Taproot envelope: the payload rides as 520-byte pushes inside +// a single tapscript leaf, bounded per element by consensus +// MAX_SCRIPT_ELEMENT_SIZE exactly like the chunk lanes; tapscript has no +// 10,000-byte script cap, so ONE leaf carries the whole payload. +const TAPROOT_ENVELOPE_CHUNK_SIZE = 520 +// BIP342 tapscript leaf version. Also the first control-block byte (even +// output-key parity keeps it 0xc0 verbatim). +const TAPROOT_LEAF_VERSION = 0xc0 +// Envelope format byte 0x00 = this version. Any other value is unrecognized +// by design (invisible, not invalid): future formats activate via their own +// recognition flag heights. +const TAPROOT_ENVELOPE_FORMAT_V0 = 0x00 + +const SATOSHI_UNIT = 100000000 + +const MAX_SAFE_SATOSHI_BIG = BigInt(Number.MAX_SAFE_INTEGER) + +// Relay-policy floor per coin for every value output this encoder authors, distinct +// from the consensus-pinned network.dustThreshold the decoder reads. Dogecoin relays an +// output under its 0.01 DOGE soft dust limit only if the whole limit is added to the fee. +const SOFT_DUST_FLOOR_BY_COIN = { + DOGE: 1000000 +} + +// 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 +// every confirmation target, which prices an ordinary action above the balance +// funding it and fails the build outright. Mainnet is deliberately unclamped: +// there the estimate is real and a ceiling would underpay a genuine fee spike. +// This bounds only the rate CHOSEN on the caller's behalf, never a rate the +// caller supplied and never the anchor the fee-drain caps derive from. +const DEFAULT_SUGGESTED_FEE_MAX_PER_VBYTE = 20 + +// Test chains by network key suffix; everything else is treated as mainnet. +const TEST_NETWORK_SUFFIXES = ['-testnet', '-testnet4', '-regtest', '-signet'] + +// The ceiling above is denominated in base units per vByte, and 20 is a +// Bitcoin-scale number: on Dogecoin the node's relay floor is already 100 +// koinu/byte (0.001 DOGE/kB) and 1.14 rejects anything at or under the floor +// through its free-transaction priority gate ("66: insufficient priority"), +// so a ceiling of 20 turns every clamped build into a guaranteed rejection. +// Lift the ceiling to a coin-correct minimum derived from the node's own +// relayfee (BTC-or-coin per kB): ten times the floor, Dogecoin's published +// recommended rate (0.01 DOGE/kB) and 10 sat/vB on BTC/LTC test chains. +const SUGGESTED_FEE_CEILING_RELAY_MULTIPLIER = 10 + +// Hard ceiling (base units) on what CPFP package sizing may ADD to one +// transaction's fee, on top of every rate cap the build already enforces. The +// rate caps bound a fee against THIS transaction's size; a package uplift is +// paid for someone else's bytes, so a mempool ancestor chain that is large, +// cheap, or misreported could otherwise buy an arbitrarily large fee with the +// caller's coin. 10,000,000 base units is 0.1 DOGE (ten times the block- +// inclusion floor for a 10 kB package, so it clears any realistic real chain) +// and stays well inside the rate caps on BTC/LTC, where they bind first anyway. +// MAX_CPFP_UPLIFT_SAT overrides it; 0 turns package sizing off. +const DEFAULT_MAX_CPFP_UPLIFT_SAT = 10000000 + +// How long a selected outpoint stays reserved against concurrent selection. +// Long enough for a caller to sign and broadcast, short enough that an +// abandoned selection auto-releases without operator intervention. In-memory +// and best-effort only (the encoder is a single stateless process): this +// narrows, but cannot fully close, a same-address double-spend race. See the +// reservation helpers and the selection loop. +const RESERVATION_TTL_MS = 5 * 60 * 1000 + +const Encoding = { + OP_RETURN: "OP_RETURN", + P2SH: "P2SH", + MULTISIGN: "MULTISIGN", + P2WSH: "P2WSH", + TAPROOT: "TAPROOT", + // Not a carrier: the caller's explicit request that the encoder pick the + // smallest-footprint carrier this network and signer can actually use. + // Resolved to one of the above before anything is built, + // so no downstream code ever sees it. + 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, RESERVATION_TTL_MS, Encoding } diff --git a/src/XChainEncoder/envelope_cancel.js b/src/XChainEncoder/envelope_cancel.js new file mode 100644 index 0000000..e6933a3 --- /dev/null +++ b/src/XChainEncoder/envelope_cancel.js @@ -0,0 +1,229 @@ +/********************************************************************* + * + * 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 handles starting the encoder and generating transactions + * + ********************************************************************/ + +const bitcoin = require('bitcoinjs-lib'); +const crypto = require('crypto'); +const util = require('node:util'); +const TxSizeEstimator = require('../build/tx_size_estimator') +const { parseSatoshiAmount, validateFeePerKb, validateOptionalBoolean, validateAddress } = require('../common/validator') +const { OperationalError } = require('../build/errors') +const { logger, SATOSHI_UNIT, RESERVATION_TTL_MS, Encoding } = require('./constants.js') +const { ensureEccLib } = require('./script_amount_helpers.js') + +module.exports = { + // Key-path cancel of an UNREVEALED envelope commit: + // sweeps the commit output back to the caller before any reveal exists. By + // contract this must be buildable from the wallet's PERSISTED RECOVERY + // RECORD ALONE ({commit outpoint, internal key derivation path, tapleaf + // hash} plus the commit value), surviving a crash between commit and + // reveal: nothing here re-derives from the envelope payload, and the P2TR + // scriptPubKey is reconstructed from internal key + merkle root (the + // tapleaf hash IS the merkle root of the single-leaf tree). The returned + // PSBT carries tapInternalKey + tapMerkleRoot so the signer can compute + // the BIP341 tweak; it conflicts with the reveal by construction (same + // outpoint) and the wallet treats it as a replacement of the reveal. + // + // Public entry point. Like createTransaction it owns the per-call reservation + // ledger: a build that throws for any reason hands the commit outpoint back at + // once instead of squatting it until RESERVATION_TTL_MS. + // A successful cancel keeps its commit-outpoint claim and gets the same + // `reservation` receipt create_tx does, so an abandoned cancel can hand the + // commit outpoint back instead of blocking every rebuild for the whole TTL. + async createEnvelopeCancelTransaction(params = {}){ + const callReservations = [] + let result + try { + result = await this.buildEnvelopeCancelTransaction(callReservations, params) + } catch (err) { + this.releaseEnvelopeCancelClaims(callReservations) + throw err + } + const reservation = this.mintReservationTicket(callReservations, Date.now()) + if (reservation && result && typeof result === 'object') result.reservation = reservation + return result + }, + + async buildEnvelopeCancelTransaction(callReservations, { commitTxid, commitVout, commitValue, internalPubkey, tapleafHash, destination, feePerKb = null, replacebyfee = false } = {}){ + if (typeof commitTxid !== 'string' || !/^[0-9a-fA-F]{64}$/.test(commitTxid)) { + throw new TypeError('commitTxid must be a 64-character hex string') + } + if (!Number.isInteger(commitVout) || commitVout < 0) { + throw new TypeError('commitVout must be a non-negative integer') + } + const value = parseSatoshiAmount(commitValue, 'commitValue') + // Accept the 33-byte compressed form (what create_tx took) or the + // 32-byte x-only form (what the recovery record may hold). + let internalKeyBuf + if (typeof internalPubkey === 'string' && /^(02|03)[0-9a-fA-F]{64}$/.test(internalPubkey)) { + internalKeyBuf = Buffer.from(internalPubkey, 'hex').subarray(1) + } else if (typeof internalPubkey === 'string' && /^[0-9a-fA-F]{64}$/.test(internalPubkey)) { + internalKeyBuf = Buffer.from(internalPubkey, 'hex') + } else { + throw new TypeError('internalPubkey must be a 66-character compressed or 64-character x-only pubkey hex string') + } + if (typeof tapleafHash !== 'string' || !/^[0-9a-fA-F]{64}$/.test(tapleafHash)) { + throw new TypeError('tapleafHash must be a 64-character hex string') + } + // validateAddress, not a local non-empty check: this is the one create + // path api.js does not route through validateAll, so without it the + // 100-char cap every other address field gets is missing here and an + // unbounded string reaches bs58check (quadratic) and psbt.addOutput. + validateAddress(destination, 'destination') + if (this.network.supportsSegwit === false) { + throw new TypeError('TAPROOT encoding is not supported on this network (no segwit support)') + } + // Same money/boolean guards create_tx gets through validateAll, so both + // create paths classify identical bad input identically (-32602). Left + // uncoerced, a non-numeric feePerKb becomes NaN, slips past both dust + // comparisons (every NaN comparison is false) and surfaces as an opaque + // -32603; and the JSON string "false" is truthy, silently arming RBF. + const feeRatePerKb = validateFeePerKb(feePerKb) + const rbfArmed = validateOptionalBoolean(replacebyfee, 'replacebyfee') === true + ensureEccLib() + const tapleafHashBuf = Buffer.from(tapleafHash, 'hex') + + // Cross-path double-spend guard. This is the one build path outside + // _buildTransaction, so without a claim here a concurrent create_tx whose + // fetched set still carries the commit output selects and reserves it while + // an unsigned cancel of that same output is outstanding. Lowercased because + // commitTxid is accepted in either case above while create_tx keys are + // canonicalized in validator.validateUtxoEntry, and an uppercase key can + // never collide with the reservation it is meant to see. Claimed + // synchronously, before the first await below, so a concurrent call + // observes it. + const outpointKey = commitTxid.toLowerCase() + ':' + commitVout + const nowClaim = Date.now() + this.evictExpiredReservations(nowClaim) + this.evictExpiredEnvelopeCancelClaims(nowClaim) + // Reserved AND not held by this path: the reservation is worth the highest + // live cancel-owner expiry whenever a cancel build holds it, so any other + // value means a foreign createTransaction claim owns the outpoint. + if (this.isOutpointReserved(outpointKey, nowClaim) && + this.maxLiveCancelOwnerExpiry(outpointKey, nowClaim) !== this.outpointReservations.get(outpointKey)){ + throw new OperationalError( + 'ENVELOPE_CANCEL_OUTPOINT_RESERVED', + `commit outpoint ${outpointKey} is reserved by a transaction built in the last ` + + `${Math.round(RESERVATION_TTL_MS / 60000)} minutes; broadcast that transaction and rebuild ` + + 'from the resulting view, or wait for the reservation to lapse', + { outpoint: outpointKey } + ) + } + // Re-taking this path's OWN live claim is allowed, and refuseDuplicateBuild + // is deliberately not wired in here, for the same reason: the header + // contract above is that a cancel rebuilds from the persisted recovery + // record alone, so a lost-response retry must not be refused for five + // minutes for producing the byte-identical transaction it is supposed to. + // + // Each build registers its own owner token rather than overwriting a single + // stamp, so an overlapping build's failure releases only what it took. A + // single stamp, overwritten and then deleted, hands the commit outpoint back + // to createTransaction while the first build's unsigned cancel still spends it. + this.claimOutpoint(callReservations, outpointKey, nowClaim) + const cancelClaim = callReservations[callReservations.length - 1] + const cancelOwnerId = crypto.randomBytes(16).toString('hex') + cancelClaim.cancelOwnerId = cancelOwnerId + let cancelOwners = this.envelopeCancelClaims.get(outpointKey) + if (!cancelOwners) { + cancelOwners = new Map() + this.envelopeCancelClaims.set(outpointKey, cancelOwners) + } + cancelOwners.set(cancelOwnerId, cancelClaim.expiry) + + // Fee-rate resolution with the same drain guards as createTransaction, + // in miniature: the caller rate is clamped to the tighter of the + // absolute MAX_FEE_RATE_KB cap and the relative multiplier x the node's + // own estimate. A cancel sweeps a prefund that scales with payload size + // and fee rate, so an unbounded rate here is a real burn surface. + let feePerBytes + let nodeFeePerBytes = null + if (feeRatePerKb){ + feePerBytes = feeRatePerKb / 1000 / SATOSHI_UNIT + try { + nodeFeePerBytes = await this.connector.getFeePerKilobyte(1) / 1000 + } catch (err) { + logger.warn(util.format('Envelope-cancel relative fee cap skipped: node fee estimate unavailable:', err.message)) + } + } else { + feePerBytes = await this.connector.getFeePerKilobyte(1) / 1000 + nodeFeePerBytes = feePerBytes + } + let capFeePerBytes = this.maxFeePerBytes + if (this.maxFeeRateMultiplier && nodeFeePerBytes != null){ + const relativeCap = nodeFeePerBytes * this.maxFeeRateMultiplier + capFeePerBytes = (capFeePerBytes != null) ? Math.min(capFeePerBytes, relativeCap) : relativeCap + } + if (capFeePerBytes != null && feePerBytes > capFeePerBytes){ + feePerBytes = capFeePerBytes + } + + const p2trPayment = bitcoin.payments.p2tr({ + internalPubkey: internalKeyBuf, + hash: tapleafHashBuf, + network: this.network + }) + + // vsize: 10 tx overhead + 58 key-path input (41 stripped + witness) + + // destination output + 2 rounding slack; padded to the chain's + // stripped-size relay floor exactly like the reveal (the key-path + // witness does not count toward stripped size either). + const destOutBytes = TxSizeEstimator.estimateOutputSizeForAddress(destination, this.network) + const cancelStrippedFloor = this.network.minStandardTxNonWitnessSize + const padNeeded = !!(cancelStrippedFloor && (10 + 41 + destOutBytes) < cancelStrippedFloor) + let strippedBytes = 10 + 41 + destOutBytes + (padNeeded ? destOutBytes : 0) + if (cancelStrippedFloor && strippedBytes < cancelStrippedFloor){ + strippedBytes = cancelStrippedFloor + } + const cancelVsize = strippedBytes + Math.ceil((2 + 1 + 1 + 65) / 4) + 2 + let cancelFee = Math.trunc(cancelVsize * feePerBytes * SATOSHI_UNIT) + if (cancelFee < this.dustAmount){ + cancelFee = this.dustAmount + } + + const sweepValue = value - cancelFee - (padNeeded ? this.dustAmount : 0) + if (sweepValue < this.dustAmount){ + throw new OperationalError( + 'ENVELOPE_CANCEL_BELOW_DUST', + `cancel would sweep ${sweepValue} satoshis (commit value ${value} minus fee ${cancelFee}${padNeeded ? ' minus floor pad' : ''}), below the dust floor (${this.dustAmount}); spend it via the reveal or CPFP instead`, + { commitValue: value, fee: cancelFee, sweepValue } + ) + } + + const psbt = new bitcoin.Psbt({ network: this.network }) + psbt.addInput({ + hash: commitTxid, + index: commitVout, + // Same BIP68 trap as the funding path above: 0xfffffffd signals RBF + // without enabling relative locktime. + sequence: (rbfArmed ? 0xfffffffd : 0xffffffff), + witnessUtxo: { + script: p2trPayment.output, + value: value + }, + tapInternalKey: internalKeyBuf, + tapMerkleRoot: tapleafHashBuf + }) + psbt.addOutput({ address: destination, value: sweepValue }) + if (padNeeded){ + psbt.addOutput({ address: destination, value: this.dustAmount }) + } + + return { psbt, encoding: Encoding.TAPROOT, cancel: true, fee: cancelFee } + }, +} diff --git a/src/XChainEncoder/fee_policy.js b/src/XChainEncoder/fee_policy.js new file mode 100644 index 0000000..f28ac80 --- /dev/null +++ b/src/XChainEncoder/fee_policy.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. + * + ********************************************************************** + * + * XChain Encoder - Encoder Class + * + * This file handles starting the encoder and generating transactions + * + ********************************************************************/ + +const config = require('../common/config'); +const { TEST_NETWORK_SUFFIXES, DEFAULT_SUGGESTED_FEE_MAX_PER_VBYTE, SUGGESTED_FEE_CEILING_RELAY_MULTIPLIER, DEFAULT_MAX_CPFP_UPLIFT_SAT } = require('./constants.js') + +function isTestNetworkKey(networkKey){ + const key = String(networkKey || '').toLowerCase() + return TEST_NETWORK_SUFFIXES.some(s => key.endsWith(s)) +} + +// The suggested-rate ceiling in BTC/byte for `networkKey`, or null when this +// chain is unclamped. SUGGESTED_FEE_MAX_PER_VBYTE overrides the default; 0 +// disables the clamp entirely. +function suggestedFeeCeilingPerByte(networkKey, satoshiUnit){ + const raw = parseFloat(config.SUGGESTED_FEE_MAX_PER_VBYTE) + const perVbyte = Number.isFinite(raw) ? raw + : (isTestNetworkKey(networkKey) ? DEFAULT_SUGGESTED_FEE_MAX_PER_VBYTE : 0) + if (!(perVbyte > 0)) return null + return perVbyte / satoshiUnit +} + +function suggestedFeeCeilingFloorPerByte(relayfeePerKb){ + const relayfee = Number(relayfeePerKb) + if (!(relayfee > 0)) return null + return (relayfee / 1000) * SUGGESTED_FEE_CEILING_RELAY_MULTIPLIER +} + +function maxCpfpUpliftSat(){ + const raw = parseFloat(config.MAX_CPFP_UPLIFT_SAT) + const bound = Number.isFinite(raw) ? raw : DEFAULT_MAX_CPFP_UPLIFT_SAT + return (bound > 0) ? Math.floor(bound) : 0 +} + +// Extra fee (base units) this transaction must pay for its whole mempool +// package to reach `targetFeePerBytes`, given the unconfirmed ancestors it +// inherits. Dogecoin Core 1.14 and every modern Core select for a block by +// ANCESTOR fee rate, so a child paying the target on its own bytes still sits +// unmined behind cheap parents: the live case was a batch paying 0.01017 +// DOGE/kB whose 0.00313 DOGE/kB funding ancestors dragged the package to +// 0.00896, under the 0.01 inclusion floor. Returns 0 whenever the package +// already clears the target, so this only ever raises a fee. +// `ancestorFees` is the package's fee in COIN units (what a node's mempool RPC +// reports). `ancestorFeeSatoshis` is the same quantity already in base units and +// takes precedence when supplied: the two-phase prefund below knows the commit's +// fee exactly, as an integer, and routing it through a coin-unit float would +// re-introduce the representation error the epsilon shave exists to absorb. +function packageFeeUpliftSatoshis({ currentFee, txSize, ancestorSize, ancestorFees, ancestorFeeSatoshis, targetFeePerBytes, satoshiUnit }){ + if (!(targetFeePerBytes > 0)) return 0 + if (!Number.isFinite(txSize) || txSize <= 0) return 0 + const packageSize = Number(ancestorSize) + if (!Number.isFinite(packageSize) || packageSize <= 0) return 0 + const ancestorFeeSat = (ancestorFeeSatoshis != null) + ? Math.round(Number(ancestorFeeSatoshis)) + : Math.round(Number(ancestorFees) * satoshiUnit) + if (!Number.isFinite(ancestorFeeSat) || ancestorFeeSat < 0) return 0 + // Rounding up a float product costs a whole base unit whenever the rate + // carries binary representation error (0.01/1000 is not exact, so a 3000-byte + // package at that rate lands on 3000000.0000000005). Shave a relative + // epsilon, twelve orders of magnitude above the double's own error and far + // below one base unit, so the ceiling reflects the arithmetic rather than the + // representation. + const packageFeeExact = (packageSize + txSize) * targetFeePerBytes * satoshiUnit + const packageFeeNeeded = Math.ceil(packageFeeExact - Math.abs(packageFeeExact) * 1e-12) + const uplift = (packageFeeNeeded - ancestorFeeSat) - currentFee + return (uplift > 0) ? uplift : 0 +} + +module.exports = { isTestNetworkKey, suggestedFeeCeilingPerByte, suggestedFeeCeilingFloorPerByte, maxCpfpUpliftSat, packageFeeUpliftSatoshis } diff --git a/src/XChainEncoder/outpoint_reservations.js b/src/XChainEncoder/outpoint_reservations.js new file mode 100644 index 0000000..7933217 --- /dev/null +++ b/src/XChainEncoder/outpoint_reservations.js @@ -0,0 +1,297 @@ +/********************************************************************* + * + * 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 handles starting the encoder and generating transactions + * + ********************************************************************/ + +const crypto = require('crypto'); +const bitcoin = require('bitcoinjs-lib'); +const { OperationalError } = require('../build/errors') +const { RESERVATION_TTL_MS } = require('./constants.js') + +module.exports = { + // Sweep the recent-build map the same way evictExpiredReservations sweeps + // the outpoint map, so neither grows unbounded in a long-lived process. + evictExpiredRecentBuilds(now) { + for (const [txid, expiry] of this.recentBuilds) { + if (expiry <= now) this.recentBuilds.delete(txid) + } + }, + + // Refuse to hand back a transaction identical to one built within the + // reservation window, then record this one. Identity is the UNSIGNED txid + // (inputs, outputs, version, locktime): a rebuild that changes any of those + // (an RBF bump, a different amount, a different input) is a different + // transaction and passes. The outpoint reservations normally stop an + // identical rebuild one layer earlier (every input is still reserved), so + // this only fires when those were bypassed or cleared; it exists so the + // "same txid twice" failure can never present as two successes again. + refuseDuplicateBuild(psbt, now) { + this.evictExpiredRecentBuilds(now) + const unsignedTx = bitcoin.Transaction.fromBuffer(psbt.data.globalMap.unsignedTx.toBuffer()) + const txid = unsignedTx.getId() + if (this.recentBuilds.has(txid)) { + throw new OperationalError( + 'DUPLICATE_TRANSACTION', + 'refusing to rebuild a transaction identical to one built in the last ' + + Math.round(RESERVATION_TTL_MS / 60000) + ' minutes (same inputs and outputs, same txid ' + + txid + '); broadcast the one you already have, or change the inputs or outputs', + { txid } + ) + } + this.recentBuilds.set(txid, now + RESERVATION_TTL_MS) + // Returned so the caller can put the txid (and therefore this record) on + // the build's reservation ticket: an explicit release has to retire the + // duplicate refusal alongside the outpoints, or a wallet that composes, + // releases and recomposes the SAME transaction is refused for the rest of + // the window by the guard instead of the reservation map. + return txid + }, + + // A reserved outpoint is one an in-flight selection has claimed and not yet + // released. Expired entries are treated as free and lazily evicted here. + isOutpointReserved(key, now) { + const expiry = this.outpointReservations.get(key) + if (expiry == null) return false + if (expiry <= now) { + this.outpointReservations.delete(key) + return false + } + return true + }, + + // Returns the expiry it wrote, which doubles as this claim's ownership stamp + // (see releaseCallReservations). + reserveOutpoint(key, now) { + const expiry = now + RESERVATION_TTL_MS + this.outpointReservations.set(key, expiry) + return expiry + }, + + // Reserve an outpoint AND record the claim on the calling createTransaction's + // own ledger, so a later throw can hand it back. + claimOutpoint(callReservations, key, now) { + callReservations.push({ key, expiry: this.reserveOutpoint(key, now) }) + }, + + // Release the reservations a single createTransaction call took, and only + // those. The stored expiry is the ownership stamp: while a claim is live no + // other call can take that outpoint (the selection loop skips reserved keys), + // so the only way the map value can differ from what this call wrote is that + // its claim already lapsed and somebody else re-reserved the outpoint against + // a later clock. A mismatch therefore means the entry is foreign, and dropping + // a foreign entry would reopen the same-address double-spend window the + // reservation map exists to close. Leave it. + releaseCallReservations(callReservations) { + for (const claim of callReservations) { + if (this.outpointReservations.get(claim.key) === claim.expiry){ + this.outpointReservations.delete(claim.key) + } + } + }, + + // The highest expiry any STILL-LIVE cancel owner holds on this outpoint, or + // null when no live owner remains. This is the value outpointReservations must + // carry while the cancel path holds an outpoint, and comparing it against the + // reservation is how the build guard tells "held only by this path" from + // "held by a foreign createTransaction claim". + maxLiveCancelOwnerExpiry(key, now) { + const owners = this.envelopeCancelClaims.get(key) + if (!owners) return null + let max = null + for (const expiry of owners.values()) { + if (expiry > now && (max === null || expiry > max)) max = expiry + } + return max + }, + + // Drop ONE cancel build's owner token and restate what the outpoint is now + // worth. While another live owner remains, outpointReservations is rewritten + // to that owner's expiry and the key stays reserved: this is the whole point + // of the owner set, because the releasing build's stamp is then no longer the + // map's value and every stamp-checked delete below leaves the entry alone. + // Returns true when the outpoint is still held by some other cancel owner. + dropCancelOwner(key, ownerId, now) { + const owners = this.envelopeCancelClaims.get(key) + if (!owners) return false + owners.delete(ownerId) + const remaining = this.maxLiveCancelOwnerExpiry(key, now) + if (remaining === null) { + this.envelopeCancelClaims.delete(key) + return false + } + this.outpointReservations.set(key, remaining) + return true + }, + + // Release an envelope-cancel claim and its ownership token together, so a + // failed cancel build hands the commit outpoint back instead of squatting it + // for the whole TTL - but only once no other cancel build still owns it. A + // retry of a lost-response cancel legitimately re-claims an outpoint the first + // build is still outstanding on, and deleting the reservation outright on the + // retry's failure freed that outpoint to createTransaction while the earlier + // unsigned cancel spent it. Ownership-checked exactly like + // releaseCallReservations: a stamp that moved belongs to somebody else. + releaseEnvelopeCancelClaims(callReservations) { + const now = Date.now() + const handBack = [] + for (const claim of callReservations) { + // A claim another cancel owner still holds is withheld from the stamped + // release below, and deliberately not left to that check: two builds a + // millisecond apart share an expiry, so the stamp alone cannot tell the + // surviving owner's reservation from this one's. + if (claim.cancelOwnerId && this.dropCancelOwner(claim.key, claim.cancelOwnerId, now)) continue + handBack.push(claim) + } + this.releaseCallReservations(handBack) + }, + + // Sweep the cancel ownership tokens alongside the reservation map, so neither + // grows unbounded in a long-lived process. An outpoint whose last owner token + // has lapsed goes with it. + evictExpiredEnvelopeCancelClaims(now) { + for (const [key, owners] of this.envelopeCancelClaims) { + for (const [ownerId, expiry] of owners) { + if (expiry <= now) owners.delete(ownerId) + } + if (owners.size === 0) this.envelopeCancelClaims.delete(key) + } + }, + + // Sweep expired reservations so the map cannot grow unbounded across a + // long-lived process. Called opportunistically at the start of selection. + evictExpiredReservations(now) { + for (const [key, expiry] of this.outpointReservations) { + if (expiry <= now) this.outpointReservations.delete(key) + } + }, + + // Sweep expired tickets so the ticket map cannot outgrow the reservation map + // it describes. A ticket expires with the LAST of its claims. + evictExpiredReservationTickets(now) { + for (const [id, ticket] of this.reservationTickets) { + if (ticket.expiry <= now) this.reservationTickets.delete(id) + } + }, + + // Mint the receipt for the claims a SUCCESSFUL build kept, so the caller can + // hand them back the moment it knows it will not broadcast (the wallet closes + // its compose modal) instead of stranding a funded address for the rest of + // RESERVATION_TTL_MS. Returns null when the build claimed nothing still live, + // and the result then simply carries no reservation block: a release RPC that + // has nothing to release must not mint a ticket that pretends otherwise. + // + // Only claims whose stamp is still the map's value go on the ticket. Anything + // else already lapsed and was re-taken by another call, and putting it on this + // ticket would hand this caller a lever over a foreign claim. + mintReservationTicket(callReservations, now) { + this.evictExpiredReservationTickets(now) + const live = [] + for (const claim of callReservations) { + // cancelOwnerId rides onto the ticket because releaseReservation has to + // retire this build's owner token as well as its reservation; leaving it + // behind would hold the outpoint until the token's own TTL lapsed. + if (this.outpointReservations.get(claim.key) === claim.expiry) { + live.push({ key: claim.key, expiry: claim.expiry, cancelOwnerId: claim.cancelOwnerId || null }) + } + } + if (live.length === 0) return null + const id = crypto.randomBytes(16).toString('hex') + let expiry = 0 + for (const claim of live) if (claim.expiry > expiry) expiry = claim.expiry + const txid = callReservations.buildTxid || null + this.reservationTickets.set(id, { + claims: live, + expiry, + txid, + // The recent-build record's own ownership stamp, read back rather than + // recomputed so a rebuild that re-registered the txid under a later + // clock is left alone by this ticket's release. + txidExpiry: txid ? (this.recentBuilds.get(txid) || null) : null + }) + return { id, outpoints: live.map(c => c.key), expiresAt: expiry } + }, + + // Explicit, ownership-stamped release of ONE build's reservations, the + // counterpart of the receipt mintReservationTicket put on that build's + // result. The wallet composes when its send modal opens and most of those + // builds are never broadcast, so without this every abandoned compose held a + // few-UTXO address's whole balance for five minutes and the next compose read + // as insufficient funds. Idempotent and never an error: an unknown, already + // released or lapsed ticket returns found:false rather than throwing, because + // a wallet firing this from a modal-close handler cannot usefully react to a + // failure and must not be taught to retry one. + // + // Ownership is enforced twice: the ticket id is 16 unguessable bytes handed + // only to the build that took the claims, and each claim is dropped only while + // the map still holds the exact expiry that build wrote. A claim whose stamp + // moved lapsed and was re-taken by somebody else, so it is REPORTED (retained) + // and left in place; dropping it would reopen the same-address double-spend + // window the reservation map exists to close. + releaseReservation(reservationId) { + if (typeof reservationId !== 'string' || !/^[0-9a-f]{32}$/.test(reservationId)) { + throw new TypeError('reservationId must be a 32-character lowercase hex string, as returned in create_tx result.reservation.id') + } + const now = Date.now() + this.evictExpiredReservations(now) + this.evictExpiredEnvelopeCancelClaims(now) + this.evictExpiredRecentBuilds(now) + this.evictExpiredReservationTickets(now) + const ticket = this.reservationTickets.get(reservationId) + if (!ticket) return { reservationId, found: false, released: [], retained: [] } + // A ticket is single-use: the claims it names are either freed now or were + // already taken over by another call, and either way it has no further use. + this.reservationTickets.delete(reservationId) + const released = [] + const retained = [] + for (const claim of ticket.claims) { + // Retire this build's cancel owner token first, whatever the stamp says. + // A ticket whose stamp has moved still has to give the token up, or the + // outpoint would stay reserved at this build's expiry long after the + // build that took it over released. dropCancelOwner restates the + // reservation at the highest expiry a surviving owner holds, which is + // also what makes the stamp check below leave that owner's entry alone. + const stillHeldByAnotherCancel = claim.cancelOwnerId + ? this.dropCancelOwner(claim.key, claim.cancelOwnerId, now) + : false + if (!stillHeldByAnotherCancel && this.outpointReservations.get(claim.key) === claim.expiry) { + this.outpointReservations.delete(claim.key) + released.push(claim.key) + } else { + retained.push(claim.key) + } + } + // Retire the duplicate-build refusal for this build too, under the same + // stamp check. Releasing the inputs without it just moves the five-minute + // wall: the recompose that follows a release is byte-identical by + // construction and would be refused DUPLICATE_TRANSACTION instead. + if (ticket.txid && ticket.txidExpiry != null && this.recentBuilds.get(ticket.txid) === ticket.txidExpiry) { + this.recentBuilds.delete(ticket.txid) + } + return { reservationId, found: true, released, retained } + }, + + // Explicit release of all reservations and recent-build records. The encoder + // holds no durable state, so this is primarily a test seam; production + // relies on the TTL. + clearReservations() { + this.outpointReservations.clear() + this.recentBuilds.clear() + this.envelopeCancelClaims.clear() + this.reservationTickets.clear() + }, +} diff --git a/src/XChainEncoder/payload_preparation.js b/src/XChainEncoder/payload_preparation.js new file mode 100644 index 0000000..07351f5 --- /dev/null +++ b/src/XChainEncoder/payload_preparation.js @@ -0,0 +1,367 @@ +/********************************************************************* + * + * 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 handles starting the encoder and generating transactions + * + ********************************************************************/ + +const bitcoin = require('bitcoinjs-lib'); +const crypto = require('crypto'); +const CryptoNetworks = require('../build/crypto_networks') +const { MAX_COMPILED_ACTION_DATA_LENGTH, ENVELOPE_MAX_PAYLOAD } = require('../common/validator') +const { OP_RETURN_SIZE, P2SH_SIZE, PW2SH_SIZE, MULTISIGN_SIZE, MAGIC_WORD, TAPROOT_ENVELOPE_CHUNK_SIZE, TAPROOT_ENVELOPE_FORMAT_V0, Encoding } = require('./constants.js') +const { resolveCallerHash160 } = require('./request_resolution.js') + +module.exports = { + isSegwitUTXO(utxo) { + try { + const script = bitcoin.script.decompile(Buffer.from(utxo.scriptPubKey, 'hex')); + // A witness program is exactly [version opcode][2..40 byte push]: OP_0 + // (0x00) for v0 (P2WPKH/P2WSH), OP_1..OP_16 (0x51..0x60) for v1..v16. + // Taproot is v1, so the old v0-only check (script[0] === 0x00) treated + // P2TR UTXOs as legacy and set nonWitnessUtxo instead of witnessUtxo - + // but Taproot signing (and the MuSig2 co-signer) require witnessUtxo. + if (!script || script.length !== 2 || !Buffer.isBuffer(script[1])) return false; + const version = script[0]; + const isWitnessVersion = version === bitcoin.opcodes.OP_0 || + (version >= bitcoin.opcodes.OP_1 && version <= bitcoin.opcodes.OP_16); + return isWitnessVersion && script[1].length >= 2 && script[1].length <= 40; + } catch (error) { + return false; + } + }, + + prepareData(data, encoding, pubKey, compressedPubKey){ + let magicWordBuffer = Buffer.from(MAGIC_WORD,'utf8') + + if (!encoding){ + if (data.length + magicWordBuffer.length <= OP_RETURN_SIZE) { + encoding = Encoding.OP_RETURN + } else { + encoding = Encoding.P2SH + } + } + let chunksSize = 0 + let dataBufferArray = [] + let i = 0 + let nextDataChunk = null + switch (encoding){ + case Encoding.OP_RETURN: + chunksSize = OP_RETURN_SIZE - magicWordBuffer.length //Single OP_RETURN output: 80-byte limit minus 4-byte magic word = 76 bytes of payload + + // All supported chains (BTC, LTC, DOGE) inherit Bitcoin Core's + // IsStandardTx rule that rejects any transaction with more than one + // nulldata/OP_RETURN output as non-standard ("multi-op-return"). A split + // payload builds a structurally valid PSBT that fails silently at broadcast + // and burns the fee UTXOs. The auto-selection path avoids this by falling + // back to P2SH; the explicit-encoding path must reject loudly here instead. + // + // The single-output 80-byte ceiling is enforced unconditionally. + // singleOpReturnPolicy:false does not skip this throw and fall + // through to a multi-chunk split loop instead: no shipped decoder + // can reassemble a payload split across multiple OP_RETURN pushes + // (it only ever reads a single OP_RETURN output), and no shipped + // coin relays a multi-OP_RETURN transaction as standard, so that + // path was a fee-burning, undecodable-transaction trap armed by one + // config flag rather than a real opt-out. Fail-closed + // unconditionally now, the same as when the flag is absent. + if (data.length > chunksSize) { + throw new RangeError( + `OP_RETURN encoding requires compiled payload <= ${chunksSize} bytes; ` + + `got ${data.length}. Use P2SH for larger payloads.` + ) + } + + // data.length <= chunksSize is now guaranteed above, so the + // payload always compiles to exactly one OP_RETURN chunk. + dataBufferArray.push(Buffer.concat([magicWordBuffer,data])) + + return {"dataBufferArray":dataBufferArray, "encoding": encoding} + case Encoding.P2SH: + case Encoding.P2WSH: + /* REDEEM SCRIPT (exactly as compiled below) + // the on-wire data push (max chunksSize bytes) + + + OP_DROP // 1 byte - drops the data so it never gates the spend + + + OP_DUP // 1 byte + + + OP_HASH160 // 1 byte + + + // 20-byte HASH160 of the caller key + // (resolveCallerHash160(pubKey): base58 addr, + // raw pubkey hex, or v0 bech32 P2WPKH all yield + // the same HASH160(pubkey)), NOT a 33-byte pubkey + + + OP_EQUALVERIFY // 1 byte + + + OP_CHECKSIG // 1 byte + // + // The trailing five ops form an ordinary P2PKH gate; the data rides + // in the leading push and is discarded at spend time by OP_DROP. + // There is no n / OP_DEPTH / 0 / OP_EQUAL tail (that older sketch + // never matched the compiled script). + */ + + chunksSize = (encoding == Encoding.P2SH?P2SH_SIZE:PW2SH_SIZE) - 44 // 44 is a conservative per-chunk overhead reservation that leaves headroom under the 520-byte consensus MAX_SCRIPT_ELEMENT_SIZE (P2SH_SIZE/PW2SH_SIZE) for the OP_DROP/OP_DUP/OP_HASH160//OP_EQUALVERIFY/OP_CHECKSIG trailer plus the leading data-push prefix. Each chunk becomes one P2SH/P2WSH output; the input spending it carries the data inside its redeem/witness script. + + let pubkeyFromBase58 = resolveCallerHash160(pubKey) + + // Slice the chunk boundaries first so a degenerate 1-byte final + // chunk can be rebalanced before compile. bitcoin.script.compile + // applies asMinimalOP: a lone data byte of 0x01-0x10 or 0x81 + // canonicalizes to a bare opcode, and the decoder's redeem-script + // Buffer gate (XChainDecoder.js: !Buffer.isBuffer(decodedRedeemScript[0])) + // then silently skips that output, corrupting reassembly of an + // otherwise-valid multi-byte payload. Shifting one byte from the + // penultimate chunk keeps every chunk >= 2 bytes; the decoder + // reassembles by concatenation so moving a byte across the + // boundary is transparent end-to-end. + let p2shChunks = [] + i = 0 + while (i < data.length){ + nextDataChunk = data.subarray(i,i+chunksSize) + p2shChunks.push(nextDataChunk) + i = i + nextDataChunk.length + } + if (p2shChunks.length >= 2){ + let last = p2shChunks[p2shChunks.length - 1] + if (last.length === 1 && ((last[0] >= 0x01 && last[0] <= 0x10) || last[0] === 0x81)){ + // Repartition the final two chunks: penultimate drops its + // last byte, final grows to 2 bytes. Both are re-sliced + // from `data` so the concatenation is byte-identical. + let prevStart = data.length - 1 - p2shChunks[p2shChunks.length - 2].length + p2shChunks[p2shChunks.length - 2] = data.subarray(prevStart, data.length - 2) + p2shChunks[p2shChunks.length - 1] = data.subarray(data.length - 2) + } + } + + for (const chunk of p2shChunks){ + let nextDataBuffer = bitcoin.script.compile([ + chunk, + bitcoin.opcodes.OP_DROP, + bitcoin.opcodes.OP_DUP, + bitcoin.opcodes.OP_HASH160, + pubkeyFromBase58, + bitcoin.opcodes.OP_EQUALVERIFY, + bitcoin.opcodes.OP_CHECKSIG, + ]) + + dataBufferArray.push(nextDataBuffer) + } + + return {"dataBufferArray":dataBufferArray, "encoding": encoding} + case Encoding.MULTISIGN: + chunksSize = MULTISIGN_SIZE + - magicWordBuffer.length + - 1 //1 byte for the OP_CHECKMULTISIG + - 1 //1 byte for the m signatures to pop + - 1 //1 byte for the n addresses to pop + - 1 //1 byte for the first address length + - 1 //1 byte for the second address length + + // Each MULTISIGN output carries its data across two 32-byte + // pubkey halves (64 data bytes total). A full chunk is already + // magic(4) + 60 = 64 bytes, but the final chunk is shorter. + // Zero-pad every chunk up to the full 64-byte slot so BOTH + // pubkey halves are always complete 32-byte values. Without + // this, a short final chunk leaves the second half empty (or + // near-empty); dataToPubkey() then produces an all-zero / + // low-entropy EC point that bitcoinjs-lib rejects as not a + // valid point. The reader strips this trailing pad using the + // payload's own self-describing compiled-script length, so the + // padding is invisible end-to-end. + let multisignSlotSize = 64 + + i = 0 + while (i < data.length){ + nextDataChunk = data.subarray(i,i+chunksSize) + let nextChunk = Buffer.concat([magicWordBuffer,nextDataChunk]) + if (nextChunk.length < multisignSlotSize){ + nextChunk = Buffer.concat([nextChunk, Buffer.alloc(multisignSlotSize - nextChunk.length, 0)]) + } + dataBufferArray.push(nextChunk) + i = i + nextDataChunk.length + } + + return {"dataBufferArray":dataBufferArray, "encoding": encoding} + case Encoding.TAPROOT: { + /* TAPROOT ENVELOPE LEAF (grammar frozen at review) + OP_FALSE OP_IF + <"XCHN"> // 4-byte magic, CLEARTEXT (same constant as every lane) + <0x00> // format byte v0, cleartext + // raw payload, 520-byte elements, in order + OP_ENDIF + OP_CHECKSIG + // + // The payload is the compiled action stream (`data` here: the + // action-string push plus the rawData push), byte-identical to + // what the chunk lanes carry, with the magic excluded because + // the envelope header already carries it. Raw by design: the + // shipped large-payload (P2WSH) precedent is unobfuscated, and + // keying on the commit txid is circular by construction. + */ + // The leaf tail and the key-path cancel both need the caller's + // real pubkey; `pubKey` may be a bare address, so the explicit + // compressedPubKey is required, mirroring MULTISIGN. + if (compressedPubKey == null) { + throw new TypeError('compressedPubKey is required for TAPROOT encoding (it becomes the envelope internal key)') + } + if (typeof compressedPubKey !== 'string' || !/^(02|03)[0-9a-fA-F]{64}$/.test(compressedPubKey)) { + throw new TypeError('compressedPubKey must be a 66-character hex string starting with 02 or 03') + } + const internalPubkey = Buffer.from(compressedPubKey, 'hex').subarray(1) + + chunksSize = TAPROOT_ENVELOPE_CHUNK_SIZE + let envelopeChunks = [] + i = 0 + while (i < data.length){ + nextDataChunk = data.subarray(i, i + chunksSize) + envelopeChunks.push(nextDataChunk) + i = i + nextDataChunk.length + } + // Same degenerate-final-chunk rebalance as the P2SH/P2WSH lane: + // bitcoin.script.compile canonicalizes a lone 0x01-0x10/0x81 byte + // to a bare opcode (asMinimalOP), which would corrupt the + // concatenation the decoder reassembles. Shift one byte across + // the final boundary so every push stays >= 2 bytes. (A 1-byte + // TOTAL payload cannot occur: the compiled stream always leads + // with a push opcode, so any non-empty payload is >= 2 bytes.) + if (envelopeChunks.length >= 2){ + let last = envelopeChunks[envelopeChunks.length - 1] + if (last.length === 1 && ((last[0] >= 0x01 && last[0] <= 0x10) || last[0] === 0x81)){ + let prevStart = data.length - 1 - envelopeChunks[envelopeChunks.length - 2].length + envelopeChunks[envelopeChunks.length - 2] = data.subarray(prevStart, data.length - 2) + envelopeChunks[envelopeChunks.length - 1] = data.subarray(data.length - 2) + } + } + + const envelopeScript = bitcoin.script.compile([ + bitcoin.opcodes.OP_0, // OP_FALSE + bitcoin.opcodes.OP_IF, + magicWordBuffer, + Buffer.from([TAPROOT_ENVELOPE_FORMAT_V0]), + ...envelopeChunks, + bitcoin.opcodes.OP_ENDIF, + internalPubkey, + bitcoin.opcodes.OP_CHECKSIG, + ]) + + // dataBufferArray carries exactly ONE element: the whole + // envelope tapscript. The emission loop turns it into one + // commit output; there is no per-chunk output fan-out. + return {"dataBufferArray":[envelopeScript], "encoding": encoding, "internalPubkey": internalPubkey} + } + default: + throw new TypeError(`Unknown encoding: "${encoding}". Valid values: OP_RETURN, P2SH, MULTISIGN, P2WSH, TAPROOT`) + } + }, + + async obfuscate(data, key){ + var cipherKey = key.substr(0,16) + var iv = key.substr(16,16) + + var cipher = crypto.createCipheriv('aes-128-ctr', cipherKey, iv); + var encryptedData = cipher.update(data) + encryptedData = Buffer.concat([encryptedData,cipher.final()]) + return encryptedData + }, + + async dataToPubkey(data){ + let bufferArray = [Buffer.from("02","hex"),data] + let bufferFill = null + if (data.length < 32){ + bufferFill = Buffer.allocUnsafe(32 - data.length) + bufferFill.fill("00", 0, bufferFill.length, "hex") + bufferArray.push(bufferFill) + } + + return Buffer.concat(bufferArray) + }, + + /** + * Refuse to build a Taproot envelope that decoders would ignore. + * + * Envelope recognition activates at a per-network height. Below it every decoder + * treats the reveal as an ordinary P2TR spend, so the caller would pay a real + * miner fee, write a real payload on chain, and own an action that does not + * exist. That refusal is silent and correct by design and nothing downstream can + * detect the loss, which is exactly why the check has to live here. Fail-closed + * on an unknown height too: a node that cannot answer getblockcount leaves us + * unable to prove recognition is active, and guessing wrong costs the caller + * real money. + * + * `null` means the network never recognizes envelopes (DOGE: no segwit). That is + * already refused by the supportsSegwit gate; this repeats it as a safety net for + * any future non-segwit chain whose definition is added without one. + */ + async assertEnvelopeRecognized(){ + const height = CryptoNetworks.getEnvelopeRecognitionHeight(this.networkKey) + if (height === null || height === undefined) { + throw new TypeError('TAPROOT encoding is not recognized on this network; ' + + 'no envelope recognition height is defined for it') + } + if (height === 0) return // genesis-active (testnet/regtest) + + const tip = this.connector && typeof this.connector.getBlockCount === 'function' + ? await this.connector.getBlockCount() + : null + if (!Number.isFinite(tip)) { + const e = new Error('Cannot confirm Taproot envelope recognition is active on this network ' + + `(recognition height ${height}); the node did not return a chain height. Refusing to build ` + + 'an envelope that decoders may ignore.') + e.operational = true + e.xchainCode = 'ENVELOPE_RECOGNITION_UNKNOWN' + throw e + } + if (tip < height) { + const e = new Error(`Taproot envelope recognition is not active on this network until block ${height} ` + + `(chain tip ${tip}, ${height - tip} block(s) to go). An envelope broadcast now would cost a real ` + + 'fee and be ignored by every decoder, so it is refused. Use P2WSH until the activation height.') + e.operational = true + e.xchainCode = 'ENVELOPE_NOT_YET_ACTIVE' + e.details = { recognitionHeight: height, chainTip: tip, blocksRemaining: height - tip } + throw e + } + }, + + // Public entry point. It owns the per-call reservation ledger: every outpoint + // the build claims is recorded there, and if the build throws for ANY reason + // (INPUT_SELECTION_RACE, INSUFFICIENT_FUNDS, a fee-cap RangeError, an upstream + // node error) those claims are handed back immediately instead of squatting + // until RESERVATION_TTL_MS. Without that, the retry an INPUT_SELECTION_RACE + // error explicitly asks for hit its OWN dead reservations and came back + // INSUFFICIENT_FUNDS for up to five minutes. The release is ownership-stamped + // so a concurrent call's entries are never dropped; see + // releaseCallReservations. The success path keeps its reservations on purpose: + // the caller is about to sign and broadcast those inputs. + // A successful build's kept claims come back as a `reservation` receipt on the + // result, which the caller hands to releaseReservation the moment it knows it + // will not broadcast; see mintReservationTicket. + async createTransaction(...args){ + const callReservations = [] + let result + try { + result = await this._buildTransaction(callReservations, ...args) + } catch (err) { + this.releaseCallReservations(callReservations) + throw err + } + const reservation = this.mintReservationTicket(callReservations, Date.now()) + if (reservation && result && typeof result === 'object') result.reservation = reservation + return result + }, +} diff --git a/src/XChainEncoder/request_resolution.js b/src/XChainEncoder/request_resolution.js new file mode 100644 index 0000000..8109bf6 --- /dev/null +++ b/src/XChainEncoder/request_resolution.js @@ -0,0 +1,203 @@ +/********************************************************************* + * + * 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 handles starting the encoder and generating transactions + * + ********************************************************************/ + +const bitcoin = require('bitcoinjs-lib'); +const config = require('../common/config'); + +// THE tracker-freshness classifier. Pure: it reads a `sync` object and a lag +// ceiling and returns a verdict; it never throws, logs, or touches a connector. +// +// It exists because the same verdict was written twice against two different +// endpoints and drifted: _buildTransaction read the `sync` sibling that get_utxos +// carries, api.js getServeReadiness() read getSyncStatus(), and each re-derived +// halted / over-lag / behind-node / mempool-ready with its own strict-equality +// chain. Both surfaces now call this, so a create_tx refusal and an unhealthy +// /status can no longer disagree about the same tracker. +// +// Fail-open is deliberate and narrow, NOT an oversight: a field that is absent +// (an older tracker predating the ce16bdd freshness surface, or one predating +// the halt/mempool markers) must not refuse every build in the fleet, so only an +// EXPLICIT negative refuses. `halted` refuses on === true; `synced` refuses on +// === false; `mempool_ready` refuses on === false; a non-numeric lag is unknown +// and bounds nothing. An entirely missing `sync` yields present:false and no code. +// +// Returns: +// present - a usable sync object was supplied +// lag - numeric lag, or null when the tracker did not report one +// overLag - lag is above maxLagBlocks +// behindNode - lag is negative, i.e. the tracker's committed tip sits ABOVE +// the node's, so its outputs live in orphaned blocks +// halted - tracker stopped polling on an unrecoverable reorg +// mempoolReady - mempool index has reconverged (true unless explicitly false) +// syncedClaimed - the tracker's own positive `synced` assertion, truthy-tested. +// Readiness probes need this POSITIVE form (an omitted field is +// not an assertion of health), while the create_tx gate refuses +// only on the explicit negative; that asymmetry is the one real +// difference between the two surfaces and is now named instead +// of being buried in two `===` chains. +// code - null when nothing refuses, else the OperationalError code +// message - operator-facing refusal text, null when code is null +// details - error details payload, null when code is null +// +// Order matters: halted first (most specific physical cause), then staleness, +// then mempool readiness. An orphaned or lagging view also de-asserts +// mempool_ready (the tracker floors that field on the same negative lag), so +// checking readiness first would name mempool reconvergence for what is really a +// node reset. +function classifyTrackerFreshness(sync, maxLagBlocks){ + if (!sync || typeof sync !== 'object'){ + return { + present: false, lag: null, overLag: false, behindNode: false, + halted: false, mempoolReady: true, syncedClaimed: false, + code: null, message: null, details: null + } + } + // NaN is excluded explicitly: it is `typeof number`, so it passed the old check + // and was then reported verbatim as the lag while answering every comparison + // below with a silent false. Unknown is the honest verdict for it. Infinity is + // NOT excluded: it compares, and it compares as unboundedly stale, which is + // exactly the refusal a garbage lag should get. + const lag = (typeof sync.lag === 'number' && !Number.isNaN(sync.lag)) ? sync.lag : null + const overLag = (lag !== null) && (lag > maxLagBlocks) + const behindNode = (lag !== null) && (lag < 0) + const halted = sync.halted === true + const mempoolReady = sync.mempool_ready !== false + const syncedClaimed = !!sync.synced + const heights = { lag, tracker_height: sync.tracker_height, node_height: sync.node_height } + const verdict = { + present: true, lag, overLag, behindNode, halted, mempoolReady, syncedClaimed, + code: null, message: null, details: null + } + if (halted){ + verdict.code = 'UTXO_TRACKER_HALTED' + verdict.message = `utxo-tracker is halted (${sync.halt_reason || 'unrecoverable reorg'}); ` + + 'refusing to select utxos from it' + verdict.details = Object.assign({}, heights, { halt_reason: sync.halt_reason || null }) + return verdict + } + if (sync.synced === false || overLag || behindNode){ + verdict.code = 'UTXO_TRACKER_STALE' + verdict.message = `utxo-tracker view is stale (lag ${lag === null ? 'unknown' : lag} blocks` + + `${overLag ? `, exceeds ${maxLagBlocks}-block threshold` : ''}` + + `${behindNode ? `, tracker is ahead of the node so its view is orphaned` : ''}); ` + + 'refusing to select utxos from it' + verdict.details = heights + return verdict + } + if (!mempoolReady){ + verdict.code = 'UTXO_TRACKER_NOT_READY' + verdict.message = 'utxo-tracker has not reconverged its mempool yet, so an already-spent ' + + 'confirmed output cannot be filtered; refusing to select utxos from it' + verdict.details = heights + return verdict + } + return verdict +} + +// Resolve the 20-byte caller HASH160 that gates a P2SH/P2WSH chunk-lane reveal, +// from ANY caller identity form, not just a base58 legacy address. The reveal tx +// that spends a chunk output must satisfy an ordinary P2PKH gate (OP_DUP +// OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG) with the SOURCE key, so the +// returned hash MUST equal HASH160(that pubkey). It is the same 20 bytes whether +// the caller sends a base58 address, a raw pubkey hex, or a v0 bech32 P2WPKH +// address (whose witness program already IS that HASH160). The decoder reads +// ONLY the leading data chunk (redeemScript[0]) and never this trailer hash, so +// this is compose-side only with NO consensus/wire surface. Base58-only parsing +// would throw "Non-base58 character" here, which breaks every wallet flow (they +// send a raw compressed pubkey) and every bech32-only source. +function resolveCallerHash160(pubKey) { + // Raw compressed (02/03 + 64 hex) or uncompressed (04 + 128 hex) pubkey first: + // a base58 address can never match this shape (base58 excludes 0/O/I/l and + // 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')) + } + // Base58 P2PKH/P2SH address: byte-identical to the original behavior. + try { return bitcoin.address.fromBase58Check(pubKey).hash } catch (_) { /* not base58 */ } + // v0 bech32 P2WPKH: the 20-byte witness program already equals HASH160(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)`) + } 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)`) +} + +// Sibling of resolveCallerHash160, same "any identity in" contract, but for +// call sites that need an address STRING rather than a HASH160 (the UTXO +// tracker's getUtxosFromAddress, and the dust-padding fallback below). Wallet +// flows send their source as a raw compressed pubkey hex in this `pubkey` +// param, which is neither base58 nor bech32, so handing it straight to +// address.toOutputScript threw " has no matching Script" and failed every +// compose that did not pre-supply `utxos`. +// Address-type choice for a bare pubkey: this network's default (P2WPKH when +// segwit-capable, else legacy P2PKH), matching the wallet's own default address +// type per coin. A caller spending from a different address type (P2SH-P2WPKH, +// taproot) must pre-supply `utxos` or pass an explicit address, because a bare +// pubkey is inherently address-type-ambiguous. +function resolveCallerAddress(pubKey, network) { + if (typeof pubKey !== 'string' || pubKey.length === 0) return pubKey + // Already a valid address on this network: pass through unchanged. + 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') + 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 +} + +// Deployment default for transparent FILE compression. ON unless +// the operator turns it off for a staged rollout; read per call rather than +// cached so a restart is not required to change it. +function defaultCompressionEnabled(){ + const raw = config.XCHAIN_COMPRESSION_DEFAULT + if (raw === undefined || raw === null || raw === '') return true + return !(raw === '0' || raw.toLowerCase() === 'false' || raw.toLowerCase() === 'off') +} + +// Bind the reveal's marker key to the outpoint it actually spends. +// The P2SH/P2WSH reveal obfuscates its OP_RETURN marker with the id of the +// funding tx parsed from p2shHex, but builds every input outpoint from the +// separately supplied p2shHash, and nothing checked the two agree. A caller +// mixing the id of one funding tx with the hex of an equivalent other one still +// produced a signable, broadcastable reveal, while the decoder derives its key +// from the input's real txid, fails the magic-word check, and silently drops the +// paid ACTION. Called right after the hex is parsed, so it fires before the +// marker is obfuscated and before any input is added. +function assertRevealFundingTxMatches(p2shHash, fundingTxid){ + if (!p2shHash) return + if (String(p2shHash).toLowerCase() !== String(fundingTxid).toLowerCase()){ + throw new TypeError( + `p2shHash (${p2shHash}) does not match the txid of the supplied p2shHex transaction (${fundingTxid}); ` + + `the reveal would spend one funding transaction while keying its marker to another` + ) + } +} + +module.exports = { classifyTrackerFreshness, resolveCallerHash160, resolveCallerAddress, defaultCompressionEnabled, assertRevealFundingTxMatches } diff --git a/src/XChainEncoder/script_amount_helpers.js b/src/XChainEncoder/script_amount_helpers.js new file mode 100644 index 0000000..06682f9 --- /dev/null +++ b/src/XChainEncoder/script_amount_helpers.js @@ -0,0 +1,92 @@ +/********************************************************************* + * + * 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 handles starting the encoder and generating transactions + * + ********************************************************************/ + +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') + +// 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] + return (tick && SOFT_DUST_FLOOR_BY_COIN[tick]) || 0 +} + +// 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 +// only INSIDE a script. Witness-stack items are not script pushes; each is +// framed by a compactSize varint, which switches width at 253, not at 76/256. +function compactSizeLen(n) { + if (n < 253) return 1 + if (n <= 0xffff) return 3 + if (n <= 0xffffffff) return 5 + return 9 +} + +// Serialized compactSize varint for `n`, the wire form whose width +// compactSizeLen models. Needed for the BIP341 tapleaf hash, whose preimage +// length-prefixes the script with a compactSize (not a script push). +function compactSizeBuffer(n) { + if (n < 253) return Buffer.from([n]) + if (n <= 0xffff) { const b = Buffer.alloc(3); b[0] = 0xfd; b.writeUInt16LE(n, 1); return b } + const b = Buffer.alloc(5); b[0] = 0xfe; b.writeUInt32LE(n, 1); return b +} + +// BIP341 tapleaf hash of the envelope script: +// taggedHash("TapLeaf", leaf_version || compactSize(len(script)) || script). +// For the single-leaf envelope tree this doubles as the taproot merkle root, +// which is exactly the third element of the wallet's cancel-recovery record +// {commit outpoint, internal key derivation path, tapleaf hash}: the key-path +// tweak cannot be reconstructed without it. +function envelopeTapLeafHash(script) { + return bitcoin.crypto.taggedHash('TapLeaf', + Buffer.concat([Buffer.from([TAPROOT_LEAF_VERSION]), compactSizeBuffer(script.length), script])) +} + +// bitcoinjs-lib refuses any P2TR construction until an ECC backend is +// registered. Registered lazily on the first envelope build rather than at +// module load so the non-envelope lanes (and the browserify bundle) never pay +// for the wasm-backed tiny-secp256k1 at startup. +let eccLibReady = false +function ensureEccLib() { + if (eccLibReady) return + bitcoin.initEccLib(require('tiny-secp256k1')) + eccLibReady = true +} + +// Narrow a satoshi amount computed in BigInt back to a Number when it is +// exactly representable, so consumers predating BigInt support (tests, PSBT +// inspectors) keep seeing Number for every value they could handle; only a +// genuinely >2^53-1 amount stays BigInt (the patched bitcoinjs/bip174 +// serializers accept both). +function asSatValue(v) { + return (typeof v === 'bigint' && v <= MAX_SAFE_SATOSHI_BIG) ? Number(v) : v +} + +// JSON-safe form of a satoshi amount for OperationalError metadata: +// JSON.stringify throws on BigInt, so a >2^53-1 amount is emitted as its +// exact decimal string instead. +function jsonSafeSat(v) { + if (typeof v !== 'bigint') return v + return v <= MAX_SAFE_SATOSHI_BIG ? Number(v) : v.toString() +} + +module.exports = { softDustFloorFor, compactSizeLen, compactSizeBuffer, envelopeTapLeafHash, ensureEccLib, asSatValue, jsonSafeSat } diff --git a/src/XChainEncoder/size_estimation.js b/src/XChainEncoder/size_estimation.js new file mode 100644 index 0000000..982d199 --- /dev/null +++ b/src/XChainEncoder/size_estimation.js @@ -0,0 +1,210 @@ +/********************************************************************* + * + * 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 handles starting the encoder and generating transactions + * + ********************************************************************/ + +const bitcoin = require('bitcoinjs-lib'); +const TxSizeEstimator = require('../build/tx_size_estimator') +const { OP_RETURN_SIZE, MAGIC_WORD, Encoding } = require('./constants.js') +const { compactSizeLen } = require('./script_amount_helpers.js') + +module.exports = { + /* + * Size-aware encoding selection: "smallest footprint by default" as the + * platform's behaviour rather than its option. Only reached when the caller + * explicitly asked for Encoding.AUTO. + * + * @param {number} compiledLength the compiled ACTION stream, AFTER compression + * @param {string|null} compressedPubKey required for the envelope's internal key + * @param {object|null} options { signerSupportsTapscript } + * @returns {string} a concrete Encoding + */ + selectEncoding(compiledLength, compressedPubKey, options){ + const magicBytes = Buffer.from(MAGIC_WORD, 'utf8').length + // Small payloads: one output, no reveal, nothing cheaper exists. + if (compiledLength + magicBytes <= OP_RETURN_SIZE) return Encoding.OP_RETURN + + const segwit = this.network.supportsSegwit !== false + + // The envelope is ~2x cheaper per byte than P2WSH and replaces ~820 + // chunk outputs per 390 KB, so it wins whenever it is available. Two + // conditions, both fail-closed: + // + // - the network must have Taproot at all (DOGE never does); + // - the SIGNER must be able to produce a tapscript script-path + // signature. This defaults to NO and must be affirmed by the caller. + // The reveal has to be signable BEFORE the commit is broadcast; + // picking the envelope for a signer that cannot spend it does not + // produce an error message, it produces stranded funds. Hardware + // signers are the live case: the wallet's Trezor integration cannot + // sign the leaf today, so those accounts must land on P2WSH. + const tapscriptSigner = !!(options && options.signerSupportsTapscript) + if (segwit && tapscriptSigner && compressedPubKey) return Encoding.TAPROOT + + // P2WSH carries 476 bytes per chunk against MULTISIGN's 60, so it is the + // segwit fallback. On a non-segwit chain P2SH is the same 476-byte lane + // and is therefore preferred over MULTISIGN there too: AUTO never + // selects the worst-density carrier, which is what makes MULTISIGN an + // explicit-request-only lane from here on. + if (segwit) return Encoding.P2WSH + return Encoding.P2SH + }, + + // Stripped (non-witness) serialized byte count of a PSBT's underlying tx. + // This is the size a node measures against its MIN_STANDARD_TX_NONWITNESS_SIZE + // relay floor. P2WSH reveal inputs carry an empty scriptSig (all data lives in + // the witness), so each contributes a fixed 41 non-witness bytes: 36-byte + // outpoint + 1-byte empty-scriptSig length + 4-byte sequence. + strippedTxSize(psbt){ + const varIntSize = (n) => n < 0xfd ? 1 : n <= 0xffff ? 3 : n <= 0xffffffff ? 5 : 9 + let size = 4 + 4 // version + locktime + size = size + varIntSize(psbt.txInputs.length) + (41 * psbt.txInputs.length) + size = size + varIntSize(psbt.txOutputs.length) + for (let out of psbt.txOutputs){ + size = size + 8 + varIntSize(out.script.length) + out.script.length + } + return size + }, + + // Raise an already-emitted output's value in place. + // + // bitcoinjs exposes no setter for an output's value (updateOutput only + // touches PSBT-level fields), and re-emitting the output is not an option: + // the commit output's index is load-bearing (the envelope reveal spends it by + // vout, the decoder reads the chunk legs in order), so it has to stay put. + // The PSBT is unsigned at this point and every serialization path reads the + // same unsigned transaction object, so the write is seen by psbt.txOutputs, + // psbt.toHex() and the reveal builder alike. Asserted rather than assumed: + // a bitcoinjs release that reshapes this would otherwise underfund a reveal + // silently, which is the exact failure this whole pass exists to prevent. + raiseOutputValue(psbt, outputIndex, delta){ + if (!Number.isInteger(delta) || delta <= 0){ + throw new RangeError('output uplift must be a positive integer') + } + const outs = psbt.data.globalMap.unsignedTx + && psbt.data.globalMap.unsignedTx.tx + && psbt.data.globalMap.unsignedTx.tx.outs + if (!Array.isArray(outs) || !outs[outputIndex]){ + throw new RangeError(`no output at index ${outputIndex} to raise`) + } + const raised = outs[outputIndex].value + delta + outs[outputIndex].value = raised + if (psbt.txOutputs[outputIndex].value !== raised){ + throw new RangeError('output value uplift did not reach the transaction bitcoinjs will serialize') + } + }, + + estimateSpendingP2shTx(redeemData){ + // Per-chunk embedded value sized to cover the spending tx's worst + // case at 1 sat/vbyte. Includes tx overhead, the OP_RETURN marker + // output, the P2SH input bringing this chunk's redeem script (sig + + // compressed pubkey + redeem script push; see estimateP2shInputWithRedeem), + // plus a small safety margin to absorb DER signature length jitter so the + // broadcast never lands fractionally under the node's min relay fee floor. + let sizeEstimated = + 10 // 4 version + 1 inputs count + 1 outputs count + 4 locktime + + TxSizeEstimator.estimateP2shInputWithRedeem(redeemData) + + TxSizeEstimator.estimateOpReturnOutput(Buffer.concat([ + Buffer.from(MAGIC_WORD,'utf8'), + Buffer.from("p2sh",'utf8') + ])) + + 8 // safety margin for DER-sig length jitter (sig push assumes 72B) + + return sizeEstimated + }, + + // Whole-tx size of the P2SH reveal (phase 2): every chunk input carrying its + // redeem script, the OP_RETURN marker output, plus extraOutputsBytes for the + // value outputs the reveal emits (the change sweep, reveal-side + // customOutputs). estimateSpendingP2shTx above is the PER-CHUNK share that + // sizes each funding leg; this is the fee base for the ONE transaction + // that spends all of them, so the funding side can top the first leg up to + // what the reveal will actually need. Both phases MUST + // price the reveal with this function: the reveal path's generic + // TxSizeEstimator.estimateInputSize() models a P2SH input as 2-of-3 + // multisig (289 bytes flat) and disagrees with the funding side's + // redeem-aware estimate, and that drift is exactly what would leave the + // funded change headroom a few satoshis short of emittable. The per-input + // +8 is the same DER-signature-length-jitter margin estimateSpendingP2shTx + // carries. + estimateP2shRevealTx(dataBufferArray, extraOutputsBytes = 0){ + let inputsBytes = 0 + for (const redeemData of dataBufferArray){ + inputsBytes = inputsBytes + TxSizeEstimator.estimateP2shInputWithRedeem(redeemData) + 8 + } + return 10 // 4 version + 1 inputs count + 1 outputs count + 4 locktime + + inputsBytes + + TxSizeEstimator.estimateOpReturnOutput(Buffer.concat([ + Buffer.from(MAGIC_WORD,'utf8'), + Buffer.from("p2sh",'utf8') + ])) + + extraOutputsBytes + }, + + estimateSpendingP2wshTx(witnessData){ + // 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 + // script) in the witness, which is weight-1 (i.e. ÷4 toward vbytes). + // That makes a P2WSH reveal materially cheaper than the equivalent P2SH + // reveal: sizing it with estimateSpendingP2shTx would over-fund ~4x. + // compactSize, not compiledPushSize: a witness stack item is length- + // prefixed by a varint on the wire, it is not a push inside a script. + // The two disagree in two bands and the old script-push model was wrong + // in both: 76..252 it over-funded by a byte, and 253..255 it UNDER-funded + // by a byte (OP_PUSHDATA1 is 2 bytes there, the varint is 3). + let witnessScriptPrefix = compactSizeLen(witnessData.length) + // Witness stack (weight 1): item count + sig item (1+72) + pubkey item + // (1+33) + witness-script item prefix + script bytes, plus the segwit + // marker+flag (2) which are also weight 1. The 72- and 33-byte items are + // both under 253, so their compactSize prefix is the literal 1 below. + let witnessBytes = 2 // marker + flag + + 1 // witness stack item count + + (1 + 72) // sig item + + (1 + 33) // compressed pubkey item + + (witnessScriptPrefix + witnessData.length) // witness script prefix + bytes + + // Non-witness (weight 4) bytes: tx overhead + the empty-scriptSig input + // outpoint + the OP_RETURN marker output. + let nonWitnessBytes = + 10 // 4 version + 1 inputs count + 1 outputs count + 4 locktime + + (36 + 1 + 4) // outpoint(36) + empty scriptSig len(1) + sequence(4) + + TxSizeEstimator.estimateOpReturnOutput(Buffer.concat([ + Buffer.from(MAGIC_WORD,'utf8'), + 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.) + let strippedFloor = this.network.minStandardTxNonWitnessSize + if (strippedFloor && nonWitnessBytes < strippedFloor){ + nonWitnessBytes = strippedFloor + } + + // vsize = ceil(total weight / 4) = nonWitnessBytes + ceil(witnessBytes/4) + let sizeEstimated = nonWitnessBytes + + Math.ceil(witnessBytes / 4) + + 8 // safety margin for DER-sig length jitter (sig push assumes 72B) + + return sizeEstimated + }, +} From bcabafa3a4f66e9ed19c0148ebab3f877e69be9d Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 11:08:37 -0700 Subject: [PATCH 133/148] refactor(encoder): move the reservation map setup out of the constructor into initReservationMaps --- src/XChainEncoder.js | 72 ++++++++++++++++++++++++-------------------- 1 file changed, 39 insertions(+), 33 deletions(-) diff --git a/src/XChainEncoder.js b/src/XChainEncoder.js index ddf41f7..72657f6 100644 --- a/src/XChainEncoder.js +++ b/src/XChainEncoder.js @@ -70,6 +70,44 @@ const DEFAULT_MAX_FEE_RATE_MULTIPLIER = 100 // that without false-positiving on routine operation. const DEFAULT_MAX_UTXO_TRACKER_LAG_BLOCKS = 2 +// The per-instance double-spend bookkeeping a build consults: outpoint +// reservations, recent builds, envelope-cancel owners and reservation tickets. +function initReservationMaps(){ + // outpoint ("txid:vout") -> reservation-expiry epoch ms. Guards against + // two create_tx calls for the same address, concurrent or a few hundred + // milliseconds apart, both selecting the same UTXO and emitting conflicting + // double-spends. Engaged for EVERY selection, caller-supplied sets + // included: the SDK fetches the funding set itself and hands it over as + // `utxos`, so "caller-supplied" is the mainstream wallet path, not a + // coin-control opt-in. Treating it as unreserved is how three chained + // MINTs on BTC testnet4 built the same transaction twice. + // See RESERVATION_TTL_MS. + this.outpointReservations = new Map() + // unsigned txid -> expiry epoch ms of every transaction this process built + // within RESERVATION_TTL_MS. Second line of defense behind the outpoint + // map: a byte-identical rebuild hashes to the same txid, and returning it + // as a fresh build let a caller journal one broadcast as two successes. + this.recentBuilds = new Map() + // outpoint -> Map(cancel-build owner id -> that build's reservation expiry). + // A cancel is deterministic from the recovery record, so it must be able to + // re-take an outpoint its OWN path already holds; an outpoint no cancel + // owner wrote belongs to another build and blocks it. The owner SET, rather + // than one expiry stamp, is what makes overlapping cancel builds of a single + // outpoint distinguishable: the reservation is worth the highest live owner + // expiry and is dropped only when the last owner lets go, so a failed retry + // can no longer delete the reservation an earlier outstanding cancel holds. + // See releaseEnvelopeCancelClaims and maxLiveCancelOwnerExpiry. + this.envelopeCancelClaims = new Map() + // reservation ticket id -> the claims ONE successful build kept, with the + // expiry stamp each of them wrote plus the recent-build record that build + // registered. This is what makes an explicit release possible without + // handing callers a way to free somebody else's inputs: the ticket id is + // unguessable and the per-claim stamp is re-checked at release time, so a + // caller can only ever drop entries its own build still owns. + // See releaseReservation. + this.reservationTickets = new Map() +} + @@ -118,39 +156,7 @@ class XChainEncoder { // parameter default above (only a literal `undefined` triggers a JS default // parameter, so this normalizes `null` the same way). this.maxUtxoTrackerLagBlocks = (maxUtxoTrackerLagBlocks == null) ? DEFAULT_MAX_UTXO_TRACKER_LAG_BLOCKS : maxUtxoTrackerLagBlocks - // outpoint ("txid:vout") -> reservation-expiry epoch ms. Guards against - // two create_tx calls for the same address, concurrent or a few hundred - // milliseconds apart, both selecting the same UTXO and emitting conflicting - // double-spends. Engaged for EVERY selection, caller-supplied sets - // included: the SDK fetches the funding set itself and hands it over as - // `utxos`, so "caller-supplied" is the mainstream wallet path, not a - // coin-control opt-in. Treating it as unreserved is how three chained - // MINTs on BTC testnet4 built the same transaction twice. - // See RESERVATION_TTL_MS. - this.outpointReservations = new Map() - // unsigned txid -> expiry epoch ms of every transaction this process built - // within RESERVATION_TTL_MS. Second line of defense behind the outpoint - // map: a byte-identical rebuild hashes to the same txid, and returning it - // as a fresh build let a caller journal one broadcast as two successes. - this.recentBuilds = new Map() - // outpoint -> Map(cancel-build owner id -> that build's reservation expiry). - // A cancel is deterministic from the recovery record, so it must be able to - // re-take an outpoint its OWN path already holds; an outpoint no cancel - // owner wrote belongs to another build and blocks it. The owner SET, rather - // than one expiry stamp, is what makes overlapping cancel builds of a single - // outpoint distinguishable: the reservation is worth the highest live owner - // expiry and is dropped only when the last owner lets go, so a failed retry - // can no longer delete the reservation an earlier outstanding cancel holds. - // See releaseEnvelopeCancelClaims and maxLiveCancelOwnerExpiry. - this.envelopeCancelClaims = new Map() - // reservation ticket id -> the claims ONE successful build kept, with the - // expiry stamp each of them wrote plus the recent-build record that build - // registered. This is what makes an explicit release possible without - // handing callers a way to free somebody else's inputs: the ticket id is - // unguessable and the per-claim stamp is re-checked at release time, so a - // caller can only ever drop entries its own build still owns. - // See releaseReservation. - this.reservationTickets = new Map() + initReservationMaps.call(this) } From ea3b2dce708fcf2eacfaac57cba862833f93e90a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 11:08:37 -0700 Subject: [PATCH 134/148] refactor(encoder): split _buildTransaction into ordered steps under src/XChainEncoder/build_transaction/ The method keeps its signature and runs the steps in the same order over one per-call state object; every moved line is the original text. --- src/XChainEncoder.js | 1966 +---------------- .../build_transaction/build_result.js | 155 ++ .../build_transaction/data_outputs.js | 317 +++ .../build_transaction/fee_rates.js | 165 ++ .../build_transaction/fee_settlement.js | 312 +++ .../build_transaction/first_input_key.js | 138 ++ .../build_transaction/input_candidates.js | 260 +++ .../build_transaction/input_selection.js | 292 +++ .../build_transaction/payload_checks.js | 262 +++ .../build_transaction/reveal_outputs.js | 292 +++ .../build_transaction/script_hash_legs.js | 323 +++ 11 files changed, 2560 insertions(+), 1922 deletions(-) create mode 100644 src/XChainEncoder/build_transaction/build_result.js create mode 100644 src/XChainEncoder/build_transaction/data_outputs.js create mode 100644 src/XChainEncoder/build_transaction/fee_rates.js create mode 100644 src/XChainEncoder/build_transaction/fee_settlement.js create mode 100644 src/XChainEncoder/build_transaction/first_input_key.js create mode 100644 src/XChainEncoder/build_transaction/input_candidates.js create mode 100644 src/XChainEncoder/build_transaction/input_selection.js create mode 100644 src/XChainEncoder/build_transaction/payload_checks.js create mode 100644 src/XChainEncoder/build_transaction/reveal_outputs.js create mode 100644 src/XChainEncoder/build_transaction/script_hash_legs.js diff --git a/src/XChainEncoder.js b/src/XChainEncoder.js index 72657f6..9bac977 100644 --- a/src/XChainEncoder.js +++ b/src/XChainEncoder.js @@ -42,6 +42,15 @@ const { logger, MAGIC_WORD, TAPROOT_LEAF_VERSION, SATOSHI_UNIT, DEFAULT_SUGGESTE const { softDustFloorFor, envelopeTapLeafHash, ensureEccLib, asSatValue, jsonSafeSat } = require('./XChainEncoder/script_amount_helpers.js') const { isTestNetworkKey, suggestedFeeCeilingPerByte, suggestedFeeCeilingFloorPerByte, maxCpfpUpliftSat, packageFeeUpliftSatoshis } = require('./XChainEncoder/fee_policy.js') const { classifyTrackerFreshness, resolveCallerAddress, defaultCompressionEnabled, assertRevealFundingTxMatches } = require('./XChainEncoder/request_resolution.js') +const { checkPayloadInput, compressPayload, compilePayload, classifyPayload, chooseEncoding, checkEnvelopeEncoding } = require('./XChainEncoder/build_transaction/payload_checks.js') +const { resolveFeeRates } = require('./XChainEncoder/build_transaction/fee_rates.js') +const { initInputState, checkExactInputs, gatherUtxos, refuseShrunkExactInputs, filterAndOrderUtxos } = require('./XChainEncoder/build_transaction/input_candidates.js') +const { bindObfuscationKey } = require('./XChainEncoder/build_transaction/first_input_key.js') +const { prepareDataChunks, priceRevealCustomOutputs, initEmissionState, emitDataOutputs, emitCustomOutputs } = require('./XChainEncoder/build_transaction/data_outputs.js') +const { initSelection, selectFundingInputs } = require('./XChainEncoder/build_transaction/input_selection.js') +const { refuseExcessiveFee, upliftForAncestors, floorEstimatedFee, prefundRevealPackage, computeChange } = require('./XChainEncoder/build_transaction/fee_settlement.js') +const { emitChangeAndPad, sweepP2shReveal, buildEnvelopeReveal } = require('./XChainEncoder/build_transaction/reveal_outputs.js') +const { finishBuild } = require('./XChainEncoder/build_transaction/build_result.js') // Default ceiling on any caller-supplied fee, expressed as a multiple of the @@ -164,1928 +173,41 @@ class XChainEncoder { encoding, change, p2shHash=null, p2shHex=null, compressedPubKey=null, unconfirmed=true, feePerKb=null, dust=null, feeQuote=null, attachPrevTx=false, compress=null, options=null){ - - // 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 - // reaches this builder with no validator in front of it, so the - // round-trip guard existed on one surface only: a `rawData` code unit - // above U+00FF was truncated to its low byte by the Latin-1 conversion - // below, and the compiled-size ceiling cannot see it because the length - // is unchanged. Same idiom as the per-entry UTXO, txid, fee and address - // re-validation further down. - // - // Runs BEFORE compression on purpose: the compression pass itself does - // Buffer.from(rawData,'binary'), so a check placed after it would measure - // bytes the truncation already produced. - // - // STRING inputs only, deliberately. The corruption is a property of the - // string-to-wire conversion; a caller handing the builder a Buffer is - // copied byte-for-byte and loses nothing, so refusing it here would - // impose RPC-surface argument-shape policy on library callers rather - // than close a data-loss path. - if (typeof data === 'string') validateDataParam(data, 'data') - if (typeof rawData === 'string') validateDataParam(rawData, 'rawData') - - // The ACTION-name recognition check has the same one-surface problem, and a - // different answer. validateAll REFUSES an unrecognized leading token on the - // JSON-RPC path (validator.js), so only a direct library caller reaches the - // compiler with one: the transaction compiles, pays a real miner fee, and the - // decoder drops the ACTION with nothing reported back. The compiled-size - // checks cannot catch it, because an unknown name is an ordinary length. - // - // REPORTED, not refused, matching the rawDataOnlyPayload advisory below - // rather than the RPC gate. createTransaction is a general PSBT builder as - // well as an ACTION builder, and its supported shapes include a payload that - // is not an XChain ACTION at all; refusing here would withdraw that contract - // from every library and browser-bundle caller to close a silence, and the - // silence is the whole of the harm. The fee-payer learns before it signs, - // which needs no acceptance change. - // - // Read BEFORE compression, on the caller's own bytes, which is the surface - // validateAll measures; the compression pass rewrites `data` afterwards. - // Buffer inputs read too, unlike the latin-1 re-check above: that one is - // string-only because a Buffer loses nothing in the conversion, while the - // decoder tokenizes the compiled push whatever shape the caller handed over. - const unknownAction = unknownActionName(data) - - // If feeQuote is provided, inject it as a custom output - if(feeQuote && feeQuote.address && feeQuote.amount > 0){ - if(!customOutputs) customOutputs = []; - customOutputs.push({ address: feeQuote.address, value: feeQuote.amount }); - } - - let feePerBytes = null - let nodeFeePerBytes = null - if (feePerKb){ - // feePerKb is the caller's rate in BASE UNITS (sat/litoshi/koinu) - // per kB: docs/openrpc.json documents it as "base units per kB" and - // the SDK's getFeeTiers() guidance is base-unit/vByte x 1000, the - // same unit MAX_FEE_RATE_KB uses. Convert to the internal BTC/byte - // that the node path (BTC/kB / 1000), the constructor cap - // (maxFeeRateKb / 1000 / SATOSHI_UNIT), and the fee formula - // (estimatedTxSize * feePerBytes * SATOSHI_UNIT) all share. The old - // /1000-only conversion left this path 1e8x too large: with the - // rate cap enabled every caller rate was silently clamped (fee - // control inert), and with it disabled a documented sat/kB rate - // produced a ~1e8x-inflated fee. - feePerBytes = feePerKb/1000/SATOSHI_UNIT - // Fetch the node's own estimate regardless of the multiplier - // setting: it anchors the relative cap below AND the absolute burn - // backstop, which must never derive its ceiling from the caller's - // own rate (that would let an inflated feePerKb lift the ceiling - // with it). - try { - nodeFeePerBytes = await 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. - logger.warn(util.format('Relative fee cap skipped: node fee estimate unavailable:', err.message)) - } - } else { - feePerBytes = await this.connector.getFeePerKilobyte(1)/1000 //Highest fee. In bitcoin context every kilobyte is 1000 bytes - nodeFeePerBytes = feePerBytes - // Clamp only the rate chosen ON THE CALLER'S BEHALF, and only on a test - // chain. nodeFeePerBytes keeps the raw estimate so the fee-drain caps - // below still anchor to what the node actually reported. - let suggestedCap = suggestedFeeCeilingPerByte(this.networkKey, SATOSHI_UNIT) - if (suggestedCap != null && feePerBytes > suggestedCap){ - // Never clamp below what the node will relay (see - // suggestedFeeCeilingFloorPerByte); the floor is coin-correct - // because it comes from the node, where the ceiling constant is not. - try { - const info = await this.connector.getNetworkInfo() - const floor = suggestedFeeCeilingFloorPerByte(info && info.relayfee) - if (floor != null && floor > suggestedCap) suggestedCap = floor - } catch (err) { - logger.warn(util.format('Suggested-fee ceiling relayfee floor unavailable; using the configured ceiling:', err.message)) - } - } - // Compare with a relative epsilon. Both sides are coin-per-byte floats - // derived by dividing by 1000 and by SATOSHI_UNIT, so a ceiling that - // EQUALS the node rate (the ordinary case on a DOGE test chain, where the - // relay-derived floor and the node rate are both ten times relayfee) can - // land one ULP above it and clamp the value to itself, logging a ceiling - // breach that did not happen. A real breach is never this close. - if (suggestedCap != null && feePerBytes > suggestedCap * (1 + 1e-12)){ - if (!this._suggestedFeeClampWarned){ - this._suggestedFeeClampWarned = true - logger.warn(`Suggested fee rate ${Math.round(feePerBytes * SATOSHI_UNIT)} per vByte exceeds the ` + - `test-chain ceiling; using ${Math.round(suggestedCap * SATOSHI_UNIT)} per vByte. ` + - `Set SUGGESTED_FEE_MAX_PER_VBYTE to change or 0 to disable.`) - } - feePerBytes = suggestedCap - } - } - - // 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 - // 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 - // backstop further down needs a caller-independent reference rate even - // when the operator disables the relative cap (multiplier 0). - if (nodeFeePerBytes == null){ - try { - const info = await this.connector.getNetworkInfo(); - const relayfee = Number(info && info.relayfee); - if (relayfee > 0) nodeFeePerBytes = relayfee / 1000; - } catch (err) { - logger.warn(util.format('Fee cap relayfee-anchor fallback failed; feePerKb cap disabled this build:', err.message)); - } - } - - // Effective fee-rate ceiling (BTC/byte): the tighter of the absolute - // MAX_FEE_RATE_KB cap and the relative multiplier × node-estimate cap. - // Bounds the per-byte rate used for fee estimation AND for sizing the - // P2SH/P2WSH funding outputs, so a hostile feePerKb cannot drain the - // caller's inputs into miner fee or over-funded data outputs. - let capFeePerBytes = this.maxFeePerBytes - if (this.maxFeeRateMultiplier && nodeFeePerBytes != null){ - const relativeCap = nodeFeePerBytes * this.maxFeeRateMultiplier - capFeePerBytes = (capFeePerBytes != null) ? Math.min(capFeePerBytes, relativeCap) : relativeCap - } - if (capFeePerBytes != null && feePerBytes > capFeePerBytes) { - logger.warn(`Fee rate ${feePerBytes * 1000 * SATOSHI_UNIT} sat/kB exceeds the fee-rate cap, clamping to ${capFeePerBytes * 1000 * SATOSHI_UNIT} sat/kB`) - feePerBytes = capFeePerBytes - } - - // A caller dust may raise the floor for this build, never lower it: a leg under the - // relay floor strands the caller's own reveal behind an unrelayable funding tx. - let finalDust = this.outputFloor - if (dust){ - finalDust = Math.max(Number(dust), this.outputFloor) - } - - // Transparent FILE payload compression, ON by default. Runs HERE, before - // the payload buffers are assembled, so everything downstream prices the - // bytes that will actually be written: the per-encoding ceiling check - // below, the size estimator, the fee quote and the encoding selection. - // - // `compress` is TRI-STATE: true/false are the caller's explicit choice, - // null/undefined take the deployment default. The distinction is not - // cosmetic. An EXPLICIT request that cannot be honoured throws, because - // the caller asked for something this payload cannot have; the DEFAULT - // pass runs over every action, most of which are not compressible FILEs, - // so the same conditions are ordinary facts and the payload rides raw - // (see compression.js's `explicit` option). Without that split, turning - // the default on would break every SEND carrying rawData. - // - // ROLLOUT: compression is consensus-safe but client-coordinated, because - // an old reader serves a compressed FILE as deflated garbage. Reader - // support must be deployed everywhere BEFORE an encoder carrying this - // default, and XCHAIN_COMPRESSION_DEFAULT=0 is the deploy-time lever that - // lets the code release and the behaviour change land separately. - const compressExplicit = (compress === true || compress === false) - const compressEnabled = compressExplicit ? compress : defaultCompressionEnabled() - let compressionResult = null - if (compressEnabled && rawData != null){ - let originalBuffer = Buffer.from(rawData, 'binary') - compressionResult = await compressPayloadForAction(data, originalBuffer, { explicit: compressExplicit }) - if (compressionResult.compressed){ - // Both halves move together: the marker and the bytes it - // describes. They must never be assigned separately. - data = compressionResult.data - rawData = compressionResult.rawData.toString('binary') - } - } - - // Refuse the single-byte shapes bitcoin.script.compile canonicalizes into - // a bare opcode, for direct library callers. The same reason as the - // round-trip guard at the head of this method: validateAll runs this - // check on the JSON-RPC path only, so a library call with rawData '\x05' - // compiled to OP_5 and the decoder dropped the byte off a fee-paid - // transaction (its element test is Buffer.isBuffer). - // - // Runs AFTER compression on purpose, unlike the guard above: this one - // measures the bytes that are about to be compiled, so a lone minimal-op - // byte that compression rewrote into a multi-byte payload is correctly - // allowed through and a payload compression rewrote is covered too. - // - // The empty-data shapes stay buildable: isMinimalOpSingleByte excludes - // the empty buffer, so the payment-only and rawData-only contracts are - // untouched and the flag-day decision they belong to is not pre-empted - // here (see the rawDataOnlyPayload advisory below). - // - // Runs on Buffer inputs too, unlike the latin-1 guard above. That guard - // is string-only because a Buffer is copied byte for byte and loses - // nothing in the conversion; canonicalization is a property of the - // COMPILED push, so a byte-for-byte-copied one-byte Buffer loses - // everything. validateActionPushDecodability normalizes either shape. - validateActionPushDecodability(data, rawData) - - // `data` is optional (openrpc.json create_tx data.required=false) and - // validateAll passes null through when omitted, but Buffer.from(null,'utf8') - // throws a Node TypeError, so a valid data-omitted request (e.g. a - // payment-only tx built from customOutputs) would fail with an opaque - // internal error. A missing payload defaults to '', identical to the - // already-supported empty-string case, which compiles cleanly downstream. - let dataBuffer = Buffer.from(data == null ? '' : data, 'utf8') - let dataToCompile = [dataBuffer] - - if (rawData != null){ - // 'binary' (Latin-1) preserves bytes 0-255 one-to-one. 'utf8' would - // corrupt arbitrary binary payloads (e.g. AES-GCM ciphertext for - // token-gated FILEs, and deflate-raw output). Existing ASCII callers - // like base64-encoded file bytes are byte-identical under both. - let rawDataBuffer = Buffer.from(rawData, 'binary') - dataToCompile.push(rawDataBuffer) - } - - let finalDataBuffer = bitcoin.script.compile(dataToCompile) - - // Does this transaction carry an XChain ACTION at all? - // - // `data` being optional already meant "payment-only tx" in the wire - // contract, but the emission path below still wrote a nulldata output - // for it: the empty payload compiled to an OP_0 push, got the 4-byte - // magic word prepended, and shipped as a magic-word-only OP_RETURN - // carrying nothing. That is a real cost (a wasted output, and a - // transaction that announces itself as XChain while containing no - // action) paid by every plain native-coin payment the wallet sends. - // A payment with nothing to say should look like an ordinary payment. - const hasActionPayload = dataBuffer.length > 0 || rawData != null - - // rawData with no `data` compiles to an OP_0-led payload, and every shipped - // decoder blanks that stream and never reads the trailing push (the arbiter gate - // in xchain-decoder/src/XChainDecoder.js, which counts it and logs it but leaves - // ACCEPTANCE unchanged on purpose). So the transaction confirms, the fee is paid, - // and the payload is never indexed as an ACTION. - // - // Reported, not refused. Whether this wire shape becomes readable end to end is a - // cross-service flag-day decision that governs the decoder gate and validator.js - // together (see isMinimalOpSingleByte), so refusing it here would settle half of a - // joint decision unilaterally and strand the decoder half when it lands. Telling - // the fee-payer before it signs needs no acceptance change at all. Drop this when - // the flag day lands. - const rawDataOnlyPayload = dataBuffer.length === 0 && rawData != null - - // Size-aware encoding selection, behind the caller's explicit AUTO opt-in. - // Runs HERE: after compression, so it prices the bytes that will really be - // written, and before the ceiling check, so an over-cap payload is refused - // against the lane that was actually chosen. - // - // Deliberately NOT the same thing as the legacy `!encoding` fallback in - // prepareData (OP_RETURN, else P2SH), which stays exactly as shipped: a - // caller who passes no encoding must keep getting today's bytes, because - // auto-selecting TAPROOT changes the response from one PSBT to a - // commit/reveal pair and no existing caller is ready for that. - if (encoding === Encoding.AUTO){ - encoding = this.selectEncoding(finalDataBuffer.length, compressedPubKey, options) - } - - // Enforce the compiled-push ceiling the indexing decoder applies, PER - // ENCODING: every legacy lane keeps the 8,192-byte - // MAX_ACTION_DATA_LENGTH (the decoder measures the compiled on-chain - // push and silently drops anything larger, so reject at encode time); - // only an explicit TAPROOT request gets the envelope ceiling. Note the - // two constants measure slightly different things: the legacy guard is - // framing-inclusive of the single on-chain push, the envelope ceiling - // measures the reassembled payload (this same compiled stream) while - // the envelope's own 520-byte push framing rides outside it. - const compiledCeiling = (encoding === Encoding.TAPROOT) ? ENVELOPE_MAX_PAYLOAD : MAX_COMPILED_ACTION_DATA_LENGTH - if (finalDataBuffer.length > compiledCeiling) { - throw new RangeError(`Payload too large: compiled size ${finalDataBuffer.length} bytes exceeds maximum ${compiledCeiling} bytes (${encoding === Encoding.TAPROOT ? 'TAPROOT envelope payload ceiling' : 'compiled on-chain ACTION push'})`) - } - - if (encoding === 'P2WSH' && this.network.supportsSegwit === false) { - throw new TypeError('P2WSH encoding is not supported on this network (no segwit support)') - } - - // Envelope availability is a property of the network definition: - // DOGE has no segwit, hence no Taproot; same gate, same error shape as - // P2WSH. The p2shHash guard exists for direct library callers (the API - // validator already rejects it): TAPROOT is a single-call flow that - // returns the commit/reveal pair together, never a second reveal call. - if (encoding === Encoding.TAPROOT) { - if (this.network.supportsSegwit === false) { - throw new TypeError('TAPROOT encoding is not supported on this network (no segwit support)') - } - if (p2shHash) { - throw new TypeError('TAPROOT encoding does not use the p2shHash reveal flow; one create_tx call returns the commit and reveal PSBTs together') - } - // Segwit support is NOT sufficient. Below the network's recognition - // height every decoder ignores an envelope reveal, so building one here - // would hand the caller a valid, broadcastable, correctly signed pair for - // an action that will never exist, and they pay real coin for it. The - // decoder's refusal is silent and correct, so nothing downstream can - // detect the loss; this is the only place it can be caught. - await this.assertEnvelopeRecognized() - ensureEccLib() - } - - let psbt = null - - // 0xfffffffd, not 1. Any value below 0xfffffffe signals RBF (BIP125), but - // bit 31 (0x80000000) is what DISABLES BIP68 relative locktime. nSequence=1 - // leaves that bit clear, so it also asserts "this input must be 1 block - // old" - invisible when spending confirmed outputs, and fatal when spending - // unconfirmed change, where the node rejects the transaction as - // `non-BIP68-final`. That made RBF and chained sends mutually exclusive: - // measured 2026-08-27 driving three chained MINTs. 0xfffffffd - // signals RBF and keeps BIP68 off, which is what Bitcoin Core itself uses. - let utxoSequence = (replacebyfee? 0xfffffffd: 0xffffffff) - // BigInt: a DOGE UTXO set can total past 2^53-1 sats, where Number - // arithmetic silently rounds the fee/change math. - let inputSatoshis = 0n - - // The P2SH/P2WSH reveal (phase 2) spends phase-1's OWN funding outputs, - // reconstructed from p2shHex further below; it never selects spendable - // UTXOs. Re-querying the tracker on the reveal is not just wasted work: - // the sender address is often already empty (phase 1 moved the funds on - // chain), so tracker lag or that empty post-phase-1 view would strand a - // mid-flow reveal. Derive inputs from the phase-1 context we were handed - // and skip the tracker entirely; fall back to it only if the phase-1 hex - // is somehow missing (an invalid reveal call). - const isReveal = !!p2shHash - let fetchedFromTracker = false - - // Exact-input mode. Normal selection sorts the candidate set - // value-descending and stops the moment the running total covers outputs - // plus fee, so a caller who names N outpoints usually gets one input. That - // makes an operator rescue of a stuck batch impossible to build here: a CPFP - // child has to descend from EVERY unconfirmed output of the stuck chain - // (including the tiny ones greedy selection will never reach), and a - // deliberate chain has to spend a named change output rather than whichever - // output happens to be largest. Exact-input mode turns selection off: the - // caller's list IS the input set, in the caller's order, all of it. - // - // It is coin-control, so it is only meaningful over a caller-supplied set. - // Refuse it rather than silently degrade when there is nothing to be exact - // about, and on the reveal path, whose inputs come from p2shHex and never - // pass through selection at all. - const exactInputs = !!(options && options.exactInputs) - if (exactInputs){ - if (isReveal){ - throw new TypeError( - 'options.exactInputs cannot be combined with p2shHash: the reveal spends the ' + - 'funding transaction\'s own outputs, which are derived from p2shHex, not selected') - } - if ((utxos == null) || (utxos.length == 0)){ - throw new TypeError( - 'options.exactInputs requires a non-empty utxos array: it names the exact input ' + - 'set to spend, so there is nothing to be exact about when the set is fetched') - } - } - - if ((utxos == null) || (utxos.length == 0)){ - if (isReveal && p2shHex){ - utxos = [] - } else { - let fetched - try { - fetched = await this.utxoTrackerConnector.getUtxosFromAddress(resolveCallerAddress(pubkey, this.network)) - } catch (err) { - // Surface a typed, credential-free operational error. A - // transport failure embeds the tracker's internal host:port, - // so upstreamErrorMessage collapses it to the generic - // fallback; the tracker's own application messages (lag, - // address-too-large) are safe and actionable and pass through. - throw new OperationalError('UTXO_TRACKER_ERROR', upstreamErrorMessage(err, 'UTXO tracker unavailable')) - } - - // Freshness gate. get_utxos carries an additive - // `sync` sibling field ({tracker_height, node_height, lag, synced, - // mempool_ready, halted?, halt_reason?}) on trackers that have picked up - // the ce16bdd freshness surface. Refuse to select from a view the tracker - // itself flags NOT synced or NOT mempool-ready, one it has halted on, or - // one whose lag is outside our own (tighter) bounds, before any input is - // chosen. - // `sync` is absent on an older tracker: fail OPEN (old behavior) rather - // than block every create_tx, since this ships ahead of every tracker - // in the fleet being upgraded. - // One classifier, shared with api.js getServeReadiness(); the ordering, - // the strict-equality fail-open rules and the operator text all live in - // classifyTrackerFreshness above. - const freshness = classifyTrackerFreshness(fetched && fetched.sync, this.maxUtxoTrackerLagBlocks) - if (freshness.code){ - throw new OperationalError(freshness.code, freshness.message, freshness.details) - } - - utxos = fetched["utxos"] - - // The tracker's utxos field must be an array. Anything else with a - // length (a string, most plausibly an error body that got read as the - // payload) walks straight past the emptiness check below and is then - // indexed element by element, so a malformed tracker response surfaced - // as "utxos[0] must be an object" and pointed the operator at a UTXO - // that does not exist instead of at the response shape. - if ((utxos != null) && !Array.isArray(utxos)){ - throw new OperationalError( - 'UTXO_TRACKER_ERROR', - `utxo-tracker returned a utxos field that is not an array (got ${typeof utxos})` - ) - } - - if ((utxos == null) || (utxos.length == 0)){ - throw new OperationalError('NO_UTXOS', "no utxos were provided and no utxos found on the blockchain") - } - fetchedFromTracker = true - - //Tracker-fetched UTXOs bypass the caller-API validation path, yet - //feed into the same PSBT construction code below. Run each through - //the same per-entry checks (64-char hex txid, integer vout/value, - //non-empty scriptPubKey hex, confirmations defaulted) so a - //malformed tracker output is rejected here instead of throwing - //deep inside bitcoinjs-lib's psbt.addInput(). Deliberately - //per-entry, NOT validateUtxoArray: the latter also enforces the - //caller-facing MAX_UTXO_COUNT cap, which must NOT gate an - //internally-fetched set (an address holding more than that many - //UTXOs would otherwise be unable to build any transaction). The - //SELECTED input count is bounded after selection instead. - for (let vi = 0; vi < utxos.length; vi++){ - validateUtxoEntry(utxos[vi], vi) - } - } - } - - // Exact-input mode promises the caller's list is the input set, so the two - // filters below (which silently SHRINK that list) have to be errors instead. - // Dropping a named mempool outpoint is the CPFP-fatal one: the whole point of - // the rescue is descending from unconfirmed outputs, and a silent drop would - // 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) - if (mempoolInput){ - throw new TypeError( - `options.exactInputs names unconfirmed utxo ${mempoolInput.txid}:${mempoolInput.vout}, ` + - 'but unconfirmed=false would drop it; pass unconfirmed: true to spend it') - } - } - const seenOutpoints = new Set() - for (const u of utxos){ - const k = u.txid + ':' + u.vout - if (seenOutpoints.has(k)){ - throw new TypeError( - `options.exactInputs names outpoint ${k} more than once; a transaction cannot spend the same output twice`) - } - seenOutpoints.add(k) - } - } - - // Remove duplicated utxos (the utxo tracker returns duplicated utxos sometimes) - // and, when unconfirmed is false, every mempool entry along with them. - // - // Single linear pass with an outpoint Set, compacting in place. It replaces a - // nested while whose inner loop re-scanned the tail for every surviving entry - // and called Array.splice on each hit: O(n^2) comparisons plus an O(n) element - // shift per removal, so a duplicate-heavy set was cubic-ish in the worst case. - // The caller-supplied path is capped at MAX_UTXO_COUNT (500), but the - // tracker-fetched path deliberately is NOT (see the per-entry validation note - // above), so a hot address with tens of thousands of outputs spent the build - // inside this loop. - // - // Semantics preserved exactly: first occurrence of an outpoint wins, relative - // order of survivors is unchanged, the mempool filter is applied BEFORE dedup - // (so an unconfirmed duplicate cannot claim the slot of a confirmed one when - // unconfirmed=false), and the array is mutated IN PLACE because downstream code - // and the caller both hold this same reference. - const seenUtxoOutpoints = new Set() - let utxoWriteIndex = 0 - 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 - // 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. - const outpointKey = nextUtxo.txid + ':' + nextUtxo.vout - if (seenUtxoOutpoints.has(outpointKey)) continue - seenUtxoOutpoints.add(outpointKey) - utxos[utxoWriteIndex++] = nextUtxo - } - utxos.length = utxoWriteIndex - - //If unconfirmed=false stripped every mempool UTXO and nothing - //confirmed remains, surface the same error as a never-funded - //address rather than crashing on utxos[0] below. The reveal path - //legitimately has an empty utxos array here (its inputs come from - //p2shHex), so only the funding/single-tx path treats empty as fatal. - if (utxos.length == 0 && !isReveal){ - throw new OperationalError('NO_UTXOS', "no utxos were provided and no utxos found on the blockchain") - } - - // Comparator, not subtraction: a >2^53-1 value is a BigInt here, and - // BigInt - Number throws. Relational operators mix the two types fine. - // Exact-input mode keeps the caller's order untouched: every named outpoint - // is spent either way, so the only thing sorting would change is WHICH one - // 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) - } - //On the reveal path utxos is empty; txidFirstInput is (re)assigned from - //p2shHex inside the data loop below before it is ever read. - // - // The OP_RETURN/MULTISIGN obfuscation key MUST bind to the txid of the input actually - // placed at ins[0]: the decoder derives its deobfuscation key from transaction.ins[0]. - // The selection loop below skips outpoints a concurrent/recent create_tx reserved - // (caller-supplied sets included), so sorted utxos[0] is NOT necessarily the first input. - // Synchronously pre-reserve the first AVAILABLE outpoint now - before the async data - // loop, so no concurrent call can claim it in between - and bind the key to it; the - // selection loop carves this outpoint out of its skip check so it is taken as ins[0]. - // Without this, a reservation skip bound the key to utxos[0] while ins[0] was utxos[1], - // the decoder failed the magic-word check, and the action silently never happened (valid - // tx, inputs spent, fee burned). - // - // The carve-out alone is NOT enough. It guarantees the pre-reserved - // outpoint is SELECTED, not that it is FIRST: the selection loop re-evaluates - // reservations against a LATER clock, and isOutpointReserved treats expiry <= now as - // free, so a foreign reservation blocking an EARLIER-sorted outpoint that lapses during - // the async data loop un-skips that outpoint and it takes ins[0] while the key stays - // bound here. Move the key-bound outpoint to the head of the selection order so ins[0] - // is correct by construction, independent of any clock. - // - // Reservation is NOT gated on fetchedFromTracker. The SDK fetches the funding - // set through get_utxos and passes it as `utxos`, so a caller-supplied set is - // the wallet's normal path; leaving it unreserved is exactly the hole that let - // two chained sends 800ms apart build the same transaction. - // - // Exact-input mode is the one shape that cannot SKIP a reserved outpoint: it - // promises every named outpoint is spent (a CPFP rescue descends from all of - // them), so dropping one would hand back a child that descends from nothing, - // the same silent shrink the unconfirmed/duplicate filters above were turned - // into errors to prevent. It still reserves - a named set is exactly the - // chained-send shape this guards against - but it refuses the build outright - // when another build holds one of the named outpoints, and it never reorders - // (the caller picked which outpoint lands at ins[0] and owns that key). - let firstReservedOutpoint = null - let txidFirstInput = null - if (utxos.length){ - if (exactInputs){ - const nowFirst = Date.now() - this.evictExpiredReservations(nowFirst) - const heldByOthers = utxos - .map((u) => u.txid + ':' + u.vout) - .filter((k) => this.isOutpointReserved(k, nowFirst)) - if (heldByOthers.length){ - throw new OperationalError( - 'INPUT_RESERVED', - `options.exactInputs names ${heldByOthers.length} outpoint(s) reserved by a transaction ` + - `built in the last ${Math.round(RESERVATION_TTL_MS / 60000)} minutes ` + - `(${heldByOthers.join(', ')}); exact-input mode cannot drop them, so broadcast that ` + - 'transaction and rebuild from the resulting view, or wait for the reservation to lapse', - { reserved: heldByOthers } - ) - } - for (const u of utxos){ - this.claimOutpoint(callReservations, u.txid + ':' + u.vout, nowFirst) - } - // No head-of-order splice: the caller's order IS the input order here, - // and firstReservedOutpoint stays null so the selection loop below - // reserves nothing twice (every outpoint is already claimed). - txidFirstInput = utxos[0]["txid"] - } else { - const nowFirst = Date.now() - this.evictExpiredReservations(nowFirst) - for (let i = 0; i < utxos.length; i++){ - const u = utxos[i] - const k = u.txid + ':' + u.vout - if (!this.isOutpointReserved(k, nowFirst)){ - this.claimOutpoint(callReservations, k, nowFirst) - firstReservedOutpoint = k - txidFirstInput = u.txid - // Head-of-order splice. The skipped entries ahead of it were all - // reserved at nowFirst, so this only reorders against outpoints this - // call was never allowed to take anyway; if one of them frees before - // selection it is still selected, just after ins[0]. Value-descending - // order among the remaining entries is preserved. - if (i > 0) utxos.unshift(utxos.splice(i, 1)[0]) - break - } - } - // Every fetched outpoint already reserved: nothing could be pre-reserved, so the - // ins[0] invariant cannot be established by construction here. Fall back to - // utxos[0] for a deterministic key. Usually the selection loop then selects - // nothing and the build surfaces the shortfall, but its own eviction pass can - // free a DIFFERENT outpoint first; the post-selection guard below catches that - // and fails closed rather than emitting a silently-undecodable action. - if (txidFirstInput === null) txidFirstInput = utxos[0]["txid"] - } - // Lowercase where the key BINDS, not only at validation: this string is the - // obfuscation key itself, the decoder's half of it always renders lowercase, - // and the ins[0] guard below compares against a lowercase hex rendering of the - // PSBT input. validateUtxoEntry canonicalizes both ingest paths, but a caller - // using the encoder as a library reaches createTransaction without it, and a - // mixed-case txid must not turn into a permanent failure wearing a retryable - // INPUT_SELECTION_RACE label. - if (txidFirstInput != null) txidFirstInput = String(txidFirstInput).toLowerCase() - } - - if (!p2shHash){//We need to prepare the data to know which inputs the p2sh will have - psbt = new bitcoin.Psbt({ network: this.network }) - } - - // With no action payload there is nothing to encode, chunk or obfuscate, - // so skip prepareData entirely and hand the emission loop an empty chunk - // list: it then writes no nulldata output and the transaction is just its - // customOutputs plus change. The encoding is reported as OP_RETURN because - // every downstream single-transaction branch keys off that value (the - // P2SH/P2WSH two-phase paths must not engage for a payment), and a caller - // that explicitly asked for a chunked encoding while supplying no payload - // has nothing to chunk regardless. - let preparedData = hasActionPayload - ? this.prepareData(finalDataBuffer, encoding, pubkey, compressedPubKey) - : { encoding: Encoding.OP_RETURN, dataBufferArray: [] } - - // P2SH/P2WSH is a two-tx flow: this funding tx (p2shHash null) creates the - // P2SH/P2WSH outputs, and a later reveal tx (p2shHash set) spends them and - // is the tx the indexer treats as the action. customOutputs (e.g. the - // native-fee protocol-fee output) must therefore be EMITTED on the reveal, - // not here on the funding tx. But the reveal's only inputs are these funding - // outputs, so the funding outputs must carry enough value for the reveal to - // pay both its miner fee and those reveal-side customOutputs. We fold the - // customOutputs total into the FIRST funding output here, and skip emitting - // customOutputs on this funding tx below (see the customOutputs block). On - // the reveal (p2shHash set) customOutputs ARE emitted, funded by this value, - // so they are paid exactly once. Single-tx encodings (OP_RETURN/MULTISIGN) - // are unaffected: they emit customOutputs directly on their only tx. - // - // Value alone is not enough. Each reveal-side customOutput also - // makes the reveal BIGGER, and the reveal's whole miner fee comes from - // these funding outputs. estimateSpendingP2shTx/estimateSpendingP2wshTx - // size the reveal as "one data input + the OP_RETURN marker" only, so a - // single-chunk reveal carrying e.g. a 34-byte native-fee output came up - // ~25 bytes short of the 1 sat/vB floor and the node rejected it with - // min-relay-fee-not-met. Multi-chunk reveals only survived by accident: - // the per-chunk estimate repeats the tx header and marker output, and - // that slack absorbed the missing bytes. So charge the bytes here too. - const isP2shFamily = preparedData["encoding"] === Encoding.P2SH || preparedData["encoding"] === Encoding.P2WSH - let revealCustomOutputsValue = 0n - let revealCustomOutputsBytes = 0 - if (!p2shHash && isP2shFamily && customOutputs && Array.isArray(customOutputs)){ - for (let i = 0; i < customOutputs.length; i++){ - revealCustomOutputsValue += BigInt(parseSatoshiAmount(customOutputs[i].value, `customOutputs[${i}].value`, { allowBig: true })) - revealCustomOutputsBytes += TxSizeEstimator.estimateOutputSizeForAddress(customOutputs[i].address, this.network) - } - } - // Round UP: a truncated fraction of a satoshi is exactly the kind of - // off-by-one that lands the reveal a hair under the relay floor. - let revealCustomOutputsFee = (revealCustomOutputsBytes > 0 && feePerBytes > 0) - ? Math.ceil(revealCustomOutputsBytes * feePerBytes * SATOSHI_UNIT) - : 0 - - let outputSatoshis = 0n - let voutPsbtIndex = 0 - let obfuscatedData - - // Reveal-headroom state for the P2SH two-phase flow. - // p2shRevealHeadroomFunded marks the one-time first-leg top-up on the - // FUNDING tx (the reveal's whole fee plus one dust for the change output - // the reveal must leave); phaseLegInputSatoshis accumulates the funding - // leg values the REVEAL spends, so the surplus sweep after the emission - // loop can price the change output it returns to the caller. - let p2shRevealHeadroomFunded = false - let phaseLegInputSatoshis = 0n - - // Reconstructed phase-1 funding tx on the reveal path, shared by the P2SH - // and P2WSH branches below (both spend its outputs by index). Hoisted to the - // loop's enclosing scope so the input-index bounds guard can reuse it, and - // parsed once (memoized via `!p2shTx`) rather than re-decoded per data chunk; - // the parse is loop-invariant (p2shHex and txidFirstInput never change here). - let p2shTx = null - - // Envelope (TAPROOT) build context, populated by the emission loop and - // consumed after input selection to construct the reveal PSBT against - // the unsigned commit's txid (stable: commit inputs are segwit-only). - let envelopeContext = null - - // Two-phase reveal prefund bookkeeping, set by whichever commit branch - // sized a whole-reveal fee into a commit output (P2SH first leg, TAPROOT - // commit output). Consumed by the package-prefund pass after the commit's - // own fee is final. See that pass for why it cannot be done in the branch. - // outputIndex - the commit output carrying the reveal's fee money - // revealSize - whole-reveal size estimate, the package's child bytes - // revealFee - the fee that output currently prefunds - let revealPrefund = null - - let estimatedTxSize = 0 - - for (let nextDataBufferIndex in preparedData["dataBufferArray"]){ - let nextDataBuffer = preparedData["dataBufferArray"][nextDataBufferIndex] - - switch (preparedData["encoding"]){ - case Encoding.OP_RETURN: - - obfuscatedData = await this.obfuscate(nextDataBuffer, txidFirstInput) - let opReturnScript = bitcoin.payments.embed({ data: [obfuscatedData] }) - - psbt.addOutput({ - script: opReturnScript.output, - value: 0 - }) - - // Oversize is handled upstream: prepareData rejects an OP_RETURN - // payload larger than chunksSize (single-OP_RETURN policy throw), - // so every obfuscatedData reaching here fits one standard nulldata - // output and this per-chunk size estimate is exact. - estimatedTxSize = estimatedTxSize - + TxSizeEstimator.estimateOpReturnOutput(obfuscatedData) - - break - case Encoding.P2SH: - if (p2shHex && !p2shTx){ - p2shTx = bitcoin.Transaction.fromHex(p2shHex) - txidFirstInput = p2shTx.getId() - assertRevealFundingTxMatches(p2shHash, txidFirstInput) - } - - if (p2shHash){ - if (!psbt){ - let opReturnData = await this.obfuscate( - Buffer.concat([ - Buffer.from(MAGIC_WORD,'utf8'), - Buffer.from("p2sh",'utf8') - ]), - txidFirstInput - ) - - psbt = new bitcoin.Psbt({ network: this.network }) - psbt.addOutput({ - script: bitcoin.payments.embed({ - data: [opReturnData] - }).output, - value: 0 - }) - - estimatedTxSize = estimatedTxSize - + TxSizeEstimator.estimateOpReturnOutput(opReturnData) - } - - if (!p2shTx || !p2shTx.outs || voutPsbtIndex >= p2shTx.outs.length) { - throw new RangeError(`p2shHex transaction does not have output at index ${voutPsbtIndex}`) - } - let nextInput = { - sequence: utxoSequence, - hash:p2shHash, - redeemScript:nextDataBuffer, - index: voutPsbtIndex, - nonWitnessUtxo:Buffer.from(p2shHex, 'hex') - } - - psbt.addInput(nextInput) - estimatedTxSize = estimatedTxSize + TxSizeEstimator.estimateInputSize(nextInput) - // Leg value this reveal input consumes; the surplus sweep - // after the emission loop needs the total to price the - // change output. - phaseLegInputSatoshis = phaseLegInputSatoshis + BigInt(p2shTx.outs[voutPsbtIndex].value) - - voutPsbtIndex = voutPsbtIndex + 1 - } else { - let spendingP2shEstimatedSize = this.estimateSpendingP2shTx(nextDataBuffer) - let spendingP2shEstimatedFee = Math.trunc((spendingP2shEstimatedSize * feePerBytes) * SATOSHI_UNIT) - - if (spendingP2shEstimatedFee < finalDust){ - spendingP2shEstimatedFee = finalDust - } - - // Over-fund the first funding output by the reveal-side - // customOutputs total, plus the miner fee for the bytes - // they add to the reveal, so the reveal can pay them - // (consumed once). - if (revealCustomOutputsValue > 0n || revealCustomOutputsFee > 0){ - spendingP2shEstimatedFee = asSatValue(BigInt(spendingP2shEstimatedFee) + revealCustomOutputsValue + BigInt(revealCustomOutputsFee)) - revealCustomOutputsValue = 0n - revealCustomOutputsFee = 0 - } - - // 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 - // any output it leaves - must be prefunded here. Sizing - // each leg at max(per-chunk fee share, dust floor) alone - // left the reveal unable to leave a single output on - // Dogecoin, where the dust floor (100000 koinu) is the - // whole leg and the reveal's own required fee consumes - // it: the SDK's FULL_BURN_FEE guard then (correctly) - // refused to sign and the confirmed phase-1 commit was - // stranded. Top the FIRST leg up (consumed once, like - // the customOutputs fold above) so the legs total covers - // the whole reveal's fee AS THE REVEAL ITSELF WILL PRICE - // IT (estimateP2shRevealTx, shared by both phases), plus - // one dust (this.dustAmount, the same constant the - // reveal-side sweep compares against) for the change - // output the reveal returns to the caller. The 43 is the - // same worst-case change-output size constant the - // single-tx path reserves below. - // - // That change output is needed ONLY when the reveal would - // otherwise emit no value output at all. A reveal already - // carrying customOutputs (the native protocol fee) leaves - // value by definition, so the burn guard is satisfied and - // funding a change output too would buy the caller a - // third output they did not ask for - which is what REG-14 - // pins against on the legacy-fee-output case. So the fee - // base keeps the 43-byte change slot either way - both - // shapes stay priced identically, which is the other half - // REG-14 pins - and only the dust that would make a change - // 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. - if (!p2shRevealHeadroomFunded){ - p2shRevealHeadroomFunded = true - let needsChangeOutput = (revealCustomOutputsBytes === 0) - let baseLegsTotal = 0 - for (const chunkBuffer of preparedData["dataBufferArray"]){ - let chunkLegFee = Math.trunc((this.estimateSpendingP2shTx(chunkBuffer) * feePerBytes) * SATOSHI_UNIT) - baseLegsTotal = baseLegsTotal + Math.max(chunkLegFee, finalDust) - } - let revealFeeNeeded = Math.trunc((this.estimateP2shRevealTx(preparedData["dataBufferArray"], 43) * 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 - } - 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) - // 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), - revealFee: revealFeeNeeded - } - } - - psbt.addOutput({ - address: bitcoin.payments.p2sh({ redeem: {output:nextDataBuffer}, network:this.network}).address, - value:spendingP2shEstimatedFee - }) - - outputSatoshis = outputSatoshis + BigInt(spendingP2shEstimatedFee) - - estimatedTxSize = estimatedTxSize + TxSizeEstimator.estimateP2shOutput() - } - - break - case Encoding.P2WSH: - if (p2shHex && !p2shTx){ - p2shTx = bitcoin.Transaction.fromHex(p2shHex) - txidFirstInput = p2shTx.getId() - assertRevealFundingTxMatches(p2shHash, txidFirstInput) - } - - if (p2shHash){ - if (!psbt){ - psbt = new bitcoin.Psbt({ network: this.network }) - psbt.addOutput({ - script: bitcoin.payments.embed({ - data: [ - await this.obfuscate( - Buffer.concat([ - Buffer.from(MAGIC_WORD,'utf8'), - Buffer.from("p2wsh",'utf8') - ]), - txidFirstInput - ) - ] - }).output, - value: 0 - }) - } - - if (!p2shTx || !p2shTx.outs || voutPsbtIndex >= p2shTx.outs.length) { - throw new RangeError(`p2shHex transaction does not have output at index ${voutPsbtIndex}`) - } - let nextInput = { - sequence: utxoSequence, - hash:p2shHash, - witnessScript:nextDataBuffer, - index: voutPsbtIndex, - witnessUtxo:{ - script:p2shTx["outs"][voutPsbtIndex]["script"], - value:p2shTx["outs"][voutPsbtIndex]["value"] - } - } - psbt.addInput(nextInput) - - estimatedTxSize = estimatedTxSize + TxSizeEstimator.estimateInputSize(nextInput) - voutPsbtIndex = voutPsbtIndex + 1 - } else { - // 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 - // the multi-input reveal tx below the node's min-relay-fee - // floor (observed on bitcoin-regtest, whose dustThreshold - // is 546: 1638 sat across 3 dust outputs vs a ~2228 sat - // 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) - let spendingP2wshEstimatedFee = Math.trunc((spendingP2wshEstimatedSize * feePerBytes) * SATOSHI_UNIT) - - if (spendingP2wshEstimatedFee < finalDust){ - spendingP2wshEstimatedFee = finalDust - } - - // Chains such as Litecoin reject a reveal tx whose - // stripped (non-witness) serialization is below their - // relay floor (minStandardTxNonWitnessSize). A reveal - // spending these data outputs is 10 (header) + 41 per - // P2WSH input + ~20 (OP_RETURN marker) stripped bytes; a - // single-chunk reveal is only 71 bytes, under Litecoin's - // 85-byte floor. The reveal builder clears the floor with - // one extra small payment output (see the p2shHash branch - // 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){ - // 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 - // could over/under-fund the pad when a caller passes a custom - // dust, leaving the two halves of the flow inconsistent. - spendingP2wshEstimatedFee = spendingP2wshEstimatedFee + this.dustAmount - } - - // Over-fund the first funding output by the reveal-side - // customOutputs total, plus the miner fee for the bytes - // they add to the reveal, so the reveal can pay them - // (consumed once). - if (revealCustomOutputsValue > 0n || revealCustomOutputsFee > 0){ - spendingP2wshEstimatedFee = asSatValue(BigInt(spendingP2wshEstimatedFee) + revealCustomOutputsValue + BigInt(revealCustomOutputsFee)) - revealCustomOutputsValue = 0n - revealCustomOutputsFee = 0 - } - - psbt.addOutput({ - address: bitcoin.payments.p2wsh({ redeem: {output:nextDataBuffer}, network:this.network}).address, - value:spendingP2wshEstimatedFee - }) - // Account for the data output's value so change is not - // over-credited. Without this, changeSatoshis below is - // computed as input - 0 - fee, so the data outputs are - // funded "for free" and total outputs exceed total - // inputs; bitcoinjs rejects tx1 with "Outputs are - // spending more than Inputs". Mirrors the P2SH and - // MULTISIGN branches, which already track their outputs. - outputSatoshis = outputSatoshis + BigInt(spendingP2wshEstimatedFee) - - estimatedTxSize = estimatedTxSize - + TxSizeEstimator.estimateP2wshOutput() - } - - break - case Encoding.MULTISIGN: - obfuscatedData = await this.obfuscate(nextDataBuffer, txidFirstInput) - let pubkey1 = await this.dataToPubkey(obfuscatedData.slice(0, 32)) - let pubkey2 = await this.dataToPubkey(obfuscatedData.slice(32, obfuscatedData.length)) - let pubkey3 = Buffer.from(compressedPubKey,"hex") - - let pubkeys = [ - pubkey1, - pubkey2, - pubkey3 - ] - - let multisignScript = bitcoin.payments.p2ms( - { - m:1, //We only need one signature - pubkeys: pubkeys, - network: this.network - } - ) - - // A bare multisig output is larger than a P2PKH, so the P2PKH - // dust floor (this.dustAmount, read per network from the coin - // bundle's dustThreshold: BTC 546, LTC 5460, DOGE 100000) is - // 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) - let multisigOutputValue = Math.max(finalDust, bareMultisigDust) - - psbt.addOutput({ - script: multisignScript.output, - value: multisigOutputValue - }) - // Account for the data output's value so change is not over-credited - // (otherwise total outputs exceed total inputs and the tx is invalid). - outputSatoshis += BigInt(multisigOutputValue) - - estimatedTxSize = estimatedTxSize - + TxSizeEstimator.estimateMultisignOutput() - - break - case Encoding.TAPROOT: { - // nextDataBuffer IS the whole envelope tapscript (single - // element). One commit output carries it; the reveal is - // built after input selection, once the commit txid exists. - const internalPubkey = preparedData["internalPubkey"] - const commitPayment = bitcoin.payments.p2tr({ - internalPubkey, - scriptTree: { output: nextDataBuffer }, - network: this.network - }) - - // The reveal's only funding is the commit output, so its - // value must prefund the reveal's miner fee plus a change - // output at the dust floor (the reveal must carry at least - // one output; change doubles as the CPFP handle), plus - // the stripped-size floor pad where the chain demands one - // (LTC: a reveal whose only output is small sits under - // minStandardTxNonWitnessSize, exactly like the P2WSH - // reveal; the payload lives in the witness and does not - // count toward stripped size). - const revealChangeAddress = change || resolveCallerAddress(pubkey, this.network) - const revealChangeOutBytes = TxSizeEstimator.estimateOutputSizeForAddress(revealChangeAddress, this.network) - const envStrippedFloor = this.network.minStandardTxNonWitnessSize - const revealPadNeeded = !!(envStrippedFloor && (10 + 41 + revealChangeOutBytes) < envStrippedFloor) - const revealOutputsBytes = revealChangeOutBytes + (revealPadNeeded ? revealChangeOutBytes : 0) - const revealVsize = TxSizeEstimator.estimateEnvelopeRevealTx(nextDataBuffer.length, revealOutputsBytes, envStrippedFloor) - let revealFee = Math.trunc(revealVsize * feePerBytes * SATOSHI_UNIT) - if (revealFee < finalDust){ - revealFee = finalDust - } - const commitValue = revealFee + this.dustAmount + (revealPadNeeded ? this.dustAmount : 0) - - revealPrefund = { - outputIndex: psbt.txOutputs.length, - revealSize: revealVsize, - revealFee - } - - psbt.addOutput({ - script: commitPayment.output, - value: commitValue - }) - outputSatoshis = outputSatoshis + BigInt(commitValue) - estimatedTxSize = estimatedTxSize + TxSizeEstimator.estimateTaprootOutput() - - envelopeContext = { - internalPubkey, - envelopeScript: nextDataBuffer, - commitPayment, - commitValue, - revealFee, - revealPadNeeded, - revealChangeAddress - } - break - } - } - } - - // 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 - // the funding outputs above so the reveal can pay them, and the reveal - // (p2shHash set) emits them. Emitting here too would put the output on the - // wrong tx (the indexer reads the reveal) and double-pay. Single-tx - // encodings and the reveal itself fall through and emit normally. - const skipCustomOutputs = !p2shHash && isP2shFamily - if (!skipCustomOutputs && customOutputs && Array.isArray(customOutputs)) { - for (let i = 0; i < customOutputs.length; i++) { - const output = customOutputs[i] - const outputValue = parseSatoshiAmount(output.value, `customOutputs[${i}].value`, { allowBig: true }) - // A 0-sat caller output is consensus-valid but relay-rejected as - // dust, so the caller signs an unbroadcastable PSBT. Reject it at - // 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. - if (outputValue <= 0) { - throw new RangeError(`customOutputs[${i}].value must be a positive integer (satoshis)`) - } - psbt.addOutput({ - address: output.address, - value: outputValue - }) - outputSatoshis += BigInt(outputValue) - estimatedTxSize += 43 // Taproot output size estimate (most expensive) - } - } - - estimatedTxSize = estimatedTxSize + 43 // change output (worst case: taproot) - - let estimatedFee = 0 - if (fee != null && fee !== false) { - // Re-parse with the same exact-integer arbiter the validator uses - // (toExactInt-based), not parseInt: parseInt silently truncates a - // fractional value ("100.5" -> 100) for direct library callers that - // 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') - } - - 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. - 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 - if (!p2shHash){//The p2sh input is already created before - const now = Date.now() - this.evictExpiredReservations(now) - let nextUtxoIndex = 0 - while (nextUtxoIndex < utxos.length){ - let nextUtxo = utxos[nextUtxoIndex] - - // Envelope commit inputs MUST be segwit. The - // reveal is pre-built against the UNSIGNED commit's txid, which - // is only stable when no selected input's signature lands in - // the txid-covered serialization. Enforced as native-segwit - // (witness-program scriptPubKey): a P2SH-wrapped segwit UTXO is - // indistinguishable from plain P2SH here and its scriptSig - // (redeem-script push) shifts the txid, so it is refused too -- - // stricter than the spec's floor, never looser. Fail closed - // rather than skip: silently dropping a caller's coin-control - // input would change what they spend. - if (preparedData["encoding"] === Encoding.TAPROOT && !this.isSegwitUTXO(nextUtxo)){ - throw new TypeError( - `TAPROOT commit inputs must be native-segwit UTXOs (witness-program scriptPubKey); ` + - `utxo ${nextUtxo.txid}:${nextUtxo.vout} is not. A non-segwit input would shift the ` + - `commit txid at signing time and strand the pre-built reveal.`) - } - - // Refuse a witness-program input on a chain whose consensus rules have - // no segwit: there the output is anyone-can-spend and a witnessUtxo - // input signs nothing the network enforces. Fail closed, never skip. - if (this.network.supportsSegwit === false && this.isSegwitUTXO(nextUtxo)){ - throw new TypeError( - `Input ${nextUtxo.txid}:${nextUtxo.vout} carries a witness-program scriptPubKey, ` + - `which this network does not support (no segwit). Spend legacy inputs on this chain.`) - } - - // Double-spend guard: skip any outpoint another create_tx claimed - // within RESERVATION_TTL_MS, and reserve the ones we take. Two - // calls for one address would otherwise both pick the largest - // UTXOs and build conflicting double-spends (or, with identical - // outputs, the identical transaction). Reserve synchronously here - // (before the getTransactionHex await below) so a concurrent call - // observes the claim. Applies to caller-supplied sets too: the SDK - // and wallet hand over a tracker-fetched set as `utxos`, and the - // tracker keeps publishing a spent input until it sees the spend, - // so a chained send re-supplies the input the previous build took. - // A caller that truly wants to respend a reserved input (an RBF - // bump) waits out the TTL or restarts the encoder; that is the - // price of never building the same spend twice. - // Exact-input mode settled reservations up front: it claimed every - // named outpoint, or refused the build. Re-checking here would see - // this call's OWN claims and skip the whole set. - if (!exactInputs){ - const outpointKey = nextUtxo.txid + ':' + nextUtxo.vout - // Skip outpoints reserved by OTHER calls, but NOT the one this - // call pre-reserved for ins[0] above (the obfuscation key binds to it). - if (outpointKey !== firstReservedOutpoint && this.isOutpointReserved(outpointKey, now)){ - reservedCandidates = reservedCandidates + 1 - nextUtxoIndex = nextUtxoIndex + 1 - continue - } - if (outpointKey !== firstReservedOutpoint){ - this.claimOutpoint(callReservations, outpointKey, now) - } - } - - nextUtxo.value = parseSatoshiAmount(nextUtxo.value, `utxos[${nextUtxoIndex}].value`, { allowBig: true }) - - if (this.isSegwitUTXO(nextUtxo)){ - let nextInput = { - hash: nextUtxo.txid, - index: nextUtxo.vout, - sequence: utxoSequence, - witnessUtxo: { - script: Buffer.from(nextUtxo.scriptPubKey, 'hex'), - value: nextUtxo.value, - } - } - // 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 - // the PSBT's own txid. witnessUtxo alone left the device - // unable to sign (and, before it failed closed, signing a - // synthesized outpoint that does not exist). Opt-in: it is - // one node round trip and one prev tx of PSBT weight per - // input, which only that caller should pay. Attached NOW, - // at build time, so the PSBT the user previews is the one - // that gets signed - hydrating it later would break the - // byte-identity guarantee the confirm surface rests on. - if (attachPrevTx) { - const prevTxHex = await this.connector.getTransactionHex(nextUtxo.txid) - nextInput.nonWitnessUtxo = Buffer.from(prevTxHex, 'hex') - } - psbt.addInput(nextInput) - estimatedTxSize = estimatedTxSize + TxSizeEstimator.estimateInputSize(nextInput) - inputSatoshis = inputSatoshis + BigInt(nextUtxo.value) - } else { - let wholeUtxoHex = await this.connector.getTransactionHex(nextUtxo.txid) - let nextInput = { - hash: nextUtxo.txid, - index: nextUtxo.vout, - sequence: utxoSequence, - nonWitnessUtxo: Buffer.from(wholeUtxoHex, 'hex') - } - psbt.addInput(nextInput) - estimatedTxSize = estimatedTxSize + TxSizeEstimator.estimateInputSize(nextInput) - inputSatoshis = inputSatoshis + BigInt(nextUtxo.value) - } - - selectedInputCount = selectedInputCount + 1 - - if (nextUtxo.confirmations == 0){ - unconfirmedInputTxids.push(nextUtxo.txid) - } - - if (fee == null || fee === false) { - estimatedFee = Math.trunc(estimatedTxSize * feePerBytes * SATOSHI_UNIT) - } - - // Exact-input mode never stops early: the caller named this set - // because the transaction has to descend from all of it (a CPFP - // rescue) or spend a specific output (a deliberate chain). Stopping - // 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)){ - break - } - - nextUtxoIndex = nextUtxoIndex + 1 - } - - // 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 - // genuinely needs more inputs than this is over standardness size and - // would be rejected at broadcast, so fail here with a precise reason. - if (selectedInputCount > MAX_UTXO_COUNT){ - throw new RangeError(`selected input count (${selectedInputCount}) exceeds the maximum (${MAX_UTXO_COUNT}) inputs for a single transaction`) - } - - // No spendable input was selected on the funding/single-tx path: the - // set was empty of usable outputs or every candidate is reserved by a - // concurrent selection. Report insufficient funds here, before - // the fee-rate cap math below, which would otherwise reject a fixed - // fee against a zero-input transaction's tiny size and mask the real - // cause. (The reveal path has p2shHash set and never reaches here.) - if (selectedInputCount === 0){ - // Name the real cause when the inputs exist but are spoken for: a - // chained send that re-supplied an input the previous build took - // is not an empty address, and the caller's fix is to wait for - // that spend to reach the tracker, not to fund the address. - const allReserved = reservedCandidates > 0 - throw new OperationalError( - 'INSUFFICIENT_FUNDS', - allReserved - ? `insufficient funds: all ${reservedCandidates} candidate input(s) are reserved by a transaction ` + - `built in the last ${Math.round(RESERVATION_TTL_MS / 60000)} minutes; broadcast that transaction ` + - 'and wait for its change to appear, or wait for the reservation to lapse' - : 'insufficient funds: no spendable inputs available (all candidates reserved or empty)', - { required: jsonSafeSat(outputSatoshis + BigInt(estimatedFee)), available: 0, outputs: jsonSafeSat(outputSatoshis), fee: estimatedFee, reservedCandidates } - ) - } - - // Fail-closed ins[0] invariant. OP_RETURN and MULTISIGN - // obfuscate their payload with txidFirstInput, and the decoder derives its - // deobfuscation key from the first input's txid, so the action only decodes if - // that outpoint actually landed at ins[0]. The head-of-order splice above makes - // that true by construction whenever an outpoint could be pre-reserved; it - // cannot when EVERY fetched outpoint was already reserved and one of them then - // freed before selection. Fail here so the caller retries, instead of returning - // a valid transaction whose action silently decodes to nothing (inputs spent, - // fee burned). P2SH/P2WSH are excluded: on the funding tx they do not use - // txidFirstInput at all, and on the reveal tx (p2shHash set) this block does not - // run and the key is re-bound to the phase-1 txid. - // A payment-only transaction has no action, hence no - // obfuscation key bound to the first input. The guard below exists to - // stop an action from silently decoding to nothing; with nothing to - // decode it would only fail a perfectly good payment whose input - // selection shifted under a concurrent reservation. - const keyBindsToFirstInput = - hasActionPayload && ( - preparedData["encoding"] === Encoding.OP_RETURN || - preparedData["encoding"] === Encoding.MULTISIGN - ) - if (keyBindsToFirstInput && txidFirstInput != null){ - // psbt.txInputs[0].hash is the internal little-endian outpoint hash; copy - // before reversing so the PSBT's own buffer is not mutated. - const actualFirstTxid = Buffer.from(psbt.txInputs[0].hash).reverse().toString('hex') - if (actualFirstTxid !== txidFirstInput){ - throw new OperationalError( - 'INPUT_SELECTION_RACE', - 'input selection raced a concurrent reservation: the obfuscation key is bound to an outpoint that is not the first input; retry the request', - { expectedFirstInput: txidFirstInput, actualFirstInput: actualFirstTxid } - ) - } - } - } - - // 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 - // they are paying. Silently lowering it would change what they sign, so - // refuse loudly instead. Without this, fee values up to the gross - // validator limit drain every selected input into miner fee. - if (fee != null && fee !== false && capFeePerBytes != null){ - const maxFeeSatoshis = Math.max(this.dustAmount, Math.ceil(estimatedTxSize * capFeePerBytes * SATOSHI_UNIT)) - if (estimatedFee > maxFeeSatoshis){ - throw new RangeError(`fee ${estimatedFee} exceeds the maximum allowed ${maxFeeSatoshis} satoshis for a ~${estimatedTxSize}-byte transaction (fee-rate cap)`) - } - } - - // Absolute burn backstop that holds even when the operator disables the - // rate cap (capFeePerBytes == null) and even when a change address is - // supplied (which skips the burn guard below). An explicit fee above 100x - // the node-derived fair fee for this size is almost certainly an error that - // would drain every selected input to the miner, so refuse it. 100x matches - // the default MAX_FEE_RATE_MULTIPLIER, so default deployments are unaffected. - // - // The fair-fee reference is the NODE's rate (nodeFeePerBytes), never the - // caller-supplied feePerKb: deriving the ceiling from feePerBytes let a - // caller inflate feePerKb to lift the ceiling with it, bypassing the - // backstop entirely whenever the rate cap was disabled. feePerBytes is - // used only as a last resort when the node can produce neither an - // estimate nor a relayfee (same graceful degradation as the relative - // cap, and the caller cannot cause that condition). - if (fee != null && fee !== false){ - const referenceFeePerBytes = nodeFeePerBytes != null ? nodeFeePerBytes : feePerBytes - if (referenceFeePerBytes != null){ - const fairFee = Math.ceil(estimatedTxSize * referenceFeePerBytes * SATOSHI_UNIT) - const hardCeiling = Math.max(this.dustAmount, fairFee * 100) - if (estimatedFee > hardCeiling){ - throw new RangeError(`fee ${estimatedFee} exceeds 100x the estimated fair fee (${fairFee} satoshis) for a ~${estimatedTxSize}-byte transaction`) - } - } - } - - // CPFP-aware package sizing. - // - // A miner fills a block by ANCESTOR fee rate, so a transaction spending - // unconfirmed inputs is only mined when its whole mempool package clears - // the target. Paying the target on this transaction's own bytes is not - // enough: a PRICE batch paying 0.01017 DOGE/kB sat unmined because its - // funding ancestors pay 0.00313 DOGE/kB, putting the package at 0.00896, - // under Dogecoin's 0.01 DOGE/kB inclusion floor. Those inputs do not - // signal RBF, so the fee cannot be replaced afterwards; it has to be - // right at build time. Lift this fee to carry the package instead. - // - // Placed after the caller-fee checks above so the caller's own fee is - // still judged as supplied, and every ceiling those checks enforce is - // re-applied here to the uplifted total. Any failure at all (no ancestor - // data, an unreachable node, a connector without the method) leaves the - // per-transaction fee exactly as it was. - const cpfpUpliftBound = maxCpfpUpliftSat() - // Whatever unconfirmed ancestors this transaction inherits, carried out of - // the block below so the two-phase prefund pass can price the reveal - // against the WHOLE chain (ancestors + commit + reveal), not just against - // the commit. They stay 0 when nothing was measured. - let commitAncestorSize = 0 - let commitAncestorFeeSat = 0 - if (unconfirmedInputTxids.length > 0 && feePerBytes > 0 && cpfpUpliftBound > 0 && - this.connector && typeof this.connector.getUnconfirmedAncestorPackage === 'function'){ - let ancestorPackage = null - try { - ancestorPackage = await this.connector.getUnconfirmedAncestorPackage(unconfirmedInputTxids) - } catch (err) { - logger.warn(util.format('Package fee sizing skipped: ancestor lookup failed:', err.message)) - } - if (ancestorPackage && Number.isFinite(Number(ancestorPackage.size)) && Number(ancestorPackage.size) > 0){ - commitAncestorSize = Number(ancestorPackage.size) - commitAncestorFeeSat = Math.round(Number(ancestorPackage.fees) * SATOSHI_UNIT) - if (!Number.isFinite(commitAncestorFeeSat) || commitAncestorFeeSat < 0){ - commitAncestorSize = 0 - commitAncestorFeeSat = 0 - } - } - const wanted = ancestorPackage ? packageFeeUpliftSatoshis({ - currentFee: estimatedFee, - txSize: estimatedTxSize, - ancestorSize: ancestorPackage.size, - ancestorFees: ancestorPackage.fees, - targetFeePerBytes: feePerBytes, - satoshiUnit: SATOSHI_UNIT - }) : 0 - - if (wanted > 0){ - // Its own absolute bound first: the uplift buys ancestor bytes, and - // the rate caps below only ever measure a fee against THIS - // transaction's size. - let allowed = Math.min(wanted, cpfpUpliftBound) - - if (capFeePerBytes != null){ - const maxFeeSatoshis = Math.max(this.dustAmount, Math.ceil(estimatedTxSize * capFeePerBytes * SATOSHI_UNIT)) - allowed = Math.min(allowed, maxFeeSatoshis - estimatedFee) - } - - const referenceFeePerBytes = nodeFeePerBytes != null ? nodeFeePerBytes : feePerBytes - if (referenceFeePerBytes != null){ - const fairFee = Math.ceil(estimatedTxSize * referenceFeePerBytes * SATOSHI_UNIT) - allowed = Math.min(allowed, Math.max(this.dustAmount, fairFee * 100) - estimatedFee) - } - - // Never spend an input that is not there. Without this bound a - // package uplift would turn a fundable build into INSUFFICIENT_FUNDS, - // which is a worse outcome than a transaction that confirms late. - if (!p2shHash){ - const headroom = inputSatoshis - outputSatoshis - BigInt(estimatedFee) - if (headroom <= 0n) allowed = 0 - else if (allowed > 0 && headroom < BigInt(allowed)) allowed = Number(headroom) - } - - if (allowed < 0) allowed = 0 - - if (allowed < wanted){ - const packageSize = ancestorPackage.size + estimatedTxSize - const packageFee = Math.round(ancestorPackage.fees * SATOSHI_UNIT) + estimatedFee + allowed - logger.warn(`Package fee uplift clamped to ${allowed} of ${wanted} base units: this transaction ` + - `spends ${unconfirmedInputTxids.length} unconfirmed input(s) whose ancestors total ` + - `${ancestorPackage.size} bytes. The package will pay ${Math.round(packageFee / packageSize * 1000)} ` + - `base units/kB against a target of ${Math.round(feePerBytes * SATOSHI_UNIT * 1000)}, so it may ` + - `stay unmined. Raise MAX_CPFP_UPLIFT_SAT, MAX_FEE_RATE_KB or the input balance to close the gap.`) - } - - if (allowed > 0){ - estimatedFee = estimatedFee + allowed - } - } - } - - if (estimatedFee < this.dustAmount){ - estimatedFee = this.dustAmount - } - - // Two-phase package prefund. - // - // A reveal spends nothing but the commit's own outputs, so the two are - // ALWAYS one mempool package and the miner judges them on the package's - // ancestor fee rate. The reveal's fee is money the commit set aside, and - // the reveal has no other input to raise it from, so a commit that ends - // up under the target rate drags the whole package under it and the - // reveal cannot rescue itself: the pair sits unmined with a confirmed - // commit stranded behind it. This is the shape a PRICE batch publish - // takes, and the commit lands under target whenever the caller supplies - // an explicit fee below the node's rate, or its own CPFP uplift was - // clamped (see the warning above), or its size estimate ran short. - // - // So prefund the reveal at the PACKAGE rate rather than at its own: the - // reveal carries the commit's shortfall, plus the shortfall of whatever - // unconfirmed ancestors the commit inherits. The money comes out of - // change, not out of thin air, and the commit's own fee is untouched - - // the pair's total is what a miner weighs, and only the reveal can still - // be raised at this point. - // - // This cannot live in the emission branch that sized the commit output: - // input selection, the caller-fee ceilings and the CPFP uplift all run - // after it, and the commit's fee and size are only final here. - if (revealPrefund && !p2shHash && feePerBytes > 0 && cpfpUpliftBound > 0){ - const wanted = packageFeeUpliftSatoshis({ - currentFee: revealPrefund.revealFee, - txSize: revealPrefund.revealSize, - ancestorSize: estimatedTxSize + commitAncestorSize, - ancestorFeeSatoshis: estimatedFee + commitAncestorFeeSat, - targetFeePerBytes: feePerBytes, - satoshiUnit: SATOSHI_UNIT - }) - - if (wanted > 0){ - // The same absolute bound the single-tx uplift answers to: this - // buys someone else's bytes, so no rate cap measured against one - // transaction's size can bound it. - let allowed = Math.min(wanted, cpfpUpliftBound) - - // Never spend an input that is not there. The prefund is paid out - // of change, so it is bounded by what change actually holds; - // turning a fundable build into INSUFFICIENT_FUNDS would be a - // worse outcome than a package that confirms late. - const headroom = inputSatoshis - outputSatoshis - BigInt(estimatedFee) - if (headroom <= 0n) allowed = 0 - else if (headroom < BigInt(allowed)) allowed = Number(headroom) - if (allowed < 0) allowed = 0 - - if (allowed < wanted){ - const packageSize = commitAncestorSize + estimatedTxSize + revealPrefund.revealSize - const packageFee = commitAncestorFeeSat + estimatedFee + revealPrefund.revealFee + allowed - logger.warn(`Reveal package prefund clamped to ${allowed} of ${wanted} base units: this commit ` + - `pays ${estimatedFee} base units over ~${estimatedTxSize} bytes, so the commit/reveal package ` + - `will pay ${Math.round(packageFee / packageSize * 1000)} base units/kB against a target of ` + - `${Math.round(feePerBytes * SATOSHI_UNIT * 1000)}. The reveal cannot raise its own fee later, ` + - `so the pair may stay unmined. Raise MAX_CPFP_UPLIFT_SAT, the commit's fee, or the input balance.`) - } - - if (allowed > 0){ - this.raiseOutputValue(psbt, revealPrefund.outputIndex, allowed) - outputSatoshis = outputSatoshis + BigInt(allowed) - revealPrefund.revealFee = revealPrefund.revealFee + allowed - // The envelope reveal is built in THIS call against these two - // numbers, so both have to move with the output or its change - // output would silently hand the uplift back to the caller. - if (envelopeContext){ - envelopeContext.commitValue = envelopeContext.commitValue + allowed - envelopeContext.revealFee = envelopeContext.revealFee + allowed - } - } - } - } - - // Validate the fee BEFORE the BigInt conversion below: a NaN/Infinity - // fee would previously surface via the Number.isFinite(changeSatoshis) - // check, but BigInt(estimatedFee) throws an opaque error on those. - if (!Number.isFinite(estimatedFee) || !Number.isInteger(estimatedFee)) { - throw new RangeError('Fee calculation produced invalid result. Check that all UTXO values and fees are valid integers.') - } - - // Exact change math in BigInt: with a >2^53-1-sat input, Number - // subtraction would round the change (and the caller would sign a - // PSBT whose change is off by up to ~2 sats per 2^53). - let changeSatoshis = inputSatoshis - outputSatoshis - BigInt(estimatedFee) - - // Reject a genuinely under-funded selection instead of returning an - // unbroadcastable PSBT whose outputs exceed its inputs (the caller would - // sign it and the network would reject it). Scoped to the funding/single - // tx path: the reveal (p2shHash set) funds itself from phase-1 outputs - // and never runs input selection, so inputSatoshis is 0 there by design - // and a negative "change" is expected and harmless. - if (!p2shHash && changeSatoshis < 0) { - const required = outputSatoshis + BigInt(estimatedFee) - // Name the reserved candidates when there were any: the shortfall then - // comes from inputs another build of the last RESERVATION_TTL_MS still - // holds, not from an under-funded address, and the caller's fix is to - // broadcast that build or wait, not to fund the address. Found live on - // TDOGE: two spendable outputs plus dust, two un-broadcast - // builds, and the third build reported the dust output as the whole - // balance, which the wallet rendered as "not enough funds". Same - // wording as the zero-selected branch above so one reader handles both. - const held = reservedCandidates > 0 - ? `; ${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` - : '' - 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 } - ) - } - - if ((changeSatoshis > this.dustAmount) && !change) { - throw new OperationalError('CHANGE_ADDRESS_REQUIRED', 'Transaction would burn significant satoshis as fees. Please provide a change address.') - } - - if ((changeSatoshis > 0) && (change)) { - // Emit change only at or above the relay floor for authored outputs; below it the - // output is non-standard or soft dust that makes the transaction unrelayable, so it - // folds into the miner fee instead. - if (changeSatoshis >= this.outputFloor) { - psbt.addOutput({ - address: change, - value: asSatValue(changeSatoshis) - }) - } - } - - // A P2WSH reveal that spends a single data chunk is just 1 input + 1 - // OP_RETURN output: 71 stripped (non-witness) bytes, which is BELOW the - // relay floor on every chain we support. The floor is a POLICY constant - // (Bitcoin Core's MIN_STANDARD_TX_NONWITNESS_SIZE = 82, Litecoin ~85), - // NOT the 65-byte CONSENSUS minimum that guards the 64-byte-transaction - // CVE. coins/BTC.js carried that consensus 65 until 2026-07-31, so this - // pad never fired on Bitcoin and a single-chunk P2WSH reveal was - // unbroadcastable there (measured live: 71 stripped bytes rejected - // "tx-size-small", 82 accepted). Corrected to 82 with the consensus pin - // regenerated across every repo vendoring BTC.js. - // - // The payload lives in the witness and does not count toward stripped - // size, so this shape stays small however large the file is. Lift the - // reveal over the chain's floor with one - // small payment output back to the caller's own address. A second - // OP_RETURN would be non-standard (multi-op-return), so we cannot pad - // with that. The funding tx already over-funded this reveal by one dust - // (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){ - psbt.addOutput({ - address: padAddress, - value: this.dustAmount - }) - } - } - - // 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 - // exist: without it the reveal's only output is the zero-value OP_RETURN - // marker, every satoshi of the legs goes to miners, and the SDK's - // FULL_BURN_FEE guard (correctly) refuses to sign, stranding the - // confirmed commit. The fee the reveal keeps is the larger of the - // dust-/explicit-fee-floored estimatedFee above and the same - // whole-reveal size estimate the funding side used (estimateP2shRevealTx, - // at this call's fee rate, including the emitted customOutputs' bytes); - // 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){ - 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) - let revealFeeKept = Math.trunc((revealSizeForFee * feePerBytes) * SATOSHI_UNIT) - if (revealFeeKept < estimatedFee){ - revealFeeKept = estimatedFee - } - - // Keep the package prefund as FEE instead of sweeping it home. - // - // The reveal spends only the commit's legs, so commit and reveal are - // one mempool package and the miner weighs them together. The funding - // side sized these legs at the package rate precisely so this - // transaction can pay the commit's shortfall; without this the extra - // lands in the surplus and goes straight back to the caller's change, - // 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 - // (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() - if (revealUpliftBound > 0 && feePerBytes > 0 && - this.connector && typeof this.connector.getUnconfirmedAncestorPackage === 'function'){ - let commitPackage = null - try { - commitPackage = await this.connector.getUnconfirmedAncestorPackage([p2shHash]) - } catch (err) { - logger.warn(util.format('Reveal package fee sizing skipped: commit lookup failed:', err.message)) - } - const wanted = commitPackage ? packageFeeUpliftSatoshis({ - currentFee: revealFeeKept, - txSize: revealSizeForFee, - ancestorSize: commitPackage.size, - ancestorFees: commitPackage.fees, - targetFeePerBytes: feePerBytes, - satoshiUnit: SATOSHI_UNIT - }) : 0 - if (wanted > 0){ - // The legs are the only money this transaction has, and every - // satoshi above the fee is already spoken for by the outputs - // it must emit. Spend the surplus and not one unit more. - // - // A reveal with no customOutputs leaves exactly one value - // output, the swept change, and that output is what keeps it - // off the SDK's FULL_BURN_FEE refusal. Reserve its floor here: - // eating it would strand the confirmed commit outright, which - // is a far worse outcome than a package that confirms late. - const emitsValueOutput = !!(customOutputs && Array.isArray(customOutputs) && customOutputs.length > 0) - const sweepReserve = emitsValueOutput ? 0n : BigInt(this.outputFloor) - const spendable = phaseLegInputSatoshis - outputSatoshis - BigInt(revealFeeKept) - sweepReserve - let allowed = Math.min(wanted, revealUpliftBound) - if (spendable <= 0n) allowed = 0 - else if (spendable < BigInt(allowed)) allowed = Number(spendable) - if (allowed < wanted){ - const packageSize = commitPackage.size + revealSizeForFee - const packageFee = Math.round(commitPackage.fees * SATOSHI_UNIT) + revealFeeKept + allowed - logger.warn(`Reveal package fee uplift clamped to ${allowed} of ${wanted} base units: the commit ` + - `package totals ${commitPackage.size} bytes, so the commit/reveal package will pay ` + - `${Math.round(packageFee / packageSize * 1000)} base units/kB against a target of ` + - `${Math.round(feePerBytes * SATOSHI_UNIT * 1000)} and may stay unmined. The reveal has no ` + - `other input to raise, so rebuild the commit with a package-aware fee.`) - } - if (allowed > 0){ - revealFeeKept = revealFeeKept + allowed - } - } - } - - 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)){ - psbt.addOutput({ - address: sweepAddress, - value: asSatValue(revealSurplus) - }) - } - } - - // Envelope reveal construction. Runs only on the - // TAPROOT funding path, after input selection and change, so the commit - // transaction is final in shape and its unsigned txid is the txid the - // network will see (segwit-only inputs, enforced above). - let revealPsbt = null - let envelopeResult = null - if (envelopeContext){ - // Every commit input is signed SIGHASH_ALL. Attribution rides - // the commit's ins[0]; ANYONECANPAY-style signing would make index 0 - // third-party-insertable in a replacement. The PSBT field makes the - // requirement explicit to whatever signs it. - for (let i = 0; i < psbt.inputCount; i++){ - psbt.updateInput(i, { sighashType: bitcoin.Transaction.SIGHASH_ALL }) - } - - const commitTx = bitcoin.Transaction.fromBuffer(psbt.data.globalMap.unsignedTx.toBuffer()) - const commitTxid = commitTx.getId() - // The envelope commit output is emitted first, so this is vout 0 by - // construction; located by script rather than assumed, so a future - // emission-order change cannot silently strand the reveal. - const commitVout = commitTx.outs.findIndex(o => o.script.equals(envelopeContext.commitPayment.output)) - - // Control block for the script-path spend: single-leaf tree, no - // merkle path, 33 bytes (leaf version + parity bit, then the - // internal key). Derived from the SAME payment object that built - // the commit output, so it cannot drift from what was committed. - const revealPayment = bitcoin.payments.p2tr({ - internalPubkey: envelopeContext.internalPubkey, - scriptTree: { output: envelopeContext.envelopeScript }, - redeem: { output: envelopeContext.envelopeScript, redeemVersion: TAPROOT_LEAF_VERSION }, - network: this.network - }) - const controlBlock = revealPayment.witness[revealPayment.witness.length - 1] - - revealPsbt = new bitcoin.Psbt({ network: this.network }) - // Reveal input 0 MUST be the commit outpoint; the decoder's - // recognition and attribution assume it. RBF preference mirrors the - // commit (the sequence toggle is the shipped replacement mechanism). - revealPsbt.addInput({ - hash: commitTxid, - index: commitVout, - sequence: utxoSequence, - witnessUtxo: { - script: envelopeContext.commitPayment.output, - value: envelopeContext.commitValue - }, - tapInternalKey: envelopeContext.internalPubkey, - tapLeafScript: [{ - leafVersion: TAPROOT_LEAF_VERSION, - script: envelopeContext.envelopeScript, - controlBlock - }] - }) - // Change back to the caller: the commit value minus the reveal's - // prefunded fee (and minus the floor pad's dust when present) is - // exactly the dust floor by construction; it exists because the - // reveal must carry an output and it doubles as the CPFP handle. - const revealChangeValue = envelopeContext.commitValue - - envelopeContext.revealFee - - (envelopeContext.revealPadNeeded ? this.dustAmount : 0) - revealPsbt.addOutput({ - address: envelopeContext.revealChangeAddress, - value: revealChangeValue - }) - if (envelopeContext.revealPadNeeded){ - revealPsbt.addOutput({ - address: envelopeContext.revealChangeAddress, - value: this.dustAmount - }) - } - - envelopeResult = { - commitTxid, - commitVout, - commitValue: envelopeContext.commitValue, - commitAddress: envelopeContext.commitPayment.address, - internalPubkey: envelopeContext.internalPubkey.toString('hex'), - tapleafHash: envelopeTapLeafHash(envelopeContext.envelopeScript).toString('hex'), - controlBlock: controlBlock.toString('hex'), - revealFee: envelopeContext.revealFee - } - } - - // Carrier scripts for the chunk lanes, so a client can VERIFY the - // commit outputs it is about to sign instead of trusting them. - // - // An inline OP_RETURN action can be read straight back out of the PSBT - // and cross-checked against what the caller asked for. A P2SH/P2WSH - // action cannot: the payload lives in a redeem script that only exists - // here, and the commit output is just its hash, so the client had no - // way to tell a faithful encoding from a substituted one. That residual - // trust is what this closes. - // - // These are the SAME compiled buffers that became the outputs above - // (dataBufferArray IS the redeem-script array for these encodings), not - // a re-derivation, so they cannot drift from what was actually built. - // Publishing them reveals nothing secret: the script is disclosed on - // chain the moment the reveal tx spends the output. - // - // The client's two checks are then: hash each script to a P2SH/P2WSH - // output and require a match in the PSBT, and require the leading data - // pushes to concatenate to the action it intended. Passing a forged - // script means failing one or the other. - - // Last gate before the transaction leaves: an identical unsigned tx built - // inside the reservation window is refused, not returned as a new success. - // Every path through _buildTransaction reaches it, the P2SH/P2WSH reveal - // included (an identical reveal is a duplicate too), and the TAPROOT reveal - // is derived from this commit, so guarding the commit guards the pair. - // createEnvelopeCancelTransaction is the one build path outside - // _buildTransaction; it takes its own outpoint reservation instead, and - // states there why duplicate refusal must not apply to it. - // The txid rides back on the per-call reservation ledger so the ticket - // minted for this build can retire this record too (see releaseReservation). - callReservations.buildTxid = this.refuseDuplicateBuild(psbt, Date.now()) - - let result = {"psbt":psbt,"encoding":preparedData["encoding"]} - - // Non-fatal advisory for the fee-payer; see rawDataOnlyPayload above. Additive - // result field, the same shape `compression` already established, so a caller - // that does not read it is unaffected. - if (rawDataOnlyPayload){ - result.warnings = [{ - code: 'RAWDATA_ONLY_NOT_DECODED', - message: 'rawData without data compiles to an OP_0-led payload that current ' + - 'XChain decoders read as empty: the transaction will confirm and the fee ' + - 'will be paid, but the payload will not be indexed as an ACTION' - }] - } - - // The library path's ACTION-name advisory; see unknownAction above. Same - // additive `warnings` field, appended rather than assigned so it can ride - // alongside the rawData-only advisory when a caller earns both. - if (unknownAction != null){ - if (!result.warnings) result.warnings = [] - result.warnings.push({ - code: 'UNKNOWN_ACTION_NAME', - message: `data leads with '${String(unknownAction).slice(0, 32)}', which is ` + - 'neither a canonical XChain ACTION name nor a known alias: the transaction ' + - 'will confirm and the fee will be paid, but every decoder drops the payload ' + - 'instead of indexing it as an ACTION. The JSON-RPC create_tx surface refuses ' + - 'this payload outright' - }) - } - - // What compression actually did, reported rather than inferred. The - // wallet has to show the REAL on-chain size, and with the - // default ON a caller can no longer assume from its own request whether - // the bytes were compressed: `reason` names why they were not. - if (compressionResult){ - result.compression = { - compressed: compressionResult.compressed, - rawLength: compressionResult.rawLength, - storedLength: compressionResult.storedLength, - reason: compressionResult.reason - } - // THE BYTES, not just the verdict. A boolean plus two lengths is not - // enough to rebuild this transaction: the compression pass rewrote the - // COMPRESSION field and replaced the payload in place, so a caller - // holding only its own pre-compression request can neither state what - // these bytes say (its confirm check reads a string the PSBT does not - // carry, and refuses its own transaction as tampered) nor rebuild the - // phase-2 reveal from them (re-deriving the marker without the deflated - // payload compiles a DIFFERENT carrier, and a reveal that does not - // reproduce the commit's chunks can never spend the commit - the funds - // are stranded, after the money is spent). - // - // So both halves ride back exactly as they went in: `data` is the action - // string written into the carrier, and `rawData` the stored payload in - // the same Latin-1 one-char-per-byte form every rawData parameter takes, - // so it can be handed straight back to create_tx/spendP2sh. Present only - // when compression actually fired; an untouched payload is already the - // caller's own. - if (compressionResult.compressed){ - result.compression.data = compressionResult.data - result.compression.rawData = compressionResult.rawData.toString('binary') - } - } - if (preparedData["encoding"] === Encoding.P2SH || preparedData["encoding"] === Encoding.P2WSH){ - result.carrierScripts = (preparedData["dataBufferArray"] || []).map(b => b.toString('hex')) - } - if (revealPsbt){ - // Two-tx response shape: the caller signs both, - // then broadcasts commit followed by reveal. carrierScripts serves - // the same verify-before-sign contract as the chunk lanes: hash the - // script to the P2TR commit output and require a match, and require - // the payload pushes to concatenate to the intended action. - // `envelope` carries what the wallet must DURABLY PERSIST BEFORE - // BROADCASTING the commit (plus its own internal-key derivation - // path): lose the tapleaf hash and the key-path cancel tweak cannot - // be reconstructed, stranding the funds. - result.revealPsbt = revealPsbt - result.carrierScripts = (preparedData["dataBufferArray"] || []).map(b => b.toString('hex')) - result.envelope = envelopeResult - } - return result + // One state object carries this call's arguments and every value a later + // step reads. Each step takes what it needs from it and writes back what it + // set, and the steps run in the order the checks and emissions depend on. + const build = { callReservations, utxos, pubkey, customOutputs, data, rawData, fee, replacebyfee, + encoding, change, p2shHash, p2shHex, compressedPubKey, + unconfirmed, feePerKb, dust, feeQuote, attachPrevTx, compress, options } + checkPayloadInput.call(this, build) + await resolveFeeRates.call(this, build) + await compressPayload.call(this, build) + compilePayload.call(this, build) + classifyPayload.call(this, build) + chooseEncoding.call(this, build) + await checkEnvelopeEncoding.call(this, build) + initInputState.call(this, build) + checkExactInputs.call(this, build) + await gatherUtxos.call(this, build) + refuseShrunkExactInputs.call(this, build) + filterAndOrderUtxos.call(this, build) + bindObfuscationKey.call(this, build) + prepareDataChunks.call(this, build) + priceRevealCustomOutputs.call(this, build) + initEmissionState.call(this, build) + await emitDataOutputs.call(this, build) + emitCustomOutputs.call(this, build) + initSelection.call(this, build) + await selectFundingInputs.call(this, build) + refuseExcessiveFee.call(this, build) + await upliftForAncestors.call(this, build) + floorEstimatedFee.call(this, build) + prefundRevealPackage.call(this, build) + computeChange.call(this, build) + emitChangeAndPad.call(this, build) + await sweepP2shReveal.call(this, build) + buildEnvelopeReveal.call(this, build) + return finishBuild.call(this, build) } } diff --git a/src/XChainEncoder/build_transaction/build_result.js b/src/XChainEncoder/build_transaction/build_result.js new file mode 100644 index 0000000..2dbbaa7 --- /dev/null +++ b/src/XChainEncoder/build_transaction/build_result.js @@ -0,0 +1,155 @@ +/********************************************************************* + * + * 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 handles starting the encoder and generating transactions + * + ********************************************************************/ + +const { Encoding } = require('../constants.js') + +// The duplicate-build gate, then the response object and its additive fields. +function finishBuild(build){ + let { callReservations, psbt, preparedData } = build + // Carrier scripts for the chunk lanes, so a client can VERIFY the + // commit outputs it is about to sign instead of trusting them. + // + // An inline OP_RETURN action can be read straight back out of the PSBT + // and cross-checked against what the caller asked for. A P2SH/P2WSH + // action cannot: the payload lives in a redeem script that only exists + // here, and the commit output is just its hash, so the client had no + // way to tell a faithful encoding from a substituted one. That residual + // trust is what this closes. + // + // These are the SAME compiled buffers that became the outputs above + // (dataBufferArray IS the redeem-script array for these encodings), not + // a re-derivation, so they cannot drift from what was actually built. + // Publishing them reveals nothing secret: the script is disclosed on + // chain the moment the reveal tx spends the output. + // + // The client's two checks are then: hash each script to a P2SH/P2WSH + // output and require a match in the PSBT, and require the leading data + // pushes to concatenate to the action it intended. Passing a forged + // script means failing one or the other. + + // Last gate before the transaction leaves: an identical unsigned tx built + // inside the reservation window is refused, not returned as a new success. + // Every path through _buildTransaction reaches it, the P2SH/P2WSH reveal + // included (an identical reveal is a duplicate too), and the TAPROOT reveal + // is derived from this commit, so guarding the commit guards the pair. + // createEnvelopeCancelTransaction is the one build path outside + // _buildTransaction; it takes its own outpoint reservation instead, and + // states there why duplicate refusal must not apply to it. + // The txid rides back on the per-call reservation ledger so the ticket + // minted for this build can retire this record too (see releaseReservation). + callReservations.buildTxid = this.refuseDuplicateBuild(psbt, Date.now()) + + let result = {"psbt":psbt,"encoding":preparedData["encoding"]} + + attachAdvisories(build, result) + attachCompression(build, result) + attachCarriers(build, result) + return result +} + +function attachAdvisories(build, result){ + let { rawDataOnlyPayload, unknownAction } = build + // Non-fatal advisory for the fee-payer; see rawDataOnlyPayload above. Additive + // result field, the same shape `compression` already established, so a caller + // that does not read it is unaffected. + if (rawDataOnlyPayload){ + result.warnings = [{ + code: 'RAWDATA_ONLY_NOT_DECODED', + message: 'rawData without data compiles to an OP_0-led payload that current ' + + 'XChain decoders read as empty: the transaction will confirm and the fee ' + + 'will be paid, but the payload will not be indexed as an ACTION' + }] + } + + // The library path's ACTION-name advisory; see unknownAction above. Same + // additive `warnings` field, appended rather than assigned so it can ride + // alongside the rawData-only advisory when a caller earns both. + if (unknownAction != null){ + if (!result.warnings) result.warnings = [] + result.warnings.push({ + code: 'UNKNOWN_ACTION_NAME', + message: `data leads with '${String(unknownAction).slice(0, 32)}', which is ` + + 'neither a canonical XChain ACTION name nor a known alias: the transaction ' + + 'will confirm and the fee will be paid, but every decoder drops the payload ' + + 'instead of indexing it as an ACTION. The JSON-RPC create_tx surface refuses ' + + 'this payload outright' + }) + } +} + +function attachCompression(build, result){ + let { compressionResult } = build + // What compression actually did, reported rather than inferred. The + // wallet has to show the REAL on-chain size, and with the + // default ON a caller can no longer assume from its own request whether + // the bytes were compressed: `reason` names why they were not. + if (compressionResult){ + result.compression = { + compressed: compressionResult.compressed, + rawLength: compressionResult.rawLength, + storedLength: compressionResult.storedLength, + reason: compressionResult.reason + } + // THE BYTES, not just the verdict. A boolean plus two lengths is not + // enough to rebuild this transaction: the compression pass rewrote the + // COMPRESSION field and replaced the payload in place, so a caller + // holding only its own pre-compression request can neither state what + // these bytes say (its confirm check reads a string the PSBT does not + // carry, and refuses its own transaction as tampered) nor rebuild the + // phase-2 reveal from them (re-deriving the marker without the deflated + // payload compiles a DIFFERENT carrier, and a reveal that does not + // reproduce the commit's chunks can never spend the commit - the funds + // are stranded, after the money is spent). + // + // So both halves ride back exactly as they went in: `data` is the action + // string written into the carrier, and `rawData` the stored payload in + // the same Latin-1 one-char-per-byte form every rawData parameter takes, + // so it can be handed straight back to create_tx/spendP2sh. Present only + // when compression actually fired; an untouched payload is already the + // caller's own. + if (compressionResult.compressed){ + result.compression.data = compressionResult.data + result.compression.rawData = compressionResult.rawData.toString('binary') + } + } +} + +function attachCarriers(build, result){ + let { preparedData, revealPsbt, envelopeResult } = build + if (preparedData["encoding"] === Encoding.P2SH || preparedData["encoding"] === Encoding.P2WSH){ + result.carrierScripts = (preparedData["dataBufferArray"] || []).map(b => b.toString('hex')) + } + if (revealPsbt){ + // Two-tx response shape: the caller signs both, + // then broadcasts commit followed by reveal. carrierScripts serves + // the same verify-before-sign contract as the chunk lanes: hash the + // script to the P2TR commit output and require a match, and require + // the payload pushes to concatenate to the intended action. + // `envelope` carries what the wallet must DURABLY PERSIST BEFORE + // BROADCASTING the commit (plus its own internal-key derivation + // path): lose the tapleaf hash and the key-path cancel tweak cannot + // be reconstructed, stranding the funds. + result.revealPsbt = revealPsbt + result.carrierScripts = (preparedData["dataBufferArray"] || []).map(b => b.toString('hex')) + result.envelope = envelopeResult + } +} + +module.exports = { finishBuild } diff --git a/src/XChainEncoder/build_transaction/data_outputs.js b/src/XChainEncoder/build_transaction/data_outputs.js new file mode 100644 index 0000000..a588f68 --- /dev/null +++ b/src/XChainEncoder/build_transaction/data_outputs.js @@ -0,0 +1,317 @@ +/********************************************************************* + * + * 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 handles starting the encoder and generating transactions + * + ********************************************************************/ + +const bitcoin = require('bitcoinjs-lib'); +const TxSizeEstimator = require('../../build/tx_size_estimator') +const { parseSatoshiAmount } = require('../../common/validator') +const { SATOSHI_UNIT, Encoding } = require('../constants.js') +const { resolveCallerAddress } = require('../request_resolution.js') +const { emitP2shChunk, emitP2wshChunk } = require('./script_hash_legs.js') + +function prepareDataChunks(build){ + let { p2shHash, psbt, hasActionPayload, finalDataBuffer, encoding, pubkey, compressedPubKey } = build + if (!p2shHash){//We need to prepare the data to know which inputs the p2sh will have + psbt = new bitcoin.Psbt({ network: this.network }) + } + + // With no action payload there is nothing to encode, chunk or obfuscate, + // so skip prepareData entirely and hand the emission loop an empty chunk + // list: it then writes no nulldata output and the transaction is just its + // customOutputs plus change. The encoding is reported as OP_RETURN because + // every downstream single-transaction branch keys off that value (the + // P2SH/P2WSH two-phase paths must not engage for a payment), and a caller + // that explicitly asked for a chunked encoding while supplying no payload + // has nothing to chunk regardless. + let preparedData = hasActionPayload + ? this.prepareData(finalDataBuffer, encoding, pubkey, compressedPubKey) + : { encoding: Encoding.OP_RETURN, dataBufferArray: [] } + Object.assign(build, { psbt, preparedData }) +} + +function priceRevealCustomOutputs(build){ + let { preparedData, p2shHash, customOutputs, feePerBytes } = build + // P2SH/P2WSH is a two-tx flow: this funding tx (p2shHash null) creates the + // P2SH/P2WSH outputs, and a later reveal tx (p2shHash set) spends them and + // is the tx the indexer treats as the action. customOutputs (e.g. the + // native-fee protocol-fee output) must therefore be EMITTED on the reveal, + // not here on the funding tx. But the reveal's only inputs are these funding + // outputs, so the funding outputs must carry enough value for the reveal to + // pay both its miner fee and those reveal-side customOutputs. We fold the + // customOutputs total into the FIRST funding output here, and skip emitting + // customOutputs on this funding tx below (see the customOutputs block). On + // the reveal (p2shHash set) customOutputs ARE emitted, funded by this value, + // so they are paid exactly once. Single-tx encodings (OP_RETURN/MULTISIGN) + // are unaffected: they emit customOutputs directly on their only tx. + // + // Value alone is not enough. Each reveal-side customOutput also + // makes the reveal BIGGER, and the reveal's whole miner fee comes from + // these funding outputs. estimateSpendingP2shTx/estimateSpendingP2wshTx + // size the reveal as "one data input + the OP_RETURN marker" only, so a + // single-chunk reveal carrying e.g. a 34-byte native-fee output came up + // ~25 bytes short of the 1 sat/vB floor and the node rejected it with + // min-relay-fee-not-met. Multi-chunk reveals only survived by accident: + // the per-chunk estimate repeats the tx header and marker output, and + // that slack absorbed the missing bytes. So charge the bytes here too. + const isP2shFamily = preparedData["encoding"] === Encoding.P2SH || preparedData["encoding"] === Encoding.P2WSH + let revealCustomOutputsValue = 0n + let revealCustomOutputsBytes = 0 + if (!p2shHash && isP2shFamily && customOutputs && Array.isArray(customOutputs)){ + for (let i = 0; i < customOutputs.length; i++){ + revealCustomOutputsValue += BigInt(parseSatoshiAmount(customOutputs[i].value, `customOutputs[${i}].value`, { allowBig: true })) + revealCustomOutputsBytes += TxSizeEstimator.estimateOutputSizeForAddress(customOutputs[i].address, this.network) + } + } + // Round UP: a truncated fraction of a satoshi is exactly the kind of + // off-by-one that lands the reveal a hair under the relay floor. + let revealCustomOutputsFee = (revealCustomOutputsBytes > 0 && feePerBytes > 0) + ? Math.ceil(revealCustomOutputsBytes * feePerBytes * SATOSHI_UNIT) + : 0 + Object.assign(build, { isP2shFamily, revealCustomOutputsValue, revealCustomOutputsBytes, revealCustomOutputsFee }) +} + +function initEmissionState(build){ + let outputSatoshis = 0n + let voutPsbtIndex = 0 + let obfuscatedData + + // Reveal-headroom state for the P2SH two-phase flow. + // p2shRevealHeadroomFunded marks the one-time first-leg top-up on the + // FUNDING tx (the reveal's whole fee plus one dust for the change output + // the reveal must leave); phaseLegInputSatoshis accumulates the funding + // leg values the REVEAL spends, so the surplus sweep after the emission + // loop can price the change output it returns to the caller. + let p2shRevealHeadroomFunded = false + let phaseLegInputSatoshis = 0n + + // Reconstructed phase-1 funding tx on the reveal path, shared by the P2SH + // and P2WSH branches below (both spend its outputs by index). Hoisted to the + // loop's enclosing scope so the input-index bounds guard can reuse it, and + // parsed once (memoized via `!p2shTx`) rather than re-decoded per data chunk; + // the parse is loop-invariant (p2shHex and txidFirstInput never change here). + let p2shTx = null + + // Envelope (TAPROOT) build context, populated by the emission loop and + // consumed after input selection to construct the reveal PSBT against + // the unsigned commit's txid (stable: commit inputs are segwit-only). + let envelopeContext = null + + // Two-phase reveal prefund bookkeeping, set by whichever commit branch + // sized a whole-reveal fee into a commit output (P2SH first leg, TAPROOT + // commit output). Consumed by the package-prefund pass after the commit's + // own fee is final. See that pass for why it cannot be done in the branch. + // outputIndex - the commit output carrying the reveal's fee money + // revealSize - whole-reveal size estimate, the package's child bytes + // revealFee - the fee that output currently prefunds + let revealPrefund = null + + let estimatedTxSize = 0 + Object.assign(build, { outputSatoshis, voutPsbtIndex, obfuscatedData, p2shRevealHeadroomFunded, phaseLegInputSatoshis, + p2shTx, envelopeContext, revealPrefund, estimatedTxSize }) +} + +// One carrier output (or reveal input) per prepared chunk, by encoding. +async function emitDataOutputs(build){ + let { preparedData } = build + for (let nextDataBufferIndex in preparedData["dataBufferArray"]){ + let nextDataBuffer = preparedData["dataBufferArray"][nextDataBufferIndex] + + switch (preparedData["encoding"]){ + case Encoding.OP_RETURN: + await emitOpReturnChunk.call(this, build, nextDataBuffer) + break + case Encoding.P2SH: + await emitP2shChunk.call(this, build, nextDataBuffer) + break + case Encoding.P2WSH: + await emitP2wshChunk.call(this, build, nextDataBuffer) + break + case Encoding.MULTISIGN: + await emitMultisignChunk.call(this, build, nextDataBuffer) + break + case Encoding.TAPROOT: { + emitEnvelopeCommit.call(this, build, nextDataBuffer) + break + } + } + } +} + +async function emitOpReturnChunk(build, nextDataBuffer){ + let { obfuscatedData, txidFirstInput, psbt, estimatedTxSize } = build + obfuscatedData = await this.obfuscate(nextDataBuffer, txidFirstInput) + let opReturnScript = bitcoin.payments.embed({ data: [obfuscatedData] }) + + psbt.addOutput({ + script: opReturnScript.output, + value: 0 + }) + + // Oversize is handled upstream: prepareData rejects an OP_RETURN + // payload larger than chunksSize (single-OP_RETURN policy throw), + // so every obfuscatedData reaching here fits one standard nulldata + // output and this per-chunk size estimate is exact. + estimatedTxSize = estimatedTxSize + + TxSizeEstimator.estimateOpReturnOutput(obfuscatedData) + Object.assign(build, { obfuscatedData, estimatedTxSize }) +} + +async function emitMultisignChunk(build, nextDataBuffer){ + let { obfuscatedData, txidFirstInput, compressedPubKey, finalDust, psbt, outputSatoshis, estimatedTxSize } = build + obfuscatedData = await this.obfuscate(nextDataBuffer, txidFirstInput) + let pubkey1 = await this.dataToPubkey(obfuscatedData.slice(0, 32)) + let pubkey2 = await this.dataToPubkey(obfuscatedData.slice(32, obfuscatedData.length)) + let pubkey3 = Buffer.from(compressedPubKey,"hex") + + let pubkeys = [ + pubkey1, + pubkey2, + pubkey3 + ] + + let multisignScript = bitcoin.payments.p2ms( + { + m:1, //We only need one signature + pubkeys: pubkeys, + network: this.network + } + ) + + // A bare multisig output is larger than a P2PKH, so the P2PKH + // dust floor (this.dustAmount, read per network from the coin + // bundle's dustThreshold: BTC 546, LTC 5460, DOGE 100000) is + // 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) + let multisigOutputValue = Math.max(finalDust, bareMultisigDust) + + psbt.addOutput({ + script: multisignScript.output, + value: multisigOutputValue + }) + // Account for the data output's value so change is not over-credited + // (otherwise total outputs exceed total inputs and the tx is invalid). + outputSatoshis += BigInt(multisigOutputValue) + + estimatedTxSize = estimatedTxSize + + TxSizeEstimator.estimateMultisignOutput() + + Object.assign(build, { obfuscatedData, outputSatoshis, estimatedTxSize }) +} + +function emitEnvelopeCommit(build, nextDataBuffer){ + let { preparedData, change, pubkey, feePerBytes, finalDust, psbt, outputSatoshis, estimatedTxSize, + revealPrefund, envelopeContext } = build + // nextDataBuffer IS the whole envelope tapscript (single + // element). One commit output carries it; the reveal is + // built after input selection, once the commit txid exists. + const internalPubkey = preparedData["internalPubkey"] + const commitPayment = bitcoin.payments.p2tr({ + internalPubkey, + scriptTree: { output: nextDataBuffer }, + network: this.network + }) + + // The reveal's only funding is the commit output, so its + // value must prefund the reveal's miner fee plus a change + // output at the dust floor (the reveal must carry at least + // one output; change doubles as the CPFP handle), plus + // the stripped-size floor pad where the chain demands one + // (LTC: a reveal whose only output is small sits under + // minStandardTxNonWitnessSize, exactly like the P2WSH + // reveal; the payload lives in the witness and does not + // count toward stripped size). + const revealChangeAddress = change || resolveCallerAddress(pubkey, this.network) + const revealChangeOutBytes = TxSizeEstimator.estimateOutputSizeForAddress(revealChangeAddress, this.network) + const envStrippedFloor = this.network.minStandardTxNonWitnessSize + const revealPadNeeded = !!(envStrippedFloor && (10 + 41 + revealChangeOutBytes) < envStrippedFloor) + const revealOutputsBytes = revealChangeOutBytes + (revealPadNeeded ? revealChangeOutBytes : 0) + const revealVsize = TxSizeEstimator.estimateEnvelopeRevealTx(nextDataBuffer.length, revealOutputsBytes, envStrippedFloor) + let revealFee = Math.trunc(revealVsize * feePerBytes * SATOSHI_UNIT) + if (revealFee < finalDust){ + revealFee = finalDust + } + const commitValue = revealFee + this.dustAmount + (revealPadNeeded ? this.dustAmount : 0) + + revealPrefund = { + outputIndex: psbt.txOutputs.length, + revealSize: revealVsize, + revealFee + } + + psbt.addOutput({ + script: commitPayment.output, + value: commitValue + }) + outputSatoshis = outputSatoshis + BigInt(commitValue) + estimatedTxSize = estimatedTxSize + TxSizeEstimator.estimateTaprootOutput() + + envelopeContext = { + internalPubkey, + envelopeScript: nextDataBuffer, + commitPayment, + commitValue, + revealFee, + revealPadNeeded, + revealChangeAddress + } + Object.assign(build, { revealPrefund, outputSatoshis, estimatedTxSize, envelopeContext }) +} + +function emitCustomOutputs(build){ + let { p2shHash, isP2shFamily, customOutputs, 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 + // the funding outputs above so the reveal can pay them, and the reveal + // (p2shHash set) emits them. Emitting here too would put the output on the + // wrong tx (the indexer reads the reveal) and double-pay. Single-tx + // encodings and the reveal itself fall through and emit normally. + const skipCustomOutputs = !p2shHash && isP2shFamily + if (!skipCustomOutputs && customOutputs && Array.isArray(customOutputs)) { + for (let i = 0; i < customOutputs.length; i++) { + const output = customOutputs[i] + const outputValue = parseSatoshiAmount(output.value, `customOutputs[${i}].value`, { allowBig: true }) + // A 0-sat caller output is consensus-valid but relay-rejected as + // dust, so the caller signs an unbroadcastable PSBT. Reject it at + // 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. + if (outputValue <= 0) { + throw new RangeError(`customOutputs[${i}].value must be a positive integer (satoshis)`) + } + psbt.addOutput({ + address: output.address, + value: outputValue + }) + outputSatoshis += BigInt(outputValue) + estimatedTxSize += 43 // Taproot output size estimate (most expensive) + } + } + Object.assign(build, { outputSatoshis, estimatedTxSize }) +} + +module.exports = { prepareDataChunks, priceRevealCustomOutputs, initEmissionState, emitDataOutputs, emitCustomOutputs } diff --git a/src/XChainEncoder/build_transaction/fee_rates.js b/src/XChainEncoder/build_transaction/fee_rates.js new file mode 100644 index 0000000..4400267 --- /dev/null +++ b/src/XChainEncoder/build_transaction/fee_rates.js @@ -0,0 +1,165 @@ +/********************************************************************* + * + * 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 handles starting the encoder and generating transactions + * + ********************************************************************/ + +const util = require('node:util'); +const { logger, SATOSHI_UNIT } = require('../constants.js') +const { suggestedFeeCeilingPerByte, suggestedFeeCeilingFloorPerByte } = require('../fee_policy.js') + +// The per-byte rate this build charges, the node rate the drain caps anchor +// to, the effective cap, and the dust floor, settled in that order. +async function resolveFeeRates(build){ + let { feePerKb } = build + let feePerBytes = null + let nodeFeePerBytes = null + Object.assign(build, { feePerBytes, nodeFeePerBytes }) + if (feePerKb){ + await useCallerFeeRate.call(this, build) + } else { + await useNodeFeeRate.call(this, build) + } + await anchorRelayFeeRate.call(this, build) + applyFeeCapAndDustFloor.call(this, build) +} + +async function useCallerFeeRate(build){ + let { feePerKb, feePerBytes, nodeFeePerBytes } = build + // feePerKb is the caller's rate in BASE UNITS (sat/litoshi/koinu) + // per kB: docs/openrpc.json documents it as "base units per kB" and + // the SDK's getFeeTiers() guidance is base-unit/vByte x 1000, the + // same unit MAX_FEE_RATE_KB uses. Convert to the internal BTC/byte + // that the node path (BTC/kB / 1000), the constructor cap + // (maxFeeRateKb / 1000 / SATOSHI_UNIT), and the fee formula + // (estimatedTxSize * feePerBytes * SATOSHI_UNIT) all share. The old + // /1000-only conversion left this path 1e8x too large: with the + // rate cap enabled every caller rate was silently clamped (fee + // control inert), and with it disabled a documented sat/kB rate + // produced a ~1e8x-inflated fee. + feePerBytes = feePerKb/1000/SATOSHI_UNIT + // Fetch the node's own estimate regardless of the multiplier + // setting: it anchors the relative cap below AND the absolute burn + // backstop, which must never derive its ceiling from the caller's + // own rate (that would let an inflated feePerKb lift the ceiling + // with it). + try { + nodeFeePerBytes = await 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. + logger.warn(util.format('Relative fee cap skipped: node fee estimate unavailable:', err.message)) + } + Object.assign(build, { feePerBytes, nodeFeePerBytes }) +} + +async function useNodeFeeRate(build){ + let { feePerBytes, nodeFeePerBytes } = build + feePerBytes = await this.connector.getFeePerKilobyte(1)/1000 //Highest fee. In bitcoin context every kilobyte is 1000 bytes + nodeFeePerBytes = feePerBytes + // Clamp only the rate chosen ON THE CALLER'S BEHALF, and only on a test + // chain. nodeFeePerBytes keeps the raw estimate so the fee-drain caps + // below still anchor to what the node actually reported. + let suggestedCap = suggestedFeeCeilingPerByte(this.networkKey, SATOSHI_UNIT) + if (suggestedCap != null && feePerBytes > suggestedCap){ + // Never clamp below what the node will relay (see + // suggestedFeeCeilingFloorPerByte); the floor is coin-correct + // because it comes from the node, where the ceiling constant is not. + try { + const info = await this.connector.getNetworkInfo() + const floor = suggestedFeeCeilingFloorPerByte(info && info.relayfee) + if (floor != null && floor > suggestedCap) suggestedCap = floor + } catch (err) { + logger.warn(util.format('Suggested-fee ceiling relayfee floor unavailable; using the configured ceiling:', err.message)) + } + } + // Compare with a relative epsilon. Both sides are coin-per-byte floats + // derived by dividing by 1000 and by SATOSHI_UNIT, so a ceiling that + // EQUALS the node rate (the ordinary case on a DOGE test chain, where the + // relay-derived floor and the node rate are both ten times relayfee) can + // land one ULP above it and clamp the value to itself, logging a ceiling + // breach that did not happen. A real breach is never this close. + if (suggestedCap != null && feePerBytes > suggestedCap * (1 + 1e-12)){ + if (!this._suggestedFeeClampWarned){ + this._suggestedFeeClampWarned = true + logger.warn(`Suggested fee rate ${Math.round(feePerBytes * SATOSHI_UNIT)} per vByte exceeds the ` + + `test-chain ceiling; using ${Math.round(suggestedCap * SATOSHI_UNIT)} per vByte. ` + + `Set SUGGESTED_FEE_MAX_PER_VBYTE to change or 0 to disable.`) + } + feePerBytes = suggestedCap + } + Object.assign(build, { feePerBytes, nodeFeePerBytes }) +} + +async function anchorRelayFeeRate(build){ + let { nodeFeePerBytes } = 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 + // 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 + // backstop further down needs a caller-independent reference rate even + // when the operator disables the relative cap (multiplier 0). + if (nodeFeePerBytes == null){ + try { + const info = await this.connector.getNetworkInfo(); + const relayfee = Number(info && info.relayfee); + if (relayfee > 0) nodeFeePerBytes = relayfee / 1000; + } catch (err) { + logger.warn(util.format('Fee cap relayfee-anchor fallback failed; feePerKb cap disabled this build:', err.message)); + } + } + Object.assign(build, { nodeFeePerBytes }) +} + +function applyFeeCapAndDustFloor(build){ + let { feePerBytes, nodeFeePerBytes, dust } = build + // Effective fee-rate ceiling (BTC/byte): the tighter of the absolute + // MAX_FEE_RATE_KB cap and the relative multiplier × node-estimate cap. + // Bounds the per-byte rate used for fee estimation AND for sizing the + // P2SH/P2WSH funding outputs, so a hostile feePerKb cannot drain the + // caller's inputs into miner fee or over-funded data outputs. + let capFeePerBytes = this.maxFeePerBytes + if (this.maxFeeRateMultiplier && nodeFeePerBytes != null){ + const relativeCap = nodeFeePerBytes * this.maxFeeRateMultiplier + capFeePerBytes = (capFeePerBytes != null) ? Math.min(capFeePerBytes, relativeCap) : relativeCap + } + if (capFeePerBytes != null && feePerBytes > capFeePerBytes) { + logger.warn(`Fee rate ${feePerBytes * 1000 * SATOSHI_UNIT} sat/kB exceeds the fee-rate cap, clamping to ${capFeePerBytes * 1000 * SATOSHI_UNIT} sat/kB`) + feePerBytes = capFeePerBytes + } + + // A caller dust may raise the floor for this build, never lower it: a leg under the + // relay floor strands the caller's own reveal behind an unrelayable funding tx. + let finalDust = this.outputFloor + if (dust){ + finalDust = Math.max(Number(dust), this.outputFloor) + } + Object.assign(build, { capFeePerBytes, feePerBytes, finalDust }) +} + +module.exports = { resolveFeeRates } diff --git a/src/XChainEncoder/build_transaction/fee_settlement.js b/src/XChainEncoder/build_transaction/fee_settlement.js new file mode 100644 index 0000000..33b0823 --- /dev/null +++ b/src/XChainEncoder/build_transaction/fee_settlement.js @@ -0,0 +1,312 @@ +/********************************************************************* + * + * 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 handles starting the encoder and generating transactions + * + ********************************************************************/ + +const util = require('node:util'); +const { OperationalError } = require('../../build/errors') +const { logger, SATOSHI_UNIT, RESERVATION_TTL_MS } = require('../constants.js') +const { jsonSafeSat } = require('../script_amount_helpers.js') +const { maxCpfpUpliftSat, packageFeeUpliftSatoshis } = require('../fee_policy.js') + +// The caller-fee ceilings: the rate cap, then the absolute burn backstop. +function refuseExcessiveFee(build){ + let { fee, capFeePerBytes, estimatedTxSize, estimatedFee, nodeFeePerBytes, feePerBytes } = build + // 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 + // they are paying. Silently lowering it would change what they sign, so + // refuse loudly instead. Without this, fee values up to the gross + // validator limit drain every selected input into miner fee. + if (fee != null && fee !== false && capFeePerBytes != null){ + const maxFeeSatoshis = Math.max(this.dustAmount, Math.ceil(estimatedTxSize * capFeePerBytes * SATOSHI_UNIT)) + if (estimatedFee > maxFeeSatoshis){ + throw new RangeError(`fee ${estimatedFee} exceeds the maximum allowed ${maxFeeSatoshis} satoshis for a ~${estimatedTxSize}-byte transaction (fee-rate cap)`) + } + } + + // Absolute burn backstop that holds even when the operator disables the + // rate cap (capFeePerBytes == null) and even when a change address is + // supplied (which skips the burn guard below). An explicit fee above 100x + // the node-derived fair fee for this size is almost certainly an error that + // would drain every selected input to the miner, so refuse it. 100x matches + // the default MAX_FEE_RATE_MULTIPLIER, so default deployments are unaffected. + // + // The fair-fee reference is the NODE's rate (nodeFeePerBytes), never the + // caller-supplied feePerKb: deriving the ceiling from feePerBytes let a + // caller inflate feePerKb to lift the ceiling with it, bypassing the + // backstop entirely whenever the rate cap was disabled. feePerBytes is + // used only as a last resort when the node can produce neither an + // estimate nor a relayfee (same graceful degradation as the relative + // cap, and the caller cannot cause that condition). + if (fee != null && fee !== false){ + const referenceFeePerBytes = nodeFeePerBytes != null ? nodeFeePerBytes : feePerBytes + if (referenceFeePerBytes != null){ + const fairFee = Math.ceil(estimatedTxSize * referenceFeePerBytes * SATOSHI_UNIT) + const hardCeiling = Math.max(this.dustAmount, fairFee * 100) + if (estimatedFee > hardCeiling){ + throw new RangeError(`fee ${estimatedFee} exceeds 100x the estimated fair fee (${fairFee} satoshis) for a ~${estimatedTxSize}-byte transaction`) + } + } + } +} + +async function upliftForAncestors(build){ + let { unconfirmedInputTxids, feePerBytes, estimatedFee, estimatedTxSize } = build + // CPFP-aware package sizing. + // + // A miner fills a block by ANCESTOR fee rate, so a transaction spending + // unconfirmed inputs is only mined when its whole mempool package clears + // the target. Paying the target on this transaction's own bytes is not + // enough: a PRICE batch paying 0.01017 DOGE/kB sat unmined because its + // funding ancestors pay 0.00313 DOGE/kB, putting the package at 0.00896, + // under Dogecoin's 0.01 DOGE/kB inclusion floor. Those inputs do not + // signal RBF, so the fee cannot be replaced afterwards; it has to be + // right at build time. Lift this fee to carry the package instead. + // + // Placed after the caller-fee checks above so the caller's own fee is + // still judged as supplied, and every ceiling those checks enforce is + // re-applied here to the uplifted total. Any failure at all (no ancestor + // data, an unreachable node, a connector without the method) leaves the + // per-transaction fee exactly as it was. + const cpfpUpliftBound = maxCpfpUpliftSat() + // Whatever unconfirmed ancestors this transaction inherits, carried out of + // the block below so the two-phase prefund pass can price the reveal + // against the WHOLE chain (ancestors + commit + reveal), not just against + // the commit. They stay 0 when nothing was measured. + let commitAncestorSize = 0 + let commitAncestorFeeSat = 0 + if (unconfirmedInputTxids.length > 0 && feePerBytes > 0 && cpfpUpliftBound > 0 && + this.connector && typeof this.connector.getUnconfirmedAncestorPackage === 'function'){ + let ancestorPackage = null + try { + ancestorPackage = await this.connector.getUnconfirmedAncestorPackage(unconfirmedInputTxids) + } catch (err) { + logger.warn(util.format('Package fee sizing skipped: ancestor lookup failed:', err.message)) + } + if (ancestorPackage && Number.isFinite(Number(ancestorPackage.size)) && Number(ancestorPackage.size) > 0){ + commitAncestorSize = Number(ancestorPackage.size) + commitAncestorFeeSat = Math.round(Number(ancestorPackage.fees) * SATOSHI_UNIT) + if (!Number.isFinite(commitAncestorFeeSat) || commitAncestorFeeSat < 0){ + commitAncestorSize = 0 + commitAncestorFeeSat = 0 + } + } + const wanted = ancestorPackage ? packageFeeUpliftSatoshis({ + currentFee: estimatedFee, + txSize: estimatedTxSize, + ancestorSize: ancestorPackage.size, + ancestorFees: ancestorPackage.fees, + targetFeePerBytes: feePerBytes, + satoshiUnit: SATOSHI_UNIT + }) : 0 + + if (wanted > 0){ + estimatedFee = upliftAncestorFee.call(this, build, ancestorPackage, wanted, cpfpUpliftBound) + } + } + Object.assign(build, { cpfpUpliftBound, commitAncestorSize, commitAncestorFeeSat, estimatedFee }) +} + +// The ancestor uplift actually applied, bounded by every ceiling and by the inputs. +function upliftAncestorFee(build, ancestorPackage, wanted, cpfpUpliftBound){ + let { capFeePerBytes, estimatedTxSize, estimatedFee, nodeFeePerBytes, feePerBytes, p2shHash, inputSatoshis, + outputSatoshis, unconfirmedInputTxids } = build +// Its own absolute bound first: the uplift buys ancestor bytes, and +// the rate caps below only ever measure a fee against THIS +// transaction's size. +let allowed = Math.min(wanted, cpfpUpliftBound) + +if (capFeePerBytes != null){ + const maxFeeSatoshis = Math.max(this.dustAmount, Math.ceil(estimatedTxSize * capFeePerBytes * SATOSHI_UNIT)) + allowed = Math.min(allowed, maxFeeSatoshis - estimatedFee) +} + +const referenceFeePerBytes = nodeFeePerBytes != null ? nodeFeePerBytes : feePerBytes +if (referenceFeePerBytes != null){ + const fairFee = Math.ceil(estimatedTxSize * referenceFeePerBytes * SATOSHI_UNIT) + allowed = Math.min(allowed, Math.max(this.dustAmount, fairFee * 100) - estimatedFee) +} + +// Never spend an input that is not there. Without this bound a +// package uplift would turn a fundable build into INSUFFICIENT_FUNDS, +// which is a worse outcome than a transaction that confirms late. +if (!p2shHash){ + const headroom = inputSatoshis - outputSatoshis - BigInt(estimatedFee) + if (headroom <= 0n) allowed = 0 + else if (allowed > 0 && headroom < BigInt(allowed)) allowed = Number(headroom) +} + +if (allowed < 0) allowed = 0 + +if (allowed < wanted){ + const packageSize = ancestorPackage.size + estimatedTxSize + const packageFee = Math.round(ancestorPackage.fees * SATOSHI_UNIT) + estimatedFee + allowed + logger.warn(`Package fee uplift clamped to ${allowed} of ${wanted} base units: this transaction ` + + `spends ${unconfirmedInputTxids.length} unconfirmed input(s) whose ancestors total ` + + `${ancestorPackage.size} bytes. The package will pay ${Math.round(packageFee / packageSize * 1000)} ` + + `base units/kB against a target of ${Math.round(feePerBytes * SATOSHI_UNIT * 1000)}, so it may ` + + `stay unmined. Raise MAX_CPFP_UPLIFT_SAT, MAX_FEE_RATE_KB or the input balance to close the gap.`) +} + +if (allowed > 0){ + estimatedFee = estimatedFee + allowed +} + return estimatedFee +} + +function floorEstimatedFee(build){ + let { estimatedFee } = build + if (estimatedFee < this.dustAmount){ + estimatedFee = this.dustAmount + } + Object.assign(build, { estimatedFee }) +} + +function prefundRevealPackage(build){ + let { revealPrefund, p2shHash, feePerBytes, cpfpUpliftBound, estimatedTxSize, commitAncestorSize, estimatedFee, + commitAncestorFeeSat } = build + // Two-phase package prefund. + // + // A reveal spends nothing but the commit's own outputs, so the two are + // ALWAYS one mempool package and the miner judges them on the package's + // ancestor fee rate. The reveal's fee is money the commit set aside, and + // the reveal has no other input to raise it from, so a commit that ends + // up under the target rate drags the whole package under it and the + // reveal cannot rescue itself: the pair sits unmined with a confirmed + // commit stranded behind it. This is the shape a PRICE batch publish + // takes, and the commit lands under target whenever the caller supplies + // an explicit fee below the node's rate, or its own CPFP uplift was + // clamped (see the warning above), or its size estimate ran short. + // + // So prefund the reveal at the PACKAGE rate rather than at its own: the + // reveal carries the commit's shortfall, plus the shortfall of whatever + // unconfirmed ancestors the commit inherits. The money comes out of + // change, not out of thin air, and the commit's own fee is untouched - + // the pair's total is what a miner weighs, and only the reveal can still + // be raised at this point. + // + // This cannot live in the emission branch that sized the commit output: + // input selection, the caller-fee ceilings and the CPFP uplift all run + // after it, and the commit's fee and size are only final here. + if (revealPrefund && !p2shHash && feePerBytes > 0 && cpfpUpliftBound > 0){ + const wanted = packageFeeUpliftSatoshis({ + currentFee: revealPrefund.revealFee, + txSize: revealPrefund.revealSize, + ancestorSize: estimatedTxSize + commitAncestorSize, + ancestorFeeSatoshis: estimatedFee + commitAncestorFeeSat, + targetFeePerBytes: feePerBytes, + satoshiUnit: SATOSHI_UNIT + }) + + if (wanted > 0){ + applyRevealPrefund.call(this, build, wanted) + } + } +} + +// Moves the bounded prefund into the commit output that carries the reveal's fee. +function applyRevealPrefund(build, wanted){ + let { cpfpUpliftBound, inputSatoshis, outputSatoshis, estimatedFee, commitAncestorSize, estimatedTxSize, + revealPrefund, commitAncestorFeeSat, feePerBytes, psbt, envelopeContext } = build +// The same absolute bound the single-tx uplift answers to: this +// buys someone else's bytes, so no rate cap measured against one +// transaction's size can bound it. +let allowed = Math.min(wanted, cpfpUpliftBound) + +// Never spend an input that is not there. The prefund is paid out +// of change, so it is bounded by what change actually holds; +// turning a fundable build into INSUFFICIENT_FUNDS would be a +// worse outcome than a package that confirms late. +const headroom = inputSatoshis - outputSatoshis - BigInt(estimatedFee) +if (headroom <= 0n) allowed = 0 +else if (headroom < BigInt(allowed)) allowed = Number(headroom) +if (allowed < 0) allowed = 0 + +if (allowed < wanted){ + const packageSize = commitAncestorSize + estimatedTxSize + revealPrefund.revealSize + const packageFee = commitAncestorFeeSat + estimatedFee + revealPrefund.revealFee + allowed + logger.warn(`Reveal package prefund clamped to ${allowed} of ${wanted} base units: this commit ` + + `pays ${estimatedFee} base units over ~${estimatedTxSize} bytes, so the commit/reveal package ` + + `will pay ${Math.round(packageFee / packageSize * 1000)} base units/kB against a target of ` + + `${Math.round(feePerBytes * SATOSHI_UNIT * 1000)}. The reveal cannot raise its own fee later, ` + + `so the pair may stay unmined. Raise MAX_CPFP_UPLIFT_SAT, the commit's fee, or the input balance.`) +} + +if (allowed > 0){ + this.raiseOutputValue(psbt, revealPrefund.outputIndex, allowed) + outputSatoshis = outputSatoshis + BigInt(allowed) + revealPrefund.revealFee = revealPrefund.revealFee + allowed + // The envelope reveal is built in THIS call against these two + // numbers, so both have to move with the output or its change + // output would silently hand the uplift back to the caller. + if (envelopeContext){ + envelopeContext.commitValue = envelopeContext.commitValue + allowed + envelopeContext.revealFee = envelopeContext.revealFee + allowed + } +} + Object.assign(build, { outputSatoshis }) +} + +function computeChange(build){ + let { estimatedFee, inputSatoshis, outputSatoshis, p2shHash, reservedCandidates, change } = build + // Validate the fee BEFORE the BigInt conversion below: BigInt(estimatedFee) + // throws an opaque error on a NaN/Infinity fee, where this check names the cause. + if (!Number.isFinite(estimatedFee) || !Number.isInteger(estimatedFee)) { + throw new RangeError('Fee calculation produced invalid result. Check that all UTXO values and fees are valid integers.') + } + + // Exact change math in BigInt: with a >2^53-1-sat input, Number + // subtraction would round the change (and the caller would sign a + // PSBT whose change is off by up to ~2 sats per 2^53). + let changeSatoshis = inputSatoshis - outputSatoshis - BigInt(estimatedFee) + + // Reject a genuinely under-funded selection instead of returning an + // unbroadcastable PSBT whose outputs exceed its inputs (the caller would + // sign it and the network would reject it). Scoped to the funding/single + // tx path: the reveal (p2shHash set) funds itself from phase-1 outputs + // and never runs input selection, so inputSatoshis is 0 there by design + // and a negative "change" is expected and harmless. + if (!p2shHash && changeSatoshis < 0) { + const required = outputSatoshis + BigInt(estimatedFee) + // Name the reserved candidates when there were any: the shortfall then + // comes from inputs another build of the last RESERVATION_TTL_MS still + // holds, not from an under-funded address, and the caller's fix is to + // broadcast that build or wait, not to fund the address. Found live on + // TDOGE: two spendable outputs plus dust, two un-broadcast + // builds, and the third build reported the dust output as the whole + // balance, which the wallet rendered as "not enough funds". Same + // wording as the zero-selected branch above so one reader handles both. + const held = reservedCandidates > 0 + ? `; ${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` + : '' + 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 } + ) + } + + if ((changeSatoshis > this.dustAmount) && !change) { + throw new OperationalError('CHANGE_ADDRESS_REQUIRED', 'Transaction would burn significant satoshis as fees. Please provide a change address.') + } + Object.assign(build, { changeSatoshis }) +} + +module.exports = { refuseExcessiveFee, upliftForAncestors, floorEstimatedFee, prefundRevealPackage, computeChange } diff --git a/src/XChainEncoder/build_transaction/first_input_key.js b/src/XChainEncoder/build_transaction/first_input_key.js new file mode 100644 index 0000000..78bcb50 --- /dev/null +++ b/src/XChainEncoder/build_transaction/first_input_key.js @@ -0,0 +1,138 @@ +/********************************************************************* + * + * 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 handles starting the encoder and generating transactions + * + ********************************************************************/ + +const { OperationalError } = require('../../build/errors') +const { RESERVATION_TTL_MS } = require('../constants.js') + +function bindObfuscationKey(build){ + let { utxos, exactInputs, callReservations } = build + //On the reveal path utxos is empty; txidFirstInput is (re)assigned from + //p2shHex inside the data loop below before it is ever read. + // + // The OP_RETURN/MULTISIGN obfuscation key MUST bind to the txid of the input actually + // placed at ins[0]: the decoder derives its deobfuscation key from transaction.ins[0]. + // The selection loop below skips outpoints a concurrent/recent create_tx reserved + // (caller-supplied sets included), so sorted utxos[0] is NOT necessarily the first input. + // Synchronously pre-reserve the first AVAILABLE outpoint now - before the async data + // loop, so no concurrent call can claim it in between - and bind the key to it; the + // selection loop carves this outpoint out of its skip check so it is taken as ins[0]. + // Without this, a reservation skip bound the key to utxos[0] while ins[0] was utxos[1], + // the decoder failed the magic-word check, and the action silently never happened (valid + // tx, inputs spent, fee burned). + // + // The carve-out alone is NOT enough. It guarantees the pre-reserved + // outpoint is SELECTED, not that it is FIRST: the selection loop re-evaluates + // reservations against a LATER clock, and isOutpointReserved treats expiry <= now as + // free, so a foreign reservation blocking an EARLIER-sorted outpoint that lapses during + // the async data loop un-skips that outpoint and it takes ins[0] while the key stays + // bound here. Move the key-bound outpoint to the head of the selection order so ins[0] + // is correct by construction, independent of any clock. + // + // Reservation is NOT gated on fetchedFromTracker. The SDK fetches the funding + // set through get_utxos and passes it as `utxos`, so a caller-supplied set is + // the wallet's normal path; leaving it unreserved is exactly the hole that let + // two chained sends 800ms apart build the same transaction. + // + // Exact-input mode is the one shape that cannot SKIP a reserved outpoint: it + // promises every named outpoint is spent (a CPFP rescue descends from all of + // them), so dropping one would hand back a child that descends from nothing, + // the same silent shrink the unconfirmed/duplicate filters above were turned + // into errors to prevent. It still reserves - a named set is exactly the + // chained-send shape this guards against - but it refuses the build outright + // when another build holds one of the named outpoints, and it never reorders + // (the caller picked which outpoint lands at ins[0] and owns that key). + let firstReservedOutpoint = null + let txidFirstInput = null + if (utxos.length){ + if (exactInputs){ + txidFirstInput = claimExactInputs.call(this, callReservations, utxos, txidFirstInput) + } else { + ({ firstReservedOutpoint, txidFirstInput } = claimFirstFreeInput.call(this, callReservations, utxos, firstReservedOutpoint, txidFirstInput)) + } + // Lowercase where the key BINDS, not only at validation: this string is the + // obfuscation key itself, the decoder's half of it always renders lowercase, + // and the ins[0] guard below compares against a lowercase hex rendering of the + // PSBT input. validateUtxoEntry canonicalizes both ingest paths, but a caller + // using the encoder as a library reaches createTransaction without it, and a + // mixed-case txid must not turn into a permanent failure wearing a retryable + // INPUT_SELECTION_RACE label. + if (txidFirstInput != null) txidFirstInput = String(txidFirstInput).toLowerCase() + } + Object.assign(build, { firstReservedOutpoint, txidFirstInput }) +} + +// Exact-input mode: claim every named outpoint or refuse the build. +function claimExactInputs(callReservations, utxos, txidFirstInput){ + const nowFirst = Date.now() + this.evictExpiredReservations(nowFirst) + const heldByOthers = utxos + .map((u) => u.txid + ':' + u.vout) + .filter((k) => this.isOutpointReserved(k, nowFirst)) + if (heldByOthers.length){ + throw new OperationalError( + 'INPUT_RESERVED', + `options.exactInputs names ${heldByOthers.length} outpoint(s) reserved by a transaction ` + + `built in the last ${Math.round(RESERVATION_TTL_MS / 60000)} minutes ` + + `(${heldByOthers.join(', ')}); exact-input mode cannot drop them, so broadcast that ` + + 'transaction and rebuild from the resulting view, or wait for the reservation to lapse', + { reserved: heldByOthers } + ) + } + for (const u of utxos){ + this.claimOutpoint(callReservations, u.txid + ':' + u.vout, nowFirst) + } + // No head-of-order splice: the caller's order IS the input order here, + // and firstReservedOutpoint stays null so the selection loop below + // reserves nothing twice (every outpoint is already claimed). + txidFirstInput = utxos[0]["txid"] + return txidFirstInput +} + +// Greedy mode: claim the first outpoint no other build holds and move it to ins[0]. +function claimFirstFreeInput(callReservations, utxos, firstReservedOutpoint, txidFirstInput){ + const nowFirst = Date.now() + this.evictExpiredReservations(nowFirst) + for (let i = 0; i < utxos.length; i++){ + const u = utxos[i] + const k = u.txid + ':' + u.vout + if (!this.isOutpointReserved(k, nowFirst)){ + this.claimOutpoint(callReservations, k, nowFirst) + firstReservedOutpoint = k + txidFirstInput = u.txid + // Head-of-order splice. The skipped entries ahead of it were all + // reserved at nowFirst, so this only reorders against outpoints this + // call was never allowed to take anyway; if one of them frees before + // selection it is still selected, just after ins[0]. Value-descending + // order among the remaining entries is preserved. + if (i > 0) utxos.unshift(utxos.splice(i, 1)[0]) + break + } + } + // Every fetched outpoint already reserved: nothing could be pre-reserved, so the + // ins[0] invariant cannot be established by construction here. Fall back to + // utxos[0] for a deterministic key. Usually the selection loop then selects + // nothing and the build surfaces the shortfall, but its own eviction pass can + // free a DIFFERENT outpoint first; the post-selection guard below catches that + // and fails closed rather than emitting a silently-undecodable action. + if (txidFirstInput === null) txidFirstInput = utxos[0]["txid"] + return { firstReservedOutpoint, txidFirstInput } +} + +module.exports = { bindObfuscationKey } diff --git a/src/XChainEncoder/build_transaction/input_candidates.js b/src/XChainEncoder/build_transaction/input_candidates.js new file mode 100644 index 0000000..6d56aef --- /dev/null +++ b/src/XChainEncoder/build_transaction/input_candidates.js @@ -0,0 +1,260 @@ +/********************************************************************* + * + * 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 handles starting the encoder and generating transactions + * + ********************************************************************/ + +const { validateUtxoEntry } = require('../../common/validator') +const { OperationalError } = require('../../build/errors') +const { upstreamErrorMessage } = require('../../common/error_sanitize') +const { classifyTrackerFreshness, resolveCallerAddress } = require('../request_resolution.js') + +function initInputState(build){ + let { replacebyfee, p2shHash } = build + let psbt = null + + // 0xfffffffd, not 1. Any value below 0xfffffffe signals RBF (BIP125), but + // bit 31 (0x80000000) is what DISABLES BIP68 relative locktime. nSequence=1 + // leaves that bit clear, so it also asserts "this input must be 1 block + // old" - invisible when spending confirmed outputs, and fatal when spending + // unconfirmed change, where the node rejects the transaction as + // `non-BIP68-final`. That made RBF and chained sends mutually exclusive: + // measured 2026-08-27 driving three chained MINTs. 0xfffffffd + // signals RBF and keeps BIP68 off, which is what Bitcoin Core itself uses. + let utxoSequence = (replacebyfee? 0xfffffffd: 0xffffffff) + // BigInt: a DOGE UTXO set can total past 2^53-1 sats, where Number + // arithmetic silently rounds the fee/change math. + let inputSatoshis = 0n + + // The P2SH/P2WSH reveal (phase 2) spends phase-1's OWN funding outputs, + // reconstructed from p2shHex further below; it never selects spendable + // UTXOs. Re-querying the tracker on the reveal is not just wasted work: + // the sender address is often already empty (phase 1 moved the funds on + // chain), so tracker lag or that empty post-phase-1 view would strand a + // mid-flow reveal. Derive inputs from the phase-1 context we were handed + // and skip the tracker entirely; fall back to it only if the phase-1 hex + // is somehow missing (an invalid reveal call). + const isReveal = !!p2shHash + let fetchedFromTracker = false + Object.assign(build, { psbt, utxoSequence, inputSatoshis, isReveal, fetchedFromTracker }) +} + +function checkExactInputs(build){ + let { options, isReveal, utxos } = build + // Exact-input mode. Normal selection sorts the candidate set + // value-descending and stops the moment the running total covers outputs + // plus fee, so a caller who names N outpoints usually gets one input. That + // makes an operator rescue of a stuck batch impossible to build here: a CPFP + // child has to descend from EVERY unconfirmed output of the stuck chain + // (including the tiny ones greedy selection will never reach), and a + // deliberate chain has to spend a named change output rather than whichever + // output happens to be largest. Exact-input mode turns selection off: the + // caller's list IS the input set, in the caller's order, all of it. + // + // It is coin-control, so it is only meaningful over a caller-supplied set. + // Refuse it rather than silently degrade when there is nothing to be exact + // about, and on the reveal path, whose inputs come from p2shHex and never + // pass through selection at all. + const exactInputs = !!(options && options.exactInputs) + if (exactInputs){ + if (isReveal){ + throw new TypeError( + 'options.exactInputs cannot be combined with p2shHash: the reveal spends the ' + + 'funding transaction\'s own outputs, which are derived from p2shHex, not selected') + } + if ((utxos == null) || (utxos.length == 0)){ + throw new TypeError( + 'options.exactInputs requires a non-empty utxos array: it names the exact input ' + + 'set to spend, so there is nothing to be exact about when the set is fetched') + } + } + Object.assign(build, { exactInputs }) +} + +// The candidate set: the caller's utxos, an empty set on a reveal, or the +// tracker's view of the caller's address. +async function gatherUtxos(build){ + let { utxos, isReveal, p2shHex } = build + if ((utxos == null) || (utxos.length == 0)){ + if (isReveal && p2shHex){ + utxos = [] + Object.assign(build, { utxos }) + } else { + await fetchTrackerUtxos.call(this, build) + } + } +} + +async function fetchTrackerUtxos(build){ + let { pubkey } = build + let fetched + try { + fetched = await this.utxoTrackerConnector.getUtxosFromAddress(resolveCallerAddress(pubkey, this.network)) + } catch (err) { + // Surface a typed, credential-free operational error. A + // transport failure embeds the tracker's internal host:port, + // so upstreamErrorMessage collapses it to the generic + // fallback; the tracker's own application messages (lag, + // address-too-large) are safe and actionable and pass through. + throw new OperationalError('UTXO_TRACKER_ERROR', upstreamErrorMessage(err, 'UTXO tracker unavailable')) + } + + // Freshness gate. get_utxos carries an additive + // `sync` sibling field ({tracker_height, node_height, lag, synced, + // mempool_ready, halted?, halt_reason?}) on trackers that have picked up + // the ce16bdd freshness surface. Refuse to select from a view the tracker + // itself flags NOT synced or NOT mempool-ready, one it has halted on, or + // one whose lag is outside our own (tighter) bounds, before any input is + // chosen. + // `sync` is absent on an older tracker: fail OPEN (old behavior) rather + // than block every create_tx, since this ships ahead of every tracker + // in the fleet being upgraded. + // One classifier, shared with api.js getServeReadiness(); the ordering, + // the strict-equality fail-open rules and the operator text all live in + // classifyTrackerFreshness above. + const freshness = classifyTrackerFreshness(fetched && fetched.sync, this.maxUtxoTrackerLagBlocks) + if (freshness.code){ + throw new OperationalError(freshness.code, freshness.message, freshness.details) + } + + acceptTrackerUtxos(build, fetched) +} + +function acceptTrackerUtxos(build, fetched){ + let { utxos, fetchedFromTracker } = build + utxos = fetched["utxos"] + + // The tracker's utxos field must be an array. Anything else with a + // length (a string, most plausibly an error body that got read as the + // payload) walks straight past the emptiness check below and is then + // indexed element by element, so a malformed tracker response surfaced + // as "utxos[0] must be an object" and pointed the operator at a UTXO + // that does not exist instead of at the response shape. + if ((utxos != null) && !Array.isArray(utxos)){ + throw new OperationalError( + 'UTXO_TRACKER_ERROR', + `utxo-tracker returned a utxos field that is not an array (got ${typeof utxos})` + ) + } + + if ((utxos == null) || (utxos.length == 0)){ + throw new OperationalError('NO_UTXOS', "no utxos were provided and no utxos found on the blockchain") + } + fetchedFromTracker = true + + //Tracker-fetched UTXOs bypass the caller-API validation path, yet + //feed into the same PSBT construction code below. Run each through + //the same per-entry checks (64-char hex txid, integer vout/value, + //non-empty scriptPubKey hex, confirmations defaulted) so a + //malformed tracker output is rejected here instead of throwing + //deep inside bitcoinjs-lib's psbt.addInput(). Deliberately + //per-entry, NOT validateUtxoArray: the latter also enforces the + //caller-facing MAX_UTXO_COUNT cap, which must NOT gate an + //internally-fetched set (an address holding more than that many + //UTXOs would otherwise be unable to build any transaction). The + //SELECTED input count is bounded after selection instead. + for (let vi = 0; vi < utxos.length; vi++){ + validateUtxoEntry(utxos[vi], vi) + } + Object.assign(build, { utxos, fetchedFromTracker }) +} + +function refuseShrunkExactInputs(build){ + let { exactInputs, unconfirmed, utxos } = build + // Exact-input mode promises the caller's list is the input set, so the two + // filters below (which silently SHRINK that list) have to be errors instead. + // Dropping a named mempool outpoint is the CPFP-fatal one: the whole point of + // the rescue is descending from unconfirmed outputs, and a silent drop would + // 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) + if (mempoolInput){ + throw new TypeError( + `options.exactInputs names unconfirmed utxo ${mempoolInput.txid}:${mempoolInput.vout}, ` + + 'but unconfirmed=false would drop it; pass unconfirmed: true to spend it') + } + } + const seenOutpoints = new Set() + for (const u of utxos){ + const k = u.txid + ':' + u.vout + if (seenOutpoints.has(k)){ + throw new TypeError( + `options.exactInputs names outpoint ${k} more than once; a transaction cannot spend the same output twice`) + } + seenOutpoints.add(k) + } + } +} + +// Dedup, mempool filter and value-descending order, all in place on `utxos`. +function filterAndOrderUtxos(build){ + let { utxos, unconfirmed, isReveal, exactInputs } = build + // Remove duplicated utxos (the utxo tracker returns duplicated utxos sometimes) + // and, when unconfirmed is false, every mempool entry along with them. + // + // Single linear pass with an outpoint Set, compacting in place. It replaces a + // nested while whose inner loop re-scanned the tail for every surviving entry + // and called Array.splice on each hit: O(n^2) comparisons plus an O(n) element + // shift per removal, so a duplicate-heavy set was cubic-ish in the worst case. + // The caller-supplied path is capped at MAX_UTXO_COUNT (500), but the + // tracker-fetched path deliberately is NOT (see the per-entry validation note + // above), so a hot address with tens of thousands of outputs spent the build + // inside this loop. + // + // Semantics preserved exactly: first occurrence of an outpoint wins, relative + // order of survivors is unchanged, the mempool filter is applied BEFORE dedup + // (so an unconfirmed duplicate cannot claim the slot of a confirmed one when + // unconfirmed=false), and the array is mutated IN PLACE because downstream code + // and the caller both hold this same reference. + const seenUtxoOutpoints = new Set() + let utxoWriteIndex = 0 + 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 + // 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. + const outpointKey = nextUtxo.txid + ':' + nextUtxo.vout + if (seenUtxoOutpoints.has(outpointKey)) continue + seenUtxoOutpoints.add(outpointKey) + utxos[utxoWriteIndex++] = nextUtxo + } + utxos.length = utxoWriteIndex + + //If unconfirmed=false stripped every mempool UTXO and nothing + //confirmed remains, surface the same error as a never-funded + //address rather than crashing on utxos[0] below. The reveal path + //legitimately has an empty utxos array here (its inputs come from + //p2shHex), so only the funding/single-tx path treats empty as fatal. + if (utxos.length == 0 && !isReveal){ + throw new OperationalError('NO_UTXOS', "no utxos were provided and no utxos found on the blockchain") + } + + // Comparator, not subtraction: a >2^53-1 value is a BigInt here, and + // BigInt - Number throws. Relational operators mix the two types fine. + // Exact-input mode keeps the caller's order untouched: every named outpoint + // is spent either way, so the only thing sorting would change is WHICH one + // 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) + } +} + +module.exports = { initInputState, checkExactInputs, gatherUtxos, refuseShrunkExactInputs, filterAndOrderUtxos } diff --git a/src/XChainEncoder/build_transaction/input_selection.js b/src/XChainEncoder/build_transaction/input_selection.js new file mode 100644 index 0000000..0613fad --- /dev/null +++ b/src/XChainEncoder/build_transaction/input_selection.js @@ -0,0 +1,292 @@ +/********************************************************************* + * + * 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 handles starting the encoder and generating transactions + * + ********************************************************************/ + +const TxSizeEstimator = require('../../build/tx_size_estimator') +const { MAX_UTXO_COUNT, parseSatoshiAmount } = require('../../common/validator') +const { OperationalError } = require('../../build/errors') +const { SATOSHI_UNIT, RESERVATION_TTL_MS, Encoding } = require('../constants.js') +const { jsonSafeSat } = require('../script_amount_helpers.js') + +function initSelection(build){ + let { estimatedTxSize, fee } = build + estimatedTxSize = estimatedTxSize + 43 // change output (worst case: taproot) + + let estimatedFee = 0 + if (fee != null && fee !== false) { + // Re-parse with the same exact-integer arbiter the validator uses + // (toExactInt-based), not parseInt: parseInt silently truncates a + // fractional value ("100.5" -> 100) for direct library callers that + // 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') + } + + 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. + 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 }) +} + +async function selectFundingInputs(build){ + let { p2shHash } = build + if (!p2shHash){//The p2sh input is already created before + await selectInputs.call(this, build) + refuseEmptySelection.call(this, build) + refuseFirstInputRace.call(this, build) + } +} + +// Walks the ordered candidates, claiming and adding inputs until they cover +// outputs plus fee (or, in exact-input mode, until every named one is spent). +async function selectInputs(build){ + let { utxos, preparedData, exactInputs, firstReservedOutpoint, callReservations, psbt, utxoSequence, + attachPrevTx, fee, feePerBytes, outputSatoshis, estimatedTxSize, estimatedFee, inputSatoshis, + selectedInputCount, unconfirmedInputTxids, reservedCandidates } = build + const now = Date.now() + this.evictExpiredReservations(now) + let nextUtxoIndex = 0 + while (nextUtxoIndex < utxos.length){ + let nextUtxo = utxos[nextUtxoIndex] + + refuseUnsignableInput.call(this, preparedData, nextUtxo) + + if (skipReservedInput.call(this, callReservations, nextUtxo, exactInputs, firstReservedOutpoint, now)){ + reservedCandidates = reservedCandidates + 1 + nextUtxoIndex = nextUtxoIndex + 1 + continue + } + + nextUtxo.value = parseSatoshiAmount(nextUtxo.value, `utxos[${nextUtxoIndex}].value`, { allowBig: true }) + + const added = await addSelectedInput.call(this, psbt, nextUtxo, utxoSequence, attachPrevTx, estimatedTxSize, inputSatoshis) + estimatedTxSize = added.estimatedTxSize + inputSatoshis = added.inputSatoshis + + selectedInputCount = selectedInputCount + 1 + + if (nextUtxo.confirmations == 0){ + unconfirmedInputTxids.push(nextUtxo.txid) + } + + if (fee == null || fee === false) { + estimatedFee = Math.trunc(estimatedTxSize * feePerBytes * SATOSHI_UNIT) + } + + // Exact-input mode never stops early: the caller named this set + // because the transaction has to descend from all of it (a CPFP + // rescue) or spend a specific output (a deliberate chain). Stopping + // 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)){ + break + } + + nextUtxoIndex = nextUtxoIndex + 1 + } + Object.assign(build, { estimatedTxSize, estimatedFee, inputSatoshis, selectedInputCount, reservedCandidates }) +} + +function refuseUnsignableInput(preparedData, nextUtxo){ + // Envelope commit inputs MUST be segwit. The + // reveal is pre-built against the UNSIGNED commit's txid, which + // is only stable when no selected input's signature lands in + // the txid-covered serialization. Enforced as native-segwit + // (witness-program scriptPubKey): a P2SH-wrapped segwit UTXO is + // indistinguishable from plain P2SH here and its scriptSig + // (redeem-script push) shifts the txid, so it is refused too -- + // stricter than the spec's floor, never looser. Fail closed + // rather than skip: silently dropping a caller's coin-control + // input would change what they spend. + if (preparedData["encoding"] === Encoding.TAPROOT && !this.isSegwitUTXO(nextUtxo)){ + throw new TypeError( + `TAPROOT commit inputs must be native-segwit UTXOs (witness-program scriptPubKey); ` + + `utxo ${nextUtxo.txid}:${nextUtxo.vout} is not. A non-segwit input would shift the ` + + `commit txid at signing time and strand the pre-built reveal.`) + } + + // Refuse a witness-program input on a chain whose consensus rules have + // no segwit: there the output is anyone-can-spend and a witnessUtxo + // input signs nothing the network enforces. Fail closed, never skip. + if (this.network.supportsSegwit === false && this.isSegwitUTXO(nextUtxo)){ + throw new TypeError( + `Input ${nextUtxo.txid}:${nextUtxo.vout} carries a witness-program scriptPubKey, ` + + `which this network does not support (no segwit). Spend legacy inputs on this chain.`) + } +} + +// True when another build holds this candidate; otherwise claims it for this build. +function skipReservedInput(callReservations, nextUtxo, exactInputs, firstReservedOutpoint, now){ + // Double-spend guard: skip any outpoint another create_tx claimed + // within RESERVATION_TTL_MS, and reserve the ones we take. Two + // calls for one address would otherwise both pick the largest + // UTXOs and build conflicting double-spends (or, with identical + // outputs, the identical transaction). Reserve synchronously here + // (before the getTransactionHex await below) so a concurrent call + // observes the claim. Applies to caller-supplied sets too: the SDK + // and wallet hand over a tracker-fetched set as `utxos`, and the + // tracker keeps publishing a spent input until it sees the spend, + // so a chained send re-supplies the input the previous build took. + // A caller that truly wants to respend a reserved input (an RBF + // bump) waits out the TTL or restarts the encoder; that is the + // price of never building the same spend twice. + // Exact-input mode settled reservations up front: it claimed every + // named outpoint, or refused the build. Re-checking here would see + // this call's OWN claims and skip the whole set. + if (!exactInputs){ + const outpointKey = nextUtxo.txid + ':' + nextUtxo.vout + // Skip outpoints reserved by OTHER calls, but NOT the one this + // call pre-reserved for ins[0] above (the obfuscation key binds to it). + if (outpointKey !== firstReservedOutpoint && this.isOutpointReserved(outpointKey, now)){ + return true + } + if (outpointKey !== firstReservedOutpoint){ + this.claimOutpoint(callReservations, outpointKey, now) + } + } + return false +} + +async function addSelectedInput(psbt, nextUtxo, utxoSequence, attachPrevTx, estimatedTxSize, inputSatoshis){ + if (this.isSegwitUTXO(nextUtxo)){ + let nextInput = { + hash: nextUtxo.txid, + index: nextUtxo.vout, + sequence: utxoSequence, + witnessUtxo: { + script: Buffer.from(nextUtxo.scriptPubKey, 'hex'), + value: nextUtxo.value, + } + } + // 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 + // the PSBT's own txid. witnessUtxo alone left the device + // unable to sign (and, before it failed closed, signing a + // synthesized outpoint that does not exist). Opt-in: it is + // one node round trip and one prev tx of PSBT weight per + // input, which only that caller should pay. Attached NOW, + // at build time, so the PSBT the user previews is the one + // that gets signed - hydrating it later would break the + // byte-identity guarantee the confirm surface rests on. + if (attachPrevTx) { + const prevTxHex = await this.connector.getTransactionHex(nextUtxo.txid) + nextInput.nonWitnessUtxo = Buffer.from(prevTxHex, 'hex') + } + psbt.addInput(nextInput) + estimatedTxSize = estimatedTxSize + TxSizeEstimator.estimateInputSize(nextInput) + inputSatoshis = inputSatoshis + BigInt(nextUtxo.value) + } else { + let wholeUtxoHex = await this.connector.getTransactionHex(nextUtxo.txid) + let nextInput = { + hash: nextUtxo.txid, + index: nextUtxo.vout, + sequence: utxoSequence, + nonWitnessUtxo: Buffer.from(wholeUtxoHex, 'hex') + } + psbt.addInput(nextInput) + estimatedTxSize = estimatedTxSize + TxSizeEstimator.estimateInputSize(nextInput) + inputSatoshis = inputSatoshis + BigInt(nextUtxo.value) + } + return { estimatedTxSize, inputSatoshis } +} + +function refuseEmptySelection(build){ + let { selectedInputCount, reservedCandidates, outputSatoshis, estimatedFee } = build + // 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 + // genuinely needs more inputs than this is over standardness size and + // would be rejected at broadcast, so fail here with a precise reason. + if (selectedInputCount > MAX_UTXO_COUNT){ + throw new RangeError(`selected input count (${selectedInputCount}) exceeds the maximum (${MAX_UTXO_COUNT}) inputs for a single transaction`) + } + + // No spendable input was selected on the funding/single-tx path: the + // set was empty of usable outputs or every candidate is reserved by a + // concurrent selection. Report insufficient funds here, before + // the fee-rate cap math below, which would otherwise reject a fixed + // fee against a zero-input transaction's tiny size and mask the real + // cause. (The reveal path has p2shHash set and never reaches here.) + if (selectedInputCount === 0){ + // Name the real cause when the inputs exist but are spoken for: a + // chained send that re-supplied an input the previous build took + // is not an empty address, and the caller's fix is to wait for + // that spend to reach the tracker, not to fund the address. + const allReserved = reservedCandidates > 0 + throw new OperationalError( + 'INSUFFICIENT_FUNDS', + allReserved + ? `insufficient funds: all ${reservedCandidates} candidate input(s) are reserved by a transaction ` + + `built in the last ${Math.round(RESERVATION_TTL_MS / 60000)} minutes; broadcast that transaction ` + + 'and wait for its change to appear, or wait for the reservation to lapse' + : 'insufficient funds: no spendable inputs available (all candidates reserved or empty)', + { required: jsonSafeSat(outputSatoshis + BigInt(estimatedFee)), available: 0, outputs: jsonSafeSat(outputSatoshis), fee: estimatedFee, reservedCandidates } + ) + } +} + +function refuseFirstInputRace(build){ + let { hasActionPayload, preparedData, txidFirstInput, psbt } = build + // Fail-closed ins[0] invariant. OP_RETURN and MULTISIGN + // obfuscate their payload with txidFirstInput, and the decoder derives its + // deobfuscation key from the first input's txid, so the action only decodes if + // that outpoint actually landed at ins[0]. The head-of-order splice above makes + // that true by construction whenever an outpoint could be pre-reserved; it + // cannot when EVERY fetched outpoint was already reserved and one of them then + // freed before selection. Fail here so the caller retries, instead of returning + // a valid transaction whose action silently decodes to nothing (inputs spent, + // fee burned). P2SH/P2WSH are excluded: on the funding tx they do not use + // txidFirstInput at all, and on the reveal tx (p2shHash set) this block does not + // run and the key is re-bound to the phase-1 txid. + // A payment-only transaction has no action, hence no + // obfuscation key bound to the first input. The guard below exists to + // stop an action from silently decoding to nothing; with nothing to + // decode it would only fail a perfectly good payment whose input + // selection shifted under a concurrent reservation. + const keyBindsToFirstInput = + hasActionPayload && ( + preparedData["encoding"] === Encoding.OP_RETURN || + preparedData["encoding"] === Encoding.MULTISIGN + ) + if (keyBindsToFirstInput && txidFirstInput != null){ + // psbt.txInputs[0].hash is the internal little-endian outpoint hash; copy + // before reversing so the PSBT's own buffer is not mutated. + const actualFirstTxid = Buffer.from(psbt.txInputs[0].hash).reverse().toString('hex') + if (actualFirstTxid !== txidFirstInput){ + throw new OperationalError( + 'INPUT_SELECTION_RACE', + 'input selection raced a concurrent reservation: the obfuscation key is bound to an outpoint that is not the first input; retry the request', + { expectedFirstInput: txidFirstInput, actualFirstInput: actualFirstTxid } + ) + } + } +} + +module.exports = { initSelection, selectFundingInputs } diff --git a/src/XChainEncoder/build_transaction/payload_checks.js b/src/XChainEncoder/build_transaction/payload_checks.js new file mode 100644 index 0000000..71ee8d7 --- /dev/null +++ b/src/XChainEncoder/build_transaction/payload_checks.js @@ -0,0 +1,262 @@ +/********************************************************************* + * + * 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 handles starting the encoder and generating transactions + * + ********************************************************************/ + +const bitcoin = require('bitcoinjs-lib'); +const { MAX_COMPILED_ACTION_DATA_LENGTH, ENVELOPE_MAX_PAYLOAD, validateDataParam, validateActionPushDecodability, unknownActionName } = require('../../common/validator') +const { compressPayloadForAction } = require('../../build/compression') +const { Encoding } = require('../constants.js') +const { ensureEccLib } = require('../script_amount_helpers.js') +const { defaultCompressionEnabled } = require('../request_resolution.js') + +// 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 + // 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 + // reaches this builder with no validator in front of it, so the + // round-trip guard existed on one surface only: a `rawData` code unit + // above U+00FF was truncated to its low byte by the Latin-1 conversion + // below, and the compiled-size ceiling cannot see it because the length + // is unchanged. Same idiom as the per-entry UTXO, txid, fee and address + // re-validation further down. + // + // Runs BEFORE compression on purpose: the compression pass itself does + // Buffer.from(rawData,'binary'), so a check placed after it would measure + // bytes the truncation already produced. + // + // STRING inputs only, deliberately. The corruption is a property of the + // string-to-wire conversion; a caller handing the builder a Buffer is + // copied byte-for-byte and loses nothing, so refusing it here would + // impose RPC-surface argument-shape policy on library callers rather + // than close a data-loss path. + if (typeof data === 'string') validateDataParam(data, 'data') + if (typeof rawData === 'string') validateDataParam(rawData, 'rawData') + + // The ACTION-name recognition check has the same one-surface problem, and a + // different answer. validateAll REFUSES an unrecognized leading token on the + // JSON-RPC path (validator.js), so only a direct library caller reaches the + // compiler with one: the transaction compiles, pays a real miner fee, and the + // decoder drops the ACTION with nothing reported back. The compiled-size + // checks cannot catch it, because an unknown name is an ordinary length. + // + // REPORTED, not refused, matching the rawDataOnlyPayload advisory below + // rather than the RPC gate. createTransaction is a general PSBT builder as + // well as an ACTION builder, and its supported shapes include a payload that + // is not an XChain ACTION at all; refusing here would withdraw that contract + // from every library and browser-bundle caller to close a silence, and the + // silence is the whole of the harm. The fee-payer learns before it signs, + // which needs no acceptance change. + // + // Read BEFORE compression, on the caller's own bytes, which is the surface + // validateAll measures; the compression pass rewrites `data` afterwards. + // Buffer inputs read too, unlike the latin-1 re-check above: that one is + // string-only because a Buffer loses nothing in the conversion, while the + // decoder tokenizes the compiled push whatever shape the caller handed over. + const unknownAction = unknownActionName(data) + + // If feeQuote is provided, inject it as a custom output + if(feeQuote && feeQuote.address && feeQuote.amount > 0){ + if(!customOutputs) customOutputs = []; + customOutputs.push({ address: feeQuote.address, value: feeQuote.amount }); + } + Object.assign(build, { unknownAction, customOutputs }) +} + +async function compressPayload(build){ + let { compress, data, rawData } = build + // Transparent FILE payload compression, ON by default. Runs HERE, before + // the payload buffers are assembled, so everything downstream prices the + // bytes that will actually be written: the per-encoding ceiling check + // below, the size estimator, the fee quote and the encoding selection. + // + // `compress` is TRI-STATE: true/false are the caller's explicit choice, + // null/undefined take the deployment default. The distinction is not + // cosmetic. An EXPLICIT request that cannot be honoured throws, because + // the caller asked for something this payload cannot have; the DEFAULT + // pass runs over every action, most of which are not compressible FILEs, + // so the same conditions are ordinary facts and the payload rides raw + // (see compression.js's `explicit` option). Without that split, turning + // the default on would break every SEND carrying rawData. + // + // ROLLOUT: compression is consensus-safe but client-coordinated, because + // an old reader serves a compressed FILE as deflated garbage. Reader + // support must be deployed everywhere BEFORE an encoder carrying this + // default, and XCHAIN_COMPRESSION_DEFAULT=0 is the deploy-time lever that + // lets the code release and the behaviour change land separately. + const compressExplicit = (compress === true || compress === false) + const compressEnabled = compressExplicit ? compress : defaultCompressionEnabled() + let compressionResult = null + if (compressEnabled && rawData != null){ + let originalBuffer = Buffer.from(rawData, 'binary') + compressionResult = await compressPayloadForAction(data, originalBuffer, { explicit: compressExplicit }) + if (compressionResult.compressed){ + // Both halves move together: the marker and the bytes it + // describes. They must never be assigned separately. + data = compressionResult.data + rawData = compressionResult.rawData.toString('binary') + } + } + Object.assign(build, { data, rawData, compressionResult }) +} + +// The compiled action stream every encoding carries, after the push-shape guard. +function compilePayload(build){ + let { data, rawData } = build + // Refuse the single-byte shapes bitcoin.script.compile canonicalizes into + // a bare opcode, for direct library callers. The same reason as the + // round-trip guard at the head of this method: validateAll runs this + // check on the JSON-RPC path only, so a library call with rawData '\x05' + // compiled to OP_5 and the decoder dropped the byte off a fee-paid + // transaction (its element test is Buffer.isBuffer). + // + // Runs AFTER compression on purpose, unlike the guard above: this one + // measures the bytes that are about to be compiled, so a lone minimal-op + // byte that compression rewrote into a multi-byte payload is correctly + // allowed through and a payload compression rewrote is covered too. + // + // The empty-data shapes stay buildable: isMinimalOpSingleByte excludes + // the empty buffer, so the payment-only and rawData-only contracts are + // untouched and the flag-day decision they belong to is not pre-empted + // here (see the rawDataOnlyPayload advisory below). + // + // Runs on Buffer inputs too, unlike the latin-1 guard above. That guard + // is string-only because a Buffer is copied byte for byte and loses + // nothing in the conversion; canonicalization is a property of the + // COMPILED push, so a byte-for-byte-copied one-byte Buffer loses + // everything. validateActionPushDecodability normalizes either shape. + validateActionPushDecodability(data, rawData) + + // `data` is optional (openrpc.json create_tx data.required=false) and + // validateAll passes null through when omitted, but Buffer.from(null,'utf8') + // throws a Node TypeError, so a valid data-omitted request (e.g. a + // payment-only tx built from customOutputs) would fail with an opaque + // internal error. A missing payload defaults to '', identical to the + // already-supported empty-string case, which compiles cleanly downstream. + let dataBuffer = Buffer.from(data == null ? '' : data, 'utf8') + let dataToCompile = [dataBuffer] + + if (rawData != null){ + // 'binary' (Latin-1) preserves bytes 0-255 one-to-one. 'utf8' would + // corrupt arbitrary binary payloads (e.g. AES-GCM ciphertext for + // token-gated FILEs, and deflate-raw output). Existing ASCII callers + // like base64-encoded file bytes are byte-identical under both. + let rawDataBuffer = Buffer.from(rawData, 'binary') + dataToCompile.push(rawDataBuffer) + } + + let finalDataBuffer = bitcoin.script.compile(dataToCompile) + Object.assign(build, { dataBuffer, finalDataBuffer }) +} + +function classifyPayload(build){ + let { dataBuffer, rawData } = build + // Does this transaction carry an XChain ACTION at all? + // + // `data` being optional already meant "payment-only tx" in the wire + // contract, but the emission path below still wrote a nulldata output + // for it: the empty payload compiled to an OP_0 push, got the 4-byte + // magic word prepended, and shipped as a magic-word-only OP_RETURN + // carrying nothing. That is a real cost (a wasted output, and a + // transaction that announces itself as XChain while containing no + // action) paid by every plain native-coin payment the wallet sends. + // A payment with nothing to say should look like an ordinary payment. + const hasActionPayload = dataBuffer.length > 0 || rawData != null + + // rawData with no `data` compiles to an OP_0-led payload, and every shipped + // decoder blanks that stream and never reads the trailing push (the arbiter gate + // in xchain-decoder/src/XChainDecoder.js, which counts it and logs it but leaves + // ACCEPTANCE unchanged on purpose). So the transaction confirms, the fee is paid, + // and the payload is never indexed as an ACTION. + // + // Reported, not refused. Whether this wire shape becomes readable end to end is a + // cross-service flag-day decision that governs the decoder gate and validator.js + // together (see isMinimalOpSingleByte), so refusing it here would settle half of a + // joint decision unilaterally and strand the decoder half when it lands. Telling + // the fee-payer before it signs needs no acceptance change at all. Drop this when + // the flag day lands. + const rawDataOnlyPayload = dataBuffer.length === 0 && rawData != null + Object.assign(build, { hasActionPayload, rawDataOnlyPayload }) +} + +// The encoding this build uses, and the per-encoding ceilings it must fit. +function chooseEncoding(build){ + let { encoding, finalDataBuffer, compressedPubKey, options } = build + // Size-aware encoding selection, behind the caller's explicit AUTO opt-in. + // Runs HERE: after compression, so it prices the bytes that will really be + // written, and before the ceiling check, so an over-cap payload is refused + // against the lane that was actually chosen. + // + // Deliberately NOT the same thing as the legacy `!encoding` fallback in + // prepareData (OP_RETURN, else P2SH), which stays exactly as shipped: a + // caller who passes no encoding must keep getting today's bytes, because + // auto-selecting TAPROOT changes the response from one PSBT to a + // commit/reveal pair and no existing caller is ready for that. + if (encoding === Encoding.AUTO){ + encoding = this.selectEncoding(finalDataBuffer.length, compressedPubKey, options) + } + + // Enforce the compiled-push ceiling the indexing decoder applies, PER + // ENCODING: every legacy lane keeps the 8,192-byte + // MAX_ACTION_DATA_LENGTH (the decoder measures the compiled on-chain + // push and silently drops anything larger, so reject at encode time); + // only an explicit TAPROOT request gets the envelope ceiling. Note the + // two constants measure slightly different things: the legacy guard is + // framing-inclusive of the single on-chain push, the envelope ceiling + // measures the reassembled payload (this same compiled stream) while + // the envelope's own 520-byte push framing rides outside it. + const compiledCeiling = (encoding === Encoding.TAPROOT) ? ENVELOPE_MAX_PAYLOAD : MAX_COMPILED_ACTION_DATA_LENGTH + if (finalDataBuffer.length > compiledCeiling) { + throw new RangeError(`Payload too large: compiled size ${finalDataBuffer.length} bytes exceeds maximum ${compiledCeiling} bytes (${encoding === Encoding.TAPROOT ? 'TAPROOT envelope payload ceiling' : 'compiled on-chain ACTION push'})`) + } + + if (encoding === 'P2WSH' && this.network.supportsSegwit === false) { + throw new TypeError('P2WSH encoding is not supported on this network (no segwit support)') + } + Object.assign(build, { encoding }) +} + +async function checkEnvelopeEncoding(build){ + let { encoding, p2shHash } = build + // Envelope availability is a property of the network definition: + // DOGE has no segwit, hence no Taproot; same gate, same error shape as + // P2WSH. The p2shHash guard exists for direct library callers (the API + // validator already rejects it): TAPROOT is a single-call flow that + // returns the commit/reveal pair together, never a second reveal call. + if (encoding === Encoding.TAPROOT) { + if (this.network.supportsSegwit === false) { + throw new TypeError('TAPROOT encoding is not supported on this network (no segwit support)') + } + if (p2shHash) { + throw new TypeError('TAPROOT encoding does not use the p2shHash reveal flow; one create_tx call returns the commit and reveal PSBTs together') + } + // Segwit support is NOT sufficient. Below the network's recognition + // height every decoder ignores an envelope reveal, so building one here + // would hand the caller a valid, broadcastable, correctly signed pair for + // an action that will never exist, and they pay real coin for it. The + // decoder's refusal is silent and correct, so nothing downstream can + // detect the loss; this is the only place it can be caught. + await this.assertEnvelopeRecognized() + ensureEccLib() + } +} + +module.exports = { checkPayloadInput, compressPayload, compilePayload, classifyPayload, chooseEncoding, checkEnvelopeEncoding } diff --git a/src/XChainEncoder/build_transaction/reveal_outputs.js b/src/XChainEncoder/build_transaction/reveal_outputs.js new file mode 100644 index 0000000..1d7e2b3 --- /dev/null +++ b/src/XChainEncoder/build_transaction/reveal_outputs.js @@ -0,0 +1,292 @@ +/********************************************************************* + * + * 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 handles starting the encoder and generating transactions + * + ********************************************************************/ + +const bitcoin = require('bitcoinjs-lib'); +const util = require('node:util'); +const TxSizeEstimator = require('../../build/tx_size_estimator') +const { logger, TAPROOT_LEAF_VERSION, SATOSHI_UNIT, Encoding } = require('../constants.js') +const { envelopeTapLeafHash, asSatValue } = require('../script_amount_helpers.js') +const { packageFeeUpliftSatoshis, maxCpfpUpliftSat } = require('../fee_policy.js') +const { resolveCallerAddress } = require('../request_resolution.js') + +// The change output, then the P2WSH reveal's stripped-size floor pad. +function emitChangeAndPad(build){ + let { changeSatoshis, change, psbt, p2shHash, preparedData, pubkey } = build + if ((changeSatoshis > 0) && (change)) { + // Emit change only at or above the relay floor for authored outputs; below it the + // output is non-standard or soft dust that makes the transaction unrelayable, so it + // folds into the miner fee instead. + if (changeSatoshis >= this.outputFloor) { + psbt.addOutput({ + address: change, + value: asSatValue(changeSatoshis) + }) + } + } + + // A P2WSH reveal that spends a single data chunk is just 1 input + 1 + // OP_RETURN output: 71 stripped (non-witness) bytes, which is BELOW the + // relay floor on every chain we support. The floor is a POLICY constant + // (Bitcoin Core's MIN_STANDARD_TX_NONWITNESS_SIZE = 82, Litecoin ~85), + // NOT the 65-byte CONSENSUS minimum that guards the 64-byte-transaction + // CVE. coins/BTC.js carried that consensus 65 until 2026-07-31, so this + // pad never fired on Bitcoin and a single-chunk P2WSH reveal was + // unbroadcastable there (measured live: 71 stripped bytes rejected + // "tx-size-small", 82 accepted). Corrected to 82 with the consensus pin + // regenerated across every repo vendoring BTC.js. + // + // The payload lives in the witness and does not count toward stripped + // size, so this shape stays small however large the file is. Lift the + // reveal over the chain's floor with one + // small payment output back to the caller's own address. A second + // OP_RETURN would be non-standard (multi-op-return), so we cannot pad + // with that. The funding tx already over-funded this reveal by one dust + // (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){ + psbt.addOutput({ + address: padAddress, + value: this.dustAmount + }) + } + } +} + +async function sweepP2shReveal(build){ + let { p2shHash, preparedData, phaseLegInputSatoshis, customOutputs, feePerBytes, estimatedFee, outputSatoshis, + change, pubkey, psbt } = 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 + // exist: without it the reveal's only output is the zero-value OP_RETURN + // marker, every satoshi of the legs goes to miners, and the SDK's + // FULL_BURN_FEE guard (correctly) refuses to sign, stranding the + // confirmed commit. The fee the reveal keeps is the larger of the + // dust-/explicit-fee-floored estimatedFee above and the same + // whole-reveal size estimate the funding side used (estimateP2shRevealTx, + // at this call's fee rate, including the emitted customOutputs' bytes); + // 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){ + 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) + let revealFeeKept = Math.trunc((revealSizeForFee * feePerBytes) * SATOSHI_UNIT) + if (revealFeeKept < estimatedFee){ + revealFeeKept = estimatedFee + } + + revealFeeKept = await 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)){ + psbt.addOutput({ + address: sweepAddress, + value: asSatValue(revealSurplus) + }) + } + } +} + +async function upliftRevealForCommit(build, revealSizeForFee, revealFeeKept){ + let { feePerBytes, p2shHash } = build + // Keep the package prefund as FEE instead of sweeping it home. + // + // The reveal spends only the commit's legs, so commit and reveal are + // one mempool package and the miner weighs them together. The funding + // side sized these legs at the package rate precisely so this + // transaction can pay the commit's shortfall; without this the extra + // lands in the surplus and goes straight back to the caller's change, + // 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 + // (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() + if (revealUpliftBound > 0 && feePerBytes > 0 && + this.connector && typeof this.connector.getUnconfirmedAncestorPackage === 'function'){ + let commitPackage = null + try { + commitPackage = await this.connector.getUnconfirmedAncestorPackage([p2shHash]) + } catch (err) { + logger.warn(util.format('Reveal package fee sizing skipped: commit lookup failed:', err.message)) + } + const wanted = commitPackage ? packageFeeUpliftSatoshis({ + currentFee: revealFeeKept, + txSize: revealSizeForFee, + ancestorSize: commitPackage.size, + ancestorFees: commitPackage.fees, + targetFeePerBytes: feePerBytes, + satoshiUnit: SATOSHI_UNIT + }) : 0 + if (wanted > 0){ + revealFeeKept = clampRevealUplift.call(this, build, commitPackage, wanted, revealUpliftBound, revealSizeForFee, revealFeeKept) + } + } + return revealFeeKept +} + +// The reveal-side uplift actually kept as fee, bounded by the legs' spendable surplus. +function clampRevealUplift(build, commitPackage, wanted, revealUpliftBound, revealSizeForFee, revealFeeKept){ + let { customOutputs, phaseLegInputSatoshis, outputSatoshis, feePerBytes } = build +// The legs are the only money this transaction has, and every +// satoshi above the fee is already spoken for by the outputs +// it must emit. Spend the surplus and not one unit more. +// +// A reveal with no customOutputs leaves exactly one value +// output, the swept change, and that output is what keeps it +// off the SDK's FULL_BURN_FEE refusal. Reserve its floor here: +// eating it would strand the confirmed commit outright, which +// is a far worse outcome than a package that confirms late. +const emitsValueOutput = !!(customOutputs && Array.isArray(customOutputs) && customOutputs.length > 0) +const sweepReserve = emitsValueOutput ? 0n : BigInt(this.outputFloor) +const spendable = phaseLegInputSatoshis - outputSatoshis - BigInt(revealFeeKept) - sweepReserve +let allowed = Math.min(wanted, revealUpliftBound) +if (spendable <= 0n) allowed = 0 +else if (spendable < BigInt(allowed)) allowed = Number(spendable) +if (allowed < wanted){ + const packageSize = commitPackage.size + revealSizeForFee + const packageFee = Math.round(commitPackage.fees * SATOSHI_UNIT) + revealFeeKept + allowed + logger.warn(`Reveal package fee uplift clamped to ${allowed} of ${wanted} base units: the commit ` + + `package totals ${commitPackage.size} bytes, so the commit/reveal package will pay ` + + `${Math.round(packageFee / packageSize * 1000)} base units/kB against a target of ` + + `${Math.round(feePerBytes * SATOSHI_UNIT * 1000)} and may stay unmined. The reveal has no ` + + `other input to raise, so rebuild the commit with a package-aware fee.`) +} +if (allowed > 0){ + revealFeeKept = revealFeeKept + allowed +} + return revealFeeKept +} + +function buildEnvelopeReveal(build){ + let { envelopeContext } = build + // Envelope reveal construction. Runs only on the + // TAPROOT funding path, after input selection and change, so the commit + // transaction is final in shape and its unsigned txid is the txid the + // network will see (segwit-only inputs, enforced above). + let revealPsbt = null + let envelopeResult = null + Object.assign(build, { revealPsbt, envelopeResult }) + if (envelopeContext){ + locateEnvelopeCommit.call(this, build) + buildRevealPsbt.call(this, build) + } +} + +// The commit's unsigned txid, the envelope output's index, and the control block. +function locateEnvelopeCommit(build){ + let { psbt, envelopeContext } = build + // Every commit input is signed SIGHASH_ALL. Attribution rides + // the commit's ins[0]; ANYONECANPAY-style signing would make index 0 + // third-party-insertable in a replacement. The PSBT field makes the + // requirement explicit to whatever signs it. + for (let i = 0; i < psbt.inputCount; i++){ + psbt.updateInput(i, { sighashType: bitcoin.Transaction.SIGHASH_ALL }) + } + + const commitTx = bitcoin.Transaction.fromBuffer(psbt.data.globalMap.unsignedTx.toBuffer()) + const commitTxid = commitTx.getId() + // The envelope commit output is emitted first, so this is vout 0 by + // construction; located by script rather than assumed, so a future + // emission-order change cannot silently strand the reveal. + const commitVout = commitTx.outs.findIndex(o => o.script.equals(envelopeContext.commitPayment.output)) + + // Control block for the script-path spend: single-leaf tree, no + // merkle path, 33 bytes (leaf version + parity bit, then the + // internal key). Derived from the SAME payment object that built + // the commit output, so it cannot drift from what was committed. + const revealPayment = bitcoin.payments.p2tr({ + internalPubkey: envelopeContext.internalPubkey, + scriptTree: { output: envelopeContext.envelopeScript }, + redeem: { output: envelopeContext.envelopeScript, redeemVersion: TAPROOT_LEAF_VERSION }, + network: this.network + }) + const controlBlock = revealPayment.witness[revealPayment.witness.length - 1] + Object.assign(build, { commitTxid, commitVout, controlBlock }) +} + +function buildRevealPsbt(build){ + let { envelopeContext, utxoSequence, commitTxid, commitVout, controlBlock, revealPsbt, envelopeResult } = build + revealPsbt = new bitcoin.Psbt({ network: this.network }) + // Reveal input 0 MUST be the commit outpoint; the decoder's + // recognition and attribution assume it. RBF preference mirrors the + // commit (the sequence toggle is the shipped replacement mechanism). + revealPsbt.addInput({ + hash: commitTxid, + index: commitVout, + sequence: utxoSequence, + witnessUtxo: { + script: envelopeContext.commitPayment.output, + value: envelopeContext.commitValue + }, + tapInternalKey: envelopeContext.internalPubkey, + tapLeafScript: [{ + leafVersion: TAPROOT_LEAF_VERSION, + script: envelopeContext.envelopeScript, + controlBlock + }] + }) + // Change back to the caller: the commit value minus the reveal's + // prefunded fee (and minus the floor pad's dust when present) is + // exactly the dust floor by construction; it exists because the + // reveal must carry an output and it doubles as the CPFP handle. + const revealChangeValue = envelopeContext.commitValue + - envelopeContext.revealFee + - (envelopeContext.revealPadNeeded ? this.dustAmount : 0) + revealPsbt.addOutput({ + address: envelopeContext.revealChangeAddress, + value: revealChangeValue + }) + if (envelopeContext.revealPadNeeded){ + revealPsbt.addOutput({ + address: envelopeContext.revealChangeAddress, + value: this.dustAmount + }) + } + + envelopeResult = { + commitTxid, + commitVout, + commitValue: envelopeContext.commitValue, + commitAddress: envelopeContext.commitPayment.address, + internalPubkey: envelopeContext.internalPubkey.toString('hex'), + tapleafHash: envelopeTapLeafHash(envelopeContext.envelopeScript).toString('hex'), + controlBlock: controlBlock.toString('hex'), + revealFee: envelopeContext.revealFee + } + Object.assign(build, { revealPsbt, envelopeResult }) +} + +module.exports = { emitChangeAndPad, sweepP2shReveal, buildEnvelopeReveal } diff --git a/src/XChainEncoder/build_transaction/script_hash_legs.js b/src/XChainEncoder/build_transaction/script_hash_legs.js new file mode 100644 index 0000000..c3a54c1 --- /dev/null +++ b/src/XChainEncoder/build_transaction/script_hash_legs.js @@ -0,0 +1,323 @@ +/********************************************************************* + * + * 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 handles starting the encoder and generating transactions + * + ********************************************************************/ + +const bitcoin = require('bitcoinjs-lib'); +const TxSizeEstimator = require('../../build/tx_size_estimator') +const { MAGIC_WORD, SATOSHI_UNIT } = require('../constants.js') +const { asSatValue } = require('../script_amount_helpers.js') +const { assertRevealFundingTxMatches } = require('../request_resolution.js') + +// A P2SH chunk: a funding output on the commit, or an input spending it on the reveal. +async function emitP2shChunk(build, nextDataBuffer){ + let { p2shHex, p2shTx, txidFirstInput, p2shHash } = build + if (p2shHex && !p2shTx){ + p2shTx = bitcoin.Transaction.fromHex(p2shHex) + txidFirstInput = p2shTx.getId() + assertRevealFundingTxMatches(p2shHash, txidFirstInput) + } + Object.assign(build, { p2shTx, txidFirstInput }) + + if (p2shHash){ + await spendP2shLeg.call(this, build, nextDataBuffer) + } else { + fundP2shLeg.call(this, build, nextDataBuffer) + } +} + +async function spendP2shLeg(build, nextDataBuffer){ + let { psbt, txidFirstInput, estimatedTxSize, p2shTx, voutPsbtIndex, utxoSequence, p2shHash, p2shHex, phaseLegInputSatoshis } = build + if (!psbt){ + let opReturnData = await this.obfuscate( + Buffer.concat([ + Buffer.from(MAGIC_WORD,'utf8'), + Buffer.from("p2sh",'utf8') + ]), + txidFirstInput + ) + + psbt = new bitcoin.Psbt({ network: this.network }) + psbt.addOutput({ + script: bitcoin.payments.embed({ + data: [opReturnData] + }).output, + value: 0 + }) + + estimatedTxSize = estimatedTxSize + + TxSizeEstimator.estimateOpReturnOutput(opReturnData) + } + + if (!p2shTx || !p2shTx.outs || voutPsbtIndex >= p2shTx.outs.length) { + throw new RangeError(`p2shHex transaction does not have output at index ${voutPsbtIndex}`) + } + let nextInput = { + sequence: utxoSequence, + hash:p2shHash, + redeemScript:nextDataBuffer, + index: voutPsbtIndex, + nonWitnessUtxo:Buffer.from(p2shHex, 'hex') + } + + psbt.addInput(nextInput) + estimatedTxSize = estimatedTxSize + TxSizeEstimator.estimateInputSize(nextInput) + // Leg value this reveal input consumes; the surplus sweep + // after the emission loop needs the total to price the + // change output. + phaseLegInputSatoshis = phaseLegInputSatoshis + BigInt(p2shTx.outs[voutPsbtIndex].value) + + voutPsbtIndex = voutPsbtIndex + 1 + Object.assign(build, { psbt, estimatedTxSize, phaseLegInputSatoshis, voutPsbtIndex }) +} + +function fundP2shLeg(build, nextDataBuffer){ + let { feePerBytes, finalDust, revealCustomOutputsValue, revealCustomOutputsFee, psbt, outputSatoshis, estimatedTxSize } = build + let spendingP2shEstimatedSize = this.estimateSpendingP2shTx(nextDataBuffer) + let spendingP2shEstimatedFee = Math.trunc((spendingP2shEstimatedSize * feePerBytes) * SATOSHI_UNIT) + + if (spendingP2shEstimatedFee < finalDust){ + spendingP2shEstimatedFee = finalDust + } + + // Over-fund the first funding output by the reveal-side + // customOutputs total, plus the miner fee for the bytes + // they add to the reveal, so the reveal can pay them + // (consumed once). + if (revealCustomOutputsValue > 0n || revealCustomOutputsFee > 0){ + spendingP2shEstimatedFee = asSatValue(BigInt(spendingP2shEstimatedFee) + revealCustomOutputsValue + BigInt(revealCustomOutputsFee)) + revealCustomOutputsValue = 0n + revealCustomOutputsFee = 0 + } + + spendingP2shEstimatedFee = topUpFirstP2shLeg.call(this, build, spendingP2shEstimatedFee) + + psbt.addOutput({ + address: bitcoin.payments.p2sh({ redeem: {output:nextDataBuffer}, network:this.network}).address, + value:spendingP2shEstimatedFee + }) + + outputSatoshis = outputSatoshis + BigInt(spendingP2shEstimatedFee) + + estimatedTxSize = estimatedTxSize + TxSizeEstimator.estimateP2shOutput() + Object.assign(build, { revealCustomOutputsValue, revealCustomOutputsFee, outputSatoshis, estimatedTxSize }) +} + +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 + // any output it leaves - must be prefunded here. Sizing + // each leg at max(per-chunk fee share, dust floor) alone + // left the reveal unable to leave a single output on + // Dogecoin, where the dust floor (100000 koinu) is the + // whole leg and the reveal's own required fee consumes + // it: the SDK's FULL_BURN_FEE guard then (correctly) + // refused to sign and the confirmed phase-1 commit was + // stranded. Top the FIRST leg up (consumed once, like + // the customOutputs fold above) so the legs total covers + // the whole reveal's fee AS THE REVEAL ITSELF WILL PRICE + // IT (estimateP2shRevealTx, shared by both phases), plus + // one dust (this.dustAmount, the same constant the + // reveal-side sweep compares against) for the change + // output the reveal returns to the caller. The 43 is the + // same worst-case change-output size constant the + // single-tx path reserves below. + // + // That change output is needed ONLY when the reveal would + // otherwise emit no value output at all. A reveal already + // carrying customOutputs (the native protocol fee) leaves + // value by definition, so the burn guard is satisfied and + // funding a change output too would buy the caller a + // third output they did not ask for - which is what REG-14 + // pins against on the legacy-fee-output case. So the fee + // base keeps the 43-byte change slot either way - both + // shapes stay priced identically, which is the other half + // REG-14 pins - and only the dust that would make a change + // 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. + if (!p2shRevealHeadroomFunded){ + p2shRevealHeadroomFunded = true + let needsChangeOutput = (revealCustomOutputsBytes === 0) + let { baseLegsTotal, revealFeeNeeded } = sumP2shRevealLegs.call(this, preparedData, feePerBytes, finalDust) + 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) + // 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), + revealFee: revealFeeNeeded + } + } + Object.assign(build, { p2shRevealHeadroomFunded, revealPrefund }) + return spendingP2shEstimatedFee +} + +// What the legs fund at their own per-chunk sizing, and what the whole reveal needs. +function sumP2shRevealLegs(preparedData, feePerBytes, finalDust){ + let baseLegsTotal = 0 + for (const chunkBuffer of preparedData["dataBufferArray"]){ + let chunkLegFee = Math.trunc((this.estimateSpendingP2shTx(chunkBuffer) * feePerBytes) * SATOSHI_UNIT) + baseLegsTotal = baseLegsTotal + Math.max(chunkLegFee, finalDust) + } + let revealFeeNeeded = Math.trunc((this.estimateP2shRevealTx(preparedData["dataBufferArray"], 43) * 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 } +} + +// A P2WSH chunk: a funding output on the commit, or an input spending it on the reveal. +async function emitP2wshChunk(build, nextDataBuffer){ + let { p2shHex, p2shTx, txidFirstInput, p2shHash } = build + if (p2shHex && !p2shTx){ + p2shTx = bitcoin.Transaction.fromHex(p2shHex) + txidFirstInput = p2shTx.getId() + assertRevealFundingTxMatches(p2shHash, txidFirstInput) + } + Object.assign(build, { p2shTx, txidFirstInput }) + + if (p2shHash){ + await spendP2wshLeg.call(this, build, nextDataBuffer) + } else { + fundP2wshLeg.call(this, build, nextDataBuffer) + } +} + +async function spendP2wshLeg(build, nextDataBuffer){ + let { psbt, txidFirstInput, p2shTx, voutPsbtIndex, utxoSequence, p2shHash, estimatedTxSize } = build + if (!psbt){ + psbt = new bitcoin.Psbt({ network: this.network }) + psbt.addOutput({ + script: bitcoin.payments.embed({ + data: [ + await this.obfuscate( + Buffer.concat([ + Buffer.from(MAGIC_WORD,'utf8'), + Buffer.from("p2wsh",'utf8') + ]), + txidFirstInput + ) + ] + }).output, + value: 0 + }) + } + + if (!p2shTx || !p2shTx.outs || voutPsbtIndex >= p2shTx.outs.length) { + throw new RangeError(`p2shHex transaction does not have output at index ${voutPsbtIndex}`) + } + let nextInput = { + sequence: utxoSequence, + hash:p2shHash, + witnessScript:nextDataBuffer, + index: voutPsbtIndex, + witnessUtxo:{ + script:p2shTx["outs"][voutPsbtIndex]["script"], + value:p2shTx["outs"][voutPsbtIndex]["value"] + } + } + psbt.addInput(nextInput) + + estimatedTxSize = estimatedTxSize + TxSizeEstimator.estimateInputSize(nextInput) + voutPsbtIndex = voutPsbtIndex + 1 + Object.assign(build, { psbt, estimatedTxSize, voutPsbtIndex }) +} + +function fundP2wshLeg(build, nextDataBuffer){ + let { feePerBytes, finalDust, preparedData, revealCustomOutputsValue, revealCustomOutputsFee, psbt, outputSatoshis, estimatedTxSize } = 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 + // the multi-input reveal tx below the node's min-relay-fee + // floor (observed on bitcoin-regtest, whose dustThreshold + // is 546: 1638 sat across 3 dust outputs vs a ~2228 sat + // 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) + let spendingP2wshEstimatedFee = Math.trunc((spendingP2wshEstimatedSize * feePerBytes) * SATOSHI_UNIT) + + if (spendingP2wshEstimatedFee < finalDust){ + spendingP2wshEstimatedFee = finalDust + } + + spendingP2wshEstimatedFee = fundRevealFloorPad.call(this, preparedData, spendingP2wshEstimatedFee) + + // Over-fund the first funding output by the reveal-side + // customOutputs total, plus the miner fee for the bytes + // they add to the reveal, so the reveal can pay them + // (consumed once). + if (revealCustomOutputsValue > 0n || revealCustomOutputsFee > 0){ + spendingP2wshEstimatedFee = asSatValue(BigInt(spendingP2wshEstimatedFee) + revealCustomOutputsValue + BigInt(revealCustomOutputsFee)) + revealCustomOutputsValue = 0n + revealCustomOutputsFee = 0 + } + + psbt.addOutput({ + address: bitcoin.payments.p2wsh({ redeem: {output:nextDataBuffer}, network:this.network}).address, + value:spendingP2wshEstimatedFee + }) + // Account for the data output's value so change is not + // over-credited. Without this, changeSatoshis below is + // computed as input - 0 - fee, so the data outputs are + // funded "for free" and total outputs exceed total + // inputs; bitcoinjs rejects tx1 with "Outputs are + // spending more than Inputs". Mirrors the P2SH and + // MULTISIGN branches, which already track their outputs. + outputSatoshis = outputSatoshis + BigInt(spendingP2wshEstimatedFee) + + estimatedTxSize = estimatedTxSize + + TxSizeEstimator.estimateP2wshOutput() + Object.assign(build, { revealCustomOutputsValue, revealCustomOutputsFee, outputSatoshis, estimatedTxSize }) +} + +function fundRevealFloorPad(preparedData, spendingP2wshEstimatedFee){ + // Chains such as Litecoin reject a reveal tx whose + // stripped (non-witness) serialization is below their + // relay floor (minStandardTxNonWitnessSize). A reveal + // spending these data outputs is 10 (header) + 41 per + // P2WSH input + ~20 (OP_RETURN marker) stripped bytes; a + // single-chunk reveal is only 71 bytes, under Litecoin's + // 85-byte floor. The reveal builder clears the floor with + // one extra small payment output (see the p2shHash branch + // 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){ + // 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 + // could over/under-fund the pad when a caller passes a custom + // dust, leaving the two halves of the flow inconsistent. + spendingP2wshEstimatedFee = spendingP2wshEstimatedFee + this.dustAmount + } + return spendingP2wshEstimatedFee +} + +module.exports = { emitP2shChunk, emitP2wshChunk } From a65b815b64700c81a73d30c022374e2b3cca1f0c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 11:08:37 -0700 Subject: [PATCH 135/148] refactor(encoder): move prepareData's per-encoding layouts into src/XChainEncoder/payload_encodings.js --- src/XChainEncoder/payload_encodings.js | 262 +++++++++++++++++++++++ src/XChainEncoder/payload_preparation.js | 209 +----------------- 2 files changed, 267 insertions(+), 204 deletions(-) create mode 100644 src/XChainEncoder/payload_encodings.js diff --git a/src/XChainEncoder/payload_encodings.js b/src/XChainEncoder/payload_encodings.js new file mode 100644 index 0000000..a8fd1e1 --- /dev/null +++ b/src/XChainEncoder/payload_encodings.js @@ -0,0 +1,262 @@ +/********************************************************************* + * + * 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 handles starting the encoder and generating transactions + * + ********************************************************************/ + +const bitcoin = require('bitcoinjs-lib'); +const { OP_RETURN_SIZE, P2SH_SIZE, PW2SH_SIZE, MULTISIGN_SIZE, TAPROOT_ENVELOPE_CHUNK_SIZE, TAPROOT_ENVELOPE_FORMAT_V0, Encoding } = require('./constants.js') +const { resolveCallerHash160 } = require('./request_resolution.js') + +// The per-encoding payload layouts prepareData dispatches to. Each returns the +// chunk buffers the emission loop turns into outputs, with the encoding used. + +function opReturnPayload(data, encoding, magicWordBuffer){ + let chunksSize = 0 + let dataBufferArray = [] + chunksSize = OP_RETURN_SIZE - magicWordBuffer.length //Single OP_RETURN output: 80-byte limit minus 4-byte magic word = 76 bytes of payload + + // All supported chains (BTC, LTC, DOGE) inherit Bitcoin Core's + // IsStandardTx rule that rejects any transaction with more than one + // nulldata/OP_RETURN output as non-standard ("multi-op-return"). A split + // payload builds a structurally valid PSBT that fails silently at broadcast + // and burns the fee UTXOs. The auto-selection path avoids this by falling + // back to P2SH; the explicit-encoding path must reject loudly here instead. + // + // The single-output 80-byte ceiling is enforced unconditionally. + // singleOpReturnPolicy:false does not skip this throw and fall + // through to a multi-chunk split loop instead: no shipped decoder + // can reassemble a payload split across multiple OP_RETURN pushes + // (it only ever reads a single OP_RETURN output), and no shipped + // coin relays a multi-OP_RETURN transaction as standard, so that + // path was a fee-burning, undecodable-transaction trap armed by one + // config flag rather than a real opt-out. Fail-closed + // unconditionally now, the same as when the flag is absent. + if (data.length > chunksSize) { + throw new RangeError( + `OP_RETURN encoding requires compiled payload <= ${chunksSize} bytes; ` + + `got ${data.length}. Use P2SH for larger payloads.` + ) + } + + // data.length <= chunksSize is now guaranteed above, so the + // payload always compiles to exactly one OP_RETURN chunk. + dataBufferArray.push(Buffer.concat([magicWordBuffer,data])) + + return {"dataBufferArray":dataBufferArray, "encoding": encoding} +} + +function scriptHashPayload(data, encoding, pubKey){ + let chunksSize = 0 + let dataBufferArray = [] + /* REDEEM SCRIPT (exactly as compiled below) + // the on-wire data push (max chunksSize bytes) + + + OP_DROP // 1 byte - drops the data so it never gates the spend + + + OP_DUP // 1 byte + + + OP_HASH160 // 1 byte + + + // 20-byte HASH160 of the caller key + // (resolveCallerHash160(pubKey): base58 addr, + // raw pubkey hex, or v0 bech32 P2WPKH all yield + // the same HASH160(pubkey)), NOT a 33-byte pubkey + + + OP_EQUALVERIFY // 1 byte + + + OP_CHECKSIG // 1 byte + // + // The trailing five ops form an ordinary P2PKH gate; the data rides + // in the leading push and is discarded at spend time by OP_DROP. + // There is no n / OP_DEPTH / 0 / OP_EQUAL tail (that older sketch + // never matched the compiled script). + */ + + chunksSize = (encoding == Encoding.P2SH?P2SH_SIZE:PW2SH_SIZE) - 44 // 44 is a conservative per-chunk overhead reservation that leaves headroom under the 520-byte consensus MAX_SCRIPT_ELEMENT_SIZE (P2SH_SIZE/PW2SH_SIZE) for the OP_DROP/OP_DUP/OP_HASH160//OP_EQUALVERIFY/OP_CHECKSIG trailer plus the leading data-push prefix. Each chunk becomes one P2SH/P2WSH output; the input spending it carries the data inside its redeem/witness script. + + let pubkeyFromBase58 = resolveCallerHash160(pubKey) + + let p2shChunks = sliceScriptHashChunks(data, chunksSize) + + for (const chunk of p2shChunks){ + let nextDataBuffer = bitcoin.script.compile([ + chunk, + bitcoin.opcodes.OP_DROP, + bitcoin.opcodes.OP_DUP, + bitcoin.opcodes.OP_HASH160, + pubkeyFromBase58, + bitcoin.opcodes.OP_EQUALVERIFY, + bitcoin.opcodes.OP_CHECKSIG, + ]) + + dataBufferArray.push(nextDataBuffer) + } + + return {"dataBufferArray":dataBufferArray, "encoding": encoding} +} + +function sliceScriptHashChunks(data, chunksSize){ + let i = 0 + let nextDataChunk = null + // Slice the chunk boundaries first so a degenerate 1-byte final + // chunk can be rebalanced before compile. bitcoin.script.compile + // applies asMinimalOP: a lone data byte of 0x01-0x10 or 0x81 + // canonicalizes to a bare opcode, and the decoder's redeem-script + // Buffer gate (XChainDecoder.js: !Buffer.isBuffer(decodedRedeemScript[0])) + // then silently skips that output, corrupting reassembly of an + // otherwise-valid multi-byte payload. Shifting one byte from the + // penultimate chunk keeps every chunk >= 2 bytes; the decoder + // reassembles by concatenation so moving a byte across the + // boundary is transparent end-to-end. + let p2shChunks = [] + i = 0 + while (i < data.length){ + nextDataChunk = data.subarray(i,i+chunksSize) + p2shChunks.push(nextDataChunk) + i = i + nextDataChunk.length + } + if (p2shChunks.length >= 2){ + let last = p2shChunks[p2shChunks.length - 1] + if (last.length === 1 && ((last[0] >= 0x01 && last[0] <= 0x10) || last[0] === 0x81)){ + // Repartition the final two chunks: penultimate drops its + // last byte, final grows to 2 bytes. Both are re-sliced + // from `data` so the concatenation is byte-identical. + let prevStart = data.length - 1 - p2shChunks[p2shChunks.length - 2].length + p2shChunks[p2shChunks.length - 2] = data.subarray(prevStart, data.length - 2) + p2shChunks[p2shChunks.length - 1] = data.subarray(data.length - 2) + } + } + return p2shChunks +} + +function multisignPayload(data, encoding, magicWordBuffer){ + let chunksSize = 0 + let dataBufferArray = [] + let i = 0 + let nextDataChunk = null + chunksSize = MULTISIGN_SIZE + - magicWordBuffer.length + - 1 //1 byte for the OP_CHECKMULTISIG + - 1 //1 byte for the m signatures to pop + - 1 //1 byte for the n addresses to pop + - 1 //1 byte for the first address length + - 1 //1 byte for the second address length + + // Each MULTISIGN output carries its data across two 32-byte + // pubkey halves (64 data bytes total). A full chunk is already + // magic(4) + 60 = 64 bytes, but the final chunk is shorter. + // Zero-pad every chunk up to the full 64-byte slot so BOTH + // pubkey halves are always complete 32-byte values. Without + // this, a short final chunk leaves the second half empty (or + // near-empty); dataToPubkey() then produces an all-zero / + // low-entropy EC point that bitcoinjs-lib rejects as not a + // valid point. The reader strips this trailing pad using the + // payload's own self-describing compiled-script length, so the + // padding is invisible end-to-end. + let multisignSlotSize = 64 + + i = 0 + while (i < data.length){ + nextDataChunk = data.subarray(i,i+chunksSize) + let nextChunk = Buffer.concat([magicWordBuffer,nextDataChunk]) + if (nextChunk.length < multisignSlotSize){ + nextChunk = Buffer.concat([nextChunk, Buffer.alloc(multisignSlotSize - nextChunk.length, 0)]) + } + dataBufferArray.push(nextChunk) + i = i + nextDataChunk.length + } + + return {"dataBufferArray":dataBufferArray, "encoding": encoding} +} + +function envelopePayload(data, encoding, compressedPubKey, magicWordBuffer){ + /* TAPROOT ENVELOPE LEAF (grammar frozen at review) + OP_FALSE OP_IF + <"XCHN"> // 4-byte magic, CLEARTEXT (same constant as every lane) + <0x00> // format byte v0, cleartext + // raw payload, 520-byte elements, in order + OP_ENDIF + OP_CHECKSIG + // + // The payload is the compiled action stream (`data` here: the + // action-string push plus the rawData push), byte-identical to + // what the chunk lanes carry, with the magic excluded because + // the envelope header already carries it. Raw by design: the + // shipped large-payload (P2WSH) precedent is unobfuscated, and + // keying on the commit txid is circular by construction. + */ + // The leaf tail and the key-path cancel both need the caller's + // real pubkey; `pubKey` may be a bare address, so the explicit + // compressedPubKey is required, mirroring MULTISIGN. + if (compressedPubKey == null) { + throw new TypeError('compressedPubKey is required for TAPROOT encoding (it becomes the envelope internal key)') + } + if (typeof compressedPubKey !== 'string' || !/^(02|03)[0-9a-fA-F]{64}$/.test(compressedPubKey)) { + throw new TypeError('compressedPubKey must be a 66-character hex string starting with 02 or 03') + } + const internalPubkey = Buffer.from(compressedPubKey, 'hex').subarray(1) + let envelopeChunks = sliceEnvelopeChunks(data) + + const envelopeScript = bitcoin.script.compile([ + bitcoin.opcodes.OP_0, // OP_FALSE + bitcoin.opcodes.OP_IF, + magicWordBuffer, + Buffer.from([TAPROOT_ENVELOPE_FORMAT_V0]), + ...envelopeChunks, + bitcoin.opcodes.OP_ENDIF, + internalPubkey, + bitcoin.opcodes.OP_CHECKSIG, + ]) + + // dataBufferArray carries exactly ONE element: the whole + // envelope tapscript. The emission loop turns it into one + // commit output; there is no per-chunk output fan-out. + return {"dataBufferArray":[envelopeScript], "encoding": encoding, "internalPubkey": internalPubkey} +} + +function sliceEnvelopeChunks(data){ + let chunksSize = 0 + let i = 0 + let nextDataChunk = null + chunksSize = TAPROOT_ENVELOPE_CHUNK_SIZE + let envelopeChunks = [] + i = 0 + while (i < data.length){ + nextDataChunk = data.subarray(i, i + chunksSize) + envelopeChunks.push(nextDataChunk) + i = i + nextDataChunk.length + } + // Same degenerate-final-chunk rebalance as the P2SH/P2WSH lane: + // bitcoin.script.compile canonicalizes a lone 0x01-0x10/0x81 byte + // to a bare opcode (asMinimalOP), which would corrupt the + // concatenation the decoder reassembles. Shift one byte across + // the final boundary so every push stays >= 2 bytes. (A 1-byte + // TOTAL payload cannot occur: the compiled stream always leads + // with a push opcode, so any non-empty payload is >= 2 bytes.) + if (envelopeChunks.length >= 2){ + let last = envelopeChunks[envelopeChunks.length - 1] + if (last.length === 1 && ((last[0] >= 0x01 && last[0] <= 0x10) || last[0] === 0x81)){ + let prevStart = data.length - 1 - envelopeChunks[envelopeChunks.length - 2].length + envelopeChunks[envelopeChunks.length - 2] = data.subarray(prevStart, data.length - 2) + envelopeChunks[envelopeChunks.length - 1] = data.subarray(data.length - 2) + } + } + return envelopeChunks +} + +module.exports = { opReturnPayload, scriptHashPayload, multisignPayload, envelopePayload } diff --git a/src/XChainEncoder/payload_preparation.js b/src/XChainEncoder/payload_preparation.js index 07351f5..429825a 100644 --- a/src/XChainEncoder/payload_preparation.js +++ b/src/XChainEncoder/payload_preparation.js @@ -24,6 +24,7 @@ const CryptoNetworks = require('../build/crypto_networks') const { MAX_COMPILED_ACTION_DATA_LENGTH, ENVELOPE_MAX_PAYLOAD } = require('../common/validator') const { OP_RETURN_SIZE, P2SH_SIZE, PW2SH_SIZE, MULTISIGN_SIZE, MAGIC_WORD, TAPROOT_ENVELOPE_CHUNK_SIZE, TAPROOT_ENVELOPE_FORMAT_V0, Encoding } = require('./constants.js') const { resolveCallerHash160 } = require('./request_resolution.js') +const { opReturnPayload, scriptHashPayload, multisignPayload, envelopePayload } = require('./payload_encodings.js') module.exports = { isSegwitUTXO(utxo) { @@ -54,216 +55,16 @@ module.exports = { encoding = Encoding.P2SH } } - let chunksSize = 0 - let dataBufferArray = [] - let i = 0 - let nextDataChunk = null switch (encoding){ case Encoding.OP_RETURN: - chunksSize = OP_RETURN_SIZE - magicWordBuffer.length //Single OP_RETURN output: 80-byte limit minus 4-byte magic word = 76 bytes of payload - - // All supported chains (BTC, LTC, DOGE) inherit Bitcoin Core's - // IsStandardTx rule that rejects any transaction with more than one - // nulldata/OP_RETURN output as non-standard ("multi-op-return"). A split - // payload builds a structurally valid PSBT that fails silently at broadcast - // and burns the fee UTXOs. The auto-selection path avoids this by falling - // back to P2SH; the explicit-encoding path must reject loudly here instead. - // - // The single-output 80-byte ceiling is enforced unconditionally. - // singleOpReturnPolicy:false does not skip this throw and fall - // through to a multi-chunk split loop instead: no shipped decoder - // can reassemble a payload split across multiple OP_RETURN pushes - // (it only ever reads a single OP_RETURN output), and no shipped - // coin relays a multi-OP_RETURN transaction as standard, so that - // path was a fee-burning, undecodable-transaction trap armed by one - // config flag rather than a real opt-out. Fail-closed - // unconditionally now, the same as when the flag is absent. - if (data.length > chunksSize) { - throw new RangeError( - `OP_RETURN encoding requires compiled payload <= ${chunksSize} bytes; ` + - `got ${data.length}. Use P2SH for larger payloads.` - ) - } - - // data.length <= chunksSize is now guaranteed above, so the - // payload always compiles to exactly one OP_RETURN chunk. - dataBufferArray.push(Buffer.concat([magicWordBuffer,data])) - - return {"dataBufferArray":dataBufferArray, "encoding": encoding} + return opReturnPayload(data, encoding, magicWordBuffer) case Encoding.P2SH: case Encoding.P2WSH: - /* REDEEM SCRIPT (exactly as compiled below) - // the on-wire data push (max chunksSize bytes) - + - OP_DROP // 1 byte - drops the data so it never gates the spend - + - OP_DUP // 1 byte - + - OP_HASH160 // 1 byte - + - // 20-byte HASH160 of the caller key - // (resolveCallerHash160(pubKey): base58 addr, - // raw pubkey hex, or v0 bech32 P2WPKH all yield - // the same HASH160(pubkey)), NOT a 33-byte pubkey - + - OP_EQUALVERIFY // 1 byte - + - OP_CHECKSIG // 1 byte - // - // The trailing five ops form an ordinary P2PKH gate; the data rides - // in the leading push and is discarded at spend time by OP_DROP. - // There is no n / OP_DEPTH / 0 / OP_EQUAL tail (that older sketch - // never matched the compiled script). - */ - - chunksSize = (encoding == Encoding.P2SH?P2SH_SIZE:PW2SH_SIZE) - 44 // 44 is a conservative per-chunk overhead reservation that leaves headroom under the 520-byte consensus MAX_SCRIPT_ELEMENT_SIZE (P2SH_SIZE/PW2SH_SIZE) for the OP_DROP/OP_DUP/OP_HASH160//OP_EQUALVERIFY/OP_CHECKSIG trailer plus the leading data-push prefix. Each chunk becomes one P2SH/P2WSH output; the input spending it carries the data inside its redeem/witness script. - - let pubkeyFromBase58 = resolveCallerHash160(pubKey) - - // Slice the chunk boundaries first so a degenerate 1-byte final - // chunk can be rebalanced before compile. bitcoin.script.compile - // applies asMinimalOP: a lone data byte of 0x01-0x10 or 0x81 - // canonicalizes to a bare opcode, and the decoder's redeem-script - // Buffer gate (XChainDecoder.js: !Buffer.isBuffer(decodedRedeemScript[0])) - // then silently skips that output, corrupting reassembly of an - // otherwise-valid multi-byte payload. Shifting one byte from the - // penultimate chunk keeps every chunk >= 2 bytes; the decoder - // reassembles by concatenation so moving a byte across the - // boundary is transparent end-to-end. - let p2shChunks = [] - i = 0 - while (i < data.length){ - nextDataChunk = data.subarray(i,i+chunksSize) - p2shChunks.push(nextDataChunk) - i = i + nextDataChunk.length - } - if (p2shChunks.length >= 2){ - let last = p2shChunks[p2shChunks.length - 1] - if (last.length === 1 && ((last[0] >= 0x01 && last[0] <= 0x10) || last[0] === 0x81)){ - // Repartition the final two chunks: penultimate drops its - // last byte, final grows to 2 bytes. Both are re-sliced - // from `data` so the concatenation is byte-identical. - let prevStart = data.length - 1 - p2shChunks[p2shChunks.length - 2].length - p2shChunks[p2shChunks.length - 2] = data.subarray(prevStart, data.length - 2) - p2shChunks[p2shChunks.length - 1] = data.subarray(data.length - 2) - } - } - - for (const chunk of p2shChunks){ - let nextDataBuffer = bitcoin.script.compile([ - chunk, - bitcoin.opcodes.OP_DROP, - bitcoin.opcodes.OP_DUP, - bitcoin.opcodes.OP_HASH160, - pubkeyFromBase58, - bitcoin.opcodes.OP_EQUALVERIFY, - bitcoin.opcodes.OP_CHECKSIG, - ]) - - dataBufferArray.push(nextDataBuffer) - } - - return {"dataBufferArray":dataBufferArray, "encoding": encoding} + return scriptHashPayload(data, encoding, pubKey) case Encoding.MULTISIGN: - chunksSize = MULTISIGN_SIZE - - magicWordBuffer.length - - 1 //1 byte for the OP_CHECKMULTISIG - - 1 //1 byte for the m signatures to pop - - 1 //1 byte for the n addresses to pop - - 1 //1 byte for the first address length - - 1 //1 byte for the second address length - - // Each MULTISIGN output carries its data across two 32-byte - // pubkey halves (64 data bytes total). A full chunk is already - // magic(4) + 60 = 64 bytes, but the final chunk is shorter. - // Zero-pad every chunk up to the full 64-byte slot so BOTH - // pubkey halves are always complete 32-byte values. Without - // this, a short final chunk leaves the second half empty (or - // near-empty); dataToPubkey() then produces an all-zero / - // low-entropy EC point that bitcoinjs-lib rejects as not a - // valid point. The reader strips this trailing pad using the - // payload's own self-describing compiled-script length, so the - // padding is invisible end-to-end. - let multisignSlotSize = 64 - - i = 0 - while (i < data.length){ - nextDataChunk = data.subarray(i,i+chunksSize) - let nextChunk = Buffer.concat([magicWordBuffer,nextDataChunk]) - if (nextChunk.length < multisignSlotSize){ - nextChunk = Buffer.concat([nextChunk, Buffer.alloc(multisignSlotSize - nextChunk.length, 0)]) - } - dataBufferArray.push(nextChunk) - i = i + nextDataChunk.length - } - - return {"dataBufferArray":dataBufferArray, "encoding": encoding} + return multisignPayload(data, encoding, magicWordBuffer) case Encoding.TAPROOT: { - /* TAPROOT ENVELOPE LEAF (grammar frozen at review) - OP_FALSE OP_IF - <"XCHN"> // 4-byte magic, CLEARTEXT (same constant as every lane) - <0x00> // format byte v0, cleartext - // raw payload, 520-byte elements, in order - OP_ENDIF - OP_CHECKSIG - // - // The payload is the compiled action stream (`data` here: the - // action-string push plus the rawData push), byte-identical to - // what the chunk lanes carry, with the magic excluded because - // the envelope header already carries it. Raw by design: the - // shipped large-payload (P2WSH) precedent is unobfuscated, and - // keying on the commit txid is circular by construction. - */ - // The leaf tail and the key-path cancel both need the caller's - // real pubkey; `pubKey` may be a bare address, so the explicit - // compressedPubKey is required, mirroring MULTISIGN. - if (compressedPubKey == null) { - throw new TypeError('compressedPubKey is required for TAPROOT encoding (it becomes the envelope internal key)') - } - if (typeof compressedPubKey !== 'string' || !/^(02|03)[0-9a-fA-F]{64}$/.test(compressedPubKey)) { - throw new TypeError('compressedPubKey must be a 66-character hex string starting with 02 or 03') - } - const internalPubkey = Buffer.from(compressedPubKey, 'hex').subarray(1) - - chunksSize = TAPROOT_ENVELOPE_CHUNK_SIZE - let envelopeChunks = [] - i = 0 - while (i < data.length){ - nextDataChunk = data.subarray(i, i + chunksSize) - envelopeChunks.push(nextDataChunk) - i = i + nextDataChunk.length - } - // Same degenerate-final-chunk rebalance as the P2SH/P2WSH lane: - // bitcoin.script.compile canonicalizes a lone 0x01-0x10/0x81 byte - // to a bare opcode (asMinimalOP), which would corrupt the - // concatenation the decoder reassembles. Shift one byte across - // the final boundary so every push stays >= 2 bytes. (A 1-byte - // TOTAL payload cannot occur: the compiled stream always leads - // with a push opcode, so any non-empty payload is >= 2 bytes.) - if (envelopeChunks.length >= 2){ - let last = envelopeChunks[envelopeChunks.length - 1] - if (last.length === 1 && ((last[0] >= 0x01 && last[0] <= 0x10) || last[0] === 0x81)){ - let prevStart = data.length - 1 - envelopeChunks[envelopeChunks.length - 2].length - envelopeChunks[envelopeChunks.length - 2] = data.subarray(prevStart, data.length - 2) - envelopeChunks[envelopeChunks.length - 1] = data.subarray(data.length - 2) - } - } - - const envelopeScript = bitcoin.script.compile([ - bitcoin.opcodes.OP_0, // OP_FALSE - bitcoin.opcodes.OP_IF, - magicWordBuffer, - Buffer.from([TAPROOT_ENVELOPE_FORMAT_V0]), - ...envelopeChunks, - bitcoin.opcodes.OP_ENDIF, - internalPubkey, - bitcoin.opcodes.OP_CHECKSIG, - ]) - - // dataBufferArray carries exactly ONE element: the whole - // envelope tapscript. The emission loop turns it into one - // commit output; there is no per-chunk output fan-out. - return {"dataBufferArray":[envelopeScript], "encoding": encoding, "internalPubkey": internalPubkey} + return envelopePayload(data, encoding, compressedPubKey, magicWordBuffer) } default: throw new TypeError(`Unknown encoding: "${encoding}". Valid values: OP_RETURN, P2SH, MULTISIGN, P2WSH, TAPROOT`) From fd288642db60c7444b3748aeaab91ddbf29a9e97 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 11:08:37 -0700 Subject: [PATCH 136/148] refactor(encoder): split buildEnvelopeCancelTransaction into record, claim, fee rate and sizing steps --- src/XChainEncoder/envelope_cancel.js | 291 +++++++++++++++------------ 1 file changed, 157 insertions(+), 134 deletions(-) diff --git a/src/XChainEncoder/envelope_cancel.js b/src/XChainEncoder/envelope_cancel.js index e6933a3..26fc05f 100644 --- a/src/XChainEncoder/envelope_cancel.js +++ b/src/XChainEncoder/envelope_cancel.js @@ -27,6 +27,158 @@ const { OperationalError } = require('../build/errors') const { logger, SATOSHI_UNIT, RESERVATION_TTL_MS, Encoding } = require('./constants.js') const { ensureEccLib } = require('./script_amount_helpers.js') +// The cancel's recovery record as handed over: validated, decoded, and read +// into the values the build uses. +function readCancelRecord(commitTxid, commitVout, commitValue, internalPubkey, tapleafHash, destination, feePerKb, replacebyfee){ + if (typeof commitTxid !== 'string' || !/^[0-9a-fA-F]{64}$/.test(commitTxid)) { + throw new TypeError('commitTxid must be a 64-character hex string') + } + if (!Number.isInteger(commitVout) || commitVout < 0) { + throw new TypeError('commitVout must be a non-negative integer') + } + const value = parseSatoshiAmount(commitValue, 'commitValue') + // Accept the 33-byte compressed form (what create_tx took) or the + // 32-byte x-only form (what the recovery record may hold). + let internalKeyBuf + if (typeof internalPubkey === 'string' && /^(02|03)[0-9a-fA-F]{64}$/.test(internalPubkey)) { + internalKeyBuf = Buffer.from(internalPubkey, 'hex').subarray(1) + } else if (typeof internalPubkey === 'string' && /^[0-9a-fA-F]{64}$/.test(internalPubkey)) { + internalKeyBuf = Buffer.from(internalPubkey, 'hex') + } else { + throw new TypeError('internalPubkey must be a 66-character compressed or 64-character x-only pubkey hex string') + } + if (typeof tapleafHash !== 'string' || !/^[0-9a-fA-F]{64}$/.test(tapleafHash)) { + throw new TypeError('tapleafHash must be a 64-character hex string') + } + // validateAddress, not a local non-empty check: this is the one create + // path api.js does not route through validateAll, so without it the + // 100-char cap every other address field gets is missing here and an + // unbounded string reaches bs58check (quadratic) and psbt.addOutput. + validateAddress(destination, 'destination') + if (this.network.supportsSegwit === false) { + throw new TypeError('TAPROOT encoding is not supported on this network (no segwit support)') + } + // Same money/boolean guards create_tx gets through validateAll, so both + // create paths classify identical bad input identically (-32602). Left + // uncoerced, a non-numeric feePerKb becomes NaN, slips past both dust + // comparisons (every NaN comparison is false) and surfaces as an opaque + // -32603; and the JSON string "false" is truthy, silently arming RBF. + const feeRatePerKb = validateFeePerKb(feePerKb) + const rbfArmed = validateOptionalBoolean(replacebyfee, 'replacebyfee') === true + ensureEccLib() + const tapleafHashBuf = Buffer.from(tapleafHash, 'hex') + return { value, internalKeyBuf, feeRatePerKb, rbfArmed, tapleafHashBuf } +} + +function claimCommitOutpoint(callReservations, commitTxid, commitVout){ + // Cross-path double-spend guard. This is the one build path outside + // _buildTransaction, so without a claim here a concurrent create_tx whose + // fetched set still carries the commit output selects and reserves it while + // an unsigned cancel of that same output is outstanding. Lowercased because + // commitTxid is accepted in either case above while create_tx keys are + // canonicalized in validator.validateUtxoEntry, and an uppercase key can + // never collide with the reservation it is meant to see. Claimed + // synchronously, before the first await below, so a concurrent call + // observes it. + const outpointKey = commitTxid.toLowerCase() + ':' + commitVout + const nowClaim = Date.now() + this.evictExpiredReservations(nowClaim) + this.evictExpiredEnvelopeCancelClaims(nowClaim) + // Reserved AND not held by this path: the reservation is worth the highest + // live cancel-owner expiry whenever a cancel build holds it, so any other + // value means a foreign createTransaction claim owns the outpoint. + if (this.isOutpointReserved(outpointKey, nowClaim) && + this.maxLiveCancelOwnerExpiry(outpointKey, nowClaim) !== this.outpointReservations.get(outpointKey)){ + throw new OperationalError( + 'ENVELOPE_CANCEL_OUTPOINT_RESERVED', + `commit outpoint ${outpointKey} is reserved by a transaction built in the last ` + + `${Math.round(RESERVATION_TTL_MS / 60000)} minutes; broadcast that transaction and rebuild ` + + 'from the resulting view, or wait for the reservation to lapse', + { outpoint: outpointKey } + ) + } + // Re-taking this path's OWN live claim is allowed, and refuseDuplicateBuild + // is deliberately not wired in here, for the same reason: the header + // contract above is that a cancel rebuilds from the persisted recovery + // record alone, so a lost-response retry must not be refused for five + // minutes for producing the byte-identical transaction it is supposed to. + // + // Each build registers its own owner token rather than overwriting a single + // stamp, so an overlapping build's failure releases only what it took. A + // single stamp, overwritten and then deleted, hands the commit outpoint back + // to createTransaction while the first build's unsigned cancel still spends it. + this.claimOutpoint(callReservations, outpointKey, nowClaim) + const cancelClaim = callReservations[callReservations.length - 1] + const cancelOwnerId = crypto.randomBytes(16).toString('hex') + cancelClaim.cancelOwnerId = cancelOwnerId + let cancelOwners = this.envelopeCancelClaims.get(outpointKey) + if (!cancelOwners) { + cancelOwners = new Map() + this.envelopeCancelClaims.set(outpointKey, cancelOwners) + } + cancelOwners.set(cancelOwnerId, cancelClaim.expiry) +} + +async function cancelFeeRate(feeRatePerKb){ + // Fee-rate resolution with the same drain guards as createTransaction, + // in miniature: the caller rate is clamped to the tighter of the + // absolute MAX_FEE_RATE_KB cap and the relative multiplier x the node's + // own estimate. A cancel sweeps a prefund that scales with payload size + // and fee rate, so an unbounded rate here is a real burn surface. + let feePerBytes + let nodeFeePerBytes = null + if (feeRatePerKb){ + feePerBytes = feeRatePerKb / 1000 / SATOSHI_UNIT + try { + nodeFeePerBytes = await this.connector.getFeePerKilobyte(1) / 1000 + } catch (err) { + logger.warn(util.format('Envelope-cancel relative fee cap skipped: node fee estimate unavailable:', err.message)) + } + } else { + feePerBytes = await this.connector.getFeePerKilobyte(1) / 1000 + nodeFeePerBytes = feePerBytes + } + let capFeePerBytes = this.maxFeePerBytes + if (this.maxFeeRateMultiplier && nodeFeePerBytes != null){ + const relativeCap = nodeFeePerBytes * this.maxFeeRateMultiplier + capFeePerBytes = (capFeePerBytes != null) ? Math.min(capFeePerBytes, relativeCap) : relativeCap + } + if (capFeePerBytes != null && feePerBytes > capFeePerBytes){ + feePerBytes = capFeePerBytes + } + return feePerBytes +} + +// The cancel's fee at the chain's stripped-size floor, and the sweep that remains. +function sizeCancelSweep(destination, value, feePerBytes){ + // vsize: 10 tx overhead + 58 key-path input (41 stripped + witness) + + // destination output + 2 rounding slack; padded to the chain's + // stripped-size relay floor exactly like the reveal (the key-path + // witness does not count toward stripped size either). + const destOutBytes = TxSizeEstimator.estimateOutputSizeForAddress(destination, this.network) + const cancelStrippedFloor = this.network.minStandardTxNonWitnessSize + const padNeeded = !!(cancelStrippedFloor && (10 + 41 + destOutBytes) < cancelStrippedFloor) + let strippedBytes = 10 + 41 + destOutBytes + (padNeeded ? destOutBytes : 0) + if (cancelStrippedFloor && strippedBytes < cancelStrippedFloor){ + strippedBytes = cancelStrippedFloor + } + const cancelVsize = strippedBytes + Math.ceil((2 + 1 + 1 + 65) / 4) + 2 + let cancelFee = Math.trunc(cancelVsize * feePerBytes * SATOSHI_UNIT) + if (cancelFee < this.dustAmount){ + cancelFee = this.dustAmount + } + + const sweepValue = value - cancelFee - (padNeeded ? this.dustAmount : 0) + if (sweepValue < this.dustAmount){ + throw new OperationalError( + 'ENVELOPE_CANCEL_BELOW_DUST', + `cancel would sweep ${sweepValue} satoshis (commit value ${value} minus fee ${cancelFee}${padNeeded ? ' minus floor pad' : ''}), below the dust floor (${this.dustAmount}); spend it via the reveal or CPFP instead`, + { commitValue: value, fee: cancelFee, sweepValue } + ) + } + return { padNeeded, cancelFee, sweepValue } +} + module.exports = { // Key-path cancel of an UNREVEALED envelope commit: // sweeps the commit output back to the caller before any reveal exists. By @@ -61,117 +213,12 @@ module.exports = { }, async buildEnvelopeCancelTransaction(callReservations, { commitTxid, commitVout, commitValue, internalPubkey, tapleafHash, destination, feePerKb = null, replacebyfee = false } = {}){ - if (typeof commitTxid !== 'string' || !/^[0-9a-fA-F]{64}$/.test(commitTxid)) { - throw new TypeError('commitTxid must be a 64-character hex string') - } - if (!Number.isInteger(commitVout) || commitVout < 0) { - throw new TypeError('commitVout must be a non-negative integer') - } - const value = parseSatoshiAmount(commitValue, 'commitValue') - // Accept the 33-byte compressed form (what create_tx took) or the - // 32-byte x-only form (what the recovery record may hold). - let internalKeyBuf - if (typeof internalPubkey === 'string' && /^(02|03)[0-9a-fA-F]{64}$/.test(internalPubkey)) { - internalKeyBuf = Buffer.from(internalPubkey, 'hex').subarray(1) - } else if (typeof internalPubkey === 'string' && /^[0-9a-fA-F]{64}$/.test(internalPubkey)) { - internalKeyBuf = Buffer.from(internalPubkey, 'hex') - } else { - throw new TypeError('internalPubkey must be a 66-character compressed or 64-character x-only pubkey hex string') - } - if (typeof tapleafHash !== 'string' || !/^[0-9a-fA-F]{64}$/.test(tapleafHash)) { - throw new TypeError('tapleafHash must be a 64-character hex string') - } - // validateAddress, not a local non-empty check: this is the one create - // path api.js does not route through validateAll, so without it the - // 100-char cap every other address field gets is missing here and an - // unbounded string reaches bs58check (quadratic) and psbt.addOutput. - validateAddress(destination, 'destination') - if (this.network.supportsSegwit === false) { - throw new TypeError('TAPROOT encoding is not supported on this network (no segwit support)') - } - // Same money/boolean guards create_tx gets through validateAll, so both - // create paths classify identical bad input identically (-32602). Left - // uncoerced, a non-numeric feePerKb becomes NaN, slips past both dust - // comparisons (every NaN comparison is false) and surfaces as an opaque - // -32603; and the JSON string "false" is truthy, silently arming RBF. - const feeRatePerKb = validateFeePerKb(feePerKb) - const rbfArmed = validateOptionalBoolean(replacebyfee, 'replacebyfee') === true - ensureEccLib() - const tapleafHashBuf = Buffer.from(tapleafHash, 'hex') + const { value, internalKeyBuf, feeRatePerKb, rbfArmed, tapleafHashBuf } = readCancelRecord.call(this, + commitTxid, commitVout, commitValue, internalPubkey, tapleafHash, destination, feePerKb, replacebyfee) - // Cross-path double-spend guard. This is the one build path outside - // _buildTransaction, so without a claim here a concurrent create_tx whose - // fetched set still carries the commit output selects and reserves it while - // an unsigned cancel of that same output is outstanding. Lowercased because - // commitTxid is accepted in either case above while create_tx keys are - // canonicalized in validator.validateUtxoEntry, and an uppercase key can - // never collide with the reservation it is meant to see. Claimed - // synchronously, before the first await below, so a concurrent call - // observes it. - const outpointKey = commitTxid.toLowerCase() + ':' + commitVout - const nowClaim = Date.now() - this.evictExpiredReservations(nowClaim) - this.evictExpiredEnvelopeCancelClaims(nowClaim) - // Reserved AND not held by this path: the reservation is worth the highest - // live cancel-owner expiry whenever a cancel build holds it, so any other - // value means a foreign createTransaction claim owns the outpoint. - if (this.isOutpointReserved(outpointKey, nowClaim) && - this.maxLiveCancelOwnerExpiry(outpointKey, nowClaim) !== this.outpointReservations.get(outpointKey)){ - throw new OperationalError( - 'ENVELOPE_CANCEL_OUTPOINT_RESERVED', - `commit outpoint ${outpointKey} is reserved by a transaction built in the last ` + - `${Math.round(RESERVATION_TTL_MS / 60000)} minutes; broadcast that transaction and rebuild ` + - 'from the resulting view, or wait for the reservation to lapse', - { outpoint: outpointKey } - ) - } - // Re-taking this path's OWN live claim is allowed, and refuseDuplicateBuild - // is deliberately not wired in here, for the same reason: the header - // contract above is that a cancel rebuilds from the persisted recovery - // record alone, so a lost-response retry must not be refused for five - // minutes for producing the byte-identical transaction it is supposed to. - // - // Each build registers its own owner token rather than overwriting a single - // stamp, so an overlapping build's failure releases only what it took. A - // single stamp, overwritten and then deleted, hands the commit outpoint back - // to createTransaction while the first build's unsigned cancel still spends it. - this.claimOutpoint(callReservations, outpointKey, nowClaim) - const cancelClaim = callReservations[callReservations.length - 1] - const cancelOwnerId = crypto.randomBytes(16).toString('hex') - cancelClaim.cancelOwnerId = cancelOwnerId - let cancelOwners = this.envelopeCancelClaims.get(outpointKey) - if (!cancelOwners) { - cancelOwners = new Map() - this.envelopeCancelClaims.set(outpointKey, cancelOwners) - } - cancelOwners.set(cancelOwnerId, cancelClaim.expiry) + claimCommitOutpoint.call(this, callReservations, commitTxid, commitVout) - // Fee-rate resolution with the same drain guards as createTransaction, - // in miniature: the caller rate is clamped to the tighter of the - // absolute MAX_FEE_RATE_KB cap and the relative multiplier x the node's - // own estimate. A cancel sweeps a prefund that scales with payload size - // and fee rate, so an unbounded rate here is a real burn surface. - let feePerBytes - let nodeFeePerBytes = null - if (feeRatePerKb){ - feePerBytes = feeRatePerKb / 1000 / SATOSHI_UNIT - try { - nodeFeePerBytes = await this.connector.getFeePerKilobyte(1) / 1000 - } catch (err) { - logger.warn(util.format('Envelope-cancel relative fee cap skipped: node fee estimate unavailable:', err.message)) - } - } else { - feePerBytes = await this.connector.getFeePerKilobyte(1) / 1000 - nodeFeePerBytes = feePerBytes - } - let capFeePerBytes = this.maxFeePerBytes - if (this.maxFeeRateMultiplier && nodeFeePerBytes != null){ - const relativeCap = nodeFeePerBytes * this.maxFeeRateMultiplier - capFeePerBytes = (capFeePerBytes != null) ? Math.min(capFeePerBytes, relativeCap) : relativeCap - } - if (capFeePerBytes != null && feePerBytes > capFeePerBytes){ - feePerBytes = capFeePerBytes - } + const feePerBytes = await cancelFeeRate.call(this, feeRatePerKb) const p2trPayment = bitcoin.payments.p2tr({ internalPubkey: internalKeyBuf, @@ -179,31 +226,7 @@ module.exports = { network: this.network }) - // vsize: 10 tx overhead + 58 key-path input (41 stripped + witness) + - // destination output + 2 rounding slack; padded to the chain's - // stripped-size relay floor exactly like the reveal (the key-path - // witness does not count toward stripped size either). - const destOutBytes = TxSizeEstimator.estimateOutputSizeForAddress(destination, this.network) - const cancelStrippedFloor = this.network.minStandardTxNonWitnessSize - const padNeeded = !!(cancelStrippedFloor && (10 + 41 + destOutBytes) < cancelStrippedFloor) - let strippedBytes = 10 + 41 + destOutBytes + (padNeeded ? destOutBytes : 0) - if (cancelStrippedFloor && strippedBytes < cancelStrippedFloor){ - strippedBytes = cancelStrippedFloor - } - const cancelVsize = strippedBytes + Math.ceil((2 + 1 + 1 + 65) / 4) + 2 - let cancelFee = Math.trunc(cancelVsize * feePerBytes * SATOSHI_UNIT) - if (cancelFee < this.dustAmount){ - cancelFee = this.dustAmount - } - - const sweepValue = value - cancelFee - (padNeeded ? this.dustAmount : 0) - if (sweepValue < this.dustAmount){ - throw new OperationalError( - 'ENVELOPE_CANCEL_BELOW_DUST', - `cancel would sweep ${sweepValue} satoshis (commit value ${value} minus fee ${cancelFee}${padNeeded ? ' minus floor pad' : ''}), below the dust floor (${this.dustAmount}); spend it via the reveal or CPFP instead`, - { commitValue: value, fee: cancelFee, sweepValue } - ) - } + const { padNeeded, cancelFee, sweepValue } = sizeCancelSweep.call(this, destination, value, feePerBytes) const psbt = new bitcoin.Psbt({ network: this.network }) psbt.addInput({ From 016062ef8287a390863d3de19e17a8f9a734f10c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 11:29:02 -0700 Subject: [PATCH 137/148] fix(encoder): suspend a build only where it waits on a value, not at every step Awaiting each build step as an async function added a suspension at every step boundary and after every selected input. Concurrent builds over one address then split its outpoints between them, and both could fail with insufficient funds where one of them should succeed. The steps are now generators that yield each node, tracker and payload promise to one loop in _buildTransaction, so a build suspends exactly where it waits and runs without a break in between. A unit test pins the first-come outcome for two builds racing for the same two outpoints. --- src/XChainEncoder.js | 88 +++++++++++++------ .../build_transaction/data_outputs.js | 22 ++--- .../build_transaction/fee_rates.js | 22 ++--- .../build_transaction/fee_settlement.js | 4 +- .../build_transaction/input_candidates.js | 8 +- .../build_transaction/input_selection.js | 14 +-- .../build_transaction/payload_checks.js | 8 +- .../build_transaction/reveal_outputs.js | 8 +- .../build_transaction/script_hash_legs.js | 18 ++-- .../16_concurrent_selection.test.js | 56 ++++++++++++ 10 files changed, 167 insertions(+), 81 deletions(-) create mode 100644 test/unit/xchain_encoder_create_transaction.test/16_concurrent_selection.test.js diff --git a/src/XChainEncoder.js b/src/XChainEncoder.js index 9bac977..b1cdc5a 100644 --- a/src/XChainEncoder.js +++ b/src/XChainEncoder.js @@ -179,39 +179,69 @@ class XChainEncoder { const build = { callReservations, utxos, pubkey, customOutputs, data, rawData, fee, replacebyfee, encoding, change, p2shHash, p2shHex, compressedPubKey, unconfirmed, feePerKb, dust, feeQuote, attachPrevTx, compress, options } - checkPayloadInput.call(this, build) - await resolveFeeRates.call(this, build) - await compressPayload.call(this, build) - compilePayload.call(this, build) - classifyPayload.call(this, build) - chooseEncoding.call(this, build) - await checkEnvelopeEncoding.call(this, build) - initInputState.call(this, build) - checkExactInputs.call(this, build) - await gatherUtxos.call(this, build) - refuseShrunkExactInputs.call(this, build) - filterAndOrderUtxos.call(this, build) - bindObfuscationKey.call(this, build) - prepareDataChunks.call(this, build) - priceRevealCustomOutputs.call(this, build) - initEmissionState.call(this, build) - await emitDataOutputs.call(this, build) - emitCustomOutputs.call(this, build) - initSelection.call(this, build) - await selectFundingInputs.call(this, build) - refuseExcessiveFee.call(this, build) - await upliftForAncestors.call(this, build) - floorEstimatedFee.call(this, build) - prefundRevealPackage.call(this, build) - computeChange.call(this, build) - emitChangeAndPad.call(this, build) - await sweepP2shReveal.call(this, build) - buildEnvelopeReveal.call(this, build) - return finishBuild.call(this, build) + // The steps are generators that yield each node, tracker or payload + // promise to this loop, so the build suspends only where it waits on a + // value, once per wait, and runs without a break in between. That is + // what keeps concurrent builds from interleaving their reservations: a + // build with nothing to wait on between claiming its first input and + // finishing selection holds the whole stretch, so a second build over + // the same address sees every outpoint it took. Awaiting each step as + // an async function would add a suspension at every step boundary and + // after every selected input, and two builds would then split one + // address's outpoints between them and both fail insufficient funds. + const steps = buildSteps.call(this, build) + let next = steps.next() + while (!next.done){ + let settled + try { + settled = await next.value + } catch (err) { + next = steps.throw(err) + continue + } + next = steps.next(settled) + } + return next.value } } +// The build's steps in the order the checks and emissions depend on. A step +// that reads the node, the tracker or a payload promise is a generator +// delegated to with yield*, which adds no suspension of its own; every other +// step runs synchronously inside the same stretch. +function* buildSteps(build){ + checkPayloadInput.call(this, build) + yield* resolveFeeRates.call(this, build) + yield* compressPayload.call(this, build) + compilePayload.call(this, build) + classifyPayload.call(this, build) + chooseEncoding.call(this, build) + yield* checkEnvelopeEncoding.call(this, build) + initInputState.call(this, build) + checkExactInputs.call(this, build) + yield* gatherUtxos.call(this, build) + refuseShrunkExactInputs.call(this, build) + filterAndOrderUtxos.call(this, build) + bindObfuscationKey.call(this, build) + prepareDataChunks.call(this, build) + priceRevealCustomOutputs.call(this, build) + initEmissionState.call(this, build) + yield* emitDataOutputs.call(this, build) + emitCustomOutputs.call(this, build) + initSelection.call(this, build) + yield* selectFundingInputs.call(this, build) + refuseExcessiveFee.call(this, build) + yield* upliftForAncestors.call(this, build) + floorEstimatedFee.call(this, build) + prefundRevealPackage.call(this, build) + computeChange.call(this, build) + emitChangeAndPad.call(this, build) + yield* sweepP2shReveal.call(this, build) + buildEnvelopeReveal.call(this, build) + return finishBuild.call(this, build) +} + Object.assign(XChainEncoder.prototype, require('./XChainEncoder/outpoint_reservations.js'), require('./XChainEncoder/payload_preparation.js'), require('./XChainEncoder/size_estimation.js'), require('./XChainEncoder/envelope_cancel.js')) diff --git a/src/XChainEncoder/build_transaction/data_outputs.js b/src/XChainEncoder/build_transaction/data_outputs.js index a588f68..6067fc7 100644 --- a/src/XChainEncoder/build_transaction/data_outputs.js +++ b/src/XChainEncoder/build_transaction/data_outputs.js @@ -127,23 +127,23 @@ function initEmissionState(build){ } // One carrier output (or reveal input) per prepared chunk, by encoding. -async function emitDataOutputs(build){ +function* emitDataOutputs(build){ let { preparedData } = build for (let nextDataBufferIndex in preparedData["dataBufferArray"]){ let nextDataBuffer = preparedData["dataBufferArray"][nextDataBufferIndex] switch (preparedData["encoding"]){ case Encoding.OP_RETURN: - await emitOpReturnChunk.call(this, build, nextDataBuffer) + yield* emitOpReturnChunk.call(this, build, nextDataBuffer) break case Encoding.P2SH: - await emitP2shChunk.call(this, build, nextDataBuffer) + yield* emitP2shChunk.call(this, build, nextDataBuffer) break case Encoding.P2WSH: - await emitP2wshChunk.call(this, build, nextDataBuffer) + yield* emitP2wshChunk.call(this, build, nextDataBuffer) break case Encoding.MULTISIGN: - await emitMultisignChunk.call(this, build, nextDataBuffer) + yield* emitMultisignChunk.call(this, build, nextDataBuffer) break case Encoding.TAPROOT: { emitEnvelopeCommit.call(this, build, nextDataBuffer) @@ -153,9 +153,9 @@ async function emitDataOutputs(build){ } } -async function emitOpReturnChunk(build, nextDataBuffer){ +function* emitOpReturnChunk(build, nextDataBuffer){ let { obfuscatedData, txidFirstInput, psbt, estimatedTxSize } = build - obfuscatedData = await this.obfuscate(nextDataBuffer, txidFirstInput) + obfuscatedData = (yield this.obfuscate(nextDataBuffer, txidFirstInput)) let opReturnScript = bitcoin.payments.embed({ data: [obfuscatedData] }) psbt.addOutput({ @@ -172,11 +172,11 @@ async function emitOpReturnChunk(build, nextDataBuffer){ Object.assign(build, { obfuscatedData, estimatedTxSize }) } -async function emitMultisignChunk(build, nextDataBuffer){ +function* emitMultisignChunk(build, nextDataBuffer){ let { obfuscatedData, txidFirstInput, compressedPubKey, finalDust, psbt, outputSatoshis, estimatedTxSize } = build - obfuscatedData = await this.obfuscate(nextDataBuffer, txidFirstInput) - let pubkey1 = await this.dataToPubkey(obfuscatedData.slice(0, 32)) - let pubkey2 = await this.dataToPubkey(obfuscatedData.slice(32, obfuscatedData.length)) + obfuscatedData = (yield this.obfuscate(nextDataBuffer, txidFirstInput)) + let pubkey1 = yield this.dataToPubkey(obfuscatedData.slice(0, 32)) + let pubkey2 = yield this.dataToPubkey(obfuscatedData.slice(32, obfuscatedData.length)) let pubkey3 = Buffer.from(compressedPubKey,"hex") let pubkeys = [ diff --git a/src/XChainEncoder/build_transaction/fee_rates.js b/src/XChainEncoder/build_transaction/fee_rates.js index 4400267..65451d7 100644 --- a/src/XChainEncoder/build_transaction/fee_rates.js +++ b/src/XChainEncoder/build_transaction/fee_rates.js @@ -24,21 +24,21 @@ const { suggestedFeeCeilingPerByte, suggestedFeeCeilingFloorPerByte } = require( // The per-byte rate this build charges, the node rate the drain caps anchor // to, the effective cap, and the dust floor, settled in that order. -async function resolveFeeRates(build){ +function* resolveFeeRates(build){ let { feePerKb } = build let feePerBytes = null let nodeFeePerBytes = null Object.assign(build, { feePerBytes, nodeFeePerBytes }) if (feePerKb){ - await useCallerFeeRate.call(this, build) + yield* useCallerFeeRate.call(this, build) } else { - await useNodeFeeRate.call(this, build) + yield* useNodeFeeRate.call(this, build) } - await anchorRelayFeeRate.call(this, build) + yield* anchorRelayFeeRate.call(this, build) applyFeeCapAndDustFloor.call(this, build) } -async function useCallerFeeRate(build){ +function* useCallerFeeRate(build){ let { feePerKb, feePerBytes, nodeFeePerBytes } = build // feePerKb is the caller's rate in BASE UNITS (sat/litoshi/koinu) // per kB: docs/openrpc.json documents it as "base units per kB" and @@ -58,7 +58,7 @@ async function useCallerFeeRate(build){ // own rate (that would let an inflated feePerKb lift the ceiling // with it). try { - nodeFeePerBytes = await this.connector.getFeePerKilobyte(1)/1000 + 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 @@ -70,9 +70,9 @@ async function useCallerFeeRate(build){ Object.assign(build, { feePerBytes, nodeFeePerBytes }) } -async function useNodeFeeRate(build){ +function* useNodeFeeRate(build){ let { feePerBytes, nodeFeePerBytes } = build - feePerBytes = await this.connector.getFeePerKilobyte(1)/1000 //Highest fee. In bitcoin context every kilobyte is 1000 bytes + feePerBytes = (yield this.connector.getFeePerKilobyte(1))/1000 //Highest fee. In bitcoin context every kilobyte is 1000 bytes nodeFeePerBytes = feePerBytes // Clamp only the rate chosen ON THE CALLER'S BEHALF, and only on a test // chain. nodeFeePerBytes keeps the raw estimate so the fee-drain caps @@ -83,7 +83,7 @@ async function useNodeFeeRate(build){ // suggestedFeeCeilingFloorPerByte); the floor is coin-correct // because it comes from the node, where the ceiling constant is not. try { - const info = await this.connector.getNetworkInfo() + const info = yield this.connector.getNetworkInfo() const floor = suggestedFeeCeilingFloorPerByte(info && info.relayfee) if (floor != null && floor > suggestedCap) suggestedCap = floor } catch (err) { @@ -108,7 +108,7 @@ async function useNodeFeeRate(build){ Object.assign(build, { feePerBytes, nodeFeePerBytes }) } -async function anchorRelayFeeRate(build){ +function* anchorRelayFeeRate(build){ let { nodeFeePerBytes } = build // Relative-cap anchor fallback. On non-regtest chains getFeePerKilobyte // THROWS when estimatesmartfee has no data (fresh node, warming mempool, @@ -126,7 +126,7 @@ async function anchorRelayFeeRate(build){ // when the operator disables the relative cap (multiplier 0). if (nodeFeePerBytes == null){ try { - const info = await this.connector.getNetworkInfo(); + const info = yield this.connector.getNetworkInfo(); const relayfee = Number(info && info.relayfee); if (relayfee > 0) nodeFeePerBytes = relayfee / 1000; } catch (err) { diff --git a/src/XChainEncoder/build_transaction/fee_settlement.js b/src/XChainEncoder/build_transaction/fee_settlement.js index 33b0823..9c11ab4 100644 --- a/src/XChainEncoder/build_transaction/fee_settlement.js +++ b/src/XChainEncoder/build_transaction/fee_settlement.js @@ -66,7 +66,7 @@ function refuseExcessiveFee(build){ } } -async function upliftForAncestors(build){ +function* upliftForAncestors(build){ let { unconfirmedInputTxids, feePerBytes, estimatedFee, estimatedTxSize } = build // CPFP-aware package sizing. // @@ -95,7 +95,7 @@ async function upliftForAncestors(build){ this.connector && typeof this.connector.getUnconfirmedAncestorPackage === 'function'){ let ancestorPackage = null try { - ancestorPackage = await this.connector.getUnconfirmedAncestorPackage(unconfirmedInputTxids) + ancestorPackage = (yield this.connector.getUnconfirmedAncestorPackage(unconfirmedInputTxids)) } catch (err) { logger.warn(util.format('Package fee sizing skipped: ancestor lookup failed:', err.message)) } diff --git a/src/XChainEncoder/build_transaction/input_candidates.js b/src/XChainEncoder/build_transaction/input_candidates.js index 6d56aef..f8f8791 100644 --- a/src/XChainEncoder/build_transaction/input_candidates.js +++ b/src/XChainEncoder/build_transaction/input_candidates.js @@ -87,23 +87,23 @@ function checkExactInputs(build){ // The candidate set: the caller's utxos, an empty set on a reveal, or the // tracker's view of the caller's address. -async function gatherUtxos(build){ +function* gatherUtxos(build){ let { utxos, isReveal, p2shHex } = build if ((utxos == null) || (utxos.length == 0)){ if (isReveal && p2shHex){ utxos = [] Object.assign(build, { utxos }) } else { - await fetchTrackerUtxos.call(this, build) + yield* fetchTrackerUtxos.call(this, build) } } } -async function fetchTrackerUtxos(build){ +function* fetchTrackerUtxos(build){ let { pubkey } = build let fetched try { - fetched = await this.utxoTrackerConnector.getUtxosFromAddress(resolveCallerAddress(pubkey, this.network)) + fetched = (yield this.utxoTrackerConnector.getUtxosFromAddress(resolveCallerAddress(pubkey, this.network))) } catch (err) { // Surface a typed, credential-free operational error. A // transport failure embeds the tracker's internal host:port, diff --git a/src/XChainEncoder/build_transaction/input_selection.js b/src/XChainEncoder/build_transaction/input_selection.js index 0613fad..39f5b24 100644 --- a/src/XChainEncoder/build_transaction/input_selection.js +++ b/src/XChainEncoder/build_transaction/input_selection.js @@ -53,10 +53,10 @@ function initSelection(build){ Object.assign(build, { estimatedTxSize, estimatedFee, selectedInputCount, unconfirmedInputTxids, reservedCandidates }) } -async function selectFundingInputs(build){ +function* selectFundingInputs(build){ let { p2shHash } = build if (!p2shHash){//The p2sh input is already created before - await selectInputs.call(this, build) + yield* selectInputs.call(this, build) refuseEmptySelection.call(this, build) refuseFirstInputRace.call(this, build) } @@ -64,7 +64,7 @@ async function selectFundingInputs(build){ // Walks the ordered candidates, claiming and adding inputs until they cover // outputs plus fee (or, in exact-input mode, until every named one is spent). -async function selectInputs(build){ +function* selectInputs(build){ let { utxos, preparedData, exactInputs, firstReservedOutpoint, callReservations, psbt, utxoSequence, attachPrevTx, fee, feePerBytes, outputSatoshis, estimatedTxSize, estimatedFee, inputSatoshis, selectedInputCount, unconfirmedInputTxids, reservedCandidates } = build @@ -84,7 +84,7 @@ async function selectInputs(build){ nextUtxo.value = parseSatoshiAmount(nextUtxo.value, `utxos[${nextUtxoIndex}].value`, { allowBig: true }) - const added = await addSelectedInput.call(this, psbt, nextUtxo, utxoSequence, attachPrevTx, estimatedTxSize, inputSatoshis) + const added = yield* addSelectedInput.call(this, psbt, nextUtxo, utxoSequence, attachPrevTx, estimatedTxSize, inputSatoshis) estimatedTxSize = added.estimatedTxSize inputSatoshis = added.inputSatoshis @@ -173,7 +173,7 @@ function skipReservedInput(callReservations, nextUtxo, exactInputs, firstReserve return false } -async function addSelectedInput(psbt, nextUtxo, utxoSequence, attachPrevTx, estimatedTxSize, inputSatoshis){ +function* addSelectedInput(psbt, nextUtxo, utxoSequence, attachPrevTx, estimatedTxSize, inputSatoshis){ if (this.isSegwitUTXO(nextUtxo)){ let nextInput = { hash: nextUtxo.txid, @@ -196,14 +196,14 @@ async function addSelectedInput(psbt, nextUtxo, utxoSequence, attachPrevTx, esti // that gets signed - hydrating it later would break the // byte-identity guarantee the confirm surface rests on. if (attachPrevTx) { - const prevTxHex = await this.connector.getTransactionHex(nextUtxo.txid) + const prevTxHex = yield this.connector.getTransactionHex(nextUtxo.txid) nextInput.nonWitnessUtxo = Buffer.from(prevTxHex, 'hex') } psbt.addInput(nextInput) estimatedTxSize = estimatedTxSize + TxSizeEstimator.estimateInputSize(nextInput) inputSatoshis = inputSatoshis + BigInt(nextUtxo.value) } else { - let wholeUtxoHex = await this.connector.getTransactionHex(nextUtxo.txid) + let wholeUtxoHex = yield this.connector.getTransactionHex(nextUtxo.txid) let nextInput = { hash: nextUtxo.txid, index: nextUtxo.vout, diff --git a/src/XChainEncoder/build_transaction/payload_checks.js b/src/XChainEncoder/build_transaction/payload_checks.js index 71ee8d7..8b3b6d0 100644 --- a/src/XChainEncoder/build_transaction/payload_checks.js +++ b/src/XChainEncoder/build_transaction/payload_checks.js @@ -81,7 +81,7 @@ function checkPayloadInput(build){ Object.assign(build, { unknownAction, customOutputs }) } -async function compressPayload(build){ +function* compressPayload(build){ let { compress, data, rawData } = build // Transparent FILE payload compression, ON by default. Runs HERE, before // the payload buffers are assembled, so everything downstream prices the @@ -107,7 +107,7 @@ async function compressPayload(build){ let compressionResult = null if (compressEnabled && rawData != null){ let originalBuffer = Buffer.from(rawData, 'binary') - compressionResult = await compressPayloadForAction(data, originalBuffer, { explicit: compressExplicit }) + compressionResult = (yield compressPayloadForAction(data, originalBuffer, { explicit: compressExplicit })) if (compressionResult.compressed){ // Both halves move together: the marker and the bytes it // describes. They must never be assigned separately. @@ -234,7 +234,7 @@ function chooseEncoding(build){ Object.assign(build, { encoding }) } -async function checkEnvelopeEncoding(build){ +function* checkEnvelopeEncoding(build){ let { encoding, p2shHash } = build // Envelope availability is a property of the network definition: // DOGE has no segwit, hence no Taproot; same gate, same error shape as @@ -254,7 +254,7 @@ async function checkEnvelopeEncoding(build){ // an action that will never exist, and they pay real coin for it. The // decoder's refusal is silent and correct, so nothing downstream can // detect the loss; this is the only place it can be caught. - await this.assertEnvelopeRecognized() + yield this.assertEnvelopeRecognized() ensureEccLib() } } diff --git a/src/XChainEncoder/build_transaction/reveal_outputs.js b/src/XChainEncoder/build_transaction/reveal_outputs.js index 1d7e2b3..685a3e6 100644 --- a/src/XChainEncoder/build_transaction/reveal_outputs.js +++ b/src/XChainEncoder/build_transaction/reveal_outputs.js @@ -71,7 +71,7 @@ function emitChangeAndPad(build){ } } -async function sweepP2shReveal(build){ +function* sweepP2shReveal(build){ let { p2shHash, preparedData, phaseLegInputSatoshis, customOutputs, feePerBytes, estimatedFee, outputSatoshis, change, pubkey, psbt } = build // Sweep the P2SH reveal's leg surplus back to the @@ -100,7 +100,7 @@ async function sweepP2shReveal(build){ revealFeeKept = estimatedFee } - revealFeeKept = await upliftRevealForCommit.call(this, build, revealSizeForFee, revealFeeKept) + revealFeeKept = (yield* upliftRevealForCommit.call(this, build, revealSizeForFee, revealFeeKept)) let revealSurplus = phaseLegInputSatoshis - outputSatoshis - BigInt(revealFeeKept) let sweepAddress = change || resolveCallerAddress(pubkey, this.network) @@ -114,7 +114,7 @@ async function sweepP2shReveal(build){ } } -async function upliftRevealForCommit(build, revealSizeForFee, revealFeeKept){ +function* upliftRevealForCommit(build, revealSizeForFee, revealFeeKept){ let { feePerBytes, p2shHash } = build // Keep the package prefund as FEE instead of sweeping it home. // @@ -138,7 +138,7 @@ async function upliftRevealForCommit(build, revealSizeForFee, revealFeeKept){ this.connector && typeof this.connector.getUnconfirmedAncestorPackage === 'function'){ let commitPackage = null try { - commitPackage = await this.connector.getUnconfirmedAncestorPackage([p2shHash]) + commitPackage = (yield this.connector.getUnconfirmedAncestorPackage([p2shHash])) } catch (err) { logger.warn(util.format('Reveal package fee sizing skipped: commit lookup failed:', err.message)) } diff --git a/src/XChainEncoder/build_transaction/script_hash_legs.js b/src/XChainEncoder/build_transaction/script_hash_legs.js index c3a54c1..5cb5ebf 100644 --- a/src/XChainEncoder/build_transaction/script_hash_legs.js +++ b/src/XChainEncoder/build_transaction/script_hash_legs.js @@ -25,7 +25,7 @@ const { asSatValue } = require('../script_amount_helpers.js') const { assertRevealFundingTxMatches } = require('../request_resolution.js') // A P2SH chunk: a funding output on the commit, or an input spending it on the reveal. -async function emitP2shChunk(build, nextDataBuffer){ +function* emitP2shChunk(build, nextDataBuffer){ let { p2shHex, p2shTx, txidFirstInput, p2shHash } = build if (p2shHex && !p2shTx){ p2shTx = bitcoin.Transaction.fromHex(p2shHex) @@ -35,16 +35,16 @@ async function emitP2shChunk(build, nextDataBuffer){ Object.assign(build, { p2shTx, txidFirstInput }) if (p2shHash){ - await spendP2shLeg.call(this, build, nextDataBuffer) + yield* spendP2shLeg.call(this, build, nextDataBuffer) } else { fundP2shLeg.call(this, build, nextDataBuffer) } } -async function spendP2shLeg(build, nextDataBuffer){ +function* spendP2shLeg(build, nextDataBuffer){ let { psbt, txidFirstInput, estimatedTxSize, p2shTx, voutPsbtIndex, utxoSequence, p2shHash, p2shHex, phaseLegInputSatoshis } = build if (!psbt){ - let opReturnData = await this.obfuscate( + let opReturnData = yield this.obfuscate( Buffer.concat([ Buffer.from(MAGIC_WORD,'utf8'), Buffer.from("p2sh",'utf8') @@ -190,7 +190,7 @@ function sumP2shRevealLegs(preparedData, feePerBytes, finalDust){ } // A P2WSH chunk: a funding output on the commit, or an input spending it on the reveal. -async function emitP2wshChunk(build, nextDataBuffer){ +function* emitP2wshChunk(build, nextDataBuffer){ let { p2shHex, p2shTx, txidFirstInput, p2shHash } = build if (p2shHex && !p2shTx){ p2shTx = bitcoin.Transaction.fromHex(p2shHex) @@ -200,26 +200,26 @@ async function emitP2wshChunk(build, nextDataBuffer){ Object.assign(build, { p2shTx, txidFirstInput }) if (p2shHash){ - await spendP2wshLeg.call(this, build, nextDataBuffer) + yield* spendP2wshLeg.call(this, build, nextDataBuffer) } else { fundP2wshLeg.call(this, build, nextDataBuffer) } } -async function spendP2wshLeg(build, nextDataBuffer){ +function* spendP2wshLeg(build, nextDataBuffer){ let { psbt, txidFirstInput, p2shTx, voutPsbtIndex, utxoSequence, p2shHash, estimatedTxSize } = build if (!psbt){ psbt = new bitcoin.Psbt({ network: this.network }) psbt.addOutput({ script: bitcoin.payments.embed({ data: [ - await this.obfuscate( + (yield this.obfuscate( Buffer.concat([ Buffer.from(MAGIC_WORD,'utf8'), Buffer.from("p2wsh",'utf8') ]), txidFirstInput - ) + )) ] }).output, value: 0 diff --git a/test/unit/xchain_encoder_create_transaction.test/16_concurrent_selection.test.js b/test/unit/xchain_encoder_create_transaction.test/16_concurrent_selection.test.js new file mode 100644 index 0000000..5a9e54f --- /dev/null +++ b/test/unit/xchain_encoder_create_transaction.test/16_concurrent_selection.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. + +// Concurrent builds over one address must not starve each other. +// +// A payment-only build with segwit inputs and no prev-tx attachment awaits +// nothing between claiming its first input and finishing input selection, so +// two such builds racing for the same two outpoints resolve first come, first +// served: one takes both and succeeds, the other finds both reserved. A build +// that suspended between steps or after each selected input would let the +// second build claim the other outpoint during the first build's pause, leave +// each holding half the funds, and fail BOTH with insufficient funds. Nothing +// here is stubbed except the node connector; the real reservation map and +// selection loop run. + +const assert = require('assert') +const { + TXID_A, TXID_B, makeSegwitUtxo, makeEncoder, TEST_ADDRESS +} = require('./fixtures/transaction') + +// Each build pays more than one outpoint holds, so it needs both of them. +function paymentNeedingBothInputs (encoder) { + const utxos = [makeSegwitUtxo(TXID_A, 0, 60000), makeSegwitUtxo(TXID_B, 0, 60000)] + return encoder.createTransaction( + utxos, TEST_ADDRESS, [{ address: TEST_ADDRESS, value: '90000' }], + null, null, 2000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) +} + +describe('XChainEncoder concurrent input selection', function () { + it('two builds racing for the same outpoints: one succeeds with both, one is refused', async function () { + const encoder = makeEncoder() + const settled = await Promise.allSettled([ + paymentNeedingBothInputs(encoder), + paymentNeedingBothInputs(encoder) + ]) + + const won = settled.filter(s => s.status === 'fulfilled') + const lost = settled.filter(s => s.status === 'rejected') + assert.strictEqual(won.length, 1, 'exactly one build must succeed; ' + + 'two failures means each build claimed one outpoint while the other paused') + assert.strictEqual(won[0].value.psbt.txInputs.length, 2) + assert.strictEqual(lost.length, 1) + assert.strictEqual(lost[0].reason.xchainCode, 'INSUFFICIENT_FUNDS') + assert.strictEqual(lost[0].reason.details && lost[0].reason.details.reservedCandidates, 2, + 'the refused build must see both outpoints held by the winner') + }) +}) From 4134b5f4538158e2d0824b1cb8012d9009d60dd7 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 11:36:35 -0700 Subject: [PATCH 138/148] style(encoder): drop trailing whitespace carried onto moved comment lines --- .../build_transaction/build_result.js | 4 ++-- .../build_transaction/data_outputs.js | 20 +++++++++---------- .../build_transaction/fee_rates.js | 6 +++--- .../build_transaction/fee_settlement.js | 4 ++-- .../build_transaction/first_input_key.js | 4 ++-- .../build_transaction/input_candidates.js | 6 +++--- .../build_transaction/input_selection.js | 6 +++--- .../build_transaction/payload_checks.js | 4 ++-- .../build_transaction/reveal_outputs.js | 4 ++-- .../build_transaction/script_hash_legs.js | 20 +++++++++---------- src/XChainEncoder/constants.js | 4 ++-- src/XChainEncoder/envelope_cancel.js | 4 ++-- src/XChainEncoder/fee_policy.js | 4 ++-- src/XChainEncoder/outpoint_reservations.js | 4 ++-- src/XChainEncoder/payload_encodings.js | 12 +++++------ src/XChainEncoder/payload_preparation.js | 12 +++++------ src/XChainEncoder/request_resolution.js | 4 ++-- src/XChainEncoder/script_amount_helpers.js | 4 ++-- src/XChainEncoder/size_estimation.js | 4 ++-- 19 files changed, 65 insertions(+), 65 deletions(-) diff --git a/src/XChainEncoder/build_transaction/build_result.js b/src/XChainEncoder/build_transaction/build_result.js index 2dbbaa7..810b3df 100644 --- a/src/XChainEncoder/build_transaction/build_result.js +++ b/src/XChainEncoder/build_transaction/build_result.js @@ -13,9 +13,9 @@ ********************************************************************** * * XChain Encoder - Encoder Class - * + * * This file handles starting the encoder and generating transactions - * + * ********************************************************************/ const { Encoding } = require('../constants.js') diff --git a/src/XChainEncoder/build_transaction/data_outputs.js b/src/XChainEncoder/build_transaction/data_outputs.js index 6067fc7..7cd5933 100644 --- a/src/XChainEncoder/build_transaction/data_outputs.js +++ b/src/XChainEncoder/build_transaction/data_outputs.js @@ -13,9 +13,9 @@ ********************************************************************** * * XChain Encoder - Encoder Class - * + * * This file handles starting the encoder and generating transactions - * + * ********************************************************************/ const bitcoin = require('bitcoinjs-lib'); @@ -30,7 +30,7 @@ function prepareDataChunks(build){ if (!p2shHash){//We need to prepare the data to know which inputs the p2sh will have psbt = new bitcoin.Psbt({ network: this.network }) } - + // With no action payload there is nothing to encode, chunk or obfuscate, // so skip prepareData entirely and hand the emission loop an empty chunk // list: it then writes no nulldata output and the transaction is just its @@ -131,7 +131,7 @@ function* emitDataOutputs(build){ let { preparedData } = build for (let nextDataBufferIndex in preparedData["dataBufferArray"]){ let nextDataBuffer = preparedData["dataBufferArray"][nextDataBufferIndex] - + switch (preparedData["encoding"]){ case Encoding.OP_RETURN: yield* emitOpReturnChunk.call(this, build, nextDataBuffer) @@ -157,12 +157,12 @@ function* emitOpReturnChunk(build, nextDataBuffer){ let { obfuscatedData, txidFirstInput, psbt, estimatedTxSize } = build obfuscatedData = (yield this.obfuscate(nextDataBuffer, txidFirstInput)) let opReturnScript = bitcoin.payments.embed({ data: [obfuscatedData] }) - + psbt.addOutput({ script: opReturnScript.output, value: 0 }) - + // Oversize is handled upstream: prepareData rejects an OP_RETURN // payload larger than chunksSize (single-OP_RETURN policy throw), // so every obfuscatedData reaching here fits one standard nulldata @@ -178,21 +178,21 @@ function* emitMultisignChunk(build, nextDataBuffer){ let pubkey1 = yield this.dataToPubkey(obfuscatedData.slice(0, 32)) let pubkey2 = yield this.dataToPubkey(obfuscatedData.slice(32, obfuscatedData.length)) let pubkey3 = Buffer.from(compressedPubKey,"hex") - + let pubkeys = [ pubkey1, pubkey2, pubkey3 ] - + let multisignScript = bitcoin.payments.p2ms( - { + { m:1, //We only need one signature pubkeys: pubkeys, network: this.network } ) - + // A bare multisig output is larger than a P2PKH, so the P2PKH // dust floor (this.dustAmount, read per network from the coin // bundle's dustThreshold: BTC 546, LTC 5460, DOGE 100000) is diff --git a/src/XChainEncoder/build_transaction/fee_rates.js b/src/XChainEncoder/build_transaction/fee_rates.js index 65451d7..1dbdaae 100644 --- a/src/XChainEncoder/build_transaction/fee_rates.js +++ b/src/XChainEncoder/build_transaction/fee_rates.js @@ -13,9 +13,9 @@ ********************************************************************** * * XChain Encoder - Encoder Class - * + * * This file handles starting the encoder and generating transactions - * + * ********************************************************************/ const util = require('node:util'); @@ -152,7 +152,7 @@ function applyFeeCapAndDustFloor(build){ logger.warn(`Fee rate ${feePerBytes * 1000 * SATOSHI_UNIT} sat/kB exceeds the fee-rate cap, clamping to ${capFeePerBytes * 1000 * SATOSHI_UNIT} sat/kB`) feePerBytes = capFeePerBytes } - + // A caller dust may raise the floor for this build, never lower it: a leg under the // relay floor strands the caller's own reveal behind an unrelayable funding tx. let finalDust = this.outputFloor diff --git a/src/XChainEncoder/build_transaction/fee_settlement.js b/src/XChainEncoder/build_transaction/fee_settlement.js index 9c11ab4..7653444 100644 --- a/src/XChainEncoder/build_transaction/fee_settlement.js +++ b/src/XChainEncoder/build_transaction/fee_settlement.js @@ -13,9 +13,9 @@ ********************************************************************** * * XChain Encoder - Encoder Class - * + * * This file handles starting the encoder and generating transactions - * + * ********************************************************************/ const util = require('node:util'); diff --git a/src/XChainEncoder/build_transaction/first_input_key.js b/src/XChainEncoder/build_transaction/first_input_key.js index 78bcb50..add6e41 100644 --- a/src/XChainEncoder/build_transaction/first_input_key.js +++ b/src/XChainEncoder/build_transaction/first_input_key.js @@ -13,9 +13,9 @@ ********************************************************************** * * XChain Encoder - Encoder Class - * + * * This file handles starting the encoder and generating transactions - * + * ********************************************************************/ const { OperationalError } = require('../../build/errors') diff --git a/src/XChainEncoder/build_transaction/input_candidates.js b/src/XChainEncoder/build_transaction/input_candidates.js index f8f8791..5f217db 100644 --- a/src/XChainEncoder/build_transaction/input_candidates.js +++ b/src/XChainEncoder/build_transaction/input_candidates.js @@ -13,9 +13,9 @@ ********************************************************************** * * XChain Encoder - Encoder Class - * + * * This file handles starting the encoder and generating transactions - * + * ********************************************************************/ const { validateUtxoEntry } = require('../../common/validator') @@ -26,7 +26,7 @@ const { classifyTrackerFreshness, resolveCallerAddress } = require('../request_r function initInputState(build){ let { replacebyfee, p2shHash } = build let psbt = null - + // 0xfffffffd, not 1. Any value below 0xfffffffe signals RBF (BIP125), but // bit 31 (0x80000000) is what DISABLES BIP68 relative locktime. nSequence=1 // leaves that bit clear, so it also asserts "this input must be 1 block diff --git a/src/XChainEncoder/build_transaction/input_selection.js b/src/XChainEncoder/build_transaction/input_selection.js index 39f5b24..a8811ba 100644 --- a/src/XChainEncoder/build_transaction/input_selection.js +++ b/src/XChainEncoder/build_transaction/input_selection.js @@ -13,9 +13,9 @@ ********************************************************************** * * XChain Encoder - Encoder Class - * + * * This file handles starting the encoder and generating transactions - * + * ********************************************************************/ const TxSizeEstimator = require('../../build/tx_size_estimator') @@ -38,7 +38,7 @@ function initSelection(build){ // value, preserving the previous error contract. estimatedFee = parseSatoshiAmount(fee, 'fee') } - + 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 diff --git a/src/XChainEncoder/build_transaction/payload_checks.js b/src/XChainEncoder/build_transaction/payload_checks.js index 8b3b6d0..d770ba8 100644 --- a/src/XChainEncoder/build_transaction/payload_checks.js +++ b/src/XChainEncoder/build_transaction/payload_checks.js @@ -13,9 +13,9 @@ ********************************************************************** * * XChain Encoder - Encoder Class - * + * * This file handles starting the encoder and generating transactions - * + * ********************************************************************/ const bitcoin = require('bitcoinjs-lib'); diff --git a/src/XChainEncoder/build_transaction/reveal_outputs.js b/src/XChainEncoder/build_transaction/reveal_outputs.js index 685a3e6..42a1172 100644 --- a/src/XChainEncoder/build_transaction/reveal_outputs.js +++ b/src/XChainEncoder/build_transaction/reveal_outputs.js @@ -13,9 +13,9 @@ ********************************************************************** * * XChain Encoder - Encoder Class - * + * * This file handles starting the encoder and generating transactions - * + * ********************************************************************/ const bitcoin = require('bitcoinjs-lib'); diff --git a/src/XChainEncoder/build_transaction/script_hash_legs.js b/src/XChainEncoder/build_transaction/script_hash_legs.js index 5cb5ebf..a4c919a 100644 --- a/src/XChainEncoder/build_transaction/script_hash_legs.js +++ b/src/XChainEncoder/build_transaction/script_hash_legs.js @@ -13,9 +13,9 @@ ********************************************************************** * * XChain Encoder - Encoder Class - * + * * This file handles starting the encoder and generating transactions - * + * ********************************************************************/ const bitcoin = require('bitcoinjs-lib'); @@ -51,7 +51,7 @@ function* spendP2shLeg(build, nextDataBuffer){ ]), txidFirstInput ) - + psbt = new bitcoin.Psbt({ network: this.network }) psbt.addOutput({ script: bitcoin.payments.embed({ @@ -59,11 +59,11 @@ function* spendP2shLeg(build, nextDataBuffer){ }).output, value: 0 }) - + estimatedTxSize = estimatedTxSize + TxSizeEstimator.estimateOpReturnOutput(opReturnData) } - + if (!p2shTx || !p2shTx.outs || voutPsbtIndex >= p2shTx.outs.length) { throw new RangeError(`p2shHex transaction does not have output at index ${voutPsbtIndex}`) } @@ -113,7 +113,7 @@ function fundP2shLeg(build, nextDataBuffer){ }) outputSatoshis = outputSatoshis + BigInt(spendingP2shEstimatedFee) - + estimatedTxSize = estimatedTxSize + TxSizeEstimator.estimateP2shOutput() Object.assign(build, { revealCustomOutputsValue, revealCustomOutputsFee, outputSatoshis, estimatedTxSize }) } @@ -198,7 +198,7 @@ function* emitP2wshChunk(build, nextDataBuffer){ assertRevealFundingTxMatches(p2shHash, txidFirstInput) } Object.assign(build, { p2shTx, txidFirstInput }) - + if (p2shHash){ yield* spendP2wshLeg.call(this, build, nextDataBuffer) } else { @@ -225,7 +225,7 @@ function* spendP2wshLeg(build, nextDataBuffer){ value: 0 }) } - + if (!p2shTx || !p2shTx.outs || voutPsbtIndex >= p2shTx.outs.length) { throw new RangeError(`p2shHex transaction does not have output at index ${voutPsbtIndex}`) } @@ -240,9 +240,9 @@ function* spendP2wshLeg(build, nextDataBuffer){ } } psbt.addInput(nextInput) - + estimatedTxSize = estimatedTxSize + TxSizeEstimator.estimateInputSize(nextInput) - voutPsbtIndex = voutPsbtIndex + 1 + voutPsbtIndex = voutPsbtIndex + 1 Object.assign(build, { psbt, estimatedTxSize, voutPsbtIndex }) } diff --git a/src/XChainEncoder/constants.js b/src/XChainEncoder/constants.js index e41827e..9b6595f 100644 --- a/src/XChainEncoder/constants.js +++ b/src/XChainEncoder/constants.js @@ -13,9 +13,9 @@ ********************************************************************** * * XChain Encoder - Encoder Class - * + * * This file handles starting the encoder and generating transactions - * + * ********************************************************************/ const { getLogger } = require('../observability'); diff --git a/src/XChainEncoder/envelope_cancel.js b/src/XChainEncoder/envelope_cancel.js index 26fc05f..70f2dcb 100644 --- a/src/XChainEncoder/envelope_cancel.js +++ b/src/XChainEncoder/envelope_cancel.js @@ -13,9 +13,9 @@ ********************************************************************** * * XChain Encoder - Encoder Class - * + * * This file handles starting the encoder and generating transactions - * + * ********************************************************************/ const bitcoin = require('bitcoinjs-lib'); diff --git a/src/XChainEncoder/fee_policy.js b/src/XChainEncoder/fee_policy.js index f28ac80..57a51f5 100644 --- a/src/XChainEncoder/fee_policy.js +++ b/src/XChainEncoder/fee_policy.js @@ -13,9 +13,9 @@ ********************************************************************** * * XChain Encoder - Encoder Class - * + * * This file handles starting the encoder and generating transactions - * + * ********************************************************************/ const config = require('../common/config'); diff --git a/src/XChainEncoder/outpoint_reservations.js b/src/XChainEncoder/outpoint_reservations.js index 7933217..7beb658 100644 --- a/src/XChainEncoder/outpoint_reservations.js +++ b/src/XChainEncoder/outpoint_reservations.js @@ -13,9 +13,9 @@ ********************************************************************** * * XChain Encoder - Encoder Class - * + * * This file handles starting the encoder and generating transactions - * + * ********************************************************************/ const crypto = require('crypto'); diff --git a/src/XChainEncoder/payload_encodings.js b/src/XChainEncoder/payload_encodings.js index a8fd1e1..470e288 100644 --- a/src/XChainEncoder/payload_encodings.js +++ b/src/XChainEncoder/payload_encodings.js @@ -13,9 +13,9 @@ ********************************************************************** * * XChain Encoder - Encoder Class - * + * * This file handles starting the encoder and generating transactions - * + * ********************************************************************/ const bitcoin = require('bitcoinjs-lib'); @@ -88,7 +88,7 @@ function scriptHashPayload(data, encoding, pubKey){ */ chunksSize = (encoding == Encoding.P2SH?P2SH_SIZE:PW2SH_SIZE) - 44 // 44 is a conservative per-chunk overhead reservation that leaves headroom under the 520-byte consensus MAX_SCRIPT_ELEMENT_SIZE (P2SH_SIZE/PW2SH_SIZE) for the OP_DROP/OP_DUP/OP_HASH160//OP_EQUALVERIFY/OP_CHECKSIG trailer plus the leading data-push prefix. Each chunk becomes one P2SH/P2WSH output; the input spending it carries the data inside its redeem/witness script. - + let pubkeyFromBase58 = resolveCallerHash160(pubKey) let p2shChunks = sliceScriptHashChunks(data, chunksSize) @@ -149,14 +149,14 @@ function multisignPayload(data, encoding, magicWordBuffer){ let dataBufferArray = [] let i = 0 let nextDataChunk = null - chunksSize = MULTISIGN_SIZE - - magicWordBuffer.length + chunksSize = MULTISIGN_SIZE + - magicWordBuffer.length - 1 //1 byte for the OP_CHECKMULTISIG - 1 //1 byte for the m signatures to pop - 1 //1 byte for the n addresses to pop - 1 //1 byte for the first address length - 1 //1 byte for the second address length - + // Each MULTISIGN output carries its data across two 32-byte // pubkey halves (64 data bytes total). A full chunk is already // magic(4) + 60 = 64 bytes, but the final chunk is shorter. diff --git a/src/XChainEncoder/payload_preparation.js b/src/XChainEncoder/payload_preparation.js index 429825a..38fb7c2 100644 --- a/src/XChainEncoder/payload_preparation.js +++ b/src/XChainEncoder/payload_preparation.js @@ -13,9 +13,9 @@ ********************************************************************** * * XChain Encoder - Encoder Class - * + * * This file handles starting the encoder and generating transactions - * + * ********************************************************************/ const bitcoin = require('bitcoinjs-lib'); @@ -47,14 +47,14 @@ module.exports = { prepareData(data, encoding, pubKey, compressedPubKey){ let magicWordBuffer = Buffer.from(MAGIC_WORD,'utf8') - + if (!encoding){ if (data.length + magicWordBuffer.length <= OP_RETURN_SIZE) { encoding = Encoding.OP_RETURN } else { encoding = Encoding.P2SH } - } + } switch (encoding){ case Encoding.OP_RETURN: return opReturnPayload(data, encoding, magicWordBuffer) @@ -74,7 +74,7 @@ module.exports = { async obfuscate(data, key){ var cipherKey = key.substr(0,16) var iv = key.substr(16,16) - + var cipher = crypto.createCipheriv('aes-128-ctr', cipherKey, iv); var encryptedData = cipher.update(data) encryptedData = Buffer.concat([encryptedData,cipher.final()]) @@ -89,7 +89,7 @@ module.exports = { bufferFill.fill("00", 0, bufferFill.length, "hex") bufferArray.push(bufferFill) } - + return Buffer.concat(bufferArray) }, diff --git a/src/XChainEncoder/request_resolution.js b/src/XChainEncoder/request_resolution.js index 8109bf6..2c57e2d 100644 --- a/src/XChainEncoder/request_resolution.js +++ b/src/XChainEncoder/request_resolution.js @@ -13,9 +13,9 @@ ********************************************************************** * * XChain Encoder - Encoder Class - * + * * This file handles starting the encoder and generating transactions - * + * ********************************************************************/ const bitcoin = require('bitcoinjs-lib'); diff --git a/src/XChainEncoder/script_amount_helpers.js b/src/XChainEncoder/script_amount_helpers.js index 06682f9..b05c8be 100644 --- a/src/XChainEncoder/script_amount_helpers.js +++ b/src/XChainEncoder/script_amount_helpers.js @@ -13,9 +13,9 @@ ********************************************************************** * * XChain Encoder - Encoder Class - * + * * This file handles starting the encoder and generating transactions - * + * ********************************************************************/ const bitcoin = require('bitcoinjs-lib'); diff --git a/src/XChainEncoder/size_estimation.js b/src/XChainEncoder/size_estimation.js index 982d199..9372645 100644 --- a/src/XChainEncoder/size_estimation.js +++ b/src/XChainEncoder/size_estimation.js @@ -13,9 +13,9 @@ ********************************************************************** * * XChain Encoder - Encoder Class - * + * * This file handles starting the encoder and generating transactions - * + * ********************************************************************/ const bitcoin = require('bitcoinjs-lib'); From 97a124c61045a5a2885ee9a0a260a785ab215f61 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 11:41:43 -0700 Subject: [PATCH 139/148] fix(encoder): suspend a key-path cancel only where it waits on the node fee rate The cancel's fee-rate step became an async function in the buildEnvelopeCancelTransaction split, and awaiting it added one suspension after the commit-outpoint claim that the inline code never had. It is now a generator driven by one loop in buildEnvelopeCancelTransaction, the same pattern _buildTransaction uses, so each node fee-rate promise is awaited once, where the inline code awaited it. A call with a node that answers at once settles in three microtask turns again on both fee-rate paths and on the below-dust refusal, and a new test pins that count. --- src/XChainEncoder/envelope_cancel.js | 27 +++++- ...5_key_path_cancel_suspension_count.test.js | 89 +++++++++++++++++++ 2 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 test/unit/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js diff --git a/src/XChainEncoder/envelope_cancel.js b/src/XChainEncoder/envelope_cancel.js index 70f2dcb..65157b0 100644 --- a/src/XChainEncoder/envelope_cancel.js +++ b/src/XChainEncoder/envelope_cancel.js @@ -119,7 +119,12 @@ function claimCommitOutpoint(callReservations, commitTxid, commitVout){ cancelOwners.set(cancelOwnerId, cancelClaim.expiry) } -async function cancelFeeRate(feeRatePerKb){ +// A generator rather than an async function: it hands each node fee-rate +// promise to the driver loop in buildEnvelopeCancelTransaction, so the cancel +// suspends once per wait, exactly where the inline code it came from did. +// Awaited as an async function it would add one more suspension after the +// commit-outpoint claim. +function* cancelFeeRate(feeRatePerKb){ // Fee-rate resolution with the same drain guards as createTransaction, // in miniature: the caller rate is clamped to the tighter of the // absolute MAX_FEE_RATE_KB cap and the relative multiplier x the node's @@ -130,12 +135,12 @@ async function cancelFeeRate(feeRatePerKb){ if (feeRatePerKb){ feePerBytes = feeRatePerKb / 1000 / SATOSHI_UNIT try { - nodeFeePerBytes = await this.connector.getFeePerKilobyte(1) / 1000 + nodeFeePerBytes = (yield this.connector.getFeePerKilobyte(1)) / 1000 } catch (err) { logger.warn(util.format('Envelope-cancel relative fee cap skipped: node fee estimate unavailable:', err.message)) } } else { - feePerBytes = await this.connector.getFeePerKilobyte(1) / 1000 + feePerBytes = (yield this.connector.getFeePerKilobyte(1)) / 1000 nodeFeePerBytes = feePerBytes } let capFeePerBytes = this.maxFeePerBytes @@ -218,7 +223,21 @@ module.exports = { claimCommitOutpoint.call(this, callReservations, commitTxid, commitVout) - const feePerBytes = await cancelFeeRate.call(this, feeRatePerKb) + // Drive the fee-rate generator inline: each promise it yields is awaited + // here once, and a rejection goes back in so its own try/catch sees it. + const feeRate = cancelFeeRate.call(this, feeRatePerKb) + let next = feeRate.next() + while (!next.done){ + let settled + try { + settled = await next.value + } catch (err) { + next = feeRate.throw(err) + continue + } + next = feeRate.next(settled) + } + const feePerBytes = next.value const p2trPayment = bitcoin.payments.p2tr({ internalPubkey: internalKeyBuf, diff --git a/test/unit/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js b/test/unit/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js new file mode 100644 index 0000000..93b5772 --- /dev/null +++ b/test/unit/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js @@ -0,0 +1,89 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// A key-path cancel suspends only where it waits on the node. +// +// The cancel claims its commit outpoint synchronously, then waits once on the +// node's fee rate. With a node that answers at once, the call settles three +// microtask turns after it is made: one for the fee-rate wait and one for +// each of the two async returns (the build into the public entry point, and +// the entry point into the caller). A fee-rate step awaited as its own async +// function adds a fourth turn after the claim, which shifts when a failed +// cancel hands its outpoint back relative to a concurrent createTransaction. +// The count is pinned at three on both fee-rate paths and on the below-dust +// refusal. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const ecc = require('tiny-secp256k1') +const XChainEncoder = require('../../../src/XChainEncoder') + +bitcoin.initEccLib(ecc) + +const XONLY = '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798' +const DESTINATION = bitcoin.payments.p2wpkh({ + pubkey: Buffer.from('02' + XONLY, 'hex'), network: bitcoin.networks.regtest +}).address + +function makeEncoder () { + const encoder = new XChainEncoder('bitcoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '') + encoder.connector = { getFeePerKilobyte: async () => 0.00001 } + return encoder +} + +function recoveryRecord (commitValue, extra = {}) { + return Object.assign({ + commitTxid: 'b'.repeat(64), + commitVout: 0, + commitValue, + internalPubkey: XONLY, + tapleafHash: 'c'.repeat(64), + destination: DESTINATION + }, extra) +} + +// Counts microtask turns until the promise settles, one `await null` per turn. +async function turnsUntilSettled (promise) { + let settled = false + promise.then(() => { settled = true }, () => { settled = true }) + let turns = 0 + while (!settled && turns < 100) { + await null + turns++ + } + return turns +} + +describe('XChainEncoder TAPROOT envelope', function () { + describe('key-path cancel suspension count', function () { + it('the node fee-rate path settles three turns after the call', async function () { + const encoder = makeEncoder() + const call = encoder.createEnvelopeCancelTransaction(recoveryRecord(100000)) + assert.strictEqual(await turnsUntilSettled(call), 3) + assert.strictEqual((await call).cancel, true) + assert.strictEqual(encoder.outpointReservations.size, 1) + }) + + it('the caller fee-rate path settles three turns after the call', async function () { + const encoder = makeEncoder() + const call = encoder.createEnvelopeCancelTransaction(recoveryRecord(100000, { feePerKb: 0.00002 })) + assert.strictEqual(await turnsUntilSettled(call), 3) + assert.strictEqual((await call).cancel, true) + }) + + it('a below-dust cancel is refused and hands its claim back three turns after the call', async function () { + const encoder = makeEncoder() + const call = encoder.createEnvelopeCancelTransaction(recoveryRecord(600)) + assert.strictEqual(await turnsUntilSettled(call), 3) + await assert.rejects(call, err => err.xchainCode === 'ENVELOPE_CANCEL_BELOW_DUST') + assert.strictEqual(encoder.outpointReservations.size, 0) + }) + }) +}) From c519418cd9e53bd3fbc6aa25d816a3ddfb516e5e Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 12:10:35 -0700 Subject: [PATCH 140/148] refactor(test): split encoder test/unit into feature subdirectories test/unit held 46 files directly, past the 20-file readability limit. Group the non-held unit tests into feature subdirectories (api, build, common/validator, coins, server, adapters, repo, xchain_encoder) by the src area each test covers. Pure git mv, no content changes; the three Hub D-OBS-2 held files (blockchain_connector, crash_handlers, observability) stay in test/unit. --- test/unit/{ => adapters}/evm_adapter.test.js | 0 test/unit/{ => api}/cors_preflight.test.js | 0 test/unit/{ => api}/encoder_stress_sweep.test.js | 0 test/unit/{ => api}/health_serve_readiness.test.js | 0 test/unit/{ => api}/jsonrpc_body_guard.test.js | 0 test/unit/{ => api}/openrpc_coverage.test.js | 0 test/unit/{ => build}/apply_bufferutils_patch.test.js | 0 test/unit/{ => build}/compression.test.js | 0 .../{ => build}/compression.test/01_action_string_helpers.test.js | 0 .../compression.test/02_create_transaction_integration.test.js | 0 ...compression_report_carries_the_bytes_that_were_written.test.js | 0 test/unit/{ => build}/compression.test/04_validator.test.js | 0 .../{ => build}/compression.test/05_constants_conformance.test.js | 0 test/unit/{ => build}/compression.test/helpers/fixtures.js | 0 test/unit/{ => build}/crypto_networks.test.js | 0 test/unit/{ => build}/package_fee_sizing.test.js | 0 .../01_package_fee_uplift_satoshis.test.js | 0 .../02_xchain_encoder_package_aware_fee_sizing.test.js | 0 .../03_two_phase_reveal_package_prefund.test.js | 0 .../04_p2sh_two_phase_package_prefund.test.js | 0 test/unit/{ => build}/tx_size_estimator.test.js | 0 test/unit/{ => build}/utxo_tracker.test.js | 0 .../unit/{ => build}/utxo_tracker.test/01_get_sync_status.test.js | 0 .../utxo_tracker.test/02_get_utxos_from_address.test.js | 0 .../03_get_utxos_from_address_validation.test.js | 0 test/unit/{ => build}/utxo_tracker.test/helpers/support.js | 0 test/unit/{ => coins}/coins_conformance.test.js | 0 test/unit/{ => coins}/xchain_encoder_consensus_pin_boot.test.js | 0 test/unit/{ => common}/error_sanitize.test.js | 0 .../{ => common/validator}/action_manifest_conformance.test.js | 0 test/unit/{ => common/validator}/large_satoshi_amounts.test.js | 0 test/unit/{ => common/validator}/validator.test.js | 0 .../validator}/validator.test/01_validate_data_param.test.js | 0 .../02_validate_action_name_encoder_action_name_gate.test.js | 0 .../validator.test/03_validate_combined_data_length.test.js | 0 .../04_alias_expansion_at_the_compiled_ceiling.test.js | 0 .../validator}/validator.test/05_validate_encoding.test.js | 0 .../validator}/validator.test/06_max_fee_satoshis.test.js | 0 .../{ => common/validator}/validator.test/07_validate_fee.test.js | 0 .../validator}/validator.test/08_validate_fee_per_kb.test.js | 0 .../validator}/validator.test/09_validate_dust.test.js | 0 .../10_validate_utxo_array_validate_utxo_entry.test.js | 0 ...date_custom_outputs_validate_custom_output_via_wrapper.test.js | 0 .../validator}/validator.test/12_validate_p2sh_params.test.js | 0 .../validator}/validator.test/13_validate_raw_tx_hex.test.js | 0 .../validator.test/14_validate_compressed_pub_key.test.js | 0 .../validator}/validator.test/15_validate_change.test.js | 0 .../validator}/validator.test/16_validate_address.test.js | 0 .../{ => common/validator}/validator.test/17_validate_all.test.js | 0 test/unit/{ => repo}/coverage_thresholds_sync.test.js | 0 test/unit/{ => repo}/docs_openrpc.test.js | 0 test/unit/{ => repo}/sibling_coverage.test.js | 0 test/unit/{ => server}/cors_origin.test.js | 0 test/unit/{ => server}/maintenance_window.test.js | 0 test/unit/{ => server}/single_instance_guard.test.js | 0 .../single_instance_guard.test/01_acquire_instance_lock.test.js | 0 .../single_instance_guard.test/02_is_pid_alive.test.js | 0 .../single_instance_guard.test/03_release_lock_on_signals.test.js | 0 test/unit/{ => xchain_encoder}/envelope_recognition_gate.test.js | 0 test/unit/{ => xchain_encoder}/suggested_fee_ceiling.test.js | 0 .../{ => xchain_encoder}/xchain_encoder_auto_encoding.test.js | 0 .../xchain_encoder_create_transaction.test.js | 0 .../01_utxo_deduplication.test.js | 0 .../02_unconfirmed_filtering.test.js | 0 .../03_utxo_sorting.test.js | 0 .../04_replace_by_fee_sequence.test.js | 0 .../05_fee_handling.test.js | 0 .../06_custom_dust_parameter.test.js | 0 .../07_change_output.test.js | 0 .../08_no_utxos_available.test.js | 0 .../09_custom_outputs.test.js | 0 .../10_op_return_encoding_path.test.js | 0 .../11_p2sh_encoding_funding.test.js | 0 .../12_p2sh_encoding_spending.test.js | 0 .../13_p2sh_native_fee_custom_outputs.test.js | 0 .../14_multisign_encoding_path.test.js | 0 .../15_return_value.test.js | 0 .../16_concurrent_selection.test.js | 0 .../16_estimate_spending_p2sh_tx.test.js | 0 .../17_payment_only.test.js | 0 .../18_payload_guards.test.js | 0 .../fixtures/transaction.js | 0 .../{ => xchain_encoder}/xchain_encoder_data_to_pubkey.test.js | 0 .../unit/{ => xchain_encoder}/xchain_encoder_exact_inputs.test.js | 0 test/unit/{ => xchain_encoder}/xchain_encoder_extra.test.js | 0 .../xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js | 0 .../xchain_encoder_extra.test/02_p2wsh_tx2_spending.test.js | 0 .../xchain_encoder_extra.test/03_payload_size_guard.test.js | 0 .../xchain_encoder_extra.test/04_fee_quote_injection.test.js | 0 .../xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.js | 0 .../xchain_encoder_extra.test/06_change_edge_cases.test.js | 0 .../xchain_encoder_extra.test/07_invalid_fee.test.js | 0 .../08_estimate_spending_p2wsh_tx.test.js | 0 .../09_remaining_branch_coverage.test.js | 0 .../xchain_encoder_extra.test/10_raw_data_parameter.test.js | 0 .../unit/{ => xchain_encoder}/xchain_encoder_fee_rate_cap.test.js | 0 .../{ => xchain_encoder}/xchain_encoder_is_segwit_u_t_x_o.test.js | 0 test/unit/{ => xchain_encoder}/xchain_encoder_obfuscate.test.js | 0 .../xchain_encoder_p2sh_reveal_headroom.test.js | 0 .../unit/{ => xchain_encoder}/xchain_encoder_prepare_data.test.js | 0 .../xchain_encoder_release_reservations.test.js | 0 .../xchain_encoder_reveal_funding_binding.test.js | 0 .../xchain_encoder_segwit_input_guard.test.js | 0 .../{ => xchain_encoder}/xchain_encoder_soft_dust_floor.test.js | 0 .../{ => xchain_encoder}/xchain_encoder_taproot_envelope.test.js | 0 .../01_create_transaction_pair_construction.test.js | 0 .../02_sign_finalize_extract_round_trip.test.js | 0 .../03_key_path_cancel_from_persisted_recovery_record.test.js | 0 .../04_tx_size_estimator_envelope_sizing.test.js | 0 .../05_key_path_cancel_suspension_count.test.js | 0 .../xchain_encoder_tracker_freshness_classifier.test.js | 0 test/unit/{ => xchain_encoder}/xchain_encoder_utxo_dedup.test.js | 0 112 files changed, 0 insertions(+), 0 deletions(-) rename test/unit/{ => adapters}/evm_adapter.test.js (100%) rename test/unit/{ => api}/cors_preflight.test.js (100%) rename test/unit/{ => api}/encoder_stress_sweep.test.js (100%) rename test/unit/{ => api}/health_serve_readiness.test.js (100%) rename test/unit/{ => api}/jsonrpc_body_guard.test.js (100%) rename test/unit/{ => api}/openrpc_coverage.test.js (100%) rename test/unit/{ => build}/apply_bufferutils_patch.test.js (100%) rename test/unit/{ => build}/compression.test.js (100%) rename test/unit/{ => build}/compression.test/01_action_string_helpers.test.js (100%) rename test/unit/{ => build}/compression.test/02_create_transaction_integration.test.js (100%) rename test/unit/{ => build}/compression.test/03_the_compression_report_carries_the_bytes_that_were_written.test.js (100%) rename test/unit/{ => build}/compression.test/04_validator.test.js (100%) rename test/unit/{ => build}/compression.test/05_constants_conformance.test.js (100%) rename test/unit/{ => build}/compression.test/helpers/fixtures.js (100%) rename test/unit/{ => build}/crypto_networks.test.js (100%) rename test/unit/{ => build}/package_fee_sizing.test.js (100%) rename test/unit/{ => build}/package_fee_sizing.test/01_package_fee_uplift_satoshis.test.js (100%) rename test/unit/{ => build}/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js (100%) rename test/unit/{ => build}/package_fee_sizing.test/03_two_phase_reveal_package_prefund.test.js (100%) rename test/unit/{ => build}/package_fee_sizing.test/04_p2sh_two_phase_package_prefund.test.js (100%) rename test/unit/{ => build}/tx_size_estimator.test.js (100%) rename test/unit/{ => build}/utxo_tracker.test.js (100%) rename test/unit/{ => build}/utxo_tracker.test/01_get_sync_status.test.js (100%) rename test/unit/{ => build}/utxo_tracker.test/02_get_utxos_from_address.test.js (100%) rename test/unit/{ => build}/utxo_tracker.test/03_get_utxos_from_address_validation.test.js (100%) rename test/unit/{ => build}/utxo_tracker.test/helpers/support.js (100%) rename test/unit/{ => coins}/coins_conformance.test.js (100%) rename test/unit/{ => coins}/xchain_encoder_consensus_pin_boot.test.js (100%) rename test/unit/{ => common}/error_sanitize.test.js (100%) rename test/unit/{ => common/validator}/action_manifest_conformance.test.js (100%) rename test/unit/{ => common/validator}/large_satoshi_amounts.test.js (100%) rename test/unit/{ => common/validator}/validator.test.js (100%) rename test/unit/{ => common/validator}/validator.test/01_validate_data_param.test.js (100%) rename test/unit/{ => common/validator}/validator.test/02_validate_action_name_encoder_action_name_gate.test.js (100%) rename test/unit/{ => common/validator}/validator.test/03_validate_combined_data_length.test.js (100%) rename test/unit/{ => common/validator}/validator.test/04_alias_expansion_at_the_compiled_ceiling.test.js (100%) rename test/unit/{ => common/validator}/validator.test/05_validate_encoding.test.js (100%) rename test/unit/{ => common/validator}/validator.test/06_max_fee_satoshis.test.js (100%) rename test/unit/{ => common/validator}/validator.test/07_validate_fee.test.js (100%) rename test/unit/{ => common/validator}/validator.test/08_validate_fee_per_kb.test.js (100%) rename test/unit/{ => common/validator}/validator.test/09_validate_dust.test.js (100%) rename test/unit/{ => common/validator}/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js (100%) rename test/unit/{ => common/validator}/validator.test/11_validate_custom_outputs_validate_custom_output_via_wrapper.test.js (100%) rename test/unit/{ => common/validator}/validator.test/12_validate_p2sh_params.test.js (100%) rename test/unit/{ => common/validator}/validator.test/13_validate_raw_tx_hex.test.js (100%) rename test/unit/{ => common/validator}/validator.test/14_validate_compressed_pub_key.test.js (100%) rename test/unit/{ => common/validator}/validator.test/15_validate_change.test.js (100%) rename test/unit/{ => common/validator}/validator.test/16_validate_address.test.js (100%) rename test/unit/{ => common/validator}/validator.test/17_validate_all.test.js (100%) rename test/unit/{ => repo}/coverage_thresholds_sync.test.js (100%) rename test/unit/{ => repo}/docs_openrpc.test.js (100%) rename test/unit/{ => repo}/sibling_coverage.test.js (100%) rename test/unit/{ => server}/cors_origin.test.js (100%) rename test/unit/{ => server}/maintenance_window.test.js (100%) rename test/unit/{ => server}/single_instance_guard.test.js (100%) rename test/unit/{ => server}/single_instance_guard.test/01_acquire_instance_lock.test.js (100%) rename test/unit/{ => server}/single_instance_guard.test/02_is_pid_alive.test.js (100%) rename test/unit/{ => server}/single_instance_guard.test/03_release_lock_on_signals.test.js (100%) rename test/unit/{ => xchain_encoder}/envelope_recognition_gate.test.js (100%) rename test/unit/{ => xchain_encoder}/suggested_fee_ceiling.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_auto_encoding.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_create_transaction.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_create_transaction.test/01_utxo_deduplication.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_create_transaction.test/02_unconfirmed_filtering.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_create_transaction.test/03_utxo_sorting.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_create_transaction.test/04_replace_by_fee_sequence.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_create_transaction.test/05_fee_handling.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_create_transaction.test/06_custom_dust_parameter.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_create_transaction.test/07_change_output.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_create_transaction.test/08_no_utxos_available.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_create_transaction.test/09_custom_outputs.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_create_transaction.test/10_op_return_encoding_path.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_create_transaction.test/11_p2sh_encoding_funding.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_create_transaction.test/12_p2sh_encoding_spending.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_create_transaction.test/13_p2sh_native_fee_custom_outputs.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_create_transaction.test/14_multisign_encoding_path.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_create_transaction.test/15_return_value.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_create_transaction.test/16_concurrent_selection.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_create_transaction.test/16_estimate_spending_p2sh_tx.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_create_transaction.test/17_payment_only.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_create_transaction.test/18_payload_guards.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_create_transaction.test/fixtures/transaction.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_data_to_pubkey.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_exact_inputs.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_extra.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_extra.test/02_p2wsh_tx2_spending.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_extra.test/03_payload_size_guard.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_extra.test/04_fee_quote_injection.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_extra.test/06_change_edge_cases.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_extra.test/07_invalid_fee.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_extra.test/10_raw_data_parameter.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_fee_rate_cap.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_is_segwit_u_t_x_o.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_obfuscate.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_p2sh_reveal_headroom.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_prepare_data.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_release_reservations.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_reveal_funding_binding.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_segwit_input_guard.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_soft_dust_floor.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_taproot_envelope.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_tracker_freshness_classifier.test.js (100%) rename test/unit/{ => xchain_encoder}/xchain_encoder_utxo_dedup.test.js (100%) diff --git a/test/unit/evm_adapter.test.js b/test/unit/adapters/evm_adapter.test.js similarity index 100% rename from test/unit/evm_adapter.test.js rename to test/unit/adapters/evm_adapter.test.js diff --git a/test/unit/cors_preflight.test.js b/test/unit/api/cors_preflight.test.js similarity index 100% rename from test/unit/cors_preflight.test.js rename to test/unit/api/cors_preflight.test.js diff --git a/test/unit/encoder_stress_sweep.test.js b/test/unit/api/encoder_stress_sweep.test.js similarity index 100% rename from test/unit/encoder_stress_sweep.test.js rename to test/unit/api/encoder_stress_sweep.test.js diff --git a/test/unit/health_serve_readiness.test.js b/test/unit/api/health_serve_readiness.test.js similarity index 100% rename from test/unit/health_serve_readiness.test.js rename to test/unit/api/health_serve_readiness.test.js diff --git a/test/unit/jsonrpc_body_guard.test.js b/test/unit/api/jsonrpc_body_guard.test.js similarity index 100% rename from test/unit/jsonrpc_body_guard.test.js rename to test/unit/api/jsonrpc_body_guard.test.js diff --git a/test/unit/openrpc_coverage.test.js b/test/unit/api/openrpc_coverage.test.js similarity index 100% rename from test/unit/openrpc_coverage.test.js rename to test/unit/api/openrpc_coverage.test.js diff --git a/test/unit/apply_bufferutils_patch.test.js b/test/unit/build/apply_bufferutils_patch.test.js similarity index 100% rename from test/unit/apply_bufferutils_patch.test.js rename to test/unit/build/apply_bufferutils_patch.test.js diff --git a/test/unit/compression.test.js b/test/unit/build/compression.test.js similarity index 100% rename from test/unit/compression.test.js rename to test/unit/build/compression.test.js diff --git a/test/unit/compression.test/01_action_string_helpers.test.js b/test/unit/build/compression.test/01_action_string_helpers.test.js similarity index 100% rename from test/unit/compression.test/01_action_string_helpers.test.js rename to test/unit/build/compression.test/01_action_string_helpers.test.js diff --git a/test/unit/compression.test/02_create_transaction_integration.test.js b/test/unit/build/compression.test/02_create_transaction_integration.test.js similarity index 100% rename from test/unit/compression.test/02_create_transaction_integration.test.js rename to test/unit/build/compression.test/02_create_transaction_integration.test.js diff --git a/test/unit/compression.test/03_the_compression_report_carries_the_bytes_that_were_written.test.js b/test/unit/build/compression.test/03_the_compression_report_carries_the_bytes_that_were_written.test.js similarity index 100% rename from test/unit/compression.test/03_the_compression_report_carries_the_bytes_that_were_written.test.js rename to test/unit/build/compression.test/03_the_compression_report_carries_the_bytes_that_were_written.test.js diff --git a/test/unit/compression.test/04_validator.test.js b/test/unit/build/compression.test/04_validator.test.js similarity index 100% rename from test/unit/compression.test/04_validator.test.js rename to test/unit/build/compression.test/04_validator.test.js diff --git a/test/unit/compression.test/05_constants_conformance.test.js b/test/unit/build/compression.test/05_constants_conformance.test.js similarity index 100% rename from test/unit/compression.test/05_constants_conformance.test.js rename to test/unit/build/compression.test/05_constants_conformance.test.js diff --git a/test/unit/compression.test/helpers/fixtures.js b/test/unit/build/compression.test/helpers/fixtures.js similarity index 100% rename from test/unit/compression.test/helpers/fixtures.js rename to test/unit/build/compression.test/helpers/fixtures.js diff --git a/test/unit/crypto_networks.test.js b/test/unit/build/crypto_networks.test.js similarity index 100% rename from test/unit/crypto_networks.test.js rename to test/unit/build/crypto_networks.test.js diff --git a/test/unit/package_fee_sizing.test.js b/test/unit/build/package_fee_sizing.test.js similarity index 100% rename from test/unit/package_fee_sizing.test.js rename to test/unit/build/package_fee_sizing.test.js diff --git a/test/unit/package_fee_sizing.test/01_package_fee_uplift_satoshis.test.js b/test/unit/build/package_fee_sizing.test/01_package_fee_uplift_satoshis.test.js similarity index 100% rename from test/unit/package_fee_sizing.test/01_package_fee_uplift_satoshis.test.js rename to test/unit/build/package_fee_sizing.test/01_package_fee_uplift_satoshis.test.js diff --git a/test/unit/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js b/test/unit/build/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js similarity index 100% rename from test/unit/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js rename to test/unit/build/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js diff --git a/test/unit/package_fee_sizing.test/03_two_phase_reveal_package_prefund.test.js b/test/unit/build/package_fee_sizing.test/03_two_phase_reveal_package_prefund.test.js similarity index 100% rename from test/unit/package_fee_sizing.test/03_two_phase_reveal_package_prefund.test.js rename to test/unit/build/package_fee_sizing.test/03_two_phase_reveal_package_prefund.test.js diff --git a/test/unit/package_fee_sizing.test/04_p2sh_two_phase_package_prefund.test.js b/test/unit/build/package_fee_sizing.test/04_p2sh_two_phase_package_prefund.test.js similarity index 100% rename from test/unit/package_fee_sizing.test/04_p2sh_two_phase_package_prefund.test.js rename to test/unit/build/package_fee_sizing.test/04_p2sh_two_phase_package_prefund.test.js diff --git a/test/unit/tx_size_estimator.test.js b/test/unit/build/tx_size_estimator.test.js similarity index 100% rename from test/unit/tx_size_estimator.test.js rename to test/unit/build/tx_size_estimator.test.js diff --git a/test/unit/utxo_tracker.test.js b/test/unit/build/utxo_tracker.test.js similarity index 100% rename from test/unit/utxo_tracker.test.js rename to test/unit/build/utxo_tracker.test.js diff --git a/test/unit/utxo_tracker.test/01_get_sync_status.test.js b/test/unit/build/utxo_tracker.test/01_get_sync_status.test.js similarity index 100% rename from test/unit/utxo_tracker.test/01_get_sync_status.test.js rename to test/unit/build/utxo_tracker.test/01_get_sync_status.test.js diff --git a/test/unit/utxo_tracker.test/02_get_utxos_from_address.test.js b/test/unit/build/utxo_tracker.test/02_get_utxos_from_address.test.js similarity index 100% rename from test/unit/utxo_tracker.test/02_get_utxos_from_address.test.js rename to test/unit/build/utxo_tracker.test/02_get_utxos_from_address.test.js diff --git a/test/unit/utxo_tracker.test/03_get_utxos_from_address_validation.test.js b/test/unit/build/utxo_tracker.test/03_get_utxos_from_address_validation.test.js similarity index 100% rename from test/unit/utxo_tracker.test/03_get_utxos_from_address_validation.test.js rename to test/unit/build/utxo_tracker.test/03_get_utxos_from_address_validation.test.js diff --git a/test/unit/utxo_tracker.test/helpers/support.js b/test/unit/build/utxo_tracker.test/helpers/support.js similarity index 100% rename from test/unit/utxo_tracker.test/helpers/support.js rename to test/unit/build/utxo_tracker.test/helpers/support.js diff --git a/test/unit/coins_conformance.test.js b/test/unit/coins/coins_conformance.test.js similarity index 100% rename from test/unit/coins_conformance.test.js rename to test/unit/coins/coins_conformance.test.js diff --git a/test/unit/xchain_encoder_consensus_pin_boot.test.js b/test/unit/coins/xchain_encoder_consensus_pin_boot.test.js similarity index 100% rename from test/unit/xchain_encoder_consensus_pin_boot.test.js rename to test/unit/coins/xchain_encoder_consensus_pin_boot.test.js diff --git a/test/unit/error_sanitize.test.js b/test/unit/common/error_sanitize.test.js similarity index 100% rename from test/unit/error_sanitize.test.js rename to test/unit/common/error_sanitize.test.js diff --git a/test/unit/action_manifest_conformance.test.js b/test/unit/common/validator/action_manifest_conformance.test.js similarity index 100% rename from test/unit/action_manifest_conformance.test.js rename to test/unit/common/validator/action_manifest_conformance.test.js diff --git a/test/unit/large_satoshi_amounts.test.js b/test/unit/common/validator/large_satoshi_amounts.test.js similarity index 100% rename from test/unit/large_satoshi_amounts.test.js rename to test/unit/common/validator/large_satoshi_amounts.test.js diff --git a/test/unit/validator.test.js b/test/unit/common/validator/validator.test.js similarity index 100% rename from test/unit/validator.test.js rename to test/unit/common/validator/validator.test.js diff --git a/test/unit/validator.test/01_validate_data_param.test.js b/test/unit/common/validator/validator.test/01_validate_data_param.test.js similarity index 100% rename from test/unit/validator.test/01_validate_data_param.test.js rename to test/unit/common/validator/validator.test/01_validate_data_param.test.js diff --git a/test/unit/validator.test/02_validate_action_name_encoder_action_name_gate.test.js b/test/unit/common/validator/validator.test/02_validate_action_name_encoder_action_name_gate.test.js similarity index 100% rename from test/unit/validator.test/02_validate_action_name_encoder_action_name_gate.test.js rename to test/unit/common/validator/validator.test/02_validate_action_name_encoder_action_name_gate.test.js diff --git a/test/unit/validator.test/03_validate_combined_data_length.test.js b/test/unit/common/validator/validator.test/03_validate_combined_data_length.test.js similarity index 100% rename from test/unit/validator.test/03_validate_combined_data_length.test.js rename to test/unit/common/validator/validator.test/03_validate_combined_data_length.test.js diff --git a/test/unit/validator.test/04_alias_expansion_at_the_compiled_ceiling.test.js b/test/unit/common/validator/validator.test/04_alias_expansion_at_the_compiled_ceiling.test.js similarity index 100% rename from test/unit/validator.test/04_alias_expansion_at_the_compiled_ceiling.test.js rename to test/unit/common/validator/validator.test/04_alias_expansion_at_the_compiled_ceiling.test.js diff --git a/test/unit/validator.test/05_validate_encoding.test.js b/test/unit/common/validator/validator.test/05_validate_encoding.test.js similarity index 100% rename from test/unit/validator.test/05_validate_encoding.test.js rename to test/unit/common/validator/validator.test/05_validate_encoding.test.js diff --git a/test/unit/validator.test/06_max_fee_satoshis.test.js b/test/unit/common/validator/validator.test/06_max_fee_satoshis.test.js similarity index 100% rename from test/unit/validator.test/06_max_fee_satoshis.test.js rename to test/unit/common/validator/validator.test/06_max_fee_satoshis.test.js diff --git a/test/unit/validator.test/07_validate_fee.test.js b/test/unit/common/validator/validator.test/07_validate_fee.test.js similarity index 100% rename from test/unit/validator.test/07_validate_fee.test.js rename to test/unit/common/validator/validator.test/07_validate_fee.test.js diff --git a/test/unit/validator.test/08_validate_fee_per_kb.test.js b/test/unit/common/validator/validator.test/08_validate_fee_per_kb.test.js similarity index 100% rename from test/unit/validator.test/08_validate_fee_per_kb.test.js rename to test/unit/common/validator/validator.test/08_validate_fee_per_kb.test.js diff --git a/test/unit/validator.test/09_validate_dust.test.js b/test/unit/common/validator/validator.test/09_validate_dust.test.js similarity index 100% rename from test/unit/validator.test/09_validate_dust.test.js rename to test/unit/common/validator/validator.test/09_validate_dust.test.js diff --git a/test/unit/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js b/test/unit/common/validator/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js similarity index 100% rename from test/unit/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js rename to test/unit/common/validator/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js diff --git a/test/unit/validator.test/11_validate_custom_outputs_validate_custom_output_via_wrapper.test.js b/test/unit/common/validator/validator.test/11_validate_custom_outputs_validate_custom_output_via_wrapper.test.js similarity index 100% rename from test/unit/validator.test/11_validate_custom_outputs_validate_custom_output_via_wrapper.test.js rename to test/unit/common/validator/validator.test/11_validate_custom_outputs_validate_custom_output_via_wrapper.test.js diff --git a/test/unit/validator.test/12_validate_p2sh_params.test.js b/test/unit/common/validator/validator.test/12_validate_p2sh_params.test.js similarity index 100% rename from test/unit/validator.test/12_validate_p2sh_params.test.js rename to test/unit/common/validator/validator.test/12_validate_p2sh_params.test.js diff --git a/test/unit/validator.test/13_validate_raw_tx_hex.test.js b/test/unit/common/validator/validator.test/13_validate_raw_tx_hex.test.js similarity index 100% rename from test/unit/validator.test/13_validate_raw_tx_hex.test.js rename to test/unit/common/validator/validator.test/13_validate_raw_tx_hex.test.js diff --git a/test/unit/validator.test/14_validate_compressed_pub_key.test.js b/test/unit/common/validator/validator.test/14_validate_compressed_pub_key.test.js similarity index 100% rename from test/unit/validator.test/14_validate_compressed_pub_key.test.js rename to test/unit/common/validator/validator.test/14_validate_compressed_pub_key.test.js diff --git a/test/unit/validator.test/15_validate_change.test.js b/test/unit/common/validator/validator.test/15_validate_change.test.js similarity index 100% rename from test/unit/validator.test/15_validate_change.test.js rename to test/unit/common/validator/validator.test/15_validate_change.test.js diff --git a/test/unit/validator.test/16_validate_address.test.js b/test/unit/common/validator/validator.test/16_validate_address.test.js similarity index 100% rename from test/unit/validator.test/16_validate_address.test.js rename to test/unit/common/validator/validator.test/16_validate_address.test.js diff --git a/test/unit/validator.test/17_validate_all.test.js b/test/unit/common/validator/validator.test/17_validate_all.test.js similarity index 100% rename from test/unit/validator.test/17_validate_all.test.js rename to test/unit/common/validator/validator.test/17_validate_all.test.js diff --git a/test/unit/coverage_thresholds_sync.test.js b/test/unit/repo/coverage_thresholds_sync.test.js similarity index 100% rename from test/unit/coverage_thresholds_sync.test.js rename to test/unit/repo/coverage_thresholds_sync.test.js diff --git a/test/unit/docs_openrpc.test.js b/test/unit/repo/docs_openrpc.test.js similarity index 100% rename from test/unit/docs_openrpc.test.js rename to test/unit/repo/docs_openrpc.test.js diff --git a/test/unit/sibling_coverage.test.js b/test/unit/repo/sibling_coverage.test.js similarity index 100% rename from test/unit/sibling_coverage.test.js rename to test/unit/repo/sibling_coverage.test.js diff --git a/test/unit/cors_origin.test.js b/test/unit/server/cors_origin.test.js similarity index 100% rename from test/unit/cors_origin.test.js rename to test/unit/server/cors_origin.test.js diff --git a/test/unit/maintenance_window.test.js b/test/unit/server/maintenance_window.test.js similarity index 100% rename from test/unit/maintenance_window.test.js rename to test/unit/server/maintenance_window.test.js diff --git a/test/unit/single_instance_guard.test.js b/test/unit/server/single_instance_guard.test.js similarity index 100% rename from test/unit/single_instance_guard.test.js rename to test/unit/server/single_instance_guard.test.js diff --git a/test/unit/single_instance_guard.test/01_acquire_instance_lock.test.js b/test/unit/server/single_instance_guard.test/01_acquire_instance_lock.test.js similarity index 100% rename from test/unit/single_instance_guard.test/01_acquire_instance_lock.test.js rename to test/unit/server/single_instance_guard.test/01_acquire_instance_lock.test.js diff --git a/test/unit/single_instance_guard.test/02_is_pid_alive.test.js b/test/unit/server/single_instance_guard.test/02_is_pid_alive.test.js similarity index 100% rename from test/unit/single_instance_guard.test/02_is_pid_alive.test.js rename to test/unit/server/single_instance_guard.test/02_is_pid_alive.test.js diff --git a/test/unit/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 similarity index 100% rename from test/unit/single_instance_guard.test/03_release_lock_on_signals.test.js rename to test/unit/server/single_instance_guard.test/03_release_lock_on_signals.test.js diff --git a/test/unit/envelope_recognition_gate.test.js b/test/unit/xchain_encoder/envelope_recognition_gate.test.js similarity index 100% rename from test/unit/envelope_recognition_gate.test.js rename to test/unit/xchain_encoder/envelope_recognition_gate.test.js diff --git a/test/unit/suggested_fee_ceiling.test.js b/test/unit/xchain_encoder/suggested_fee_ceiling.test.js similarity index 100% rename from test/unit/suggested_fee_ceiling.test.js rename to test/unit/xchain_encoder/suggested_fee_ceiling.test.js diff --git a/test/unit/xchain_encoder_auto_encoding.test.js b/test/unit/xchain_encoder/xchain_encoder_auto_encoding.test.js similarity index 100% rename from test/unit/xchain_encoder_auto_encoding.test.js rename to test/unit/xchain_encoder/xchain_encoder_auto_encoding.test.js diff --git a/test/unit/xchain_encoder_create_transaction.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test.js similarity index 100% rename from test/unit/xchain_encoder_create_transaction.test.js rename to test/unit/xchain_encoder/xchain_encoder_create_transaction.test.js diff --git a/test/unit/xchain_encoder_create_transaction.test/01_utxo_deduplication.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/01_utxo_deduplication.test.js similarity index 100% rename from test/unit/xchain_encoder_create_transaction.test/01_utxo_deduplication.test.js rename to test/unit/xchain_encoder/xchain_encoder_create_transaction.test/01_utxo_deduplication.test.js diff --git a/test/unit/xchain_encoder_create_transaction.test/02_unconfirmed_filtering.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/02_unconfirmed_filtering.test.js similarity index 100% rename from test/unit/xchain_encoder_create_transaction.test/02_unconfirmed_filtering.test.js rename to test/unit/xchain_encoder/xchain_encoder_create_transaction.test/02_unconfirmed_filtering.test.js diff --git a/test/unit/xchain_encoder_create_transaction.test/03_utxo_sorting.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/03_utxo_sorting.test.js similarity index 100% rename from test/unit/xchain_encoder_create_transaction.test/03_utxo_sorting.test.js rename to test/unit/xchain_encoder/xchain_encoder_create_transaction.test/03_utxo_sorting.test.js diff --git a/test/unit/xchain_encoder_create_transaction.test/04_replace_by_fee_sequence.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/04_replace_by_fee_sequence.test.js similarity index 100% rename from test/unit/xchain_encoder_create_transaction.test/04_replace_by_fee_sequence.test.js rename to test/unit/xchain_encoder/xchain_encoder_create_transaction.test/04_replace_by_fee_sequence.test.js diff --git a/test/unit/xchain_encoder_create_transaction.test/05_fee_handling.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/05_fee_handling.test.js similarity index 100% rename from test/unit/xchain_encoder_create_transaction.test/05_fee_handling.test.js rename to test/unit/xchain_encoder/xchain_encoder_create_transaction.test/05_fee_handling.test.js diff --git a/test/unit/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 similarity index 100% rename from test/unit/xchain_encoder_create_transaction.test/06_custom_dust_parameter.test.js rename to test/unit/xchain_encoder/xchain_encoder_create_transaction.test/06_custom_dust_parameter.test.js diff --git a/test/unit/xchain_encoder_create_transaction.test/07_change_output.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/07_change_output.test.js similarity index 100% rename from test/unit/xchain_encoder_create_transaction.test/07_change_output.test.js rename to test/unit/xchain_encoder/xchain_encoder_create_transaction.test/07_change_output.test.js diff --git a/test/unit/xchain_encoder_create_transaction.test/08_no_utxos_available.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/08_no_utxos_available.test.js similarity index 100% rename from test/unit/xchain_encoder_create_transaction.test/08_no_utxos_available.test.js rename to test/unit/xchain_encoder/xchain_encoder_create_transaction.test/08_no_utxos_available.test.js diff --git a/test/unit/xchain_encoder_create_transaction.test/09_custom_outputs.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/09_custom_outputs.test.js similarity index 100% rename from test/unit/xchain_encoder_create_transaction.test/09_custom_outputs.test.js rename to test/unit/xchain_encoder/xchain_encoder_create_transaction.test/09_custom_outputs.test.js diff --git a/test/unit/xchain_encoder_create_transaction.test/10_op_return_encoding_path.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/10_op_return_encoding_path.test.js similarity index 100% rename from test/unit/xchain_encoder_create_transaction.test/10_op_return_encoding_path.test.js rename to test/unit/xchain_encoder/xchain_encoder_create_transaction.test/10_op_return_encoding_path.test.js diff --git a/test/unit/xchain_encoder_create_transaction.test/11_p2sh_encoding_funding.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/11_p2sh_encoding_funding.test.js similarity index 100% rename from test/unit/xchain_encoder_create_transaction.test/11_p2sh_encoding_funding.test.js rename to test/unit/xchain_encoder/xchain_encoder_create_transaction.test/11_p2sh_encoding_funding.test.js diff --git a/test/unit/xchain_encoder_create_transaction.test/12_p2sh_encoding_spending.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/12_p2sh_encoding_spending.test.js similarity index 100% rename from test/unit/xchain_encoder_create_transaction.test/12_p2sh_encoding_spending.test.js rename to test/unit/xchain_encoder/xchain_encoder_create_transaction.test/12_p2sh_encoding_spending.test.js diff --git a/test/unit/xchain_encoder_create_transaction.test/13_p2sh_native_fee_custom_outputs.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/13_p2sh_native_fee_custom_outputs.test.js similarity index 100% rename from test/unit/xchain_encoder_create_transaction.test/13_p2sh_native_fee_custom_outputs.test.js rename to test/unit/xchain_encoder/xchain_encoder_create_transaction.test/13_p2sh_native_fee_custom_outputs.test.js diff --git a/test/unit/xchain_encoder_create_transaction.test/14_multisign_encoding_path.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/14_multisign_encoding_path.test.js similarity index 100% rename from test/unit/xchain_encoder_create_transaction.test/14_multisign_encoding_path.test.js rename to test/unit/xchain_encoder/xchain_encoder_create_transaction.test/14_multisign_encoding_path.test.js diff --git a/test/unit/xchain_encoder_create_transaction.test/15_return_value.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/15_return_value.test.js similarity index 100% rename from test/unit/xchain_encoder_create_transaction.test/15_return_value.test.js rename to test/unit/xchain_encoder/xchain_encoder_create_transaction.test/15_return_value.test.js diff --git a/test/unit/xchain_encoder_create_transaction.test/16_concurrent_selection.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/16_concurrent_selection.test.js similarity index 100% rename from test/unit/xchain_encoder_create_transaction.test/16_concurrent_selection.test.js rename to test/unit/xchain_encoder/xchain_encoder_create_transaction.test/16_concurrent_selection.test.js diff --git a/test/unit/xchain_encoder_create_transaction.test/16_estimate_spending_p2sh_tx.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/16_estimate_spending_p2sh_tx.test.js similarity index 100% rename from test/unit/xchain_encoder_create_transaction.test/16_estimate_spending_p2sh_tx.test.js rename to test/unit/xchain_encoder/xchain_encoder_create_transaction.test/16_estimate_spending_p2sh_tx.test.js diff --git a/test/unit/xchain_encoder_create_transaction.test/17_payment_only.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/17_payment_only.test.js similarity index 100% rename from test/unit/xchain_encoder_create_transaction.test/17_payment_only.test.js rename to test/unit/xchain_encoder/xchain_encoder_create_transaction.test/17_payment_only.test.js diff --git a/test/unit/xchain_encoder_create_transaction.test/18_payload_guards.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/18_payload_guards.test.js similarity index 100% rename from test/unit/xchain_encoder_create_transaction.test/18_payload_guards.test.js rename to test/unit/xchain_encoder/xchain_encoder_create_transaction.test/18_payload_guards.test.js diff --git a/test/unit/xchain_encoder_create_transaction.test/fixtures/transaction.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/fixtures/transaction.js similarity index 100% rename from test/unit/xchain_encoder_create_transaction.test/fixtures/transaction.js rename to test/unit/xchain_encoder/xchain_encoder_create_transaction.test/fixtures/transaction.js diff --git a/test/unit/xchain_encoder_data_to_pubkey.test.js b/test/unit/xchain_encoder/xchain_encoder_data_to_pubkey.test.js similarity index 100% rename from test/unit/xchain_encoder_data_to_pubkey.test.js rename to test/unit/xchain_encoder/xchain_encoder_data_to_pubkey.test.js diff --git a/test/unit/xchain_encoder_exact_inputs.test.js b/test/unit/xchain_encoder/xchain_encoder_exact_inputs.test.js similarity index 100% rename from test/unit/xchain_encoder_exact_inputs.test.js rename to test/unit/xchain_encoder/xchain_encoder_exact_inputs.test.js diff --git a/test/unit/xchain_encoder_extra.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test.js similarity index 100% rename from test/unit/xchain_encoder_extra.test.js rename to test/unit/xchain_encoder/xchain_encoder_extra.test.js diff --git a/test/unit/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js similarity index 100% rename from test/unit/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js rename to test/unit/xchain_encoder/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js diff --git a/test/unit/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 similarity index 100% rename from test/unit/xchain_encoder_extra.test/02_p2wsh_tx2_spending.test.js rename to test/unit/xchain_encoder/xchain_encoder_extra.test/02_p2wsh_tx2_spending.test.js diff --git a/test/unit/xchain_encoder_extra.test/03_payload_size_guard.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/03_payload_size_guard.test.js similarity index 100% rename from test/unit/xchain_encoder_extra.test/03_payload_size_guard.test.js rename to test/unit/xchain_encoder/xchain_encoder_extra.test/03_payload_size_guard.test.js diff --git a/test/unit/xchain_encoder_extra.test/04_fee_quote_injection.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/04_fee_quote_injection.test.js similarity index 100% rename from test/unit/xchain_encoder_extra.test/04_fee_quote_injection.test.js rename to test/unit/xchain_encoder/xchain_encoder_extra.test/04_fee_quote_injection.test.js diff --git a/test/unit/xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.js similarity index 100% rename from test/unit/xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.js rename to test/unit/xchain_encoder/xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.js diff --git a/test/unit/xchain_encoder_extra.test/06_change_edge_cases.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/06_change_edge_cases.test.js similarity index 100% rename from test/unit/xchain_encoder_extra.test/06_change_edge_cases.test.js rename to test/unit/xchain_encoder/xchain_encoder_extra.test/06_change_edge_cases.test.js diff --git a/test/unit/xchain_encoder_extra.test/07_invalid_fee.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/07_invalid_fee.test.js similarity index 100% rename from test/unit/xchain_encoder_extra.test/07_invalid_fee.test.js rename to test/unit/xchain_encoder/xchain_encoder_extra.test/07_invalid_fee.test.js diff --git a/test/unit/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js similarity index 100% rename from test/unit/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js rename to test/unit/xchain_encoder/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js diff --git a/test/unit/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js similarity index 100% rename from test/unit/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js rename to test/unit/xchain_encoder/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js diff --git a/test/unit/xchain_encoder_extra.test/10_raw_data_parameter.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/10_raw_data_parameter.test.js similarity index 100% rename from test/unit/xchain_encoder_extra.test/10_raw_data_parameter.test.js rename to test/unit/xchain_encoder/xchain_encoder_extra.test/10_raw_data_parameter.test.js diff --git a/test/unit/xchain_encoder_fee_rate_cap.test.js b/test/unit/xchain_encoder/xchain_encoder_fee_rate_cap.test.js similarity index 100% rename from test/unit/xchain_encoder_fee_rate_cap.test.js rename to test/unit/xchain_encoder/xchain_encoder_fee_rate_cap.test.js diff --git a/test/unit/xchain_encoder_is_segwit_u_t_x_o.test.js b/test/unit/xchain_encoder/xchain_encoder_is_segwit_u_t_x_o.test.js similarity index 100% rename from test/unit/xchain_encoder_is_segwit_u_t_x_o.test.js rename to test/unit/xchain_encoder/xchain_encoder_is_segwit_u_t_x_o.test.js diff --git a/test/unit/xchain_encoder_obfuscate.test.js b/test/unit/xchain_encoder/xchain_encoder_obfuscate.test.js similarity index 100% rename from test/unit/xchain_encoder_obfuscate.test.js rename to test/unit/xchain_encoder/xchain_encoder_obfuscate.test.js diff --git a/test/unit/xchain_encoder_p2sh_reveal_headroom.test.js b/test/unit/xchain_encoder/xchain_encoder_p2sh_reveal_headroom.test.js similarity index 100% rename from test/unit/xchain_encoder_p2sh_reveal_headroom.test.js rename to test/unit/xchain_encoder/xchain_encoder_p2sh_reveal_headroom.test.js diff --git a/test/unit/xchain_encoder_prepare_data.test.js b/test/unit/xchain_encoder/xchain_encoder_prepare_data.test.js similarity index 100% rename from test/unit/xchain_encoder_prepare_data.test.js rename to test/unit/xchain_encoder/xchain_encoder_prepare_data.test.js diff --git a/test/unit/xchain_encoder_release_reservations.test.js b/test/unit/xchain_encoder/xchain_encoder_release_reservations.test.js similarity index 100% rename from test/unit/xchain_encoder_release_reservations.test.js rename to test/unit/xchain_encoder/xchain_encoder_release_reservations.test.js diff --git a/test/unit/xchain_encoder_reveal_funding_binding.test.js b/test/unit/xchain_encoder/xchain_encoder_reveal_funding_binding.test.js similarity index 100% rename from test/unit/xchain_encoder_reveal_funding_binding.test.js rename to test/unit/xchain_encoder/xchain_encoder_reveal_funding_binding.test.js diff --git a/test/unit/xchain_encoder_segwit_input_guard.test.js b/test/unit/xchain_encoder/xchain_encoder_segwit_input_guard.test.js similarity index 100% rename from test/unit/xchain_encoder_segwit_input_guard.test.js rename to test/unit/xchain_encoder/xchain_encoder_segwit_input_guard.test.js diff --git a/test/unit/xchain_encoder_soft_dust_floor.test.js b/test/unit/xchain_encoder/xchain_encoder_soft_dust_floor.test.js similarity index 100% rename from test/unit/xchain_encoder_soft_dust_floor.test.js rename to test/unit/xchain_encoder/xchain_encoder_soft_dust_floor.test.js diff --git a/test/unit/xchain_encoder_taproot_envelope.test.js b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test.js similarity index 100% rename from test/unit/xchain_encoder_taproot_envelope.test.js rename to test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test.js diff --git a/test/unit/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js similarity index 100% rename from test/unit/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js rename to test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js diff --git a/test/unit/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js similarity index 100% rename from test/unit/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js rename to test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js diff --git a/test/unit/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js similarity index 100% rename from test/unit/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js rename to test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js diff --git a/test/unit/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js similarity index 100% rename from test/unit/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js rename to test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js diff --git a/test/unit/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js similarity index 100% rename from test/unit/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js rename to test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js diff --git a/test/unit/xchain_encoder_tracker_freshness_classifier.test.js b/test/unit/xchain_encoder/xchain_encoder_tracker_freshness_classifier.test.js similarity index 100% rename from test/unit/xchain_encoder_tracker_freshness_classifier.test.js rename to test/unit/xchain_encoder/xchain_encoder_tracker_freshness_classifier.test.js diff --git a/test/unit/xchain_encoder_utxo_dedup.test.js b/test/unit/xchain_encoder/xchain_encoder_utxo_dedup.test.js similarity index 100% rename from test/unit/xchain_encoder_utxo_dedup.test.js rename to test/unit/xchain_encoder/xchain_encoder_utxo_dedup.test.js From a743fa27d5c372728e6e93f6d90b8832087f8e91 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 12:10:35 -0700 Subject: [PATCH 141/148] refactor(test): repoint requires and pins after the test/unit split Companion commit to the pure test/unit directory-split move: fix every relative require()/require.resolve()/path.join(__dirname, ...) call in the moved files so each still resolves to its original target, update bin/pins/test-rename-map.json so the 43 moved top-level test files retarget straight to their new location (single-hop, matching the pin's own recorded path), update bin/pins/suite-title-splits.json so the 8 split-parent entries carry their new old-key and part paths, and repoint five stray comment references (docs/openrpc.build.js, src/api.js, src/build/apply_bufferutils_patch.js, src/common/validator/constants.js, bin/coverage-thresholds.json) that named a test file by its old path. Also fixes the relative require in xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js, which origin/develop added under the split directory after this branch was first built and picked up the same one-level-deeper move on rebase. --- bin/coverage-thresholds.json | 2 +- bin/pins/suite-title-splits.json | 172 +++++++++--------- bin/pins/test-rename-map.json | 86 ++++----- docs/openrpc.build.js | 4 +- src/api.js | 4 +- src/build/apply_bufferutils_patch.js | 2 +- src/common/validator/constants.js | 2 +- test/unit/adapters/evm_adapter.test.js | 6 +- test/unit/api/cors_preflight.test.js | 2 +- test/unit/api/encoder_stress_sweep.test.js | 6 +- test/unit/api/health_serve_readiness.test.js | 4 +- test/unit/api/jsonrpc_body_guard.test.js | 2 +- test/unit/api/openrpc_coverage.test.js | 8 +- .../build/apply_bufferutils_patch.test.js | 8 +- test/unit/build/compression.test.js | 2 +- .../01_action_string_helpers.test.js | 2 +- .../02_create_transaction_integration.test.js | 2 +- ...arries_the_bytes_that_were_written.test.js | 2 +- .../compression.test/04_validator.test.js | 2 +- .../05_constants_conformance.test.js | 8 +- .../compression.test/helpers/fixtures.js | 2 +- test/unit/build/crypto_networks.test.js | 2 +- test/unit/build/package_fee_sizing.test.js | 2 +- .../01_package_fee_uplift_satoshis.test.js | 2 +- ...n_encoder_package_aware_fee_sizing.test.js | 4 +- ...3_two_phase_reveal_package_prefund.test.js | 4 +- .../04_p2sh_two_phase_package_prefund.test.js | 4 +- test/unit/build/tx_size_estimator.test.js | 2 +- test/unit/build/utxo_tracker.test.js | 2 +- .../utxo_tracker.test/helpers/support.js | 2 +- test/unit/coins/coins_conformance.test.js | 8 +- .../xchain_encoder_consensus_pin_boot.test.js | 4 +- test/unit/common/error_sanitize.test.js | 2 +- .../action_manifest_conformance.test.js | 6 +- .../validator/large_satoshi_amounts.test.js | 6 +- test/unit/common/validator/validator.test.js | 2 +- .../01_validate_data_param.test.js | 2 +- ...tion_name_encoder_action_name_gate.test.js | 2 +- .../03_validate_combined_data_length.test.js | 2 +- ..._expansion_at_the_compiled_ceiling.test.js | 2 +- .../05_validate_encoding.test.js | 2 +- .../06_max_fee_satoshis.test.js | 2 +- .../validator.test/07_validate_fee.test.js | 2 +- .../08_validate_fee_per_kb.test.js | 2 +- .../validator.test/09_validate_dust.test.js | 2 +- ...ate_utxo_array_validate_utxo_entry.test.js | 2 +- ...validate_custom_output_via_wrapper.test.js | 2 +- .../12_validate_p2sh_params.test.js | 2 +- .../13_validate_raw_tx_hex.test.js | 2 +- .../14_validate_compressed_pub_key.test.js | 2 +- .../validator.test/15_validate_change.test.js | 2 +- .../16_validate_address.test.js | 2 +- .../validator.test/17_validate_all.test.js | 2 +- .../repo/coverage_thresholds_sync.test.js | 2 +- test/unit/repo/docs_openrpc.test.js | 2 +- test/unit/repo/sibling_coverage.test.js | 2 +- test/unit/server/cors_origin.test.js | 2 +- test/unit/server/maintenance_window.test.js | 4 +- .../unit/server/single_instance_guard.test.js | 2 +- .../01_acquire_instance_lock.test.js | 2 +- .../02_is_pid_alive.test.js | 2 +- .../03_release_lock_on_signals.test.js | 2 +- .../envelope_recognition_gate.test.js | 6 +- .../suggested_fee_ceiling.test.js | 2 +- .../xchain_encoder_auto_encoding.test.js | 6 +- .../08_no_utxos_available.test.js | 2 +- .../fixtures/transaction.js | 6 +- .../xchain_encoder_data_to_pubkey.test.js | 2 +- .../xchain_encoder_exact_inputs.test.js | 6 +- .../xchain_encoder_extra.test.js | 4 +- .../01_p2wsh_tx1_funding.test.js | 4 +- .../02_p2wsh_tx2_spending.test.js | 4 +- .../03_payload_size_guard.test.js | 4 +- .../04_fee_quote_injection.test.js | 4 +- .../05_max_fee_rate_kb_cap.test.js | 4 +- .../06_change_edge_cases.test.js | 4 +- .../07_invalid_fee.test.js | 4 +- .../08_estimate_spending_p2wsh_tx.test.js | 6 +- .../09_remaining_branch_coverage.test.js | 4 +- .../10_raw_data_parameter.test.js | 4 +- .../xchain_encoder_fee_rate_cap.test.js | 4 +- .../xchain_encoder_is_segwit_u_t_x_o.test.js | 2 +- .../xchain_encoder_obfuscate.test.js | 2 +- ...chain_encoder_p2sh_reveal_headroom.test.js | 4 +- .../xchain_encoder_prepare_data.test.js | 2 +- ...chain_encoder_release_reservations.test.js | 6 +- ...ain_encoder_reveal_funding_binding.test.js | 4 +- .../xchain_encoder_segwit_input_guard.test.js | 4 +- .../xchain_encoder_soft_dust_floor.test.js | 4 +- .../xchain_encoder_taproot_envelope.test.js | 4 +- ...eate_transaction_pair_construction.test.js | 4 +- ...2_sign_finalize_extract_round_trip.test.js | 2 +- ...cel_from_persisted_recovery_record.test.js | 2 +- ..._tx_size_estimator_envelope_sizing.test.js | 2 +- ...5_key_path_cancel_suspension_count.test.js | 2 +- ...coder_tracker_freshness_classifier.test.js | 6 +- .../xchain_encoder_utxo_dedup.test.js | 2 +- 97 files changed, 284 insertions(+), 282 deletions(-) diff --git a/bin/coverage-thresholds.json b/bin/coverage-thresholds.json index 864ff92..d1115fc 100644 --- a/bin/coverage-thresholds.json +++ b/bin/coverage-thresholds.json @@ -1,5 +1,5 @@ { - "comment": "Coverage floors for the CI coverage job (regression floors, ~1-1.5 points below measured, not tier targets; raise as coverage climbs). Mirrored into the coverage:check npm script in package.json and guarded by test/unit/coverage_thresholds_sync.test.js. Re-measured 2026-08-14 WITH the declared siblings checked out, which is what the coverage job now does: 87.77 lines/statements, 86.67 branches, 67.48 functions over 587 unit tests; the functions floor stays at its existing 66, already inside the band, and remains a holding line rather than a target.", + "comment": "Coverage floors for the CI coverage job (regression floors, ~1-1.5 points below measured, not tier targets; raise as coverage climbs). Mirrored into the coverage:check npm script in package.json and guarded by test/unit/repo/coverage_thresholds_sync.test.js. Re-measured 2026-08-14 WITH the declared siblings checked out, which is what the coverage job now does: 87.77 lines/statements, 86.67 branches, 67.48 functions over 587 unit tests; the functions floor stays at its existing 66, already inside the band, and remains a holding line rather than a target.", "lines": 86.2, "statements": 86.2, "branches": 85.1, diff --git a/bin/pins/suite-title-splits.json b/bin/pins/suite-title-splits.json index 5066b74..0baab71 100644 --- a/bin/pins/suite-title-splits.json +++ b/bin/pins/suite-title-splits.json @@ -21,73 +21,73 @@ "test/unit/blockchain_connector.test/06_get_fee_per_kilobyte.test.js", "test/unit/blockchain_connector.test/07_rpc_credential_log_sanitization.test.js" ], - "test/unit/compression.test.js": [ - "test/unit/compression.test.js", - "test/unit/compression.test/01_action_string_helpers.test.js", - "test/unit/compression.test/02_create_transaction_integration.test.js", - "test/unit/compression.test/03_the_compression_report_carries_the_bytes_that_were_written.test.js", - "test/unit/compression.test/04_validator.test.js", - "test/unit/compression.test/05_constants_conformance.test.js" - ], - "test/unit/package_fee_sizing.test.js": [ - "test/unit/package_fee_sizing.test.js", - "test/unit/package_fee_sizing.test/01_package_fee_uplift_satoshis.test.js", - "test/unit/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js", - "test/unit/package_fee_sizing.test/03_two_phase_reveal_package_prefund.test.js", - "test/unit/package_fee_sizing.test/04_p2sh_two_phase_package_prefund.test.js" - ], - "test/unit/single_instance_guard.test.js": [ - "test/unit/single_instance_guard.test.js", - "test/unit/single_instance_guard.test/01_acquire_instance_lock.test.js", - "test/unit/single_instance_guard.test/02_is_pid_alive.test.js", - "test/unit/single_instance_guard.test/03_release_lock_on_signals.test.js" - ], - "test/unit/utxo_tracker.test.js": [ - "test/unit/utxo_tracker.test.js", - "test/unit/utxo_tracker.test/01_get_sync_status.test.js", - "test/unit/utxo_tracker.test/02_get_utxos_from_address.test.js", - "test/unit/utxo_tracker.test/03_get_utxos_from_address_validation.test.js" - ], - "test/unit/xchain_encoder_create_transaction.test.js": [ - "test/unit/xchain_encoder_create_transaction.test.js", - "test/unit/xchain_encoder_create_transaction.test/01_utxo_deduplication.test.js", - "test/unit/xchain_encoder_create_transaction.test/02_unconfirmed_filtering.test.js", - "test/unit/xchain_encoder_create_transaction.test/03_utxo_sorting.test.js", - "test/unit/xchain_encoder_create_transaction.test/04_replace_by_fee_sequence.test.js", - "test/unit/xchain_encoder_create_transaction.test/05_fee_handling.test.js", - "test/unit/xchain_encoder_create_transaction.test/06_custom_dust_parameter.test.js", - "test/unit/xchain_encoder_create_transaction.test/07_change_output.test.js", - "test/unit/xchain_encoder_create_transaction.test/08_no_utxos_available.test.js", - "test/unit/xchain_encoder_create_transaction.test/09_custom_outputs.test.js", - "test/unit/xchain_encoder_create_transaction.test/10_op_return_encoding_path.test.js", - "test/unit/xchain_encoder_create_transaction.test/11_p2sh_encoding_funding.test.js", - "test/unit/xchain_encoder_create_transaction.test/12_p2sh_encoding_spending.test.js", - "test/unit/xchain_encoder_create_transaction.test/13_p2sh_native_fee_custom_outputs.test.js", - "test/unit/xchain_encoder_create_transaction.test/14_multisign_encoding_path.test.js", - "test/unit/xchain_encoder_create_transaction.test/15_return_value.test.js", - "test/unit/xchain_encoder_create_transaction.test/16_estimate_spending_p2sh_tx.test.js", - "test/unit/xchain_encoder_create_transaction.test/17_payment_only.test.js", - "test/unit/xchain_encoder_create_transaction.test/18_payload_guards.test.js" - ], - "test/unit/xchain_encoder_extra.test.js": [ - "test/unit/xchain_encoder_extra.test.js", - "test/unit/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js", - "test/unit/xchain_encoder_extra.test/02_p2wsh_tx2_spending.test.js", - "test/unit/xchain_encoder_extra.test/03_payload_size_guard.test.js", - "test/unit/xchain_encoder_extra.test/04_fee_quote_injection.test.js", - "test/unit/xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.js", - "test/unit/xchain_encoder_extra.test/06_change_edge_cases.test.js", - "test/unit/xchain_encoder_extra.test/07_invalid_fee.test.js", - "test/unit/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js", - "test/unit/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js", - "test/unit/xchain_encoder_extra.test/10_raw_data_parameter.test.js" - ], - "test/unit/xchain_encoder_taproot_envelope.test.js": [ - "test/unit/xchain_encoder_taproot_envelope.test.js", - "test/unit/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js", - "test/unit/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js", - "test/unit/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js", - "test/unit/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js" + "test/unit/build/compression.test.js": [ + "test/unit/build/compression.test.js", + "test/unit/build/compression.test/01_action_string_helpers.test.js", + "test/unit/build/compression.test/02_create_transaction_integration.test.js", + "test/unit/build/compression.test/03_the_compression_report_carries_the_bytes_that_were_written.test.js", + "test/unit/build/compression.test/04_validator.test.js", + "test/unit/build/compression.test/05_constants_conformance.test.js" + ], + "test/unit/build/package_fee_sizing.test.js": [ + "test/unit/build/package_fee_sizing.test.js", + "test/unit/build/package_fee_sizing.test/01_package_fee_uplift_satoshis.test.js", + "test/unit/build/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js", + "test/unit/build/package_fee_sizing.test/03_two_phase_reveal_package_prefund.test.js", + "test/unit/build/package_fee_sizing.test/04_p2sh_two_phase_package_prefund.test.js" + ], + "test/unit/server/single_instance_guard.test.js": [ + "test/unit/server/single_instance_guard.test.js", + "test/unit/server/single_instance_guard.test/01_acquire_instance_lock.test.js", + "test/unit/server/single_instance_guard.test/02_is_pid_alive.test.js", + "test/unit/server/single_instance_guard.test/03_release_lock_on_signals.test.js" + ], + "test/unit/build/utxo_tracker.test.js": [ + "test/unit/build/utxo_tracker.test.js", + "test/unit/build/utxo_tracker.test/01_get_sync_status.test.js", + "test/unit/build/utxo_tracker.test/02_get_utxos_from_address.test.js", + "test/unit/build/utxo_tracker.test/03_get_utxos_from_address_validation.test.js" + ], + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test.js": [ + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test.js", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/01_utxo_deduplication.test.js", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/02_unconfirmed_filtering.test.js", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/03_utxo_sorting.test.js", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/04_replace_by_fee_sequence.test.js", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/05_fee_handling.test.js", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/06_custom_dust_parameter.test.js", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/07_change_output.test.js", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/08_no_utxos_available.test.js", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/09_custom_outputs.test.js", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/10_op_return_encoding_path.test.js", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/11_p2sh_encoding_funding.test.js", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/12_p2sh_encoding_spending.test.js", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/13_p2sh_native_fee_custom_outputs.test.js", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/14_multisign_encoding_path.test.js", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/15_return_value.test.js", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/16_estimate_spending_p2sh_tx.test.js", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/17_payment_only.test.js", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/18_payload_guards.test.js" + ], + "test/unit/xchain_encoder/xchain_encoder_extra.test.js": [ + "test/unit/xchain_encoder/xchain_encoder_extra.test.js", + "test/unit/xchain_encoder/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js", + "test/unit/xchain_encoder/xchain_encoder_extra.test/02_p2wsh_tx2_spending.test.js", + "test/unit/xchain_encoder/xchain_encoder_extra.test/03_payload_size_guard.test.js", + "test/unit/xchain_encoder/xchain_encoder_extra.test/04_fee_quote_injection.test.js", + "test/unit/xchain_encoder/xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.js", + "test/unit/xchain_encoder/xchain_encoder_extra.test/06_change_edge_cases.test.js", + "test/unit/xchain_encoder/xchain_encoder_extra.test/07_invalid_fee.test.js", + "test/unit/xchain_encoder/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js", + "test/unit/xchain_encoder/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js", + "test/unit/xchain_encoder/xchain_encoder_extra.test/10_raw_data_parameter.test.js" + ], + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test.js": [ + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test.js", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js" ], "test/xchain_encoder.test.js": [ "test/xchain_encoder.test.js", @@ -168,25 +168,25 @@ "test/smoke/smoke.test/03_data_to_pubkey.test.js", "test/smoke/smoke.test/04_api_server_startup.test.js" ], - "test/unit/validator.test.js": [ - "test/unit/validator.test.js", - "test/unit/validator.test/01_validate_data_param.test.js", - "test/unit/validator.test/02_validate_action_name_encoder_action_name_gate.test.js", - "test/unit/validator.test/03_validate_combined_data_length.test.js", - "test/unit/validator.test/04_alias_expansion_at_the_compiled_ceiling.test.js", - "test/unit/validator.test/05_validate_encoding.test.js", - "test/unit/validator.test/06_max_fee_satoshis.test.js", - "test/unit/validator.test/07_validate_fee.test.js", - "test/unit/validator.test/08_validate_fee_per_kb.test.js", - "test/unit/validator.test/09_validate_dust.test.js", - "test/unit/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js", - "test/unit/validator.test/11_validate_custom_outputs_validate_custom_output_via_wrapper.test.js", - "test/unit/validator.test/12_validate_p2sh_params.test.js", - "test/unit/validator.test/13_validate_raw_tx_hex.test.js", - "test/unit/validator.test/14_validate_compressed_pub_key.test.js", - "test/unit/validator.test/15_validate_change.test.js", - "test/unit/validator.test/16_validate_address.test.js", - "test/unit/validator.test/17_validate_all.test.js" + "test/unit/common/validator/validator.test.js": [ + "test/unit/common/validator/validator.test.js", + "test/unit/common/validator/validator.test/01_validate_data_param.test.js", + "test/unit/common/validator/validator.test/02_validate_action_name_encoder_action_name_gate.test.js", + "test/unit/common/validator/validator.test/03_validate_combined_data_length.test.js", + "test/unit/common/validator/validator.test/04_alias_expansion_at_the_compiled_ceiling.test.js", + "test/unit/common/validator/validator.test/05_validate_encoding.test.js", + "test/unit/common/validator/validator.test/06_max_fee_satoshis.test.js", + "test/unit/common/validator/validator.test/07_validate_fee.test.js", + "test/unit/common/validator/validator.test/08_validate_fee_per_kb.test.js", + "test/unit/common/validator/validator.test/09_validate_dust.test.js", + "test/unit/common/validator/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js", + "test/unit/common/validator/validator.test/11_validate_custom_outputs_validate_custom_output_via_wrapper.test.js", + "test/unit/common/validator/validator.test/12_validate_p2sh_params.test.js", + "test/unit/common/validator/validator.test/13_validate_raw_tx_hex.test.js", + "test/unit/common/validator/validator.test/14_validate_compressed_pub_key.test.js", + "test/unit/common/validator/validator.test/15_validate_change.test.js", + "test/unit/common/validator/validator.test/16_validate_address.test.js", + "test/unit/common/validator/validator.test/17_validate_all.test.js" ] } } diff --git a/bin/pins/test-rename-map.json b/bin/pins/test-rename-map.json index cb74ec2..e170484 100644 --- a/bin/pins/test-rename-map.json +++ b/bin/pins/test-rename-map.json @@ -69,48 +69,50 @@ "test/security/payload-size.test.js": "test/security/payload_size.test.js", "test/security/rate-limit-log.test.js": "test/security/rate_limit_log.test.js", "test/setup.js": "test/setup/index.js", - "test/unit/ActionManifestConformance.test.js": "test/unit/action_manifest_conformance.test.js", + "test/unit/ActionManifestConformance.test.js": "test/unit/common/validator/action_manifest_conformance.test.js", "test/unit/BlockchainConnector.test.js": "test/unit/blockchain_connector.test.js", - "test/unit/CryptoNetworks.test.js": "test/unit/crypto_networks.test.js", - "test/unit/LargeSatoshiAmounts.test.js": "test/unit/large_satoshi_amounts.test.js", - "test/unit/TxSizeEstimator.test.js": "test/unit/tx_size_estimator.test.js", - "test/unit/UtxoTracker.test.js": "test/unit/utxo_tracker.test.js", - "test/unit/XChainEncoder.autoEncoding.test.js": "test/unit/xchain_encoder_auto_encoding.test.js", - "test/unit/XChainEncoder.consensusPinBoot.test.js": "test/unit/xchain_encoder_consensus_pin_boot.test.js", - "test/unit/XChainEncoder.createTransaction.test.js": "test/unit/xchain_encoder_create_transaction.test.js", - "test/unit/XChainEncoder.dataToPubkey.test.js": "test/unit/xchain_encoder_data_to_pubkey.test.js", - "test/unit/XChainEncoder.exactInputs.test.js": "test/unit/xchain_encoder_exact_inputs.test.js", - "test/unit/XChainEncoder.extra.test.js": "test/unit/xchain_encoder_extra.test.js", - "test/unit/XChainEncoder.feeRateCap.test.js": "test/unit/xchain_encoder_fee_rate_cap.test.js", - "test/unit/XChainEncoder.isSegwitUTXO.test.js": "test/unit/xchain_encoder_is_segwit_u_t_x_o.test.js", - "test/unit/XChainEncoder.obfuscate.test.js": "test/unit/xchain_encoder_obfuscate.test.js", - "test/unit/XChainEncoder.p2shRevealHeadroom.test.js": "test/unit/xchain_encoder_p2sh_reveal_headroom.test.js", - "test/unit/XChainEncoder.prepareData.test.js": "test/unit/xchain_encoder_prepare_data.test.js", - "test/unit/XChainEncoder.releaseReservations.test.js": "test/unit/xchain_encoder_release_reservations.test.js", - "test/unit/XChainEncoder.revealFundingBinding.test.js": "test/unit/xchain_encoder_reveal_funding_binding.test.js", - "test/unit/XChainEncoder.segwitInputGuard.test.js": "test/unit/xchain_encoder_segwit_input_guard.test.js", - "test/unit/XChainEncoder.softDustFloor.test.js": "test/unit/xchain_encoder_soft_dust_floor.test.js", - "test/unit/XChainEncoder.taprootEnvelope.test.js": "test/unit/xchain_encoder_taproot_envelope.test.js", - "test/unit/XChainEncoder.trackerFreshnessClassifier.test.js": "test/unit/xchain_encoder_tracker_freshness_classifier.test.js", - "test/unit/XChainEncoder.utxoDedup.test.js": "test/unit/xchain_encoder_utxo_dedup.test.js", - "test/unit/applyBufferutilsPatch.test.js": "test/unit/apply_bufferutils_patch.test.js", - "test/unit/coins-conformance.test.js": "test/unit/coins_conformance.test.js", - "test/unit/corsOrigin.test.js": "test/unit/cors_origin.test.js", - "test/unit/corsPreflight.test.js": "test/unit/cors_preflight.test.js", - "test/unit/coverage-thresholds-sync.test.js": "test/unit/coverage_thresholds_sync.test.js", + "test/unit/CryptoNetworks.test.js": "test/unit/build/crypto_networks.test.js", + "test/unit/LargeSatoshiAmounts.test.js": "test/unit/common/validator/large_satoshi_amounts.test.js", + "test/unit/TxSizeEstimator.test.js": "test/unit/build/tx_size_estimator.test.js", + "test/unit/UtxoTracker.test.js": "test/unit/build/utxo_tracker.test.js", + "test/unit/XChainEncoder.autoEncoding.test.js": "test/unit/xchain_encoder/xchain_encoder_auto_encoding.test.js", + "test/unit/XChainEncoder.consensusPinBoot.test.js": "test/unit/coins/xchain_encoder_consensus_pin_boot.test.js", + "test/unit/XChainEncoder.createTransaction.test.js": "test/unit/xchain_encoder/xchain_encoder_create_transaction.test.js", + "test/unit/XChainEncoder.dataToPubkey.test.js": "test/unit/xchain_encoder/xchain_encoder_data_to_pubkey.test.js", + "test/unit/XChainEncoder.exactInputs.test.js": "test/unit/xchain_encoder/xchain_encoder_exact_inputs.test.js", + "test/unit/XChainEncoder.extra.test.js": "test/unit/xchain_encoder/xchain_encoder_extra.test.js", + "test/unit/XChainEncoder.feeRateCap.test.js": "test/unit/xchain_encoder/xchain_encoder_fee_rate_cap.test.js", + "test/unit/XChainEncoder.isSegwitUTXO.test.js": "test/unit/xchain_encoder/xchain_encoder_is_segwit_u_t_x_o.test.js", + "test/unit/XChainEncoder.obfuscate.test.js": "test/unit/xchain_encoder/xchain_encoder_obfuscate.test.js", + "test/unit/XChainEncoder.p2shRevealHeadroom.test.js": "test/unit/xchain_encoder/xchain_encoder_p2sh_reveal_headroom.test.js", + "test/unit/XChainEncoder.prepareData.test.js": "test/unit/xchain_encoder/xchain_encoder_prepare_data.test.js", + "test/unit/XChainEncoder.releaseReservations.test.js": "test/unit/xchain_encoder/xchain_encoder_release_reservations.test.js", + "test/unit/XChainEncoder.revealFundingBinding.test.js": "test/unit/xchain_encoder/xchain_encoder_reveal_funding_binding.test.js", + "test/unit/XChainEncoder.segwitInputGuard.test.js": "test/unit/xchain_encoder/xchain_encoder_segwit_input_guard.test.js", + "test/unit/XChainEncoder.softDustFloor.test.js": "test/unit/xchain_encoder/xchain_encoder_soft_dust_floor.test.js", + "test/unit/XChainEncoder.taprootEnvelope.test.js": "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test.js", + "test/unit/XChainEncoder.trackerFreshnessClassifier.test.js": "test/unit/xchain_encoder/xchain_encoder_tracker_freshness_classifier.test.js", + "test/unit/XChainEncoder.utxoDedup.test.js": "test/unit/xchain_encoder/xchain_encoder_utxo_dedup.test.js", + "test/unit/applyBufferutilsPatch.test.js": "test/unit/build/apply_bufferutils_patch.test.js", + "test/unit/coins-conformance.test.js": "test/unit/coins/coins_conformance.test.js", + "test/unit/compression.test.js": "test/unit/build/compression.test.js", + "test/unit/corsOrigin.test.js": "test/unit/server/cors_origin.test.js", + "test/unit/corsPreflight.test.js": "test/unit/api/cors_preflight.test.js", + "test/unit/coverage-thresholds-sync.test.js": "test/unit/repo/coverage_thresholds_sync.test.js", "test/unit/crashHandlers.test.js": "test/unit/crash_handlers.test.js", - "test/unit/docs-openrpc.test.js": "test/unit/docs_openrpc.test.js", - "test/unit/encoderStressSweep.test.js": "test/unit/encoder_stress_sweep.test.js", - "test/unit/envelopeRecognitionGate.test.js": "test/unit/envelope_recognition_gate.test.js", - "test/unit/errorSanitize.test.js": "test/unit/error_sanitize.test.js", - "test/unit/evmAdapter.test.js": "test/unit/evm_adapter.test.js", - "test/unit/health-serve-readiness.test.js": "test/unit/health_serve_readiness.test.js", - "test/unit/jsonrpc-body-guard.test.js": "test/unit/jsonrpc_body_guard.test.js", - "test/unit/maintenanceWindow.test.js": "test/unit/maintenance_window.test.js", - "test/unit/openrpc-coverage.test.js": "test/unit/openrpc_coverage.test.js", - "test/unit/packageFeeSizing.test.js": "test/unit/package_fee_sizing.test.js", + "test/unit/docs-openrpc.test.js": "test/unit/repo/docs_openrpc.test.js", + "test/unit/encoderStressSweep.test.js": "test/unit/api/encoder_stress_sweep.test.js", + "test/unit/envelopeRecognitionGate.test.js": "test/unit/xchain_encoder/envelope_recognition_gate.test.js", + "test/unit/errorSanitize.test.js": "test/unit/common/error_sanitize.test.js", + "test/unit/evmAdapter.test.js": "test/unit/adapters/evm_adapter.test.js", + "test/unit/health-serve-readiness.test.js": "test/unit/api/health_serve_readiness.test.js", + "test/unit/jsonrpc-body-guard.test.js": "test/unit/api/jsonrpc_body_guard.test.js", + "test/unit/maintenanceWindow.test.js": "test/unit/server/maintenance_window.test.js", + "test/unit/openrpc-coverage.test.js": "test/unit/api/openrpc_coverage.test.js", + "test/unit/packageFeeSizing.test.js": "test/unit/build/package_fee_sizing.test.js", "test/unit/security/configuration/dependency-advisories.test.js": "test/unit/security/configuration/dependency_advisories.test.js", - "test/unit/sibling-coverage.test.js": "test/unit/sibling_coverage.test.js", - "test/unit/singleInstanceGuard.test.js": "test/unit/single_instance_guard.test.js", - "test/unit/suggestedFeeCeiling.test.js": "test/unit/suggested_fee_ceiling.test.js" -} \ No newline at end of file + "test/unit/sibling-coverage.test.js": "test/unit/repo/sibling_coverage.test.js", + "test/unit/singleInstanceGuard.test.js": "test/unit/server/single_instance_guard.test.js", + "test/unit/suggestedFeeCeiling.test.js": "test/unit/xchain_encoder/suggested_fee_ceiling.test.js", + "test/unit/validator.test.js": "test/unit/common/validator/validator.test.js" +} diff --git a/docs/openrpc.build.js b/docs/openrpc.build.js index 7d08795..ee279a9 100644 --- a/docs/openrpc.build.js +++ b/docs/openrpc.build.js @@ -7,7 +7,7 @@ * * Generates docs/openrpc.json (OpenRPC 1.3.2) for the encoder's JSON-RPC API. * METHODS below mirrors the jsonRpcController in src/api.js; the unit test - * test/unit/openrpc_coverage.test.js fails if the two drift apart. + * test/unit/api/openrpc_coverage.test.js fails if the two drift apart. * * Run: node docs/openrpc.build.js */ @@ -149,7 +149,7 @@ const spec = { // Writing is the SCRIPT's job, not the module's, so a test can require this file // for the spec it would emit and compare it against the checked-in artifact -// without rewriting that artifact mid-run (see test/unit/openrpc_coverage.test.js). +// without rewriting that artifact mid-run (see test/unit/api/openrpc_coverage.test.js). if (require.main === module) { const out = path.join(__dirname, 'openrpc.json'); fs.writeFileSync(out, JSON.stringify(spec, null, 2) + '\n'); diff --git a/src/api.js b/src/api.js index 86c354e..9023682 100644 --- a/src/api.js +++ b/src/api.js @@ -150,7 +150,7 @@ app.use(helmet()); // same order is what lets a browser read the gate 401 and the limiter/gate 429s // rather than an opaque network error. Trade: a preflight skips the limiter and // both gates for a 204 that does no upstream work, as in every sibling service. -// Ordering pinned by test/unit/cors_preflight.test.js. +// Ordering pinned by test/unit/api/cors_preflight.test.js. app.use(cors({ origin: parseCorsOrigin(CORS_ORIGIN) })); // 3mb (was 1mb): the TAPROOT envelope raises the largest legitimate request @@ -290,7 +290,7 @@ app.get('/status', probeGate.hold(async (req, res) => { })) // Machine-readable API spec (OpenRPC 1.3.2). Regenerated by docs/openrpc.build.js; -// test/unit/openrpc_coverage.test.js keeps it in lockstep with jsonRpcController. +// test/unit/api/openrpc_coverage.test.js keeps it in lockstep with jsonRpcController. let openrpcSpec = null app.get('/openrpc.json', (req, res) => { if (!openrpcSpec) diff --git a/src/build/apply_bufferutils_patch.js b/src/build/apply_bufferutils_patch.js index 9c679b7..68c80f4 100644 --- a/src/build/apply_bufferutils_patch.js +++ b/src/build/apply_bufferutils_patch.js @@ -39,7 +39,7 @@ * * Re-sync seam (uuid:311476c7). Everything below this banner is pinned * byte-identical to the SDK copy's body by - * test/unit/apply_bufferutils_patch.test.js. Convergence runs SDK-ward: change + * test/unit/build/apply_bufferutils_patch.test.js. Convergence runs SDK-ward: change * the SDK copy first, then copy its body here. Only the two headers differ, * because each names the other and its own read-side relationship. * diff --git a/src/common/validator/constants.js b/src/common/validator/constants.js index 525c757..7acddfc 100644 --- a/src/common/validator/constants.js +++ b/src/common/validator/constants.js @@ -106,7 +106,7 @@ const COMPRESSED_PUBKEY_RE = /^(02|03)[0-9a-fA-F]{64}$/ // this set, after alias expansion, silently no-actioning the tx); the encoder // had no equivalent gate, so a typoed or too-new ACTION name would encode, pay // fees, and broadcast, then vanish silently at decode. Pinned against drift by -// test/unit/action_manifest_conformance.test.js, mirroring the decoder's own +// test/unit/common/validator/action_manifest_conformance.test.js, mirroring the decoder's own // conformance guard. const VALID_ACTION_NAMES = new Set([ 'ADDRESS', 'AIRDROP', 'ANCHOR', 'ATTEST', diff --git a/test/unit/adapters/evm_adapter.test.js b/test/unit/adapters/evm_adapter.test.js index 2064ea1..8463362 100644 --- a/test/unit/adapters/evm_adapter.test.js +++ b/test/unit/adapters/evm_adapter.test.js @@ -14,9 +14,9 @@ // undefined). They do NOT exercise any real EVM tx path (there is none yet). const assert = require('assert') -const evmFinality = require('../../src/adapters/evm_finality') -const { ChainAdapter, AdapterNotImplementedError } = require('../../src/adapters/chain_adapter') -const { EvmAdapter } = require('../../src/adapters/evm_adapter') +const evmFinality = require('../../../src/adapters/evm_finality') +const { ChainAdapter, AdapterNotImplementedError } = require('../../../src/adapters/chain_adapter') +const { EvmAdapter } = require('../../../src/adapters/evm_adapter') describe('EVM finality gate (evmFinality)', registerFinalityChoices) diff --git a/test/unit/api/cors_preflight.test.js b/test/unit/api/cors_preflight.test.js index c3df102..61d1e06 100644 --- a/test/unit/api/cors_preflight.test.js +++ b/test/unit/api/cors_preflight.test.js @@ -32,7 +32,7 @@ const assert = require('assert') -const API_PATH = require.resolve('../../src/api.js') +const API_PATH = require.resolve('../../../src/api.js') const ORIGIN = 'https://wallet.example' const API_KEY = 'preflight-test-key' diff --git a/test/unit/api/encoder_stress_sweep.test.js b/test/unit/api/encoder_stress_sweep.test.js index 3c46372..f214744 100644 --- a/test/unit/api/encoder_stress_sweep.test.js +++ b/test/unit/api/encoder_stress_sweep.test.js @@ -24,14 +24,14 @@ for (const k of _envKeys) { process.env[k] = (k === 'NETWORK') ? 'bitcoin-regtest' : (k === 'NODE_PORT') ? '8333' : 'x' } } -const { makeRpcBatchGuard } = require('../../src/api') +const { makeRpcBatchGuard } = require('../../../src/api') for (const k of _envKeys) { if (_savedEnv[k] === undefined) delete process.env[k] else process.env[k] = _savedEnv[k] } -const v = require('../../src/common/validator') +const v = require('../../../src/common/validator') const axios = require('axios') -const UtxoTracker = require('../../src/build/utxo_tracker') +const UtxoTracker = require('../../../src/build/utxo_tracker') describe('encoder stress-sweep unit @regression', function () { this.timeout(10000) diff --git a/test/unit/api/health_serve_readiness.test.js b/test/unit/api/health_serve_readiness.test.js index 07c3c13..06e82aa 100644 --- a/test/unit/api/health_serve_readiness.test.js +++ b/test/unit/api/health_serve_readiness.test.js @@ -24,7 +24,7 @@ ********************************************************************/ const assert = require('assert'); -const { jsonRpcController, encoder } = require('../../src/api'); +const { jsonRpcController, encoder } = require('../../../src/api'); const http = require('http'); function stubSync(status) { @@ -157,7 +157,7 @@ describe('health(): tracker_synced is serve-readiness (create_tx parity) @regres * /status only. The JSON-RPC health() shape is documented in docs/openrpc.json * and stays as it is. */ describe('GET /status publishes the lag ceiling its readiness was gated on @regression', function () { - const { app, encoder } = require('../../src/api'); + const { app, encoder } = require('../../../src/api'); async function statusBody() { const server = await new Promise((resolve) => { diff --git a/test/unit/api/jsonrpc_body_guard.test.js b/test/unit/api/jsonrpc_body_guard.test.js index 5a256a6..12c8607 100644 --- a/test/unit/api/jsonrpc_body_guard.test.js +++ b/test/unit/api/jsonrpc_body_guard.test.js @@ -91,7 +91,7 @@ describe('JSON-RPC body guard (Express 5 / body-parser 2.x regression)', functio }); it('src/api.js wires the guard before the jsonRouter mount', () => { - const src = fs.readFileSync(path.join(__dirname, '../../src/api.js'), 'utf8'); + const src = fs.readFileSync(path.join(__dirname, '../../../src/api.js'), 'utf8'); const guardIdx = src.indexOf('if (req.body === undefined) req.body = {}'); const routerIdx = src.indexOf('jsonRouter('); assert.notStrictEqual(guardIdx, -1, 'req.body guard missing from src/api.js'); diff --git a/test/unit/api/openrpc_coverage.test.js b/test/unit/api/openrpc_coverage.test.js index 7ee92d8..777360b 100644 --- a/test/unit/api/openrpc_coverage.test.js +++ b/test/unit/api/openrpc_coverage.test.js @@ -24,8 +24,8 @@ const path = require('path'); const assert = require('assert'); { - const src = fs.readFileSync(path.join(__dirname, '../../src/api/json_rpc_methods.js'), 'utf8'); - const spec = JSON.parse(fs.readFileSync(path.join(__dirname, '../../docs/openrpc.json'), 'utf8')); + const src = fs.readFileSync(path.join(__dirname, '../../../src/api/json_rpc_methods.js'), 'utf8'); + const spec = JSON.parse(fs.readFileSync(path.join(__dirname, '../../../docs/openrpc.json'), 'utf8')); // Method names across the per-group builder literals, which is every // method the factory composes into the controller. @@ -58,7 +58,7 @@ describe('openrpc.json method coverage', () => { // regeneration would have deleted a shipped field's only documentation. The // generator is the source of truth; this asserts the artifact still equals it. it('docs/openrpc.json is exactly what docs/openrpc.build.js emits', () => { - const built = require('../../docs/openrpc.build.js').spec; + const built = require('../../../docs/openrpc.build.js').spec; assert.ok(Array.isArray(built.methods) && built.methods.length > 0, 'generator export broken: no methods'); assert.deepStrictEqual(spec, JSON.parse(JSON.stringify(built)), @@ -70,7 +70,7 @@ describe('openrpc.json method coverage', () => { // is how create_tx came to validate and act on `attachPrevTx` while the published // contract never named it: the field was undiscoverable from the spec and every // generated client dropped it. Every params. validateAll reads must be declared. - const validatorSrc = fs.readFileSync(path.join(__dirname, '../../src/common/validator.js'), 'utf8'); + const validatorSrc = fs.readFileSync(path.join(__dirname, '../../../src/common/validator.js'), 'utf8'); // Slice validateAll's body only. `params.pubkey` also appears in an unrelated // function earlier in the file, and including it would make this pass for the // wrong reason. The digit class is required: p2shHash, p2shHex, compressedPubKey. diff --git a/test/unit/build/apply_bufferutils_patch.test.js b/test/unit/build/apply_bufferutils_patch.test.js index a273087..d55c21f 100644 --- a/test/unit/build/apply_bufferutils_patch.test.js +++ b/test/unit/build/apply_bufferutils_patch.test.js @@ -7,7 +7,7 @@ const assert = require('assert'); const fs = require('fs'); const path = require('path'); -const bufferutils = require('../../src/build/apply_bufferutils_patch.js'); +const bufferutils = require('../../../src/build/apply_bufferutils_patch.js'); // The two copies share one body and differ only in the licence/doc banner, // because each header names the other copy and its own read-side relationship. @@ -163,9 +163,9 @@ describe('applyBufferutilsPatch', function () { // bodies byte-identical and let the fork be a deliberate, visible act // rather than a silent one. describe('twin guard against the SDK copy', function () { - const repoRoot = path.resolve(__dirname, '../../..'); + const repoRoot = path.resolve(__dirname, '../../../..'); const sdkRoot = path.join(repoRoot, 'xchain-sdk'); - const mine = path.join(__dirname, '../../src/build/apply_bufferutils_patch.js'); + const mine = path.join(__dirname, '../../../src/build/apply_bufferutils_patch.js'); // NOT renamed by THIS repo's codemod: this resolves into xchain-sdk, // which owns its own rename independently. A bare-literal rewrite // pass cannot tell this string from a same-repo reference and @@ -189,7 +189,7 @@ describe('applyBufferutilsPatch', function () { // The patch reaches into bip174 internals. An undeclared dependency // resolves today only because bitcoinjs-lib happens to hoist it, // and vanishes on any transitive bump. - const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../package.json'), 'utf8')); + const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../../package.json'), 'utf8')); const source = fs.readFileSync(mine, 'utf8'); const required = new Set(); for (const m of source.matchAll(/require\('([^']+)'\)/g)) { diff --git a/test/unit/build/compression.test.js b/test/unit/build/compression.test.js index 93f50a2..42d580b 100644 --- a/test/unit/build/compression.test.js +++ b/test/unit/build/compression.test.js @@ -34,7 +34,7 @@ const assert = require('assert') const crypto = require('crypto') const zlib = require('zlib') -const compression = require('../../src/build/compression') +const compression = require('../../../src/build/compression') const { PUBLIC_FILE, GATED_FILE, diff --git a/test/unit/build/compression.test/01_action_string_helpers.test.js b/test/unit/build/compression.test/01_action_string_helpers.test.js index e8dc26a..afc720c 100644 --- a/test/unit/build/compression.test/01_action_string_helpers.test.js +++ b/test/unit/build/compression.test/01_action_string_helpers.test.js @@ -32,7 +32,7 @@ const assert = require('assert') -const compression = require('../../../src/build/compression') +const compression = require('../../../../src/build/compression') const { PUBLIC_FILE, GATED_FILE } = require('./helpers/fixtures') describe('encoder FILE payload compression (spec Part B)', function () { diff --git a/test/unit/build/compression.test/02_create_transaction_integration.test.js b/test/unit/build/compression.test/02_create_transaction_integration.test.js index f7107a3..9bb0b2e 100644 --- a/test/unit/build/compression.test/02_create_transaction_integration.test.js +++ b/test/unit/build/compression.test/02_create_transaction_integration.test.js @@ -35,7 +35,7 @@ const crypto = require('crypto') const zlib = require('zlib') const bitcoin = require('bitcoinjs-lib') -const compression = require('../../../src/build/compression') +const compression = require('../../../../src/build/compression') const { PUBKEY, PUBLIC_FILE, diff --git a/test/unit/build/compression.test/03_the_compression_report_carries_the_bytes_that_were_written.test.js b/test/unit/build/compression.test/03_the_compression_report_carries_the_bytes_that_were_written.test.js index a12f0c8..de03f26 100644 --- a/test/unit/build/compression.test/03_the_compression_report_carries_the_bytes_that_were_written.test.js +++ b/test/unit/build/compression.test/03_the_compression_report_carries_the_bytes_that_were_written.test.js @@ -34,7 +34,7 @@ const assert = require('assert') const crypto = require('crypto') const zlib = require('zlib') -const compression = require('../../../src/build/compression') +const compression = require('../../../../src/build/compression') const { PUBLIC_FILE, compressibleBytes, diff --git a/test/unit/build/compression.test/04_validator.test.js b/test/unit/build/compression.test/04_validator.test.js index 524ca67..7cda5f3 100644 --- a/test/unit/build/compression.test/04_validator.test.js +++ b/test/unit/build/compression.test/04_validator.test.js @@ -32,7 +32,7 @@ const assert = require('assert') -const validator = require('../../../src/common/validator') +const validator = require('../../../../src/common/validator') const { TXID_A, PUBLIC_FILE } = require('./helpers/fixtures') describe('encoder FILE payload compression (spec Part B)', function () { diff --git a/test/unit/build/compression.test/05_constants_conformance.test.js b/test/unit/build/compression.test/05_constants_conformance.test.js index 8f0015f..f59f168 100644 --- a/test/unit/build/compression.test/05_constants_conformance.test.js +++ b/test/unit/build/compression.test/05_constants_conformance.test.js @@ -35,8 +35,8 @@ const crypto = require('crypto') const path = require('path') const fs = require('fs') -const compression = require('../../../src/build/compression') -const validator = require('../../../src/common/validator') +const compression = require('../../../../src/build/compression') +const validator = require('../../../../src/common/validator') const { PUBLIC_FILE, compressibleBytes } = require('./helpers/fixtures') describe('encoder FILE payload compression (spec Part B)', function () { @@ -52,7 +52,7 @@ describe('encoder FILE payload compression (spec Part B)', function () { describe('encoder FILE payload compression (spec Part B)', function () { describe('constants conformance', function () { const DOCS = process.env.XCHAIN_DOCUMENTATION_DIR || - path.join(__dirname, '..', '..', '..', '..', 'xchain-documentation') + path.join(__dirname, '../../../../../xchain-documentation') const DOCS_CONSTANTS = path.join(DOCS, 'protocol', 'constants.js') it('they equal the canonical declaration (skips without the docs sibling)', function () { @@ -73,7 +73,7 @@ describe('encoder FILE payload compression (spec Part B)', function () { // raw, or vice versa. it('agrees with the SDK helper on every decision (skips without the sdk sibling)', async function () { const SDK = process.env.XCHAIN_SDK_DIR || - path.join(__dirname, '..', '..', '..', '..', 'xchain-sdk') + path.join(__dirname, '../../../../../xchain-sdk') const SDK_COMPRESSION = path.join(SDK, 'src', 'protocol', 'compression.js') if (!fs.existsSync(SDK_COMPRESSION)) { if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') diff --git a/test/unit/build/compression.test/helpers/fixtures.js b/test/unit/build/compression.test/helpers/fixtures.js index d74cfbf..593b3d4 100644 --- a/test/unit/build/compression.test/helpers/fixtures.js +++ b/test/unit/build/compression.test/helpers/fixtures.js @@ -34,7 +34,7 @@ const bitcoin = require('bitcoinjs-lib') const ecc = require('tiny-secp256k1') const { ECPairFactory } = require('ecpair') -const XChainEncoder = require('../../../../src/XChainEncoder') +const XChainEncoder = require('../../../../../src/XChainEncoder') bitcoin.initEccLib(ecc) const ECPair = ECPairFactory(ecc) diff --git a/test/unit/build/crypto_networks.test.js b/test/unit/build/crypto_networks.test.js index ff447b0..d72c09f 100644 --- a/test/unit/build/crypto_networks.test.js +++ b/test/unit/build/crypto_networks.test.js @@ -10,7 +10,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const CryptoNetworks = require('../../src/build/crypto_networks') +const CryptoNetworks = require('../../../src/build/crypto_networks') describe('CryptoNetworks', () => { diff --git a/test/unit/build/package_fee_sizing.test.js b/test/unit/build/package_fee_sizing.test.js index 9f90f6f..5d66bd7 100644 --- a/test/unit/build/package_fee_sizing.test.js +++ b/test/unit/build/package_fee_sizing.test.js @@ -21,7 +21,7 @@ const assert = require('assert') const axios = require('axios') -const BlockchainConnector = require('../../src/build/blockchain_connector') +const BlockchainConnector = require('../../../src/build/blockchain_connector') const util = require('util'); diff --git a/test/unit/build/package_fee_sizing.test/01_package_fee_uplift_satoshis.test.js b/test/unit/build/package_fee_sizing.test/01_package_fee_uplift_satoshis.test.js index cd85c11..7a4642e 100644 --- a/test/unit/build/package_fee_sizing.test/01_package_fee_uplift_satoshis.test.js +++ b/test/unit/build/package_fee_sizing.test/01_package_fee_uplift_satoshis.test.js @@ -9,7 +9,7 @@ // contact legal@dankest.llc. const assert = require('assert') -const XChainEncoder = require('../../../src/XChainEncoder') +const XChainEncoder = require('../../../../src/XChainEncoder') const SATOSHI_UNIT = 100000000 diff --git a/test/unit/build/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js b/test/unit/build/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js index db8cb33..641598e 100644 --- a/test/unit/build/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js +++ b/test/unit/build/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js @@ -10,7 +10,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') +const XChainEncoder = require('../../../../src/XChainEncoder') const SATOSHI_UNIT = 100000000 @@ -19,7 +19,7 @@ const TXID_PARENT_B = 'b'.repeat(64) const TXID_SHARED = 'c'.repeat(64) const pubkeyBuf = Buffer.from('0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', 'hex') -const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const DOGE_REGTEST = require('../../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST }).address const INPUT_VALUE = 100000000 // 1 DOGE in koinu const NODE_RATE_PER_KB = 0.01 // Dogecoin's block-inclusion floor diff --git a/test/unit/build/package_fee_sizing.test/03_two_phase_reveal_package_prefund.test.js b/test/unit/build/package_fee_sizing.test/03_two_phase_reveal_package_prefund.test.js index 98a3c74..bbc2684 100644 --- a/test/unit/build/package_fee_sizing.test/03_two_phase_reveal_package_prefund.test.js +++ b/test/unit/build/package_fee_sizing.test/03_two_phase_reveal_package_prefund.test.js @@ -10,7 +10,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') +const XChainEncoder = require('../../../../src/XChainEncoder') const ecc = require('tiny-secp256k1'); const SATOSHI_UNIT = 100000000 @@ -30,7 +30,7 @@ bitcoin.initEccLib(ecc) const KEY = ECPairFactory(ecc).fromPrivateKey(Buffer.alloc(32, 7)) const PUBKEY_HEX = Buffer.from(KEY.publicKey).toString('hex') -const BTC_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('bitcoin-regtest') +const BTC_REGTEST = require('../../../../src/build/crypto_networks').getBitcoinJsNetwork('bitcoin-regtest') const BTC_RATE_KB = 0.0001 // 10 sat/byte const BTC_TARGET_PER_BYTE = BTC_RATE_KB * SATOSHI_UNIT / 1000 const COMMIT_INPUT_VALUE = 100000000 diff --git a/test/unit/build/package_fee_sizing.test/04_p2sh_two_phase_package_prefund.test.js b/test/unit/build/package_fee_sizing.test/04_p2sh_two_phase_package_prefund.test.js index 5427686..beb0c65 100644 --- a/test/unit/build/package_fee_sizing.test/04_p2sh_two_phase_package_prefund.test.js +++ b/test/unit/build/package_fee_sizing.test/04_p2sh_two_phase_package_prefund.test.js @@ -10,7 +10,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') +const XChainEncoder = require('../../../../src/XChainEncoder') const SATOSHI_UNIT = 100000000 @@ -21,7 +21,7 @@ const TXID_PARENT_A = 'a'.repeat(64) // instead of sweeping it back to the caller. const DOGE = 'dogecoin-regtest' -const DOGE_REGTEST_NET = require('../../../src/build/crypto_networks').getBitcoinJsNetwork(DOGE) +const DOGE_REGTEST_NET = require('../../../../src/build/crypto_networks').getBitcoinJsNetwork(DOGE) const RATE_KB = 1000000 // 1000 koinu/byte, the venue rate const TARGET_PER_BYTE = RATE_KB / 1000 const pubkeyBuf = Buffer.from('0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', 'hex') diff --git a/test/unit/build/tx_size_estimator.test.js b/test/unit/build/tx_size_estimator.test.js index 46e2a49..e3cbad4 100644 --- a/test/unit/build/tx_size_estimator.test.js +++ b/test/unit/build/tx_size_estimator.test.js @@ -10,7 +10,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const TxSizeEstimator = require('../../src/build/tx_size_estimator') +const TxSizeEstimator = require('../../../src/build/tx_size_estimator') describe('TxSizeEstimator', () => { diff --git a/test/unit/build/utxo_tracker.test.js b/test/unit/build/utxo_tracker.test.js index 68fb8a9..ef255f8 100644 --- a/test/unit/build/utxo_tracker.test.js +++ b/test/unit/build/utxo_tracker.test.js @@ -9,7 +9,7 @@ // contact legal@dankest.llc. const assert = require('assert') -const UtxoTracker = require('../../src/build/utxo_tracker') +const UtxoTracker = require('../../../src/build/utxo_tracker') describe('UtxoTracker constructor', () => { it('builds the correct URL from host and port', () => { diff --git a/test/unit/build/utxo_tracker.test/helpers/support.js b/test/unit/build/utxo_tracker.test/helpers/support.js index 5dc5c6a..54b08de 100644 --- a/test/unit/build/utxo_tracker.test/helpers/support.js +++ b/test/unit/build/utxo_tracker.test/helpers/support.js @@ -9,7 +9,7 @@ // contact legal@dankest.llc. const axios = require('axios') -const UtxoTracker = require('../../../../src/build/utxo_tracker') +const UtxoTracker = require('../../../../../src/build/utxo_tracker') function makeTracker () { return new UtxoTracker('127.0.0.1', 18420) diff --git a/test/unit/coins/coins_conformance.test.js b/test/unit/coins/coins_conformance.test.js index d57a6eb..e518ba7 100644 --- a/test/unit/coins/coins_conformance.test.js +++ b/test/unit/coins/coins_conformance.test.js @@ -32,11 +32,11 @@ const assert = require('assert'); const fs = require('fs'); const path = require('path'); -const coins = require('../../src/coins'); -const { CONSENSUS_CONFIG_PIN } = require('../../src/coins/consensus_pin.js'); +const coins = require('../../../src/coins'); +const { CONSENSUS_CONFIG_PIN } = require('../../../src/coins/consensus_pin.js'); -const LOCAL_COINS_DIR = path.join(__dirname, '..', '..', 'src', 'coins'); -const HUB_DIR = process.env.XCHAIN_HUB_DIR || path.join(__dirname, '..', '..', '..', 'xchain-hub'); +const LOCAL_COINS_DIR = path.join(__dirname, '../../../src/coins'); +const HUB_DIR = process.env.XCHAIN_HUB_DIR || path.join(__dirname, '../../../../xchain-hub'); const CANON_DIR = path.join(HUB_DIR, 'src', 'coins'); const CANON_PRESENT = fs.existsSync(CANON_DIR); const REQUIRE_SIBLINGS = process.env.XCHAIN_REQUIRE_SIBLINGS === '1'; diff --git a/test/unit/coins/xchain_encoder_consensus_pin_boot.test.js b/test/unit/coins/xchain_encoder_consensus_pin_boot.test.js index 56566e2..7a05bd0 100644 --- a/test/unit/coins/xchain_encoder_consensus_pin_boot.test.js +++ b/test/unit/coins/xchain_encoder_consensus_pin_boot.test.js @@ -18,8 +18,8 @@ const assert = require('assert'); -const coins = require('../../src/coins'); -const XChainEncoder = require('../../src/XChainEncoder'); +const coins = require('../../../src/coins'); +const XChainEncoder = require('../../../src/XChainEncoder'); describe('XChainEncoder consensus-pin boot guard', function(){ let original; diff --git a/test/unit/common/error_sanitize.test.js b/test/unit/common/error_sanitize.test.js index 482046f..c66f4a1 100644 --- a/test/unit/common/error_sanitize.test.js +++ b/test/unit/common/error_sanitize.test.js @@ -13,7 +13,7 @@ *********************************************************************/ const assert = require('assert') -const { upstreamErrorMessage, isTransportError, leaksInternalDetail } = require('../../src/common/error_sanitize') +const { upstreamErrorMessage, isTransportError, leaksInternalDetail } = require('../../../src/common/error_sanitize') // Guards the encoder's outbound error sanitization: useful upstream RPC reasons // pass through, transport-level failures (which leak the internal node host:port) diff --git a/test/unit/common/validator/action_manifest_conformance.test.js b/test/unit/common/validator/action_manifest_conformance.test.js index 7d97a3f..e0c042f 100644 --- a/test/unit/common/validator/action_manifest_conformance.test.js +++ b/test/unit/common/validator/action_manifest_conformance.test.js @@ -24,9 +24,9 @@ const assert = require('assert'); const fs = require('fs'); const path = require('path'); -const v = require('../../src/common/validator.js'); +const v = require('../../../../src/common/validator.js'); -const VENDORED = path.join(__dirname, '..', 'fixtures', 'action-manifest.json'); +const VENDORED = path.join(__dirname, '../../../fixtures/action-manifest.json'); const MANIFEST = JSON.parse(fs.readFileSync(VENDORED, 'utf8')); function manifestSlice(flag) { @@ -58,7 +58,7 @@ describe('ACTION manifest conformance: encoder validateActionName gate @regressi // sibling xchain-documentation is not checked out, matching the decoder's // own ActionManifestConformance convention). describe('byte-identity to canonical manifest', function () { - const DOCS = process.env.XCHAIN_DOCS_DIR || path.join(__dirname, '..', '..', '..', 'xchain-documentation'); + const DOCS = process.env.XCHAIN_DOCS_DIR || path.join(__dirname, '../../../../../xchain-documentation'); const CANON = path.join(DOCS, 'protocol', 'action-manifest.json'); before(function () { if (!fs.existsSync(CANON)) { if (process.env.XCHAIN_REQUIRE_SIBLINGS === '1') throw new Error('XCHAIN_REQUIRE_SIBLINGS=1 but canonical action-manifest.json not found at ' + CANON); this.skip(); } }); it('vendored test/fixtures/action-manifest.json is byte-identical to canonical', function () { diff --git a/test/unit/common/validator/large_satoshi_amounts.test.js b/test/unit/common/validator/large_satoshi_amounts.test.js index 3f3cfd6..a23d0e4 100644 --- a/test/unit/common/validator/large_satoshi_amounts.test.js +++ b/test/unit/common/validator/large_satoshi_amounts.test.js @@ -15,13 +15,13 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../src/XChainEncoder') // loads applyBufferutilsPatch +const XChainEncoder = require('../../../../src/XChainEncoder') // loads applyBufferutilsPatch const { parseSatoshiAmount, validateUtxoEntry, validateCustomOutputs, MAX_SATOSHI_U64 -} = require('../../src/common/validator') +} = require('../../../../src/common/validator') const MAX_SAFE = Number.MAX_SAFE_INTEGER // 9007199254740991 const BIG_STR = '9007199254740993' // 2^53 + 1, not representable as Number @@ -33,7 +33,7 @@ const pubkeyBuf = Buffer.from( ) const TXID_A = 'a'.repeat(64) -const LTC_REGTEST = require('../../src/build/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') +const LTC_REGTEST = require('../../../../src/build/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') const bufferutils = require('bitcoinjs-lib/src/bufferutils'); const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: LTC_REGTEST }).address diff --git a/test/unit/common/validator/validator.test.js b/test/unit/common/validator/validator.test.js index 4890a37..14790f0 100644 --- a/test/unit/common/validator/validator.test.js +++ b/test/unit/common/validator/validator.test.js @@ -18,7 +18,7 @@ ********************************************************************/ const assert = require('assert'); -const v = require('../../src/common/validator.js'); +const v = require('../../../../src/common/validator.js'); describe('Encoder input validator', function () { diff --git a/test/unit/common/validator/validator.test/01_validate_data_param.test.js b/test/unit/common/validator/validator.test/01_validate_data_param.test.js index df27268..0312fc3 100644 --- a/test/unit/common/validator/validator.test/01_validate_data_param.test.js +++ b/test/unit/common/validator/validator.test/01_validate_data_param.test.js @@ -18,7 +18,7 @@ ********************************************************************/ const assert = require('assert'); -const v = require('../../../src/common/validator.js'); +const v = require('../../../../../src/common/validator.js'); describe('Encoder input validator', function () { diff --git a/test/unit/common/validator/validator.test/02_validate_action_name_encoder_action_name_gate.test.js b/test/unit/common/validator/validator.test/02_validate_action_name_encoder_action_name_gate.test.js index df7f504..f431e31 100644 --- a/test/unit/common/validator/validator.test/02_validate_action_name_encoder_action_name_gate.test.js +++ b/test/unit/common/validator/validator.test/02_validate_action_name_encoder_action_name_gate.test.js @@ -18,7 +18,7 @@ ********************************************************************/ const assert = require('assert'); -const v = require('../../../src/common/validator.js'); +const v = require('../../../../../src/common/validator.js'); describe('Encoder input validator', function () { diff --git a/test/unit/common/validator/validator.test/03_validate_combined_data_length.test.js b/test/unit/common/validator/validator.test/03_validate_combined_data_length.test.js index cb0e9d6..7334832 100644 --- a/test/unit/common/validator/validator.test/03_validate_combined_data_length.test.js +++ b/test/unit/common/validator/validator.test/03_validate_combined_data_length.test.js @@ -18,7 +18,7 @@ ********************************************************************/ const assert = require('assert'); -const v = require('../../../src/common/validator.js'); +const v = require('../../../../../src/common/validator.js'); const bitcoin = require('bitcoinjs-lib'); describe('Encoder input validator', function () { diff --git a/test/unit/common/validator/validator.test/04_alias_expansion_at_the_compiled_ceiling.test.js b/test/unit/common/validator/validator.test/04_alias_expansion_at_the_compiled_ceiling.test.js index 949edfb..f00104e 100644 --- a/test/unit/common/validator/validator.test/04_alias_expansion_at_the_compiled_ceiling.test.js +++ b/test/unit/common/validator/validator.test/04_alias_expansion_at_the_compiled_ceiling.test.js @@ -18,7 +18,7 @@ ********************************************************************/ const assert = require('assert'); -const v = require('../../../src/common/validator.js'); +const v = require('../../../../../src/common/validator.js'); describe('Encoder input validator', function () { diff --git a/test/unit/common/validator/validator.test/05_validate_encoding.test.js b/test/unit/common/validator/validator.test/05_validate_encoding.test.js index f1e0f07..00fa38e 100644 --- a/test/unit/common/validator/validator.test/05_validate_encoding.test.js +++ b/test/unit/common/validator/validator.test/05_validate_encoding.test.js @@ -18,7 +18,7 @@ ********************************************************************/ const assert = require('assert'); -const v = require('../../../src/common/validator.js'); +const v = require('../../../../../src/common/validator.js'); describe('Encoder input validator', function () { diff --git a/test/unit/common/validator/validator.test/06_max_fee_satoshis.test.js b/test/unit/common/validator/validator.test/06_max_fee_satoshis.test.js index ef0aacc..1b08fcf 100644 --- a/test/unit/common/validator/validator.test/06_max_fee_satoshis.test.js +++ b/test/unit/common/validator/validator.test/06_max_fee_satoshis.test.js @@ -18,7 +18,7 @@ ********************************************************************/ const assert = require('assert'); -const v = require('../../../src/common/validator.js'); +const v = require('../../../../../src/common/validator.js'); describe('Encoder input validator', function () { diff --git a/test/unit/common/validator/validator.test/07_validate_fee.test.js b/test/unit/common/validator/validator.test/07_validate_fee.test.js index 4a40ff1..73a7857 100644 --- a/test/unit/common/validator/validator.test/07_validate_fee.test.js +++ b/test/unit/common/validator/validator.test/07_validate_fee.test.js @@ -18,7 +18,7 @@ ********************************************************************/ const assert = require('assert'); -const v = require('../../../src/common/validator.js'); +const v = require('../../../../../src/common/validator.js'); describe('Encoder input validator', function () { 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 6492268..1788033 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 @@ -18,7 +18,7 @@ ********************************************************************/ const assert = require('assert'); -const v = require('../../../src/common/validator.js'); +const v = require('../../../../../src/common/validator.js'); describe('Encoder input validator', function () { diff --git a/test/unit/common/validator/validator.test/09_validate_dust.test.js b/test/unit/common/validator/validator.test/09_validate_dust.test.js index aec8e59..a76339f 100644 --- a/test/unit/common/validator/validator.test/09_validate_dust.test.js +++ b/test/unit/common/validator/validator.test/09_validate_dust.test.js @@ -18,7 +18,7 @@ ********************************************************************/ const assert = require('assert'); -const v = require('../../../src/common/validator.js'); +const v = require('../../../../../src/common/validator.js'); describe('Encoder input validator', function () { diff --git a/test/unit/common/validator/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js b/test/unit/common/validator/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js index 00f2144..a23b930 100644 --- a/test/unit/common/validator/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js +++ b/test/unit/common/validator/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js @@ -18,7 +18,7 @@ ********************************************************************/ const assert = require('assert'); -const v = require('../../../src/common/validator.js'); +const v = require('../../../../../src/common/validator.js'); const HEX64 = 'a'.repeat(64); diff --git a/test/unit/common/validator/validator.test/11_validate_custom_outputs_validate_custom_output_via_wrapper.test.js b/test/unit/common/validator/validator.test/11_validate_custom_outputs_validate_custom_output_via_wrapper.test.js index 4730190..b0483c6 100644 --- a/test/unit/common/validator/validator.test/11_validate_custom_outputs_validate_custom_output_via_wrapper.test.js +++ b/test/unit/common/validator/validator.test/11_validate_custom_outputs_validate_custom_output_via_wrapper.test.js @@ -18,7 +18,7 @@ ********************************************************************/ const assert = require('assert'); -const v = require('../../../src/common/validator.js'); +const v = require('../../../../../src/common/validator.js'); describe('Encoder input validator', function () { diff --git a/test/unit/common/validator/validator.test/12_validate_p2sh_params.test.js b/test/unit/common/validator/validator.test/12_validate_p2sh_params.test.js index 5430889..a4185cf 100644 --- a/test/unit/common/validator/validator.test/12_validate_p2sh_params.test.js +++ b/test/unit/common/validator/validator.test/12_validate_p2sh_params.test.js @@ -18,7 +18,7 @@ ********************************************************************/ const assert = require('assert'); -const v = require('../../../src/common/validator.js'); +const v = require('../../../../../src/common/validator.js'); const HEX64 = 'a'.repeat(64); diff --git a/test/unit/common/validator/validator.test/13_validate_raw_tx_hex.test.js b/test/unit/common/validator/validator.test/13_validate_raw_tx_hex.test.js index fe19982..70ff127 100644 --- a/test/unit/common/validator/validator.test/13_validate_raw_tx_hex.test.js +++ b/test/unit/common/validator/validator.test/13_validate_raw_tx_hex.test.js @@ -18,7 +18,7 @@ ********************************************************************/ const assert = require('assert'); -const v = require('../../../src/common/validator.js'); +const v = require('../../../../../src/common/validator.js'); describe('Encoder input validator', function () { diff --git a/test/unit/common/validator/validator.test/14_validate_compressed_pub_key.test.js b/test/unit/common/validator/validator.test/14_validate_compressed_pub_key.test.js index dc60941..567eade 100644 --- a/test/unit/common/validator/validator.test/14_validate_compressed_pub_key.test.js +++ b/test/unit/common/validator/validator.test/14_validate_compressed_pub_key.test.js @@ -18,7 +18,7 @@ ********************************************************************/ const assert = require('assert'); -const v = require('../../../src/common/validator.js'); +const v = require('../../../../../src/common/validator.js'); const HEX64 = 'a'.repeat(64); diff --git a/test/unit/common/validator/validator.test/15_validate_change.test.js b/test/unit/common/validator/validator.test/15_validate_change.test.js index d0d1a35..73bba62 100644 --- a/test/unit/common/validator/validator.test/15_validate_change.test.js +++ b/test/unit/common/validator/validator.test/15_validate_change.test.js @@ -18,7 +18,7 @@ ********************************************************************/ const assert = require('assert'); -const v = require('../../../src/common/validator.js'); +const v = require('../../../../../src/common/validator.js'); describe('Encoder input validator', function () { diff --git a/test/unit/common/validator/validator.test/16_validate_address.test.js b/test/unit/common/validator/validator.test/16_validate_address.test.js index ef1a9e8..0315f05 100644 --- a/test/unit/common/validator/validator.test/16_validate_address.test.js +++ b/test/unit/common/validator/validator.test/16_validate_address.test.js @@ -18,7 +18,7 @@ ********************************************************************/ const assert = require('assert'); -const v = require('../../../src/common/validator.js'); +const v = require('../../../../../src/common/validator.js'); describe('Encoder input validator', function () { diff --git a/test/unit/common/validator/validator.test/17_validate_all.test.js b/test/unit/common/validator/validator.test/17_validate_all.test.js index 0701c9e..fef428d 100644 --- a/test/unit/common/validator/validator.test/17_validate_all.test.js +++ b/test/unit/common/validator/validator.test/17_validate_all.test.js @@ -18,7 +18,7 @@ ********************************************************************/ const assert = require('assert'); -const v = require('../../../src/common/validator.js'); +const v = require('../../../../../src/common/validator.js'); const HEX64 = 'a'.repeat(64); diff --git a/test/unit/repo/coverage_thresholds_sync.test.js b/test/unit/repo/coverage_thresholds_sync.test.js index 9fab91c..db9a0b3 100644 --- a/test/unit/repo/coverage_thresholds_sync.test.js +++ b/test/unit/repo/coverage_thresholds_sync.test.js @@ -10,7 +10,7 @@ const path = require('node:path'); // coverage:check script that did not exist in that repo at all, a job that could only // ever exit 1, and the missing-script case is asserted here for that reason. describe('coverage ratchet floors', () => { - const repoRoot = path.join(__dirname, '..', '..'); + const repoRoot = path.join(__dirname, '../../..'); const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); const declared = JSON.parse( fs.readFileSync(path.join(repoRoot, 'bin', 'coverage-thresholds.json'), 'utf8'), diff --git a/test/unit/repo/docs_openrpc.test.js b/test/unit/repo/docs_openrpc.test.js index 40e7221..234c555 100644 --- a/test/unit/repo/docs_openrpc.test.js +++ b/test/unit/repo/docs_openrpc.test.js @@ -5,7 +5,7 @@ // the (side-effecting) build script. const assert = require('assert'); -const doc = require('../../docs/openrpc.json'); +const doc = require('../../../docs/openrpc.json'); describe('docs/openrpc.json', function () { it('declares an OpenRPC version and info block', function () { diff --git a/test/unit/repo/sibling_coverage.test.js b/test/unit/repo/sibling_coverage.test.js index 8434d42..a6324b1 100644 --- a/test/unit/repo/sibling_coverage.test.js +++ b/test/unit/repo/sibling_coverage.test.js @@ -45,7 +45,7 @@ const assert = require('assert'); const fs = require('fs'); const path = require('path'); -const REPO_ROOT = path.join(__dirname, '..', '..'); +const REPO_ROOT = path.join(__dirname, '../../..'); const SIBLING_ROOT = process.env.XCHAIN_SIBLING_ROOT || path.join(REPO_ROOT, '..'); const STRICT = process.env.XCHAIN_REQUIRE_SIBLINGS === '1'; diff --git a/test/unit/server/cors_origin.test.js b/test/unit/server/cors_origin.test.js index 592f2dc..c2caaf3 100644 --- a/test/unit/server/cors_origin.test.js +++ b/test/unit/server/cors_origin.test.js @@ -31,7 +31,7 @@ const assert = require('assert') const express = require('express') const cors = require('cors') -const { parseCorsOrigin } = require('../../src/server/cors_origin') +const { parseCorsOrigin } = require('../../../src/server/cors_origin') // Mount cors exactly as src/api.js does and ask what a browser would receive. // Returns the ACAO header (or null) for each probed origin. diff --git a/test/unit/server/maintenance_window.test.js b/test/unit/server/maintenance_window.test.js index 7399cb3..788c964 100644 --- a/test/unit/server/maintenance_window.test.js +++ b/test/unit/server/maintenance_window.test.js @@ -45,7 +45,7 @@ const { MAX_SENTINEL_BYTES, MAX_REASON_CHARS, MAX_WINDOW_MS -} = require('../../src/server/maintenance_window'); +} = require('../../../src/server/maintenance_window'); const NOW = Date.parse('2026-09-02T12:00:00.000Z'); const in2h = new Date(NOW + 2 * 3600 * 1000).toISOString(); @@ -241,7 +241,7 @@ describe('maintenanceWindow: an operator-declared scheduled outage @regression', }); }); -const { jsonRpcController, encoder } = require('../../src/api'); +const { jsonRpcController, encoder } = require('../../../src/api'); let dir, sentinel, origEnv; function setupApi() { diff --git a/test/unit/server/single_instance_guard.test.js b/test/unit/server/single_instance_guard.test.js index 6a531dc..19c9b76 100644 --- a/test/unit/server/single_instance_guard.test.js +++ b/test/unit/server/single_instance_guard.test.js @@ -10,7 +10,7 @@ const assert = require('assert') -const { assertSingleInstance } = require('../../src/server/single_instance_guard') +const { assertSingleInstance } = require('../../../src/server/single_instance_guard') describe('singleInstanceGuard', function () { diff --git a/test/unit/server/single_instance_guard.test/01_acquire_instance_lock.test.js b/test/unit/server/single_instance_guard.test/01_acquire_instance_lock.test.js index c680324..ba4231b 100644 --- a/test/unit/server/single_instance_guard.test/01_acquire_instance_lock.test.js +++ b/test/unit/server/single_instance_guard.test/01_acquire_instance_lock.test.js @@ -3,7 +3,7 @@ const fs = require('fs') const os = require('os') const path = require('path') -const { acquireInstanceLock, isPidAlive } = require('../../../src/server/single_instance_guard') +const { acquireInstanceLock, isPidAlive } = require('../../../../src/server/single_instance_guard') // The lock records { pid, cmd } so a later boot can tell the process that // took it from whatever holds that pid number now. A bare integer diff --git a/test/unit/server/single_instance_guard.test/02_is_pid_alive.test.js b/test/unit/server/single_instance_guard.test/02_is_pid_alive.test.js index d5ec83d..2948274 100644 --- a/test/unit/server/single_instance_guard.test/02_is_pid_alive.test.js +++ b/test/unit/server/single_instance_guard.test/02_is_pid_alive.test.js @@ -1,6 +1,6 @@ const assert = require('assert') -const { isPidAlive } = require('../../../src/server/single_instance_guard') +const { isPidAlive } = require('../../../../src/server/single_instance_guard') describe('singleInstanceGuard', function () { describe('isPidAlive', function () { 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 87e2a0b..c9fd67e 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 @@ -1,6 +1,6 @@ const assert = require('assert') -const { releaseLockOnSignals } = require('../../../src/server/single_instance_guard') +const { releaseLockOnSignals } = require('../../../../src/server/single_instance_guard') // The lock is released on the way out through a SIGNAL, because that is how // this process actually dies: `docker stop` and `docker restart` diff --git a/test/unit/xchain_encoder/envelope_recognition_gate.test.js b/test/unit/xchain_encoder/envelope_recognition_gate.test.js index 383c1de..1e1f2f6 100644 --- a/test/unit/xchain_encoder/envelope_recognition_gate.test.js +++ b/test/unit/xchain_encoder/envelope_recognition_gate.test.js @@ -23,8 +23,8 @@ const assert = require('assert'); const fs = require('fs'); const path = require('path'); -const XChainEncoder = require('../../src/XChainEncoder'); -const CryptoNetworks = require('../../src/build/crypto_networks'); +const XChainEncoder = require('../../../src/XChainEncoder'); +const CryptoNetworks = require('../../../src/build/crypto_networks'); // The gate reads only the resolved recognition height and connector.getBlockCount, // so a network KEY plus a stub connector is the whole surface; building a real @@ -117,7 +117,7 @@ describe('assertEnvelopeRecognized()', function () { describe('encoder activation heights are byte-equal to the decoder consensus copy', function () { const DECODER = process.env.XCHAIN_DECODER_DIR || - path.join(__dirname, '..', '..', '..', 'xchain-decoder'); + path.join(__dirname, '../../../../xchain-decoder'); const DECODER_CONSTANTS = path.join(DECODER, 'src', 'protocol', 'constants.js'); before(function () { diff --git a/test/unit/xchain_encoder/suggested_fee_ceiling.test.js b/test/unit/xchain_encoder/suggested_fee_ceiling.test.js index 36ee2c9..9c2e5a9 100644 --- a/test/unit/xchain_encoder/suggested_fee_ceiling.test.js +++ b/test/unit/xchain_encoder/suggested_fee_ceiling.test.js @@ -23,7 +23,7 @@ 'use strict'; const assert = require('assert'); -const XChainEncoder = require('../../src/XChainEncoder'); +const XChainEncoder = require('../../../src/XChainEncoder'); const SATOSHI_UNIT = 100000000; const ceiling = (net) => XChainEncoder.suggestedFeeCeilingPerByte(net, SATOSHI_UNIT); diff --git a/test/unit/xchain_encoder/xchain_encoder_auto_encoding.test.js b/test/unit/xchain_encoder/xchain_encoder_auto_encoding.test.js index 4ecfc92..9676861 100644 --- a/test/unit/xchain_encoder/xchain_encoder_auto_encoding.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_auto_encoding.test.js @@ -31,9 +31,9 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const ecc = require('tiny-secp256k1') -const XChainEncoder = require('../../src/XChainEncoder') -const validator = require('../../src/common/validator') -const compression = require('../../src/build/compression') +const XChainEncoder = require('../../../src/XChainEncoder') +const validator = require('../../../src/common/validator') +const compression = require('../../../src/build/compression') bitcoin.initEccLib(ecc) diff --git a/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/08_no_utxos_available.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/08_no_utxos_available.test.js index 33e94e7..4caba08 100644 --- a/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/08_no_utxos_available.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/08_no_utxos_available.test.js @@ -113,7 +113,7 @@ describe('XChainEncoder.createTransaction()', () => { return { utxos: [makeLegacyUtxo(TXID_A, 0, 100000000)] } } - const dogeNetwork = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') + const dogeNetwork = require('../../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') const dogeAddress = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: dogeNetwork }).address const rawPubkeyHex = pubkeyBuf.toString('hex') await encoder.createTransaction( diff --git a/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/fixtures/transaction.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/fixtures/transaction.js index 5105229..3bf5177 100644 --- a/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/fixtures/transaction.js +++ b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/fixtures/transaction.js @@ -11,7 +11,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const crypto = require('crypto') -const XChainEncoder = require('../../../../src/XChainEncoder') +const XChainEncoder = require('../../../../../src/XChainEncoder') const pubkeyBuf = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', @@ -91,8 +91,8 @@ function makeEncoder (networkName = 'litecoin-regtest') { return encoder } -const LTC_REGTEST = require('../../../../src/build/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') -const TxSizeEstimator = require('../../../../src/build/tx_size_estimator'); +const LTC_REGTEST = require('../../../../../src/build/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') +const TxSizeEstimator = require('../../../../../src/build/tx_size_estimator'); const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: LTC_REGTEST diff --git a/test/unit/xchain_encoder/xchain_encoder_data_to_pubkey.test.js b/test/unit/xchain_encoder/xchain_encoder_data_to_pubkey.test.js index 780899d..81d04af 100644 --- a/test/unit/xchain_encoder/xchain_encoder_data_to_pubkey.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_data_to_pubkey.test.js @@ -9,7 +9,7 @@ // contact legal@dankest.llc. const assert = require('assert') -const XChainEncoder = require('../../src/XChainEncoder') +const XChainEncoder = require('../../../src/XChainEncoder') function makeEncoder () { return new XChainEncoder( diff --git a/test/unit/xchain_encoder/xchain_encoder_exact_inputs.test.js b/test/unit/xchain_encoder/xchain_encoder_exact_inputs.test.js index 02c22c9..800f3dc 100644 --- a/test/unit/xchain_encoder/xchain_encoder_exact_inputs.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_exact_inputs.test.js @@ -20,8 +20,8 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../src/XChainEncoder') -const validator = require('../../src/common/validator') +const XChainEncoder = require('../../../src/XChainEncoder') +const validator = require('../../../src/common/validator') const pubkeyBuf = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', @@ -61,7 +61,7 @@ function makeEncoder () { return encoder } -const LTC_REGTEST = require('../../src/build/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') +const LTC_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: LTC_REGTEST diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test.js index f46fe9d..b212a5c 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test.js @@ -21,7 +21,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../src/XChainEncoder') +const XChainEncoder = require('../../../src/XChainEncoder') const pubkeyBuf = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', @@ -66,7 +66,7 @@ function makeP2pkhUtxo (txid, vout, value) { return { txid, vout, value, confirmations: 6, scriptPubKey: script } } -const DOGE_REGTEST = require('../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js index 8de3e17..2b8ebed 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js @@ -10,7 +10,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') +const XChainEncoder = require('../../../../src/XChainEncoder') const pubkeyBuf = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', @@ -51,7 +51,7 @@ function makeP2pkhUtxo (txid, vout, value) { return { txid, vout, value, confirmations: 6, scriptPubKey: script } } -const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const DOGE_REGTEST = require('../../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST 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 99ea321..5479e67 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 @@ -10,7 +10,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') +const XChainEncoder = require('../../../../src/XChainEncoder') const pubkeyBuf = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', @@ -51,7 +51,7 @@ function makeP2pkhUtxo (txid, vout, value) { return { txid, vout, value, confirmations: 6, scriptPubKey: script } } -const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const DOGE_REGTEST = require('../../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test/03_payload_size_guard.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/03_payload_size_guard.test.js index 4c49ab7..f6fefaa 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test/03_payload_size_guard.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test/03_payload_size_guard.test.js @@ -10,7 +10,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') +const XChainEncoder = require('../../../../src/XChainEncoder') const pubkeyBuf = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', @@ -54,7 +54,7 @@ function makeP2pkhUtxo (txid, vout, value) { return { txid, vout, value, confirmations: 6, scriptPubKey: script } } -const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const DOGE_REGTEST = require('../../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test/04_fee_quote_injection.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/04_fee_quote_injection.test.js index 84b338f..5dde19e 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test/04_fee_quote_injection.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test/04_fee_quote_injection.test.js @@ -10,7 +10,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') +const XChainEncoder = require('../../../../src/XChainEncoder') const pubkeyBuf = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', @@ -54,7 +54,7 @@ function makeP2pkhUtxo (txid, vout, value) { return { txid, vout, value, confirmations: 6, scriptPubKey: script } } -const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const DOGE_REGTEST = require('../../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.js index 12c39d1..32fa34a 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.js @@ -10,7 +10,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') +const XChainEncoder = require('../../../../src/XChainEncoder') const pubkeyBuf = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', @@ -54,7 +54,7 @@ function makeP2pkhUtxo (txid, vout, value) { return { txid, vout, value, confirmations: 6, scriptPubKey: script } } -const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const DOGE_REGTEST = require('../../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test/06_change_edge_cases.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/06_change_edge_cases.test.js index b344f84..297dd3b 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test/06_change_edge_cases.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test/06_change_edge_cases.test.js @@ -10,7 +10,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') +const XChainEncoder = require('../../../../src/XChainEncoder') const pubkeyBuf = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', @@ -54,7 +54,7 @@ function makeP2pkhUtxo (txid, vout, value) { return { txid, vout, value, confirmations: 6, scriptPubKey: script } } -const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const DOGE_REGTEST = require('../../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test/07_invalid_fee.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/07_invalid_fee.test.js index e630ca5..431a5df 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test/07_invalid_fee.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test/07_invalid_fee.test.js @@ -10,7 +10,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') +const XChainEncoder = require('../../../../src/XChainEncoder') const pubkeyBuf = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', @@ -54,7 +54,7 @@ function makeP2pkhUtxo (txid, vout, value) { return { txid, vout, value, confirmations: 6, scriptPubKey: script } } -const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const DOGE_REGTEST = require('../../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js index f81a7bc..5e71427 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js @@ -10,7 +10,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') +const XChainEncoder = require('../../../../src/XChainEncoder') const pubkeyBuf = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', @@ -54,8 +54,8 @@ function makeP2pkhUtxo (txid, vout, value) { return { txid, vout, value, confirmations: 6, scriptPubKey: script } } -const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') -const TxSizeEstimator = require('../../../src/build/tx_size_estimator'); +const DOGE_REGTEST = require('../../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const TxSizeEstimator = require('../../../../src/build/tx_size_estimator'); const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js index 7e428d8..52c8a24 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js @@ -10,7 +10,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') +const XChainEncoder = require('../../../../src/XChainEncoder') const pubkeyBuf = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', @@ -54,7 +54,7 @@ function makeP2pkhUtxo (txid, vout, value) { return { txid, vout, value, confirmations: 6, scriptPubKey: script } } -const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const DOGE_REGTEST = require('../../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test/10_raw_data_parameter.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/10_raw_data_parameter.test.js index 09f6645..95025ab 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test/10_raw_data_parameter.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test/10_raw_data_parameter.test.js @@ -10,7 +10,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../../src/XChainEncoder') +const XChainEncoder = require('../../../../src/XChainEncoder') const pubkeyBuf = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', @@ -54,7 +54,7 @@ function makeP2pkhUtxo (txid, vout, value) { return { txid, vout, value, confirmations: 6, scriptPubKey: script } } -const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const DOGE_REGTEST = require('../../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST 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 c38f704..2028e69 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 @@ -23,7 +23,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../src/XChainEncoder') +const XChainEncoder = require('../../../src/XChainEncoder') const pubkeyBuf = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', @@ -31,7 +31,7 @@ const pubkeyBuf = Buffer.from( ) const TXID_A = 'a'.repeat(64) -const LTC_REGTEST = require('../../src/build/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') +const LTC_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: LTC_REGTEST diff --git a/test/unit/xchain_encoder/xchain_encoder_is_segwit_u_t_x_o.test.js b/test/unit/xchain_encoder/xchain_encoder_is_segwit_u_t_x_o.test.js index 81b824b..11b62d3 100644 --- a/test/unit/xchain_encoder/xchain_encoder_is_segwit_u_t_x_o.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_is_segwit_u_t_x_o.test.js @@ -9,7 +9,7 @@ // contact legal@dankest.llc. const assert = require('assert') -const XChainEncoder = require('../../src/XChainEncoder') +const XChainEncoder = require('../../../src/XChainEncoder') function makeEncoder () { return new XChainEncoder( diff --git a/test/unit/xchain_encoder/xchain_encoder_obfuscate.test.js b/test/unit/xchain_encoder/xchain_encoder_obfuscate.test.js index ef14987..3c54a7d 100644 --- a/test/unit/xchain_encoder/xchain_encoder_obfuscate.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_obfuscate.test.js @@ -10,7 +10,7 @@ const assert = require('assert') const crypto = require('crypto') -const XChainEncoder = require('../../src/XChainEncoder') +const XChainEncoder = require('../../../src/XChainEncoder') function makeEncoder () { return new XChainEncoder( 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 7bc82a6..0fa5a63 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 @@ -28,8 +28,8 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../src/XChainEncoder') -const CryptoNetworks = require('../../src/build/crypto_networks') +const XChainEncoder = require('../../../src/XChainEncoder') +const CryptoNetworks = require('../../../src/build/crypto_networks') const SATOSHI_UNIT = 100000000 const pubkeyBuf = Buffer.from( diff --git a/test/unit/xchain_encoder/xchain_encoder_prepare_data.test.js b/test/unit/xchain_encoder/xchain_encoder_prepare_data.test.js index 4556a6c..0e646a3 100644 --- a/test/unit/xchain_encoder/xchain_encoder_prepare_data.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_prepare_data.test.js @@ -10,7 +10,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../src/XChainEncoder') +const XChainEncoder = require('../../../src/XChainEncoder') const MAGIC_WORD = 'XCHN' const MAGIC_LEN = 4 diff --git a/test/unit/xchain_encoder/xchain_encoder_release_reservations.test.js b/test/unit/xchain_encoder/xchain_encoder_release_reservations.test.js index d69f444..8fb762a 100644 --- a/test/unit/xchain_encoder/xchain_encoder_release_reservations.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_release_reservations.test.js @@ -20,7 +20,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../src/XChainEncoder') +const XChainEncoder = require('../../../src/XChainEncoder') const pubkeyBuf = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', @@ -29,7 +29,7 @@ const pubkeyBuf = Buffer.from( const TXID_ONE = 'a'.repeat(64) const TXID_TWO = 'b'.repeat(64) -const LTC_REGTEST = require('../../src/build/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') +const LTC_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: LTC_REGTEST }).address const PUBKEY_HEX = pubkeyBuf.toString('hex') @@ -207,7 +207,7 @@ describe('XChainEncoder reservation release', () => { }) describe('release_inputs JSON-RPC method', () => { - const { jsonRpcController, encoder } = require('../../src/api') + const { jsonRpcController, encoder } = require('../../../src/api') afterEach(() => encoder.clearReservations()) diff --git a/test/unit/xchain_encoder/xchain_encoder_reveal_funding_binding.test.js b/test/unit/xchain_encoder/xchain_encoder_reveal_funding_binding.test.js index 7bde83d..ede2f9d 100644 --- a/test/unit/xchain_encoder/xchain_encoder_reveal_funding_binding.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_reveal_funding_binding.test.js @@ -17,7 +17,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../src/XChainEncoder') +const XChainEncoder = require('../../../src/XChainEncoder') const pubkeyBuf = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', @@ -81,7 +81,7 @@ const BTC_REGTEST_ADDR = bitcoin.payments.p2pkh({ network: bitcoin.networks.regtest }).address -const DOGE_REGTEST = require('../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') +const DOGE_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('dogecoin-regtest') const DOGE_REGTEST_ADDR = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST diff --git a/test/unit/xchain_encoder/xchain_encoder_segwit_input_guard.test.js b/test/unit/xchain_encoder/xchain_encoder_segwit_input_guard.test.js index 693d402..5261067 100644 --- a/test/unit/xchain_encoder/xchain_encoder_segwit_input_guard.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_segwit_input_guard.test.js @@ -16,8 +16,8 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../src/XChainEncoder') -const CryptoNetworks = require('../../src/build/crypto_networks') +const XChainEncoder = require('../../../src/XChainEncoder') +const CryptoNetworks = require('../../../src/build/crypto_networks') const pubkeyBuf = Buffer.from( '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', diff --git a/test/unit/xchain_encoder/xchain_encoder_soft_dust_floor.test.js b/test/unit/xchain_encoder/xchain_encoder_soft_dust_floor.test.js index d43caff..76a3154 100644 --- a/test/unit/xchain_encoder/xchain_encoder_soft_dust_floor.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_soft_dust_floor.test.js @@ -14,8 +14,8 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const XChainEncoder = require('../../src/XChainEncoder') -const CryptoNetworks = require('../../src/build/crypto_networks') +const XChainEncoder = require('../../../src/XChainEncoder') +const CryptoNetworks = require('../../../src/build/crypto_networks') const SOFT_DUST_DOGE = 1000000 // 0.01 DOGE in koinu const HARD_DUST_DOGE = 100000 // the pinned network.dustThreshold diff --git a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test.js b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test.js index a39fdb8..f0e0f86 100644 --- a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test.js @@ -22,8 +22,8 @@ const crypto = require('crypto') const bitcoin = require('bitcoinjs-lib') const ecc = require('tiny-secp256k1') const { ECPairFactory } = require('ecpair') -const XChainEncoder = require('../../src/XChainEncoder') -const vectors = require('../../../xchain-documentation/protocol/test-vectors/taproot_envelope.json'); +const XChainEncoder = require('../../../src/XChainEncoder') +const vectors = require('../../../../xchain-documentation/protocol/test-vectors/taproot_envelope.json'); bitcoin.initEccLib(ecc) const ECPair = ECPairFactory(ecc) diff --git a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js index e79b149..0ed8983 100644 --- a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js @@ -14,8 +14,8 @@ const crypto = require('crypto') const bitcoin = require('bitcoinjs-lib') const ecc = require('tiny-secp256k1') const { ECPairFactory } = require('ecpair') -const XChainEncoder = require('../../../src/XChainEncoder') -const { ENVELOPE_MAX_PAYLOAD, MAX_COMPILED_ACTION_DATA_LENGTH, MAX_STANDARD_TX_WEIGHT } = require('../../../src/common/validator') +const XChainEncoder = require('../../../../src/XChainEncoder') +const { ENVELOPE_MAX_PAYLOAD, MAX_COMPILED_ACTION_DATA_LENGTH, MAX_STANDARD_TX_WEIGHT } = require('../../../../src/common/validator') bitcoin.initEccLib(ecc) const ECPair = ECPairFactory(ecc) diff --git a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js index a944232..f21eb6d 100644 --- a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js @@ -14,7 +14,7 @@ const crypto = require('crypto') const bitcoin = require('bitcoinjs-lib') const ecc = require('tiny-secp256k1') const { ECPairFactory } = require('ecpair') -const XChainEncoder = require('../../../src/XChainEncoder') +const XChainEncoder = require('../../../../src/XChainEncoder') bitcoin.initEccLib(ecc) const ECPair = ECPairFactory(ecc) diff --git a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js index ead7ace..4bde2ba 100644 --- a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js @@ -14,7 +14,7 @@ const crypto = require('crypto') const bitcoin = require('bitcoinjs-lib') const ecc = require('tiny-secp256k1') const { ECPairFactory } = require('ecpair') -const XChainEncoder = require('../../../src/XChainEncoder') +const XChainEncoder = require('../../../../src/XChainEncoder') bitcoin.initEccLib(ecc) const ECPair = ECPairFactory(ecc) diff --git a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js index 632498d..f547caa 100644 --- a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js @@ -10,7 +10,7 @@ // const assert = require('assert') -const TxSizeEstimator = require('../../../src/build/tx_size_estimator') +const TxSizeEstimator = require('../../../../src/build/tx_size_estimator') describe('XChainEncoder TAPROOT envelope', function () { describe('TxSizeEstimator envelope sizing', function () { diff --git a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js index 93b5772..8049f33 100644 --- a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js @@ -23,7 +23,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const ecc = require('tiny-secp256k1') -const XChainEncoder = require('../../../src/XChainEncoder') +const XChainEncoder = require('../../../../src/XChainEncoder') bitcoin.initEccLib(ecc) diff --git a/test/unit/xchain_encoder/xchain_encoder_tracker_freshness_classifier.test.js b/test/unit/xchain_encoder/xchain_encoder_tracker_freshness_classifier.test.js index 8e2c16c..0b79e5d 100644 --- a/test/unit/xchain_encoder/xchain_encoder_tracker_freshness_classifier.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_tracker_freshness_classifier.test.js @@ -38,14 +38,14 @@ process.env.NODE_USER = process.env.NODE_USER || 'test' process.env.NODE_PASSWORD = process.env.NODE_PASSWORD || 'test' const assert = require('assert') -const XChainEncoder = require('../../src/XChainEncoder') -const { jsonRpcController, encoder: apiEncoder } = require('../../src/api') +const XChainEncoder = require('../../../src/XChainEncoder') +const { jsonRpcController, encoder: apiEncoder } = require('../../../src/api') const { TXID_A, makeUtxo, makeEncoder, getTestAddress -} = require('../integration/helpers/utxoFactory') +} = require('../../integration/helpers/utxoFactory') const classify = XChainEncoder.classifyTrackerFreshness diff --git a/test/unit/xchain_encoder/xchain_encoder_utxo_dedup.test.js b/test/unit/xchain_encoder/xchain_encoder_utxo_dedup.test.js index ec87b5f..9e88e69 100644 --- a/test/unit/xchain_encoder/xchain_encoder_utxo_dedup.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_utxo_dedup.test.js @@ -35,7 +35,7 @@ const { makeMempoolUtxo, makeEncoder, getTestAddress -} = require('../integration/helpers/utxoFactory') +} = require('../../integration/helpers/utxoFactory') const NETWORK = 'bitcoin-regtest' From 5c8f18d70364c530ec9f7dba68f06363d119ad5c Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 12:10:35 -0700 Subject: [PATCH 142/148] fix(test): give ci's unit tier the same env guard as npm test The bare test/unit/**/*.test.js mocha invocation on the "ci" script omitted --require ./test/setup/index.js, unlike "test". Without it XCHAIN_LOG_PATCH is unset, so the first file that requires src/api.js (observability install runs at module load) leaves the global console patched for the rest of the process. Directory placement, not test content, decides whether a later file that asserts on raw console.warn calls runs before some other file's cleanup resets it, and mocha's default file order is unsorted glob order, not alphabetical across directories, so any reshuffle can flip which side of that reset line a file lands on. This split moved test/unit/api/cors_preflight.test.js (the file that leaves the patch installed) earlier and test/unit's three held observability/crash/blockchain-connector files later relative to it, so test/unit/build/package_fee_sizing.test's two console.warn assertions started failing under "npm run ci" (which does not sort) while "npm test" (which sets the guard) stayed green. Rather than pick directory names to keep the accidental ordering that used to protect it, give "ci" the same guard "test" already has. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 92816fc..d4b1554 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "test:regtest": "mocha --timeout 0 --require ./test/prepare_regtest.test.js", "coverage": "c8 --reporter=text --reporter=html --include 'src/**/*.js' mocha --timeout 10000 'test/unit/**/*.test.js' --exit", "coverage:check": "c8 --check-coverage --lines 86.2 --statements 86.2 --branches 85.1 --functions 66 --reporter=text-summary --include 'src/**/*.js' mocha --timeout 10000 'test/unit/**/*.test.js' --exit", - "ci": "npx mocha --timeout 10000 'test/unit/**/*.test.js' --recursive --exit && npm run ci:security && npm run ci:regression && npm run ci:e2e && npm run ci:conformance", + "ci": "npx mocha --timeout 10000 --require ./test/setup/index.js 'test/unit/**/*.test.js' --recursive --exit && npm run ci:security && npm run ci:regression && npm run ci:e2e && npm run ci:conformance", "ci:security": "npx mocha --timeout 30000 'test/security/**/*.test.js' --recursive --exit", "ci:regression": "npx mocha --timeout 30000 'test/regression/**/*.test.js' --recursive --exit", "ci:e2e": "npx mocha --timeout 30000 'test/e2e/**/*.test.js' --recursive --exit", From c4fe1a06cd54dbbe197956577be33a5f450cc476 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 12:32:33 -0700 Subject: [PATCH 143/148] fix(test): give the coverage scripts the same setup require as npm test --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d4b1554..ee68799 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,8 @@ "smoke-test": "npx mocha --timeout 10000 'test/smoke/**/*.test.js'", "test": "npx mocha --timeout 10000 --require ./test/setup/index.js 'test/unit/**/*.test.js' --recursive --exit", "test:regtest": "mocha --timeout 0 --require ./test/prepare_regtest.test.js", - "coverage": "c8 --reporter=text --reporter=html --include 'src/**/*.js' mocha --timeout 10000 'test/unit/**/*.test.js' --exit", - "coverage:check": "c8 --check-coverage --lines 86.2 --statements 86.2 --branches 85.1 --functions 66 --reporter=text-summary --include 'src/**/*.js' mocha --timeout 10000 'test/unit/**/*.test.js' --exit", + "coverage": "c8 --reporter=text --reporter=html --include 'src/**/*.js' mocha --timeout 10000 --require ./test/setup/index.js 'test/unit/**/*.test.js' --exit", + "coverage:check": "c8 --check-coverage --lines 86.2 --statements 86.2 --branches 85.1 --functions 66 --reporter=text-summary --include 'src/**/*.js' mocha --timeout 10000 --require ./test/setup/index.js 'test/unit/**/*.test.js' --exit", "ci": "npx mocha --timeout 10000 --require ./test/setup/index.js 'test/unit/**/*.test.js' --recursive --exit && npm run ci:security && npm run ci:regression && npm run ci:e2e && npm run ci:conformance", "ci:security": "npx mocha --timeout 30000 'test/security/**/*.test.js' --recursive --exit", "ci:regression": "npx mocha --timeout 30000 'test/regression/**/*.test.js' --recursive --exit", From 21e9bbc8c0a4877441a89d88004045929a17703a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 12:16:36 -0700 Subject: [PATCH 144/148] test: restore the validator suite title suffix the split dropped --- .../10_validate_utxo_array_validate_utxo_entry.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/common/validator/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js b/test/unit/common/validator/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js index a23b930..2e3d4e8 100644 --- a/test/unit/common/validator/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js +++ b/test/unit/common/validator/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js @@ -51,7 +51,7 @@ describe('Encoder input validator', function () { assert.throws(() => v.validateUtxoEntry({ ...goodUtxo(), scriptPubKey: '' }, 3), /scriptPubKey/); }); - it('rejects vout values bare Number() would coerce to a plausible index', function () { + it('rejects vout values bare Number() would coerce to a plausible index (uuid:4555d78c)', function () { // null/''/false/[] all Number()-coerce to 0 and once validated as // vout 0 the encoder would spend a different outpoint (txid:0). for (const bad of [null, '', false, true, [], [7], '2.0', '0x2']) { From ccc4e21f8b9f667377ae439be2e110222e5b1519 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 12:25:53 -0700 Subject: [PATCH 145/148] chore(pins): re-take the encoder suite-title pin across every tier and reset its move maps --- bin/pins/at1-suite-titles.json | 2427 ++++++++++++++++++++++++------ bin/pins/suite-title-splits.json | 180 +-- bin/pins/test-rename-map.json | 119 +- 3 files changed, 1997 insertions(+), 729 deletions(-) diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index 4c1aefd..6cd8ac7 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -1,5 +1,9 @@ { "titleSets": { + "01301310489a6d18": [ + "XChainEncoder.createTransaction() unconfirmed filtering excludes mempool UTXOs when unconfirmed=false", + "XChainEncoder.createTransaction() unconfirmed filtering keeps mempool UTXOs when unconfirmed=true" + ], "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", @@ -19,6 +23,41 @@ "XChainEncoder create_tx options.exactInputs validator refuses a stringly-typed exactInputs, which would read as true", "XChainEncoder create_tx options.exactInputs validator still refuses an unknown options key and names exactInputs as valid" ], + "048d6d9734e4b8ea": [ + "Encoder input validator validateActionName (encoder ACTION-name gate) accepts a bare canonical name with no pipe-delimited payload", + "Encoder input validator validateActionName (encoder ACTION-name gate) accepts every canonical ACTION name from VALID_ACTION_NAMES", + "Encoder input validator validateActionName (encoder ACTION-name gate) accepts every known alias and expands it the same way the decoder does", + "Encoder input validator validateActionName (encoder ACTION-name gate) does not reject the intentionally-supported empty/absent data shapes", + "Encoder input validator validateActionName (encoder ACTION-name gate) is case-sensitive: a lowercase canonical name is not recognized", + "Encoder input validator validateActionName (encoder ACTION-name gate) is wired into validateAll and fires before feeQuote/utxo checks", + "Encoder input validator validateActionName (encoder ACTION-name gate) rejects a typoed ACTION name with a RangeError", + "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" + ], + "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", + "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.30: TICK by ID reference caret prefix preserved" + ], + "051aa0f149569c03": [ + "packageFeeUpliftSatoshis() @regression @tier1 reproduces the live DOGE shortfall", + "packageFeeUpliftSatoshis() @regression @tier1 returns 0 for an empty package or a missing target", + "packageFeeUpliftSatoshis() @regression @tier1 returns 0 when the package already clears the target", + "packageFeeUpliftSatoshis() @regression @tier1 subtracts what the transaction already pays", + "packageFeeUpliftSatoshis() @regression @tier1 takes the ancestor fee in base units when one is given, ahead of the coin-unit figure" + ], + "06a3215a6e86ed2d": [ + "Category H: Error Handling at Integration Boundaries Custom dust parameter fee floor still uses network dustAmount, not custom dust", + "Category H: Error Handling at Integration Boundaries Custom dust parameter overrides dust for MULTISIGN output value but not fee floor", + "Category H: Error Handling at Integration Boundaries H-1: BlockchainConnector.getFeePerKilobyte() failure does NOT fail when feePerKb is provided (bypasses RPC)", + "Category H: Error Handling at Integration Boundaries H-1: BlockchainConnector.getFeePerKilobyte() failure propagates error when fee estimation RPC fails", + "Category H: Error Handling at Integration Boundaries H-2: BlockchainConnector.getTransactionHex() failure propagates error when fetching raw tx for legacy UTXO fails", + "Category H: Error Handling at Integration Boundaries H-3: UtxoTracker unreachable propagates error when tracker is down and utxos=null", + "Category H: Error Handling at Integration Boundaries H-4: Invalid network name throws TypeError during construction for invalid network", + "Category H: Error Handling at Integration Boundaries H-5: MULTISIGN without compressedPubKey throws when compressedPubKey is null", + "Category H: Error Handling at Integration Boundaries H-6: P2SH without valid pubkey address throws when pubkey is not a valid base58 address", + "Category H: Error Handling at Integration Boundaries Return value shape returns { psbt, encoding } where psbt is a Psbt instance" + ], "0748973400fcace3": [ "compression ON by default (\u00a75.2/\u00a77) an explicit false still opts out", "compression ON by default (\u00a75.2/\u00a77) compresses a FILE with no flag passed, and reports what it did", @@ -44,6 +83,91 @@ "size-aware encoding selection (\u00a76) validator accepts the options bag and refuses unknown keys", "size-aware encoding selection (\u00a76) validator does not require compressedPubKey for AUTO (it degrades instead)" ], + "07b9c396d0fbecca": [ + "E2E-5: UTXO, Fee, and Change Integration E2E-5.8: Fee cap enforcement caps fee when maxFeeRateKb is set" + ], + "07d57a6f8f81fa07": [ + "Encoding Chunk Boundaries: Full Pipeline dataToPubkey unit boundaries 0 bytes \u2192 33-byte pubkey (02 + 32 bytes zero-fill)", + "Encoding Chunk Boundaries: Full Pipeline dataToPubkey unit boundaries 1 byte \u2192 33-byte pubkey (02 + 1 byte data + 31 bytes zero-fill)", + "Encoding Chunk Boundaries: Full Pipeline dataToPubkey unit boundaries < 32 bytes (16 bytes) \u2192 33-byte pubkey (02 prefix + data + zero-fill)", + "Encoding Chunk Boundaries: Full Pipeline dataToPubkey unit boundaries > 32 bytes (34 bytes) \u2192 35-byte result (no guard, exceeds compressed pubkey size)", + "Encoding Chunk Boundaries: Full Pipeline dataToPubkey unit boundaries exactly 32 bytes \u2192 33-byte pubkey (02 prefix, no zero-fill)" + ], + "08d13b4a948a8f78": [ + "XChainEncoder.createTransaction() OP_RETURN encoding path OP_RETURN output has value 0", + "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" + ], + "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", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 discards a root that confirms between its two RPC calls", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 does not leak the RPC password when a mempool call fails", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 keeps an earlier root's branch when a later root confirms mid-sequence", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 reads the modern Core field layout (vsize and fees.base)", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns an empty package for an empty or non-array input", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns an empty package when every input is already confirmed", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null on an HTTP-500 RPC error body", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null on an RPC error rather than throwing", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when an entry carries no readable size or fee", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when the flat fee field is an empty string", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when the flat fee field is false", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when the flat fee field is null", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when the nested fees.base field is null", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when the node is unreachable", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when the second call fails for a reason other than absence", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when the size field is a boolean rather than a number", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 skips a txid the mempool does not hold (already confirmed)", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 still prices a legitimate zero-fee ancestor, in both field layouts", + "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 sums an unconfirmed parent and everything above it" + ], + "0d8834d5fc73c75b": [ + "E2E-2: Two-Transaction P2SH/P2WSH Orchestration E2E-2.1: P2SH full sequence (ISSUE) tx1 creates P2SH output, tx2 spends with redeemScript", + "E2E-2: Two-Transaction P2SH/P2WSH Orchestration E2E-2.2: P2SH tx2 OP_RETURN marker tx2 marker deobfuscates to XCHNp2sh", + "E2E-2: Two-Transaction P2SH/P2WSH Orchestration E2E-2.3: P2SH data fidelity redeemScript contains original ACTION data (unobfuscated)", + "E2E-2: Two-Transaction P2SH/P2WSH Orchestration E2E-2.4: P2WSH full sequence (FILE) tx1 creates P2WSH output, tx2 spends with witnessScript", + "E2E-2: Two-Transaction P2SH/P2WSH Orchestration E2E-2.5: P2WSH tx2 OP_RETURN marker tx2 marker deobfuscates to XCHNp2sh", + "E2E-2: Two-Transaction P2SH/P2WSH Orchestration E2E-2.6: P2WSH data fidelity witnessScript contains original ACTION data (unobfuscated)", + "E2E-2: Two-Transaction P2SH/P2WSH Orchestration E2E-2.7: tx1\u2192tx2 ID chaining tx2 references tx1 ID as input hash", + "E2E-2: Two-Transaction P2SH/P2WSH Orchestration E2E-2.8: tx1\u2192tx2 hex chaining tx2 nonWitnessUtxo matches tx1 raw hex", + "E2E-2: Two-Transaction P2SH/P2WSH Orchestration E2E-2.9: Multi-chunk P2SH large ACTION requiring multiple P2SH outputs all have valid structure" + ], + "0da11bf4c436ca7c": [ + "E2E-5: UTXO, Fee, and Change Integration E2E-5.6: UtxoTracker fallback calls UtxoTracker when utxos is empty array", + "E2E-5: UTXO, Fee, and Change Integration E2E-5.6: UtxoTracker fallback calls UtxoTracker when utxos is null" + ], + "0e4bd255929ec3b3": [ + "Category D: UTXO & Fee Integration D-5: UtxoTracker fallback calls UtxoTracker when utxos param is empty array", + "Category D: UTXO & Fee Integration D-5: UtxoTracker fallback calls UtxoTracker when utxos param is null" + ], + "0e58538e3d6aa84b": [ + "Encoder input validator validateDust null/false pass; coerces; rejects NaN and negative" + ], + "0e91f384a4f7fca1": [ + "REG-05: Multi-Chain Network Configs REG-05.1: All 9 networks resolve bitcoin-mainnet resolves with dustThreshold", + "REG-05: Multi-Chain Network Configs REG-05.1: All 9 networks resolve bitcoin-regtest resolves with dustThreshold", + "REG-05: Multi-Chain Network Configs REG-05.1: All 9 networks resolve bitcoin-testnet resolves with dustThreshold", + "REG-05: Multi-Chain Network Configs REG-05.1: All 9 networks resolve dogecoin-mainnet resolves with dustThreshold", + "REG-05: Multi-Chain Network Configs REG-05.1: All 9 networks resolve dogecoin-regtest resolves with dustThreshold", + "REG-05: Multi-Chain Network Configs REG-05.1: All 9 networks resolve dogecoin-testnet resolves with dustThreshold", + "REG-05: Multi-Chain Network Configs REG-05.1: All 9 networks resolve litecoin-mainnet resolves with dustThreshold", + "REG-05: Multi-Chain Network Configs REG-05.1: All 9 networks resolve litecoin-regtest resolves with dustThreshold", + "REG-05: Multi-Chain Network Configs REG-05.1: All 9 networks resolve litecoin-testnet resolves with dustThreshold", + "REG-05: Multi-Chain Network Configs REG-05.1: All 9 networks resolve unknown network throws", + "REG-05: Multi-Chain Network Configs REG-05.2: Bitcoin (regtest) OP_RETURN dustAmount is 546", + "REG-05: Multi-Chain Network Configs REG-05.2: Bitcoin (regtest) OP_RETURN produces valid PSBT with correct encoding", + "REG-05: Multi-Chain Network Configs REG-05.3: Dogecoin (regtest) OP_RETURN P2WSH encoding throws TypeError", + "REG-05: Multi-Chain Network Configs REG-05.3: Dogecoin (regtest) OP_RETURN dustAmount is 100000 (Dogecoin Core hard dust limit)", + "REG-05: Multi-Chain Network Configs REG-05.3: Dogecoin (regtest) OP_RETURN produces valid PSBT with correct encoding", + "REG-05: Multi-Chain Network Configs REG-05.4: Litecoin (regtest) OP_RETURN dustAmount is 5460 (10x BTC)", + "REG-05: Multi-Chain Network Configs REG-05.4: Litecoin (regtest) OP_RETURN fee is floored to 5460 not 546", + "REG-05: Multi-Chain Network Configs REG-05.4: Litecoin (regtest) OP_RETURN produces valid PSBT with correct encoding", + "REG-05: Multi-Chain Network Configs REG-05.5: P2SH dust per chain bitcoin-regtest P2SH output value >= 546", + "REG-05: Multi-Chain Network Configs REG-05.5: P2SH dust per chain dogecoin-regtest P2SH output value >= 100000", + "REG-05: Multi-Chain Network Configs REG-05.5: P2SH dust per chain litecoin-regtest P2SH output value >= 5460", + "REG-05: Multi-Chain Network Configs REG-05.6: MULTISIGN dust per chain bitcoin-regtest MULTISIGN output value = 546", + "REG-05: Multi-Chain Network Configs REG-05.6: MULTISIGN dust per chain litecoin-regtest MULTISIGN output value = 5460" + ], "0f2abe37f99c5a17": [ "Security: remediated dependency advisories @regression @tier4 ADV-1: package.json pins axios at or above 1.18.0", "Security: remediated dependency advisories @regression @tier4 ADV-1: package.json pins brace-expansion at or above 5.0.9", @@ -91,73 +215,54 @@ "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" ], - "140f5ed1c625c9ab": [ - "encoder FILE payload compression (spec Part B) action-string helpers detects FILE v0 and gating correctly", - "encoder FILE payload compression (spec Part B) action-string helpers pads intermediate optional fields to reach index 10", - "encoder FILE payload compression (spec Part B) action-string helpers preserves the gating fields when a client sets the marker", - "encoder FILE payload compression (spec Part B) action-string helpers setting the field empty leaves the action byte-identical", - "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 (spec Part B) constants conformance agrees with the SDK helper on every decision (skips without the sdk sibling)", - "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)", - "encoder FILE payload compression (spec Part B) createTransaction integration an incompressible payload opted in still builds, just uncompressed", - "encoder FILE payload compression (spec Part B) createTransaction integration compresses by DEFAULT, with no compress flag passed at all", - "encoder FILE payload compression (spec Part B) createTransaction integration compresses when opted in, and the estimator prices the COMPRESSED bytes", - "encoder FILE payload compression (spec Part B) createTransaction integration honours an explicit opt-out", - "encoder FILE payload compression (spec Part B) createTransaction integration refuses at build time when a gated FILE asks for encoder-side compression", - "encoder FILE payload compression (spec Part B) createTransaction integration the compressed transaction carries the marker AND reconstructs byte-exactly", - "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", - "encoder FILE payload compression (spec Part B) the compression report carries the bytes that were written says nothing extra when compression did not fire", - "encoder FILE payload compression (spec Part B) the compression report carries the bytes that were written the marker WITHOUT the stored bytes builds a different carrier (why both halves ride)", - "encoder FILE payload compression (spec Part B) validator accepts compress as an optional boolean; absent means \"use the deployment default\"" + "13527b5e48f4a7cf": [ + "Encoder input validator validateFee null/false pass; coerces; rejects NaN, negative, over-max" ], - "1afcffc2f1fa70d9": [ - "UtxoTracker constructor builds the correct URL from host and port", - "UtxoTracker constructor stores the port", - "UtxoTracker.getSyncStatus() propagates transport errors", - "UtxoTracker.getSyncStatus() returns the result object on success", - "UtxoTracker.getSyncStatus() sends get_sync_status JSON-RPC method", - "UtxoTracker.getSyncStatus() sends params as an empty object", - "UtxoTracker.getSyncStatus() sets a timeout on the request", - "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", - "UtxoTracker.getUtxosFromAddress() accepts an empty utxos array", + "13761a81f887787c": [ + "XChainEncoder.createTransaction() MULTISIGN encoding path builds a valid tx where total outputs do not exceed inputs", + "XChainEncoder.createTransaction() MULTISIGN encoding path creates multisig output with correct structure", + "XChainEncoder.createTransaction() MULTISIGN encoding path encodes a short final chunk without throwing (x=0 pubkey regression)", + "XChainEncoder.createTransaction() MULTISIGN encoding path third pubkey is the real compressed public key" + ], + "13fa2adf6419a698": [ + "XChainEncoder.createTransaction() P2SH encoding path (tx1: funding) P2SH output value covers the spending tx fee", + "XChainEncoder.createTransaction() P2SH encoding path (tx1: funding) creates P2SH output for large data" + ], + "13fc0912a0f597f5": [ + "E2E-5: UTXO, Fee, and Change Integration E2E-5.10: Legacy UTXO handling P2PKH UTXO triggers getTransactionHex and uses nonWitnessUtxo" + ], + "140130fb419b99c8": [ + "M-11 (encoder half): utxo-tracker freshness gate absent freshness surface passes (backward compat) builds normally when sync is present but lag is null (unknown, not \"above\")", + "M-11 (encoder half): utxo-tracker freshness gate absent freshness surface passes (backward compat) builds normally when the tracker response has no sync field (pre-ce16bdd tracker)", + "M-11 (encoder half): utxo-tracker freshness gate api.js maps UTXO_TRACKER_STALE to -32010 forwards the stale-tracker error with a machine-readable reason and lag details", + "M-11 (encoder half): utxo-tracker freshness gate caller-supplied UTXOs bypass the gate never consults or gates on the tracker when UTXOs are explicitly provided", + "M-11 (encoder half): utxo-tracker freshness gate lag below threshold passes builds normally at exactly the threshold (lag == 2 is not \"above\")", + "M-11 (encoder half): utxo-tracker freshness gate lag below threshold passes builds normally when lag is under the default 2-block threshold", + "M-11 (encoder half): utxo-tracker freshness gate stale gate fires respects a custom UTXO_TRACKER_MAX_LAG_BLOCKS threshold passed to the constructor", + "M-11 (encoder half): utxo-tracker freshness gate stale gate fires throws UTXO_TRACKER_STALE when lag exceeds the configured threshold, even if synced=true", + "M-11 (encoder half): utxo-tracker freshness gate stale gate fires throws UTXO_TRACKER_STALE when the tracker reports synced=false", + "M-11 (encoder half): utxo-tracker freshness gate unusable-source gates fire builds normally once the mempool has reconverged and the tracker is running", + "M-11 (encoder half): utxo-tracker freshness gate unusable-source gates fire fails open for a tracker whose sync sibling carries neither field", + "M-11 (encoder half): utxo-tracker freshness gate unusable-source gates fire names the orphaned view, not mempool readiness, when both are de-asserted", + "M-11 (encoder half): utxo-tracker freshness gate unusable-source gates fire throws UTXO_TRACKER_HALTED when the tracker halted, even at lag 0 and synced=true", + "M-11 (encoder half): utxo-tracker freshness gate unusable-source gates fire throws UTXO_TRACKER_NOT_READY while the mempool has not reconverged", + "M-11 (encoder half): utxo-tracker freshness gate unusable-source gates fire throws UTXO_TRACKER_STALE when the tracker is ahead of the node (negative lag)" + ], + "145ea55c07323321": [ + "rawData-only create_tx warning @regression declares the warnings field in the create_tx result contract", + "rawData-only create_tx warning @regression does not warn on an ordinary data-carrying build", + "rawData-only create_tx warning @regression still builds the shape, and warns that it will not be indexed" + ], + "149a2b69c0c72f7f": [ "UtxoTracker.getUtxosFromAddress() calls getSyncStatus before fetching UTXOs", "UtxoTracker.getUtxosFromAddress() coerces numeric-string confirmations and rejects non-integer values", "UtxoTracker.getUtxosFromAddress() defaults confirmations to 0 when null", "UtxoTracker.getUtxosFromAddress() defaults confirmations to 0 when undefined", "UtxoTracker.getUtxosFromAddress() fetches normally from a tracker that omits mempool_ready", - "UtxoTracker.getUtxosFromAddress() handles multiple valid UTXOs correctly", "UtxoTracker.getUtxosFromAddress() names the block lag, not mempool reconvergence, when a lagging tracker de-asserts both", "UtxoTracker.getUtxosFromAddress() preserves existing confirmations when > 0", - "UtxoTracker.getUtxosFromAddress() reports the correct index in malformed UTXO error for second item", - "UtxoTracker.getUtxosFromAddress() rethrows get_utxos transport errors", "UtxoTracker.getUtxosFromAddress() returns the result object on success", "UtxoTracker.getUtxosFromAddress() sends get_utxos with the address param", - "UtxoTracker.getUtxosFromAddress() throws TypeError for malformed UTXO (missing txid)", - "UtxoTracker.getUtxosFromAddress() throws TypeError for malformed UTXO (missing value)", - "UtxoTracker.getUtxosFromAddress() throws TypeError for malformed UTXO (missing vout)", - "UtxoTracker.getUtxosFromAddress() throws TypeError for malformed UTXO (txid not a string)", - "UtxoTracker.getUtxosFromAddress() throws TypeError when a UTXO element is null", - "UtxoTracker.getUtxosFromAddress() throws TypeError when scriptPubKey is an empty string", - "UtxoTracker.getUtxosFromAddress() throws TypeError when scriptPubKey is missing", - "UtxoTracker.getUtxosFromAddress() throws TypeError when txid is 64 chars but not hex", - "UtxoTracker.getUtxosFromAddress() throws TypeError when txid is not a 64-char hex string (too short)", - "UtxoTracker.getUtxosFromAddress() throws TypeError when utxos is not an array", - "UtxoTracker.getUtxosFromAddress() throws when UTXO result is missing", - "UtxoTracker.getUtxosFromAddress() throws when UTXO result is not an object", "UtxoTracker.getUtxosFromAddress() throws when getSyncStatus transport fails", "UtxoTracker.getUtxosFromAddress() throws when lag is null (tracker has not indexed any blocks)", "UtxoTracker.getUtxosFromAddress() throws when lag is undefined", @@ -166,6 +271,98 @@ "UtxoTracker.getUtxosFromAddress() throws when the tracker is halted, even at lag 0 with synced=true", "UtxoTracker.getUtxosFromAddress() throws when tracker is not synced (synced=false)" ], + "156289203491c9e5": [ + "Chaos Category C: Library & Crypto Failures C-1: Psbt.addInput() throws addInput failure propagates from createTransaction", + "Chaos Category C: Library & Crypto Failures C-2a: Psbt.addOutput() all calls fail first addOutput (OP_RETURN data) failure propagates", + "Chaos Category C: Library & Crypto Failures C-2b: Psbt.addOutput() fails on Nth call only second addOutput (change output) failure propagates", + "Chaos Category C: Library & Crypto Failures C-3: crypto.createCipheriv failure cipher failure in obfuscate() propagates", + "Chaos Category C: Library & Crypto Failures C-4: bitcoin.script.compile failure compile failure on initial data buffer propagates TypeError", + "Chaos Category C: Library & Crypto Failures C-5: Non-base58 caller identity for P2SH encoding an identity that is no address and no pubkey is refused by name", + "Chaos Category C: Library & Crypto Failures C-5: Non-base58 caller identity for P2SH encoding bech32 P2WPKH caller resolves to the same HASH160 as the raw pubkey" + ], + "16caf10d6b0f828b": [ + "Chaos Category F: API Layer Failures F-1: Validator with malformed params create_envelope_cancel_tx rejects positional array params at the gate", + "Chaos Category F: API Layer Failures F-1: Validator with malformed params data exceeding 65536 bytes \u2192 RangeError", + "Chaos Category F: API Layer Failures F-1: Validator with malformed params invalid encoding value \u2192 TypeError", + "Chaos Category F: API Layer Failures F-1: Validator with malformed params negative fee \u2192 RangeError", + "Chaos Category F: API Layer Failures F-1: Validator with malformed params null params \u2192 TypeError", + "Chaos Category F: API Layer Failures F-1: Validator with malformed params positional array params \u2192 TypeError naming the SHAPE, not a missing field", + "Chaos Category F: API Layer Failures F-1: Validator with malformed params string params \u2192 TypeError", + "Chaos Category F: API Layer Failures F-1: Validator with malformed params unknown/typoed ACTION name \u2192 RangeError", + "Chaos Category F: API Layer Failures F-1: Validator with malformed params utxos array exceeding 500 \u2192 RangeError", + "Chaos Category F: API Layer Failures F-3: psbt.toHex() callability (api.js line 124 exposure) MULTISIGN result: psbt.toHex() succeeds", + "Chaos Category F: API Layer Failures F-3: psbt.toHex() callability (api.js line 124 exposure) OP_RETURN result: psbt.toHex() succeeds", + "Chaos Category F: API Layer Failures F-3: psbt.toHex() callability (api.js line 124 exposure) P2SH result: psbt.toHex() succeeds", + "Chaos Category F: API Layer Failures F-3b: psbt.toHex() monkey-patch (documents api.js exposure) toHex failure after successful createTransaction \u2192 unhandled in api.js" + ], + "171d238e570fea73": [ + "Category D: UTXO & Fee Integration D-3: Duplicate UTXO deduplication removes duplicate UTXOs with same txid+vout" + ], + "1a362ab39ee459fd": [ + "Category E: Custom Outputs (COINPAY Integration) E-1: Custom outputs added with correct address and value includes both custom outputs in PSBT", + "Category E: Custom Outputs (COINPAY Integration) E-2: Custom outputs affect change calculation custom output value is deducted from change", + "Category E: Custom Outputs (COINPAY Integration) E-3: Non-array customOutputs ignored null customOutputs is silently skipped", + "Category E: Custom Outputs (COINPAY Integration) E-3: Non-array customOutputs ignored object instead of array is silently skipped", + "Category E: Custom Outputs (COINPAY Integration) E-4: Custom outputs + P2SH ACTION payload funding tx carries the P2SH data outputs and folds the custom-output value in", + "Category E: Custom Outputs (COINPAY Integration) E-4: Custom outputs + P2SH ACTION payload reveal tx emits the custom output alongside the ACTION data", + "Category E: Custom Outputs (COINPAY Integration) Empty customOutputs array empty array produces no extra outputs" + ], + "1b91de49aeb88df1": [ + "XChainEncoder.createTransaction(): payload size guard throws RangeError when compiled payload exceeds MAX_COMPILED_ACTION_DATA_LENGTH (8192)" + ], + "1c00492f188266d1": [ + "XChainEncoder.createTransaction() estimateSpendingP2shTx() increases monotonically with redeem data size", + "XChainEncoder.createTransaction() estimateSpendingP2shTx() returns 10 + P2SH input + OP_RETURN + 8-byte safety margin" + ], + "1c5c9824a2732293": [ + "Chaos Category B: Input & Data Corruption B-1: Empty UTXO array after dedup (unguarded crash) all-mempool UTXOs + unconfirmed=false \u2192 TypeError crash", + "Chaos Category B: Input & Data Corruption B-1: Empty UTXO array after dedup (unguarded crash) confirmed UTXO survives dedup \u2192 no crash", + "Chaos Category B: Input & Data Corruption B-1: Empty UTXO array after dedup (unguarded crash) single mempool UTXO + unconfirmed=false \u2192 crash", + "Chaos Category B: Input & Data Corruption B-2: UTXO values at arithmetic boundaries float string \"100.7\" is rejected (parseInt would truncate to 100)", + "Chaos Category B: Input & Data Corruption B-2: UTXO values at arithmetic boundaries hex string value \"0xff\" is rejected (parseInt would read it as 0)", + "Chaos Category B: Input & Data Corruption B-2: UTXO values at arithmetic boundaries value=0 \u2192 INSUFFICIENT_FUNDS, available reported as 0", + "Chaos Category B: Input & Data Corruption B-2: UTXO values at arithmetic boundaries value=1 \u2192 INSUFFICIENT_FUNDS rather than negative change", + "Chaos Category B: Input & Data Corruption B-3: Obfuscation key edge cases (degenerate AES keys) all-F txid (fff...f) works as AES key", + "Chaos Category B: Input & Data Corruption B-3: Obfuscation key edge cases (degenerate AES keys) all-zero txid (000...0) works as AES key", + "Chaos Category B: Input & Data Corruption B-3: Obfuscation key edge cases (degenerate AES keys) short txid (32 chars) \u2192 throws error (buffer length or cipher IV)", + "Chaos Category B: Input & Data Corruption B-4: Maximum payload stress (8192 byte compiled boundary) 8189-byte data \u2192 compiled=8192 \u2192 at limit, P2WSH succeeds", + "Chaos Category B: Input & Data Corruption B-4: Maximum payload stress (8192 byte compiled boundary) 8190-byte data \u2192 compiled=8193 \u2192 RangeError: Payload too large", + "Chaos Category B: Input & Data Corruption B-5: Corrupted scriptPubKey in UTXOs empty scriptPubKey \u2192 falls to legacy path, succeeds with mocked rawtx", + "Chaos Category B: Input & Data Corruption B-5: Corrupted scriptPubKey in UTXOs non-hex scriptPubKey \u2192 falls to legacy path, succeeds", + "Chaos Category B: Input & Data Corruption B-5: Corrupted scriptPubKey in UTXOs null scriptPubKey \u2192 falls to legacy path, succeeds", + "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" + ], + "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", + "XChainEncoder.createTransaction() payload guards (library boundary) rejects a `data` string that is not well-formed Unicode", + "XChainEncoder.createTransaction() payload guards (library boundary) rejects a rawData code unit above U+00FF instead of truncating it", + "XChainEncoder.createTransaction() payload guards (library boundary) rejects a single minimal-opcode `data` byte", + "XChainEncoder.createTransaction() payload guards (library boundary) rejects a single minimal-opcode `data` byte passed as a Buffer", + "XChainEncoder.createTransaction() payload guards (library boundary) rejects a single minimal-opcode rawData byte", + "XChainEncoder.createTransaction() payload guards (library boundary) rejects a single minimal-opcode rawData byte passed as a Buffer", + "XChainEncoder.createTransaction() payload guards (library boundary) still accepts a Buffer rawData from a library caller", + "XChainEncoder.createTransaction() payload guards (library boundary) still builds a payment-only transaction with no payload at all", + "XChainEncoder.createTransaction() payload guards (library boundary) still builds an ordinary multi-byte data + rawData payload", + "XChainEncoder.createTransaction() payload guards (library boundary) still builds the deliberately-supported rawData-only shape", + "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" + ], + "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", + "XChainEncoder.createTransaction() payment-only still emits the OP_RETURN when an action IS present", + "XChainEncoder.createTransaction() payment-only treats an empty-string action the same as an absent one" + ], + "21640a976ee201c8": [ + "XChainEncoder.createTransaction() return value returns { psbt, encoding } where psbt is a Psbt instance" + ], + "23e242d978109d26": [ + "XChainEncoder.createTransaction() attachPrevTx attaches the full previous transaction when asked", + "XChainEncoder.createTransaction() attachPrevTx leaves segwit inputs witnessUtxo-only by default", + "XChainEncoder.createTransaction() attachPrevTx still attaches nothing when the input is legacy anyway" + ], "2442df610ebb6911": [ "coin-registry conformance (vendored copy) @regression byte-identity to canonical xchain-hub/src/coins BTC.js is byte-identical to the canonical xchain-hub copy", "coin-registry conformance (vendored copy) @regression byte-identity to canonical xchain-hub/src/coins DOGE.js is byte-identical to the canonical xchain-hub copy", @@ -180,6 +377,38 @@ "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files LTC/testnet vendored pin matches the vendored consensusHash", "coin-registry conformance (vendored copy) @regression pin == consensusHash over the vendored files verifyConsensusPin passes for every network on the vendored bundle" ], + "271d27a1e5622be1": [ + "Category D: UTXO & Fee Integration D-9: No change address throws when change > dust throws error about burning satoshis" + ], + "27d7bba206f363af": [ + "UtxoTracker.getUtxosFromAddress() accepts an empty utxos array", + "UtxoTracker.getUtxosFromAddress() handles multiple valid UTXOs correctly", + "UtxoTracker.getUtxosFromAddress() reports the correct index in malformed UTXO error for second item", + "UtxoTracker.getUtxosFromAddress() rethrows get_utxos transport errors", + "UtxoTracker.getUtxosFromAddress() throws TypeError for malformed UTXO (missing txid)", + "UtxoTracker.getUtxosFromAddress() throws TypeError for malformed UTXO (missing value)", + "UtxoTracker.getUtxosFromAddress() throws TypeError for malformed UTXO (missing vout)", + "UtxoTracker.getUtxosFromAddress() throws TypeError for malformed UTXO (txid not a string)", + "UtxoTracker.getUtxosFromAddress() throws TypeError when a UTXO element is null", + "UtxoTracker.getUtxosFromAddress() throws TypeError when scriptPubKey is an empty string", + "UtxoTracker.getUtxosFromAddress() throws TypeError when scriptPubKey is missing", + "UtxoTracker.getUtxosFromAddress() throws TypeError when txid is 64 chars but not hex", + "UtxoTracker.getUtxosFromAddress() throws TypeError when txid is not a 64-char hex string (too short)", + "UtxoTracker.getUtxosFromAddress() throws TypeError when utxos is not an array", + "UtxoTracker.getUtxosFromAddress() throws when UTXO result is missing", + "UtxoTracker.getUtxosFromAddress() throws when UTXO result is not an object" + ], + "27e24996d31ce20e": [ + "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) a payload at exactly the ceiling builds a reveal within MAX_STANDARD_TX_WEIGHT (\u00a74)", + "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) applies the per-encoding \u00a74 ceiling: over ENVELOPE_MAX_PAYLOAD rejects, over 8192 passes", + "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) emits customOutputs on the COMMIT (fee-destination outputs ride the commit, \u00a73.5)", + "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) pads the reveal over the LTC stripped-size floor and prefunds the pad (\u00a73.5)", + "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) propagates RBF to both transactions of the pair", + "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) refuses TAPROOT on DOGE with the P2WSH gate error shape (\u00a73.6)", + "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) refuses non-segwit commit inputs (\u00a73.5, fail closed)", + "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" + ], "2a1c91a7aceda8eb": [ "_buildTransaction outpoint dedup / mempool filter applies the mempool filter BEFORE dedup, so a confirmed twin survives", "_buildTransaction outpoint dedup / mempool filter collapses a set that is nothing but copies of one outpoint", @@ -190,6 +419,10 @@ "_buildTransaction outpoint dedup / mempool filter keeps the FIRST occurrence of a repeated outpoint and drops the rest", "_buildTransaction outpoint dedup / mempool filter preserves the relative order of the survivors" ], + "2a2db054ce1a2706": [ + "Encoding Chunk Boundaries: Full Pipeline forced encoding override invalid encoding string throws TypeError from prepareData", + "Encoding Chunk Boundaries: Full Pipeline forced encoding override small data forced to P2SH produces P2SH output" + ], "2aa020357e5b268b": [ "XChainEncoder input selection: segwit inputs on a chain without segwit leaves the same P2WPKH input alone on a segwit-capable network", "XChainEncoder input selection: segwit inputs on a chain without segwit names the offending outpoint, so a coin-control caller can drop it", @@ -197,6 +430,24 @@ "XChainEncoder input selection: segwit inputs on a chain without segwit refuses a witness v1 (taproot) input there too, not only witness v0", "XChainEncoder input selection: segwit inputs on a chain without segwit still spends a legacy input on that same network" ], + "2b12b033eef48c5a": [ + "REG-03: Fee & UTXO Selection REG-03.1: UTXO selection order adds UTXOs until inputs cover outputs + fee", + "REG-03: Fee & UTXO Selection REG-03.1: UTXO selection order largest UTXO is used first (sorted descending by value)", + "REG-03: Fee & UTXO Selection REG-03.1: UTXO selection order single large UTXO avoids pulling in additional UTXOs", + "REG-03: Fee & UTXO Selection REG-03.2: Deduplication duplicate txid+vout entries collapsed to one input", + "REG-03: Fee & UTXO Selection REG-03.3: Unconfirmed filtering mempool UTXOs excluded when unconfirmed=false", + "REG-03: Fee & UTXO Selection REG-03.3: Unconfirmed filtering mempool UTXOs included when unconfirmed=true", + "REG-03: Fee & UTXO Selection REG-03.4: Fee calculation explicit fee parameter is used verbatim", + "REG-03: Fee & UTXO Selection REG-03.4: Fee calculation fee is floored to network dustAmount", + "REG-03: Fee & UTXO Selection REG-03.4: Fee calculation feePerKb parameter bypasses RPC getFeePerKilobyte call", + "REG-03: Fee & UTXO Selection REG-03.4: Fee calculation maxFeeRateKb cap produces lower fee than uncapped encoder", + "REG-03: Fee & UTXO Selection REG-03.5: Change output change output value = input - outputs - fee", + "REG-03: Fee & UTXO Selection REG-03.5: Change output no change address throws when changeSatoshis > dust", + "REG-03: Fee & UTXO Selection REG-03.6: UTXO tracker fallback calls tracker when utxos param is null", + "REG-03: Fee & UTXO Selection REG-03.6: UTXO tracker fallback throws when tracker returns empty list", + "REG-03: Fee & UTXO Selection REG-03.7: Legacy UTXO handling fetches raw tx hex via connector for P2PKH UTXOs", + "REG-03: Fee & UTXO Selection REG-03.7: Legacy UTXO handling segwit UTXOs do NOT call getTransactionHex" + ], "2c605d4ec40ce8e5": [ "ChainAdapter base every surface method throws AdapterNotImplementedError with the method name", "EVM finality gate (evmFinality) .finalityGate() resolves a known chain case-insensitively", @@ -217,6 +468,27 @@ "EvmAdapter (pre-work stub) normalizes the chain key and validates it at construction", "EvmAdapter (pre-work stub) surfaces THE L2 TRAP guard per chain" ], + "2dd7e128b3fa1284": [ + "E2E-5: UTXO, Fee, and Change Integration E2E-5.13: Custom outputs coexist with ACTION custom output value deducted from change" + ], + "3200c672043a4ec4": [ + "Encoding Chunk Boundaries: Full Pipeline MULTISIGN encoding boundaries 59-char data (compiled=60): valid 1-MULTISIGN output", + "Encoding Chunk Boundaries: Full Pipeline MULTISIGN encoding boundaries 60-char data (compiled=61): produces exactly 2 MULTISIGN outputs", + "Encoding Chunk Boundaries: Full Pipeline MULTISIGN encoding boundaries MULTISIGN without compressedPubKey throws" + ], + "325536fb1b16a149": [ + "E2E-5: UTXO, Fee, and Change Integration E2E-5.15: Null feePerKb triggers RPC call calls getFeePerKilobyte when feePerKb is null" + ], + "34a2845bc2d97b6c": [ + "XChainEncoder.createTransaction() - maxFeeRateKb cap caps the fee rate when connector returns a rate above maxFeeRateKb" + ], + "357ca3318e99187c": [ + "XChainEncoder TAPROOT envelope prepareData grammar (\u00a73.2) emits OP_FALSE OP_IF <0x00> OP_ENDIF OP_CHECKSIG", + "XChainEncoder TAPROOT envelope prepareData grammar (\u00a73.2) matches the golden grammar vector", + "XChainEncoder TAPROOT envelope prepareData grammar (\u00a73.2) rebalances a degenerate 1-byte minimal-opcode final chunk (compile would canonicalize it away)", + "XChainEncoder TAPROOT envelope prepareData grammar (\u00a73.2) requires compressedPubKey and validates its shape", + "XChainEncoder TAPROOT envelope prepareData grammar (\u00a73.2) splits the payload into 520-byte pushes, in order" + ], "358184203c4ccfba": [ "encoder stress-sweep unit @regression JSON-RPC batch-size guard passes a batch at the cap and a single request", "encoder stress-sweep unit @regression JSON-RPC batch-size guard rejects an over-cap batch array with 400 and does not call next", @@ -224,6 +496,53 @@ "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)" ], + "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", + "REG-04: Validator Functions REG-04.11: validateP2shParams() returns nulls when both false", + "REG-04: Validator Functions REG-04.11: validateP2shParams() throws TypeError for empty p2shHex string", + "REG-04: Validator Functions REG-04.11: validateP2shParams() throws TypeError for non-hex-64 p2shHash", + "REG-04: Validator Functions REG-04.11: validateP2shParams() throws TypeError when only p2shHash is provided", + "REG-04: Validator Functions REG-04.11: validateP2shParams() throws TypeError when only p2shHex is provided" + ], + "3649849b71c1d16c": [ + "E2E-5: UTXO, Fee, and Change Integration E2E-5.14: Explicit feePerKb bypasses RPC does not call getFeePerKilobyte when feePerKb provided" + ], + "3675428cec810c11": [ + "REG-14: P2SH/P2WSH reveal clears the min-relay fee floor P2SH funds a dust-floored reveal carrying a fee output", + "REG-14: P2SH/P2WSH reveal clears the min-relay fee floor P2SH funds a multi-chunk reveal carrying a fee output", + "REG-14: P2SH/P2WSH reveal clears the min-relay fee floor P2SH funds a reveal carrying a legacy-address fee output", + "REG-14: P2SH/P2WSH reveal clears the min-relay fee floor P2SH funds a reveal carrying a segwit-address fee output", + "REG-14: P2SH/P2WSH reveal clears the min-relay fee floor P2SH funds a reveal with no customOutputs", + "REG-14: P2SH/P2WSH reveal clears the min-relay fee floor P2WSH funds a dust-floored reveal carrying a fee output", + "REG-14: P2SH/P2WSH reveal clears the min-relay fee floor P2WSH funds a multi-chunk reveal carrying a fee output", + "REG-14: P2SH/P2WSH reveal clears the min-relay fee floor P2WSH funds a reveal carrying a legacy-address fee output", + "REG-14: P2SH/P2WSH reveal clears the min-relay fee floor P2WSH funds a reveal carrying a segwit-address fee output", + "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" + ], + "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", + "REG-07: Action Pipeline Regression REG-07.2: ISSUE P2SH path full ISSUE (25+ fields) auto-selects P2SH", + "REG-07: Action Pipeline Regression REG-07.2: ISSUE P2SH path minimal ISSUE fits OP_RETURN", + "REG-07: Action Pipeline Regression REG-07.3: MULTISEND v2 (multiple recipients) two TICK|AMOUNT|DEST triplets preserved", + "REG-07: Action Pipeline Regression REG-07.4: ORDER DEX round-trip ORDER|0|BUY|JDOG|100|BRRR|50|0 survives encoding", + "REG-07: Action Pipeline Regression REG-07.5: BROADCAST oversized OP_RETURN rejected long broadcast auto-selects P2SH (the valid carrier for oversized data)", + "REG-07: Action Pipeline Regression REG-07.5: BROADCAST oversized OP_RETURN rejected long broadcast forced to OP_RETURN is rejected", + "REG-07: Action Pipeline Regression REG-07.6: FILE P2WSH path (bitcoin-regtest) large FILE uses P2WSH on segwit-capable network", + "REG-07: Action Pipeline Regression REG-07.7: BATCH semicolon delimiter semicolon-separated commands preserved verbatim", + "REG-07: Action Pipeline Regression REG-07.8: data + rawData dual field both data and rawData present in decompiled output", + "REG-07: Action Pipeline Regression REG-07.9: Special characters in payload TICK name with special chars preserved", + "REG-07: Action Pipeline Regression REG-07.9: Special characters in payload TICK_ID caret prefix preserved" + ], + "3b83f98eb236d6d9": [ + "Category D: UTXO & Fee Integration Fee source selection calls connector.getFeePerKilobyte when feePerKb is null", + "Category D: UTXO & Fee Integration Fee source selection uses feePerKb parameter without RPC call" + ], + "3d0c3f469f5a49b0": [ + "E2E-5: UTXO, Fee, and Change Integration E2E-5.4: Unconfirmed UTXOs excluded filters mempool UTXOs when unconfirmed=false" + ], "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\"", @@ -252,11 +571,61 @@ "CryptoNetworks .getFirstBlock() returns 67847500 for dogecoin-testnet", "CryptoNetworks .getFirstBlock() returns 950000 for bitcoin-mainnet" ], + "41d964a43ddfab20": [ + "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", + "Security: rate-limit refusal counter/logger limitedHandler logs the count accumulated since the last line at the first refusal after a full window, then resets", + "Security: rate-limit refusal counter/logger limitedHandler sends 429 with the exact JSON-RPC body the message option carried", + "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" + ], + "427e40092d870bf8": [ + "Fee Calculation Boundaries Math.trunc precision fractional fee is truncated toward zero, not rounded", + "Fee Calculation Boundaries explicit fee parameter explicit fee=0 still gets floored to dustAmount", + "Fee Calculation Boundaries explicit fee parameter explicit fee=1 still gets floored to dustAmount", + "Fee Calculation Boundaries explicit fee parameter fee=10000 sets estimatedFee=10000 regardless of tx size", + "Fee Calculation Boundaries fee exceeding total inputs 1-sat UTXO with dust-floored fee throws INSUFFICIENT_FUNDS", + "Fee Calculation Boundaries fee exceeding total inputs multiple small UTXOs all exhausted, still insufficient, throws INSUFFICIENT_FUNDS", + "Fee Calculation Boundaries feePerKb = 0 \u2192 floored to dustAmount BTC: fee floors to 546 sats", + "Fee Calculation Boundaries feePerKb = 0 \u2192 floored to dustAmount DOGE: fee floors to 100000 sats (Dogecoin high dust threshold)", + "Fee Calculation Boundaries feePerKb = 0 \u2192 floored to dustAmount LTC: fee floors to 5460 sats", + "Fee Calculation Boundaries maxFeeRateKb caps feePerBytes feePerKb below cap \u2192 cap has no effect", + "Fee Calculation Boundaries maxFeeRateKb caps feePerBytes high feePerKb with cap produces lower fee than without cap", + "Fee Calculation Boundaries negative feePerKb \u2192 floored to dustAmount negative feePerKb does not produce negative fee" + ], "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", "coverage ratchet floors ships the coverage:check script the CI coverage job invokes" ], + "44230034b18955bb": [ + "XChainEncoder TAPROOT envelope key-path cancel suspension count a below-dust cancel is refused and hands its claim back three turns after the call", + "XChainEncoder TAPROOT envelope key-path cancel suspension count the caller fee-rate path settles three turns after the call", + "XChainEncoder TAPROOT envelope key-path cancel suspension count the node fee-rate path settles three turns after the call" + ], + "447682ee980135d8": [ + "XChainEncoder.createTransaction() - feeQuote injection adds feeQuote as an extra output when address and amount > 0", + "XChainEncoder.createTransaction() - feeQuote injection does not add feeQuote when amount is 0", + "XChainEncoder.createTransaction() - feeQuote injection does not add feeQuote when feeQuote has no address" + ], + "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", + "Custom Output Boundaries empty customOutputs array empty array has no effect on outputs or fees", + "Custom Output Boundaries many custom outputs 10 custom outputs each worth 100000 \u2192 correct total in outputSatoshis", + "Custom Output Boundaries many custom outputs each custom output adds 43 bytes to estimated tx size", + "Custom Output Boundaries non-integer custom output values are rejected \"1.5\" value is rejected, not truncated to 1", + "Custom Output Boundaries non-integer custom output values are rejected \"999999.9\" is rejected, not truncated to 999999", + "Custom Output Boundaries non-integer custom output values are rejected a clean integer custom output value still builds correctly" + ], + "471e523a3c7464b3": [ + "E2E-5: UTXO, Fee, and Change Integration E2E-5.11: SegWit UTXO handling P2WPKH UTXO uses witnessUtxo, no raw tx fetch" + ], + "48791dc9a10cb986": [ + "Encoding Chunk Boundaries: Full Pipeline P2SH chunk-split threshold 473-char data (compiled=476) \u2192 1 P2SH output (exactly fits)", + "Encoding Chunk Boundaries: Full Pipeline P2SH chunk-split threshold 474-char data (compiled=477) \u2192 2 P2SH outputs (first overflow)", + "Encoding Chunk Boundaries: Full Pipeline P2SH chunk-split threshold P2SH data integrity: 475-char data reassembles from tx2 redeemScripts" + ], "4a88b66dc7f07157": [ "XChainEncoder.prepareData() MULTISIGN encoding multiple chunks for data exceeding limit", "XChainEncoder.prepareData() MULTISIGN encoding prepends XCHN magic word to each chunk", @@ -292,92 +661,146 @@ "XChainEncoder.prepareData() caller identity resolution (P2SH chunk lane) throws a clear error for an unresolvable identity", "XChainEncoder.prepareData() invalid encoding throws TypeError for unrecognized encoding string" ], - "57c53b6db4d98a77": [ - "XChainEncoder.createTransaction() MULTISIGN encoding path builds a valid tx where total outputs do not exceed inputs", - "XChainEncoder.createTransaction() MULTISIGN encoding path creates multisig output with correct structure", - "XChainEncoder.createTransaction() MULTISIGN encoding path encodes a short final chunk without throwing (x=0 pubkey regression)", - "XChainEncoder.createTransaction() MULTISIGN encoding path third pubkey is the real compressed public key", - "XChainEncoder.createTransaction() OP_RETURN encoding path OP_RETURN output has value 0", - "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", - "XChainEncoder.createTransaction() P2SH encoding path (tx1: funding) P2SH output value covers the spending tx fee", - "XChainEncoder.createTransaction() P2SH encoding path (tx1: funding) creates P2SH output for large data", - "XChainEncoder.createTransaction() P2SH encoding path (tx2: spending) creates tx2 with P2SH input and OP_RETURN marker", - "XChainEncoder.createTransaction() P2SH native-fee customOutputs phase 1 (funding) does NOT emit the fee-destination output", - "XChainEncoder.createTransaction() P2SH native-fee customOutputs phase 1 (funding) over-funds the P2SH output by the fee value plus its byte cost", - "XChainEncoder.createTransaction() P2SH native-fee customOutputs phase 2 (reveal) emits the fee-destination output funded by phase 1", - "XChainEncoder.createTransaction() UTXO deduplication removes duplicate UTXOs with same txid+vout", - "XChainEncoder.createTransaction() UTXO sorting (largest first) uses the largest UTXO first", - "XChainEncoder.createTransaction() attachPrevTx attaches the full previous transaction when asked", - "XChainEncoder.createTransaction() attachPrevTx leaves segwit inputs witnessUtxo-only by default", - "XChainEncoder.createTransaction() attachPrevTx still attaches nothing when the input is legacy anyway", - "XChainEncoder.createTransaction() change output adds change output when there is leftover and change address given", - "XChainEncoder.createTransaction() change output throws when change address is falsy and change would be burned", - "XChainEncoder.createTransaction() custom dust parameter clamps a caller dust below the output floor up to the floor", - "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", - "XChainEncoder.createTransaction() custom outputs adds custom outputs with correct address and value", - "XChainEncoder.createTransaction() custom outputs skips custom outputs when customOutputs is not an array", - "XChainEncoder.createTransaction() custom outputs skips custom outputs when customOutputs is null", - "XChainEncoder.createTransaction() estimateSpendingP2shTx() increases monotonically with redeem data size", - "XChainEncoder.createTransaction() estimateSpendingP2shTx() returns 10 + P2SH input + OP_RETURN + 8-byte safety margin", - "XChainEncoder.createTransaction() fee handling calls connector.getFeePerKilobyte when feePerKb is null", - "XChainEncoder.createTransaction() fee handling floors fee to dustAmount when computed fee is lower", - "XChainEncoder.createTransaction() fee handling throws RangeError when the custom fee exceeds 100x the fair-fee estimate (burn backstop)", - "XChainEncoder.createTransaction() fee handling uses custom fee when provided", - "XChainEncoder.createTransaction() fee handling uses feePerKb parameter when provided (no RPC call)", - "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", - "XChainEncoder.createTransaction() no UTXOs available resolves a raw pubkey to P2PKH before querying the tracker on a network without segwit", - "XChainEncoder.createTransaction() no UTXOs available throws when utxos is empty and tracker returns nothing", - "XChainEncoder.createTransaction() no UTXOs available throws when utxos is null and tracker returns null", - "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", - "XChainEncoder.createTransaction() payload guards (library boundary) rejects a `data` string that is not well-formed Unicode", - "XChainEncoder.createTransaction() payload guards (library boundary) rejects a rawData code unit above U+00FF instead of truncating it", - "XChainEncoder.createTransaction() payload guards (library boundary) rejects a single minimal-opcode `data` byte", - "XChainEncoder.createTransaction() payload guards (library boundary) rejects a single minimal-opcode `data` byte passed as a Buffer", - "XChainEncoder.createTransaction() payload guards (library boundary) rejects a single minimal-opcode rawData byte", - "XChainEncoder.createTransaction() payload guards (library boundary) rejects a single minimal-opcode rawData byte passed as a Buffer", - "XChainEncoder.createTransaction() payload guards (library boundary) still accepts a Buffer rawData from a library caller", - "XChainEncoder.createTransaction() payload guards (library boundary) still builds a payment-only transaction with no payload at all", - "XChainEncoder.createTransaction() payload guards (library boundary) still builds an ordinary multi-byte data + rawData payload", - "XChainEncoder.createTransaction() payload guards (library boundary) still builds the deliberately-supported rawData-only shape", - "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", - "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", - "XChainEncoder.createTransaction() payment-only still emits the OP_RETURN when an action IS present", - "XChainEncoder.createTransaction() payment-only treats an empty-string action the same as an absent one", - "XChainEncoder.createTransaction() replace-by-fee sequence arms RBF without enabling BIP68 relative locktime", - "XChainEncoder.createTransaction() replace-by-fee sequence sets sequence to 0xfffffffd when rbf=true (RBF armed, BIP68 still disabled)", - "XChainEncoder.createTransaction() replace-by-fee sequence sets sequence to 0xffffffff when rbf=false", - "XChainEncoder.createTransaction() return value returns { psbt, encoding } where psbt is a Psbt instance", - "XChainEncoder.createTransaction() unconfirmed filtering excludes mempool UTXOs when unconfirmed=false", - "XChainEncoder.createTransaction() unconfirmed filtering keeps mempool UTXOs when unconfirmed=true" - ], - "58706b2a54fa0008": [ - "JSON-RPC body guard (Express 5 / body-parser 2.x regression) does not crash WITH the guard (GET -> not 500)", - "JSON-RPC body guard (Express 5 / body-parser 2.x regression) does not crash WITH the guard on an empty POST (-> not 500)", - "JSON-RPC body guard (Express 5 / body-parser 2.x regression) reproduces the crash WITHOUT the guard (bodiless GET -> 500)", - "JSON-RPC body guard (Express 5 / body-parser 2.x regression) src/api.js wires the guard before the jsonRouter mount", - "JSON-RPC body guard (Express 5 / body-parser 2.x regression) still serves a valid JSON-RPC POST WITH the guard" + "4aa31300b417710e": [ + "REG-04: Validator Functions REG-04.9: validateUtxoArray() accepts array at exactly MAX_UTXO_COUNT", + "REG-04: Validator Functions REG-04.9: validateUtxoArray() accepts valid array", + "REG-04: Validator Functions REG-04.9: validateUtxoArray() returns null for null input", + "REG-04: Validator Functions REG-04.9: validateUtxoArray() throws RangeError for array > MAX_UTXO_COUNT entries", + "REG-04: Validator Functions REG-04.9: validateUtxoArray() throws TypeError for non-array" ], - "5ada45b5f0f336ef": [ - "XChainEncoder fee-rate cap accepts a generous but plausible absolute fee (priority/RBF headroom)", - "XChainEncoder fee-rate cap applies the absolute MAX_FEE_RATE_KB cap when it is tighter than the relative cap", - "XChainEncoder fee-rate cap burn backstop anchors on the relayfee when the smart-fee estimate is unavailable (cap disabled)", - "XChainEncoder fee-rate cap burn backstop ceiling is anchored to the node rate, not a caller-inflated feePerKb (cap disabled)", - "XChainEncoder fee-rate cap caps the absolute fee against MAX_FEE_RATE_KB even without a node estimate", - "XChainEncoder fee-rate cap clamps a hostile feePerKb via the relayfee anchor when estimatesmartfee is unavailable", - "XChainEncoder fee-rate cap clamps an over-cap feePerKb to the relative cap instead of draining inputs", - "XChainEncoder fee-rate cap converts a caller sat/kB rate to the same units as the cap and fee formula", - "XChainEncoder fee-rate cap default-path node estimate is never clamped by its own cap", - "XChainEncoder fee-rate cap honors feePerKb only when neither estimatesmartfee nor relayfee is available", - "XChainEncoder fee-rate cap multiplier 0 disables the relative cap, but the fixed 100x burn backstop still applies", - "XChainEncoder fee-rate cap rejects a drain-shaped absolute fee with a RangeError" + "4ccb179cd2d34bce": [ + "Encoder input validator validateDataParam null passes, string passes, non-string throws with field name", + "Encoder input validator validateDataParam rejects a data string that is not well-formed Unicode (utf8 would emit U+FFFD)", + "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" ], - "63c55285b6bac324": [ + "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", + "two-phase reveal package prefund @regression @tier1 carries ancestors the commit's own capped uplift could not pay for", + "two-phase reveal package prefund @regression @tier1 clamps at MAX_CPFP_UPLIFT_SAT and warns that the package stays under target", + "two-phase reveal package prefund @regression @tier1 leaves the reveal its change output rather than paying the uplift out of it", + "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" + ], + "4e788c4ec94c6c63": [ + "Encoder input validator MAX_FEE_SATOSHIS is pinned at 21,000 BTC in satoshis, not 21M BTC" + ], + "4f79a402f81e163a": [ + "reservation release on throw @regression INPUT_SELECTION_RACE: the retry it asks for succeeds inside the TTL", + "reservation release on throw @regression INSUFFICIENT_FUNDS on an under-funded build also releases its claims", + "reservation release on throw @regression a foreign reservation survives another call throwing", + "reservation release on throw @regression a successful build keeps its reservations for the TTL", + "reservation release on throw @regression an outpoint re-reserved by another call after ours lapsed is left alone" + ], + "5086151532f51b80": [ + "XChainEncoder TAPROOT envelope TxSizeEstimator envelope sizing estimateEnvelopeRevealTx covers and does not wildly over-fund a real reveal", + "XChainEncoder TAPROOT envelope TxSizeEstimator envelope sizing estimateTaprootOutput matches the serialized P2TR output size", + "XChainEncoder TAPROOT envelope TxSizeEstimator envelope sizing uses compactSize framing for scripts above 65535 bytes (5-byte prefix band)" + ], + "5140a8874ea32fb0": [ + "Security: global in-flight concurrency cap resolveLimit honours an explicit value and treats <= 0 as disabled", + "Security: global in-flight concurrency cap resolveLimit keeps the caller default when the env var is unset or unparseable" + ], + "51af3196bafc7da3": [ + "encoder FILE payload compression (spec Part B) constants conformance agrees with the SDK helper on every decision (skips without the sdk sibling)", + "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)" + ], + "52004ee9ee184868": [ + "api #create_tx should create a simple tx from api" + ], + "52961e7b06b0e39f": [ + "BlockchainConnector constructor builds the correct URL from host and port", + "BlockchainConnector constructor stores rpcUser and rpcPassword" + ], + "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" + ], + "57b96d6f511dbf43": [ + "Encoder input validator validateEncoding accepts the valid set, null passes, unknown throws" + ], + "58706b2a54fa0008": [ + "JSON-RPC body guard (Express 5 / body-parser 2.x regression) does not crash WITH the guard (GET -> not 500)", + "JSON-RPC body guard (Express 5 / body-parser 2.x regression) does not crash WITH the guard on an empty POST (-> not 500)", + "JSON-RPC body guard (Express 5 / body-parser 2.x regression) reproduces the crash WITHOUT the guard (bodiless GET -> 500)", + "JSON-RPC body guard (Express 5 / body-parser 2.x regression) src/api.js wires the guard before the jsonRouter mount", + "JSON-RPC body guard (Express 5 / body-parser 2.x regression) still serves a valid JSON-RPC POST WITH the guard" + ], + "59ae18cc4c09d4bd": [ + "XChainEncoder.estimateSpendingP2wshTx() does not add an OP_PUSHDATA1 byte in the 76..252 band (len \u2261 1 mod 4)", + "XChainEncoder.estimateSpendingP2wshTx() includes the 8-byte safety margin in the result", + "XChainEncoder.estimateSpendingP2wshTx() increases monotonically with data size", + "XChainEncoder.estimateSpendingP2wshTx() is smaller than estimateSpendingP2shTx for the same data (witness discount)", + "XChainEncoder.estimateSpendingP2wshTx() switches prefix width at 253, not at 76 or 256", + "XChainEncoder.estimateSpendingP2wshTx() uses a 1-byte compactSize prefix for a witness script < 76 bytes", + "XChainEncoder.estimateSpendingP2wshTx() uses a 1-byte compactSize prefix for a witness script in 76..252", + "XChainEncoder.estimateSpendingP2wshTx() uses a 3-byte compactSize prefix for a witness script >= 253 bytes" + ], + "5ad7a5c09e3d72bb": [ + "REG-04: Validator Functions REG-04.8: validateUtxoEntry() preserves existing confirmations", + "REG-04: Validator Functions REG-04.8: validateUtxoEntry() sets confirmations=0 when absent", + "REG-04: Validator Functions REG-04.8: validateUtxoEntry() throws RangeError for negative value", + "REG-04: Validator Functions REG-04.8: validateUtxoEntry() throws TypeError for array", + "REG-04: Validator Functions REG-04.8: validateUtxoEntry() throws TypeError for empty scriptPubKey", + "REG-04: Validator Functions REG-04.8: validateUtxoEntry() throws TypeError for missing scriptPubKey", + "REG-04: Validator Functions REG-04.8: validateUtxoEntry() throws TypeError for negative vout", + "REG-04: Validator Functions REG-04.8: validateUtxoEntry() throws TypeError for non-object", + "REG-04: Validator Functions REG-04.8: validateUtxoEntry() throws TypeError for txid that is not 64-char hex", + "REG-04: Validator Functions REG-04.8: validateUtxoEntry() throws TypeError for txid with non-hex chars", + "REG-04: Validator Functions REG-04.8: validateUtxoEntry() validates a complete valid entry and coerces value/vout" + ], + "5ada45b5f0f336ef": [ + "XChainEncoder fee-rate cap accepts a generous but plausible absolute fee (priority/RBF headroom)", + "XChainEncoder fee-rate cap applies the absolute MAX_FEE_RATE_KB cap when it is tighter than the relative cap", + "XChainEncoder fee-rate cap burn backstop anchors on the relayfee when the smart-fee estimate is unavailable (cap disabled)", + "XChainEncoder fee-rate cap burn backstop ceiling is anchored to the node rate, not a caller-inflated feePerKb (cap disabled)", + "XChainEncoder fee-rate cap caps the absolute fee against MAX_FEE_RATE_KB even without a node estimate", + "XChainEncoder fee-rate cap clamps a hostile feePerKb via the relayfee anchor when estimatesmartfee is unavailable", + "XChainEncoder fee-rate cap clamps an over-cap feePerKb to the relative cap instead of draining inputs", + "XChainEncoder fee-rate cap converts a caller sat/kB rate to the same units as the cap and fee formula", + "XChainEncoder fee-rate cap default-path node estimate is never clamped by its own cap", + "XChainEncoder fee-rate cap honors feePerKb only when neither estimatesmartfee nor relayfee is available", + "XChainEncoder fee-rate cap multiplier 0 disables the relative cap, but the fixed 100x burn backstop still applies", + "XChainEncoder fee-rate cap rejects a drain-shaped absolute fee with a RangeError" + ], + "5c149d2d20812d9a": [ + "Security: global in-flight concurrency cap api.js mounts the JSON-RPC router and /status inside hold()", + "Security: global in-flight concurrency cap bounds the probe reserve too, so /status is not an uncapped bypass", + "Security: global in-flight concurrency cap frees a slot when a request completes, so the next caller is served", + "Security: global in-flight concurrency cap frees a slot when the client aborts mid-request", + "Security: global in-flight concurrency cap is disabled by a cap of 0 (operator escape hatch)", + "Security: global in-flight concurrency cap keeps a held slot while the upstream work runs on after the client aborts", + "Security: global in-flight concurrency cap negative control: with hold() stubbed out, the aborted slot is handed to the next caller", + "Security: global in-flight concurrency cap passes a request through hold() untouched when the gate holds no slot for it", + "Security: global in-flight concurrency cap refuses the (cap+1)th concurrent request with 429, though every request has a distinct IP", + "Security: global in-flight concurrency cap still answers the /status readiness probe while the main gate sheds" + ], + "61c7893f33172ff0": [ + "XChainEncoder.createTransaction() UTXO sorting (largest first) uses the largest UTXO first" + ], + "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", + "singleInstanceGuard releaseLockOnSignals does not force an exit when the re-raise did kill the process", + "singleInstanceGuard releaseLockOnSignals forces exit 130 when a re-raised SIGINT is ignored (PID 1)", + "singleInstanceGuard releaseLockOnSignals forces exit 143 when a re-raised SIGTERM is ignored (PID 1)", + "singleInstanceGuard releaseLockOnSignals leaves OTHER listeners for the same signal registered", + "singleInstanceGuard releaseLockOnSignals re-raises the same signal so the death stays a signalled death", + "singleInstanceGuard releaseLockOnSignals registers a handler for both signals a stop can send", + "singleInstanceGuard releaseLockOnSignals releases the lock on SIGINT too", + "singleInstanceGuard releaseLockOnSignals releases the lock on SIGTERM, which is what `docker stop` sends" + ], + "630b933b3e1320d0": [ + "XChainEncoder.createTransaction() - remaining branch coverage throws RangeError when a UTXO value is not a valid satoshi amount", + "XChainEncoder.createTransaction() - remaining branch coverage throws RangeError when customOutputs[i].value is negative", + "XChainEncoder.createTransaction() - remaining branch coverage throws RangeError when customOutputs[i].value is not a valid satoshi amount", + "XChainEncoder.createTransaction() - remaining branch coverage throws when unconfirmed=false strips all UTXOs (line 325 path)" + ], + "63c55285b6bac324": [ "applyBufferutilsPatch BigInt fee accounting (getFee / getFeeRate / extractTransaction) returns 0 for a zero-fee PSBT with BigInt values instead of re-throwing", "applyBufferutilsPatch BigInt fee accounting (getFee / getFeeRate / extractTransaction) returns a 0 fee rate for a sub-1-sat/vbyte fee instead of re-throwing", "applyBufferutilsPatch BigInt fee accounting (getFee / getFeeRate / extractTransaction) still computes a non-zero fee and fee rate across the BigInt path", @@ -392,18 +815,198 @@ "applyBufferutilsPatch twin guard against the SDK copy is byte-identical to the SDK copy below the banner", "applyBufferutilsPatch varuint encode/decode round-trips across size classes" ], + "64da3d9aebea4a81": [ + "E2E-5: UTXO, Fee, and Change Integration E2E-5.1: Single UTXO covers all 1 input, OP_RETURN + change; change = input - fee" + ], + "64e3f3fd5d6fd7e3": [ + "XChainEncoder #createTransaction should create a simple OP_RETURN" + ], + "65825b2a5099ec3e": [ + "Fuzz: obfuscate() round-trips and preserves length over 2000 random (data, txid) pairs" + ], "65be62f0e174b504": [ "docs/openrpc.json declares an OpenRPC version and info block", "docs/openrpc.json documents the ping health method", "docs/openrpc.json exposes a non-empty, well-formed methods array", "docs/openrpc.json has unique method names" ], + "68346d71243fcd6b": [ + "REG-06: P2SH/P2WSH Two-Transaction Sequence REG-06.1: P2SH tx1 structure tx1 auto-selects P2SH for ISSUE payload", + "REG-06: P2SH/P2WSH Two-Transaction Sequence REG-06.1: P2SH tx1 structure tx1 has P2SH output with value >= dust", + "REG-06: P2SH/P2WSH Two-Transaction Sequence REG-06.1: P2SH tx1 structure tx1 has change output", + "REG-06: P2SH/P2WSH Two-Transaction Sequence REG-06.2: P2SH tx2 structure redeemScript has [data] OP_DROP OP_DUP OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG", + "REG-06: P2SH/P2WSH Two-Transaction Sequence REG-06.2: P2SH tx2 structure tx2 has OP_RETURN marker output (value=0)", + "REG-06: P2SH/P2WSH Two-Transaction Sequence REG-06.2: P2SH tx2 structure tx2 has P2SH input with redeemScript", + "REG-06: P2SH/P2WSH Two-Transaction Sequence REG-06.3: P2SH chaining integrity tx2 OP_RETURN marker deobfuscates to XCHNp2sh using tx1Id", + "REG-06: P2SH/P2WSH Two-Transaction Sequence REG-06.3: P2SH chaining integrity tx2 first input hash references tx1 ID", + "REG-06: P2SH/P2WSH Two-Transaction Sequence REG-06.3: P2SH chaining integrity tx2 input nonWitnessUtxo matches tx1 raw hex", + "REG-06: P2SH/P2WSH Two-Transaction Sequence REG-06.4: P2SH data fidelity redeemScript data chunk decompiles back to original ACTION string", + "REG-06: P2SH/P2WSH Two-Transaction Sequence REG-06.5: P2WSH tx1 structure tx1 creates P2WSH output (OP_0 <32-byte-hash>)", + "REG-06: P2SH/P2WSH Two-Transaction Sequence REG-06.6: P2WSH tx2 structure tx2 OP_RETURN marker deobfuscates to XCHNp2wsh", + "REG-06: P2SH/P2WSH Two-Transaction Sequence REG-06.6: P2WSH tx2 structure tx2 has witnessScript in input", + "REG-06: P2SH/P2WSH Two-Transaction Sequence REG-06.6: P2WSH tx2 structure witnessScript starts with [data] OP_DROP", + "REG-06: P2SH/P2WSH Two-Transaction Sequence REG-06.7: P2WSH data fidelity witnessScript data chunks reassemble to the original ACTION string", + "REG-06: P2SH/P2WSH Two-Transaction Sequence REG-06.8: P2WSH single-chunk stripped-size floor Bitcoin reveal (compiled 476B) clears the tx-size-small floor", + "REG-06: P2SH/P2WSH Two-Transaction Sequence REG-06.8: P2WSH single-chunk stripped-size floor Bitcoin reveal (compiled 75B) clears the tx-size-small floor", + "REG-06: P2SH/P2WSH Two-Transaction Sequence REG-06.8: P2WSH single-chunk stripped-size floor Litecoin reveal (compiled 476B) clears the tx-size-small floor", + "REG-06: P2SH/P2WSH Two-Transaction Sequence REG-06.8: P2WSH single-chunk stripped-size floor Litecoin reveal (compiled 75B) clears the tx-size-small floor" + ], + "68626fb6051d2aba": [ + "XChainEncoder.createTransaction() custom dust parameter clamps a caller dust below the output floor up to the floor", + "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", + "XChainEncoder.createTransaction(): non-segwit UTXO path uses multiple UTXOs when first is insufficient to cover fee" + ], "6a874601dbd714de": [ "XChainEncoder consensus-pin boot guard fails closed: a pin mismatch aborts construction rather than authoring under a drifted bundle", "XChainEncoder consensus-pin boot guard the real check passes for the bundle actually vendored here", "XChainEncoder consensus-pin boot guard verifies the vendored bundle at construction, with the net portion of the network key" ], - "750c1552249846a5": [ + "6b03cb1158d948c7": [ + "Category D: tracker-fetched UTXO above 2^53-1 satoshis reaches the freshness gate, which a sync-less envelope failed open past", + "Category D: tracker-fetched UTXO above 2^53-1 satoshis selects a >2^53-1 tracker record without losing precision", + "Category D: tracker-fetched UTXO above 2^53-1 satoshis serves the producer shape, not a simplified double" + ], + "6c8feaefe1116923": [ + "REG-02: Obfuscation Round-Trip REG-02.1: Basic obfuscate() method deterministic: same data+key always produces same output", + "REG-02: Obfuscation Round-Trip REG-02.1: Basic obfuscate() method key derivation uses first 16 chars as key, next 16 as IV", + "REG-02: Obfuscation Round-Trip REG-02.1: Basic obfuscate() method output differs from plaintext", + "REG-02: Obfuscation Round-Trip REG-02.1: Basic obfuscate() method output length equals input length (CTR mode is length-preserving)", + "REG-02: Obfuscation Round-Trip REG-02.1: Basic obfuscate() method round-trips: obfuscate then deobfuscate recovers original", + "REG-02: Obfuscation Round-Trip REG-02.2: TXID sensitivity same ACTION with different TXIDs produces different ciphertext", + "REG-02: Obfuscation Round-Trip REG-02.2: TXID sensitivity wrong TXID for deobfuscation produces non-XCHN prefix", + "REG-02: Obfuscation Round-Trip REG-02.3: UTXO sort determines obfuscation key input order does not change key derivation", + "REG-02: Obfuscation Round-Trip REG-02.3: UTXO sort determines obfuscation key largest UTXO TXID is always the obfuscation key", + "REG-02: Obfuscation Round-Trip REG-02.4: P2SH/P2WSH marker strings P2SH tx2 OP_RETURN marker deobfuscates to XCHNp2sh", + "REG-02: Obfuscation Round-Trip REG-02.4: P2SH/P2WSH marker strings P2WSH tx2 OP_RETURN marker deobfuscates to XCHNp2wsh" + ], + "6d14427e043988d0": [ + "Encoder input validator alias expansion at the compiled ceiling accepts the CAST payload sitting exactly at the compiled ceiling", + "Encoder input validator alias expansion at the compiled ceiling rejects the CAST payload one byte over the ceiling with a RangeError", + "Encoder input validator alias expansion at the compiled ceiling the accepted at-ceiling CAST is stored as a record past the cap", + "Encoder input validator alias expansion at the compiled ceiling the at-ceiling CAST push measures exactly the cap" + ], + "6e6b6189dd2623bf": [ + "REG-04: Validator Functions REG-04.13: validateChange() accepts non-empty string <= 100 chars", + "REG-04: Validator Functions REG-04.13: validateChange() returns null for null", + "REG-04: Validator Functions REG-04.13: validateChange() throws TypeError for empty string", + "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" + ], + "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", + "P2SH two-phase package prefund @regression @tier1 funds the package rate into the leg when the commit pays under target", + "P2SH two-phase package prefund @regression @tier1 the reveal keeps the package money as fee instead of sweeping it back" + ], + "70440ad9037c909f": [ + "Category D: UTXO & Fee Integration D-1: Single large UTXO covers everything produces 1 input, 2 outputs (OP_RETURN + change)" + ], + "71168814d61ac771": [ + "Encoding Chunk Boundaries: Full Pipeline OP_RETURN auto-select threshold (75/76 chars) 74-char data (compiled=75) \u2192 OP_RETURN (75+4=79 < 80)", + "Encoding Chunk Boundaries: Full Pipeline OP_RETURN auto-select threshold (75/76 chars) 75-char data (compiled=76) \u2192 OP_RETURN (exactly fits 76+4=80)", + "Encoding Chunk Boundaries: Full Pipeline OP_RETURN auto-select threshold (75/76 chars) 76-char data (compiled=78) \u2192 P2SH (78+4=82 > 80)" + ], + "72c9c5f2d2749796": [ + "BlockchainConnector RPC-credential log sanitization does not leak the RPC password when an axios call fails" + ], + "7334b6c4e6a33bbe": [ + "envelope-cancel outpoint reservation @regression a failed retry leaves the outstanding cancel's reservation in place", + "envelope-cancel outpoint reservation @regression a foreign claim taken after the cancel is never dropped by its release", + "envelope-cancel outpoint reservation @regression a live cancel claim keeps create_tx from selecting the commit outpoint", + "envelope-cancel outpoint reservation @regression claims the commit outpoint on a successful cancel build", + "envelope-cancel outpoint reservation @regression clearReservations drops the cancel ownership stamps too", + "envelope-cancel outpoint reservation @regression create_tx still cannot select the commit outpoint after a failed retry", + "envelope-cancel outpoint reservation @regression last cancel owner out frees the outpoint, not the first", + "envelope-cancel outpoint reservation @regression normalizes a mixed-case commitTxid to the lowercase reservation key", + "envelope-cancel outpoint reservation @regression refuses a cancel of an outpoint another build holds", + "envelope-cancel outpoint reservation @regression releases the claim when the build throws", + "envelope-cancel outpoint reservation @regression stays retry-safe: a repeat cancel rebuilds the byte-identical PSBT", + "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" + ], + "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", + "minimal-op canonicalization guards (empty-data shape flag-day scoped) isMinimalOpSingleByte predicate does not flag a single 0x00 byte (compiles to a real 1-byte push)", + "minimal-op canonicalization guards (empty-data shape flag-day scoped) isMinimalOpSingleByte predicate does not flag multi-byte or non-minimal single bytes", + "minimal-op canonicalization guards (empty-data shape flag-day scoped) isMinimalOpSingleByte predicate excludes the empty buffer (the deliberately-supported empty-data shape)", + "minimal-op canonicalization guards (empty-data shape flag-day scoped) isMinimalOpSingleByte predicate flags 1-byte minimal-op values", + "minimal-op canonicalization guards (empty-data shape flag-day scoped) prepareData rebalances a degenerate 1-byte final P2SH/P2WSH chunk P2SH: 1-byte-final chunk with a NON-minimal byte still round-trips", + "minimal-op canonicalization guards (empty-data shape flag-day scoped) prepareData rebalances a degenerate 1-byte final P2SH/P2WSH chunk P2SH: 477-byte payload ending in 0x81 round-trips", + "minimal-op canonicalization guards (empty-data shape flag-day scoped) prepareData rebalances a degenerate 1-byte final P2SH/P2WSH chunk P2SH: 477-byte payload ending in a minimal-op byte (0x05) round-trips", + "minimal-op canonicalization guards (empty-data shape flag-day scoped) prepareData rebalances a degenerate 1-byte final P2SH/P2WSH chunk P2SH: ordinary multi-chunk payload is unaffected", + "minimal-op canonicalization guards (empty-data shape flag-day scoped) prepareData rebalances a degenerate 1-byte final P2SH/P2WSH chunk P2WSH: 1-byte-final chunk with a NON-minimal byte still round-trips", + "minimal-op canonicalization guards (empty-data shape flag-day scoped) prepareData rebalances a degenerate 1-byte final P2SH/P2WSH chunk P2WSH: 477-byte payload ending in 0x81 round-trips", + "minimal-op canonicalization guards (empty-data shape flag-day scoped) prepareData rebalances a degenerate 1-byte final P2SH/P2WSH chunk P2WSH: 477-byte payload ending in a minimal-op byte (0x05) round-trips", + "minimal-op canonicalization guards (empty-data shape flag-day scoped) prepareData rebalances a degenerate 1-byte final P2SH/P2WSH chunk P2WSH: ordinary multi-chunk payload is unaffected", + "minimal-op canonicalization guards (empty-data shape flag-day scoped) validateActionPushDecodability (single-byte minimal-op drop) accepts a 1-byte non-minimal data-only action (0x41)", + "minimal-op canonicalization guards (empty-data shape flag-day scoped) validateActionPushDecodability (single-byte minimal-op drop) accepts a normal multi-byte data-only action", + "minimal-op canonicalization guards (empty-data shape flag-day scoped) validateActionPushDecodability (single-byte minimal-op drop) accepts a normal two-push action + rawData", + "minimal-op canonicalization guards (empty-data shape flag-day scoped) validateActionPushDecodability (single-byte minimal-op drop) rejects a 1-byte minimal-op data-only action (0x05, 0x0f)", + "minimal-op canonicalization guards (empty-data shape flag-day scoped) validateActionPushDecodability (single-byte minimal-op drop) rejects a 1-byte minimal-op rawData push (0x03 and 0x81 via Latin-1)", + "minimal-op canonicalization guards (empty-data shape flag-day scoped) validateAll wires the guard rejects a 1-byte minimal-op data through validateAll with a RangeError", + "minimal-op canonicalization guards (empty-data shape flag-day scoped) validateAll wires the guard still accepts rawData-only through validateAll" + ], + "76765d0ff666ac82": [ + "E2E-5: UTXO, Fee, and Change Integration E2E-5.2: Multiple UTXOs aggregated adds UTXOs largest-first until sufficient" + ], + "779e7e9a758ea87d": [ + "XChainEncoder.createTransaction() replace-by-fee sequence arms RBF without enabling BIP68 relative locktime", + "XChainEncoder.createTransaction() replace-by-fee sequence sets sequence to 0xfffffffd when rbf=true (RBF armed, BIP68 still disabled)", + "XChainEncoder.createTransaction() replace-by-fee sequence sets sequence to 0xffffffff when rbf=false" + ], + "78e833e8f04d5380": [ + "CORS is mounted above the API-key gate @regression answers a preflight instead of 401ing it, on a keyed deploy", + "CORS is mounted above the API-key gate @regression emits CORS headers on the 401 the gate returns", + "CORS is mounted above the API-key gate @regression lets a correctly keyed request past the gate", + "CORS is mounted above the API-key gate @regression still rejects a wrong key", + "a CORS-disabled keyed deploy is unchanged by the mount position @regression grants no origin on a keyed request either", + "a CORS-disabled keyed deploy is unchanged by the mount position @regression still 401s an unauthenticated preflight and grants no origin" + ], + "78ea596173e06da0": [ + "XChainEncoder.createTransaction() P2SH encoding path (tx2: spending) creates tx2 with P2SH input and OP_RETURN marker" + ], + "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" + ], + "7fabeeeb5816e16e": [ + "BlockchainConnector.getTransactionHex() includes the txid in the \"not found\" error message", + "BlockchainConnector.getTransactionHex() passes hexFormat=false when requested", + "BlockchainConnector.getTransactionHex() rethrows transport errors directly", + "BlockchainConnector.getTransactionHex() returns the hex string on success", + "BlockchainConnector.getTransactionHex() sends auth credentials", + "BlockchainConnector.getTransactionHex() sends getrawtransaction with correct txid and hexFormat=true", + "BlockchainConnector.getTransactionHex() throws \"not found\" message when error.code is -5", + "BlockchainConnector.getTransactionHex() throws generic error when result is missing and error is not -5" + ], + "7fbd6025650718a6": [ + "Security: obfuscation key binding actually transforms the payload (not emitted in the clear)", + "Security: obfuscation key binding binds the ciphertext to the TXID: a different TXID does not recover it", + "Security: obfuscation key binding fails closed on a too-short key (invalid key/IV length)", + "Security: obfuscation key binding fails closed on an empty key", + "Security: obfuscation key binding produces distinct ciphertext for the same data under different TXIDs" + ], + "807a63a77985ea07": [ "singleInstanceGuard acquireInstanceLock [REGRESSION] breaks a legacy bare-pid lock whose holder is npm's shell wrapper", "singleInstanceGuard acquireInstanceLock [REGRESSION] breaks a legacy bare-pid lock whose holder is the npm parent", "singleInstanceGuard acquireInstanceLock [REGRESSION] breaks a lock whose live pid has been REUSED by another process", @@ -427,96 +1030,67 @@ "singleInstanceGuard acquireInstanceLock release does not delete a lock that has changed hands", "singleInstanceGuard acquireInstanceLock still refuses when the live pid IS the process that took the lock", "singleInstanceGuard acquireInstanceLock takes the lock, writes our pid, and release removes the file", - "singleInstanceGuard acquireInstanceLock waits out an illegible lock instead of breaking it on sight", - "singleInstanceGuard assertSingleInstance names every in-process store in the replica refusal", - "singleInstanceGuard assertSingleInstance passes when ENCODER_REPLICAS is empty", - "singleInstanceGuard assertSingleInstance passes when ENCODER_REPLICAS is unset", - "singleInstanceGuard assertSingleInstance passes when ENCODER_REPLICAS=1", - "singleInstanceGuard assertSingleInstance throws on non-integer values", - "singleInstanceGuard assertSingleInstance throws when ENCODER_REPLICAS > 1", - "singleInstanceGuard isPidAlive reports our own pid alive", - "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", - "singleInstanceGuard releaseLockOnSignals does not force an exit when the re-raise did kill the process", - "singleInstanceGuard releaseLockOnSignals forces exit 130 when a re-raised SIGINT is ignored (PID 1)", - "singleInstanceGuard releaseLockOnSignals forces exit 143 when a re-raised SIGTERM is ignored (PID 1)", - "singleInstanceGuard releaseLockOnSignals leaves OTHER listeners for the same signal registered", - "singleInstanceGuard releaseLockOnSignals re-raises the same signal so the death stays a signalled death", - "singleInstanceGuard releaseLockOnSignals registers a handler for both signals a stop can send", - "singleInstanceGuard releaseLockOnSignals releases the lock on SIGINT too", - "singleInstanceGuard releaseLockOnSignals releases the lock on SIGTERM, which is what `docker stop` sends" - ], - "78e833e8f04d5380": [ - "CORS is mounted above the API-key gate @regression answers a preflight instead of 401ing it, on a keyed deploy", - "CORS is mounted above the API-key gate @regression emits CORS headers on the 401 the gate returns", - "CORS is mounted above the API-key gate @regression lets a correctly keyed request past the gate", - "CORS is mounted above the API-key gate @regression still rejects a wrong key", - "a CORS-disabled keyed deploy is unchanged by the mount position @regression grants no origin on a keyed request either", - "a CORS-disabled keyed deploy is unchanged by the mount position @regression still 401s an unauthenticated preflight and grants no origin" + "singleInstanceGuard acquireInstanceLock waits out an illegible lock instead of breaking it on sight" ], - "85489f2e3279585d": [ - "Encoder input validator MAX_FEE_SATOSHIS is pinned at 21,000 BTC in satoshis, not 21M BTC", - "Encoder input validator alias expansion at the compiled ceiling accepts the CAST payload sitting exactly at the compiled ceiling", - "Encoder input validator alias expansion at the compiled ceiling rejects the CAST payload one byte over the ceiling with a RangeError", - "Encoder input validator alias expansion at the compiled ceiling the accepted at-ceiling CAST is stored as a record past the cap", - "Encoder input validator alias expansion at the compiled ceiling the at-ceiling CAST push measures exactly the cap", - "Encoder input validator validateActionName (encoder ACTION-name gate) accepts a bare canonical name with no pipe-delimited payload", - "Encoder input validator validateActionName (encoder ACTION-name gate) accepts every canonical ACTION name from VALID_ACTION_NAMES", - "Encoder input validator validateActionName (encoder ACTION-name gate) accepts every known alias and expands it the same way the decoder does", - "Encoder input validator validateActionName (encoder ACTION-name gate) does not reject the intentionally-supported empty/absent data shapes", - "Encoder input validator validateActionName (encoder ACTION-name gate) is case-sensitive: a lowercase canonical name is not recognized", - "Encoder input validator validateActionName (encoder ACTION-name gate) is wired into validateAll and fires before feeQuote/utxo checks", - "Encoder input validator validateActionName (encoder ACTION-name gate) rejects a typoed ACTION name with a RangeError", - "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", - "Encoder input validator validateAddress accepts a valid string (incl. exactly 100 chars); rejects empty, non-string, and over-length", - "Encoder input validator validateAll exercises validateFeeQuote validation paths through validateAll", - "Encoder input validator validateAll rbf/unconfirmed absent or null stay undefined so downstream defaults apply", - "Encoder input validator validateAll rejects an absent or null pubkey (openrpc marks it required)", - "Encoder input validator validateAll rejects non-boolean rbf/unconfirmed instead of truthiness-coercing (string \"false\" must not flip policy to true)", - "Encoder input validator validateAll returns a fully-coerced, normalized param set", - "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", - "Encoder input validator validateChange null passes; rejects empty and over-long", - "Encoder input validator validateCombinedDataLength accepts a single-push payload at the compiled ceiling (backwards-compatible)", - "Encoder input validator validateCombinedDataLength accepts an explicit OP_RETURN payload at/under the 76-byte ceiling", - "Encoder input validator validateCombinedDataLength accounts for per-push overhead on dual-push payloads at the boundary", - "Encoder input validator validateCombinedDataLength applies the envelope ceiling to AUTO, like TAPROOT", - "Encoder input validator validateCombinedDataLength counts data + rawData and throws over the limit", - "Encoder input validator validateCombinedDataLength counts the OP_PUSHDATA4 band on a push past 65,535 bytes", - "Encoder input validator validateCombinedDataLength does NOT apply the OP_RETURN ceiling when encoding is omitted (P2SH auto-fallback)", - "Encoder input validator validateCombinedDataLength leaves the OP_PUSHDATA2 band alone at its upper edge", - "Encoder input validator validateCombinedDataLength measures rawData-only payloads (data defaults to an OP_0 push)", - "Encoder input validator validateCombinedDataLength rejects an explicit OP_RETURN payload above the 76-byte ceiling", - "Encoder input validator validateCombinedDataLength rejects dual-push payloads whose compiled size exceeds the ceiling", - "Encoder input validator validateCombinedDataLength rejects the documented dual-push undercount case (raw sum within old limit)", - "Encoder input validator validateCombinedDataLength returns early for null data and accepts within-limit payloads", - "Encoder input validator validateCompressedPubKey null passes; enforces the 02/03 + 64-hex shape", - "Encoder input validator validateCustomOutputs (+ validateCustomOutput via wrapper) coerces value and rejects malformed entries", - "Encoder input validator validateCustomOutputs (+ validateCustomOutput via wrapper) null passes; non-array and over-cap throw", - "Encoder input validator validateDataParam null passes, string passes, non-string throws with field name", - "Encoder input validator validateDataParam rejects a data string that is not well-formed Unicode (utf8 would emit U+FFFD)", - "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", - "Encoder input validator validateDust null/false pass; coerces; rejects NaN and negative", - "Encoder input validator validateEncoding accepts the valid set, null passes, unknown throws", - "Encoder input validator validateFee null/false pass; coerces; rejects NaN, negative, over-max", - "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", - "Encoder input validator validateP2shParams both-omitted yields nulls; mismatch throws", - "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", - "Encoder input validator validatePubkey passes null/valid through, throws on bad input", + "8125cb09f89c6f70": [ "Encoder input validator validateRawTxHex accepts hex between the p2shHex cap and the broadcast cap (an envelope reveal)", "Encoder input validator validateRawTxHex accepts well-formed hex and returns it", "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", - "Encoder input validator validateUtxoArray / validateUtxoEntry coerces numeric-string confirmations and rejects untyped values", - "Encoder input validator validateUtxoArray / validateUtxoEntry coerces vout/value and defaults confirmations", - "Encoder input validator validateUtxoArray / validateUtxoEntry null passes; non-array and over-cap throw", - "Encoder input validator validateUtxoArray / validateUtxoEntry preserves an explicit confirmations value", - "Encoder input validator validateUtxoArray / validateUtxoEntry rejects each malformed field", - "Encoder input validator validateUtxoArray / validateUtxoEntry rejects vout values bare Number() would coerce to a plausible index (uuid:4555d78c)" + "Encoder input validator validateRawTxHex rejects non-strings, empty, odd-length and non-hex input" + ], + "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)" + ], + "82be72919ffb1b6d": [ + "REG-31: a shortfall caused by reserved inputs names them @regression a genuinely under-funded address reports zero reserved candidates and no hold wording", + "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" + ], + "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", + "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.23: FILE small (P2SH) file metadata + content encoded as P2SH", + "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.24: FILE large (P2WSH) large file encoded as P2WSH when forced", + "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.25: ADDRESS require-memo flag preserved", + "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.26: LINK action indices preserved", + "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" + ], + "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" + ], + "883e1aa41a43b1c9": [ + "E2E-4: Obfuscation Integrity E2E-4.1: OP_RETURN obfuscation round-trip deobfuscated OP_RETURN data has XCHN prefix and original ACTION", + "E2E-4: Obfuscation Integrity E2E-4.2: MULTISIGN obfuscation round-trip fake pubkey data deobfuscates to original payload", + "E2E-4: Obfuscation Integrity E2E-4.3: P2SH tx2 marker obfuscation round-trip tx2 OP_RETURN marker deobfuscates with tx1 ID to XCHNp2sh", + "E2E-4: Obfuscation Integrity E2E-4.4: TXID sensitivity same ACTION with different TXIDs produces different ciphertext", + "E2E-4: Obfuscation Integrity E2E-4.5: UTXO sorting preserves obfuscation key key derives from largest-value UTXO regardless of input order", + "E2E-4: Obfuscation Integrity E2E-4.6: Wrong TXID detection (negative test) deobfuscation with wrong TXID does not produce XCHN magic", + "E2E-4: Obfuscation Integrity E2E-4.7: Oversized OP_RETURN rejected forced OP_RETURN beyond a single output is rejected" + ], + "8b96a6f9f68661f9": [ + "XChainEncoder package-aware fee sizing @regression @tier1 MAX_CPFP_UPLIFT_SAT=0 turns package sizing off entirely", + "XChainEncoder package-aware fee sizing @regression @tier1 clamps the uplift at MAX_CPFP_UPLIFT_SAT and warns that the package stays under target", + "XChainEncoder package-aware fee sizing @regression @tier1 clamps the uplift to the fee-rate cap rather than blowing through it", + "XChainEncoder package-aware fee sizing @regression @tier1 counts a shared ancestor once by handing the node every unconfirmed input txid", + "XChainEncoder package-aware fee sizing @regression @tier1 degrades when the connector has no package method at all", + "XChainEncoder package-aware fee sizing @regression @tier1 does not lower the fee when the ancestors already pay well above the target", + "XChainEncoder package-aware fee sizing @regression @tier1 falls back to the per-transaction fee when the ancestor lookup fails", + "XChainEncoder package-aware fee sizing @regression @tier1 leaves the fee untouched when every input is confirmed, and never asks the node", + "XChainEncoder package-aware fee sizing @regression @tier1 lifts the fee so a cheap ancestor package clears the target rate", + "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" ], "8c8c02bad5e89e5b": [ "errorSanitize.upstreamErrorMessage collapses a credentialed RPC URL that arrives with no transport code", @@ -529,6 +1103,25 @@ "errorSanitize.upstreamErrorMessage isTransportError matches network errnos but not application errors", "errorSanitize.upstreamErrorMessage still forwards node reasons the wallet classifies on" ], + "8f0e3d8435a01701": [ + "Category D: UTXO & Fee Integration D-7: Fee capped by maxFeePerBytes limits fee when maxFeeRateKb is set" + ], + "8f324f20d5abfee5": [ + "XChainEncoder.createTransaction() - change edge cases does not add change output when changeSatoshis is 0", + "XChainEncoder.createTransaction() - change edge cases does not throw when changeSatoshis <= dustAmount and no change address" + ], + "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", + "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.16: ORDER BUY all DEX fields preserved", + "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.17: ORDER SELL SELL type preserved", + "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.18: COINPAY order match index preserved", + "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.19: DISPENSER all dispenser params preserved", + "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.20: SWAP cross-chain identifiers preserved" + ], + "9235b63db069724c": [ + "E2E-5: UTXO, Fee, and Change Integration E2E-5.5: Unconfirmed UTXOs included uses mempool UTXOs when unconfirmed=true" + ], "923b72fe3989bb3c": [ "XChainEncoder reservation release a claim another call has since re-taken is retained, never dropped", "XChainEncoder reservation release a foreign ticket id releases nothing and leaves the claims standing", @@ -544,6 +1137,26 @@ "release_inputs JSON-RPC method maps a malformed or missing reservationId to invalid params", "release_inputs JSON-RPC method releases the claims the presented ticket owns" ], + "926d15e7ddd15fee": [ + "uppercase-hex txid / obfuscation-key binding @regression MULTISIGN: same, on the bare-multisig payload path", + "uppercase-hex txid / obfuscation-key binding @regression OP_RETURN: an uppercase caller txid builds and decodes under the ins[0] key", + "uppercase-hex txid / obfuscation-key binding @regression validateUtxoEntry canonicalizes txid case at ingest" + ], + "9716e0ba5467b267": [ + "REG-18: chained sends reserve caller-supplied inputs @regression a different transaction over an unreserved input is not mistaken for a duplicate", + "REG-18: chained sends reserve caller-supplied inputs @regression a stale view that still lists the spent input alongside the change selects the change", + "REG-18: chained sends reserve caller-supplied inputs @regression an RBF bump of the same input is a different transaction and passes the duplicate gate", + "REG-18: chained sends reserve caller-supplied inputs @regression an identical rebuild is refused as DUPLICATE_TRANSACTION when the outpoint map was bypassed", + "REG-18: chained sends reserve caller-supplied inputs @regression clearReservations releases both the outpoint map and the recent-build record", + "REG-18: chained sends reserve caller-supplied inputs @regression recent-build records expire with the reservation TTL", + "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", @@ -551,6 +1164,17 @@ "XChainEncoder reveal: p2shHash must be the id of p2shHex still builds a P2SH reveal when p2shHash is the funding tx id", "XChainEncoder reveal: p2shHash must be the id of p2shHex still builds a P2WSH reveal when p2shHash is the funding tx id" ], + "995c5c346314f418": [ + "BlockchainConnector.isRegtest() keeps the node RPC code and message from an HTTP-200 error body", + "BlockchainConnector.isRegtest() keeps the node RPC code and message from an HTTP-500 error body", + "BlockchainConnector.isRegtest() returns false when chain is \"main\"", + "BlockchainConnector.isRegtest() returns false when chain is \"test\"", + "BlockchainConnector.isRegtest() returns true when chain is \"regtest\"", + "BlockchainConnector.isRegtest() sends getblockchaininfo method", + "BlockchainConnector.isRegtest() throws when result has no chain property", + "BlockchainConnector.isRegtest() throws when result is missing", + "BlockchainConnector.isRegtest() wraps transport errors" + ], "995c9801f2581aab": [ "openrpc.json method coverage create_tx spec params === the params validateAll reads", "openrpc.json method coverage docs/openrpc.json is exactly what docs/openrpc.build.js emits", @@ -605,6 +1229,75 @@ "health(): tracker_synced is serve-readiness (create_tx parity) @regression reports tracker_halted false for a running tracker", "health(): tracker_synced is serve-readiness (create_tx parity) @regression tracker verdict synced:false stays not synced regardless of lag" ], + "9ad3745dcc03e270": [ + "singleInstanceGuard isPidAlive reports our own pid alive" + ], + "9adb839d3209d4b9": [ + "Category A: ACTION Payload Encoding Fidelity A-10: DISPENSER action dispenser params preserved", + "Category A: ACTION Payload Encoding Fidelity A-11: TICK with special characters special chars in TICK name survive encoding", + "Category A: ACTION Payload Encoding Fidelity A-12: TICK by ID reference caret prefix for TICK_ID preserved", + "Category A: ACTION Payload Encoding Fidelity A-13: Maximum OP_RETURN boundary ACTION of exactly 72 bytes fits in single OP_RETURN", + "Category A: ACTION Payload Encoding Fidelity A-14: One byte over OP_RETURN boundary ACTION exceeding 76 compiled bytes auto-selects P2SH", + "Category A: ACTION Payload Encoding Fidelity A-1: Minimal SEND (fits OP_RETURN) SEND|0|JDOG|1| survives encoding round-trip", + "Category A: ACTION Payload Encoding Fidelity A-2: SEND with memo memo field is preserved through encoding", + "Category A: ACTION Payload Encoding Fidelity A-3: Multi-send version 1 multiple AMOUNT|DESTINATION pairs survive", + "Category A: ACTION Payload Encoding Fidelity A-4: Full ISSUE with all fields (P2SH) encodes as P2SH and preserves all 25+ fields", + "Category A: ACTION Payload Encoding Fidelity A-5: Short ISSUE (fits OP_RETURN) ISSUE|0|X auto-selects OP_RETURN", + "Category A: ACTION Payload Encoding Fidelity A-6: BATCH with multiple commands semicolon-separated commands preserved", + "Category A: ACTION Payload Encoding Fidelity A-9: ORDER action DEX order fields preserved", + "Category A: ACTION Payload Encoding Fidelity All ACTION types: basic OP_RETURN encoding ADDRESS action string survives encoding round-trip", + "Category A: ACTION Payload Encoding Fidelity All ACTION types: basic OP_RETURN encoding AIRDROP action string survives encoding round-trip", + "Category A: ACTION Payload Encoding Fidelity All ACTION types: basic OP_RETURN encoding BROADCAST action string survives encoding round-trip", + "Category A: ACTION Payload Encoding Fidelity All ACTION types: basic OP_RETURN encoding CALLBACK action string survives encoding round-trip", + "Category A: ACTION Payload Encoding Fidelity All ACTION types: basic OP_RETURN encoding COINPAY action string survives encoding round-trip", + "Category A: ACTION Payload Encoding Fidelity All ACTION types: basic OP_RETURN encoding DESTROY action string survives encoding round-trip", + "Category A: ACTION Payload Encoding Fidelity All ACTION types: basic OP_RETURN encoding DISPENSER action string survives encoding round-trip", + "Category A: ACTION Payload Encoding Fidelity All ACTION types: basic OP_RETURN encoding DIVIDEND action string survives encoding round-trip", + "Category A: ACTION Payload Encoding Fidelity All ACTION types: basic OP_RETURN encoding ISSUE (minimal) action string survives encoding round-trip", + "Category A: ACTION Payload Encoding Fidelity All ACTION types: basic OP_RETURN encoding ISSUE v1 action string survives encoding round-trip", + "Category A: ACTION Payload Encoding Fidelity All ACTION types: basic OP_RETURN encoding LINK action string survives encoding round-trip", + "Category A: ACTION Payload Encoding Fidelity All ACTION types: basic OP_RETURN encoding LIST action string survives encoding round-trip", + "Category A: ACTION Payload Encoding Fidelity All ACTION types: basic OP_RETURN encoding MESSAGE action string survives encoding round-trip", + "Category A: ACTION Payload Encoding Fidelity All ACTION types: basic OP_RETURN encoding MINT action string survives encoding round-trip", + "Category A: ACTION Payload Encoding Fidelity All ACTION types: basic OP_RETURN encoding ORDER action string survives encoding round-trip", + "Category A: ACTION Payload Encoding Fidelity All ACTION types: basic OP_RETURN encoding SEND action string survives encoding round-trip", + "Category A: ACTION Payload Encoding Fidelity All ACTION types: basic OP_RETURN encoding SEND v1 action string survives encoding round-trip", + "Category A: ACTION Payload Encoding Fidelity All ACTION types: basic OP_RETURN encoding SLEEP action string survives encoding round-trip", + "Category A: ACTION Payload Encoding Fidelity All ACTION types: basic OP_RETURN encoding SWAP action string survives encoding round-trip", + "Category A: ACTION Payload Encoding Fidelity All ACTION types: basic OP_RETURN encoding SWEEP action string survives encoding round-trip", + "Category A: ACTION Payload Encoding Fidelity data + rawData dual parameter both data and rawData are preserved in encoding" + ], + "9ca9d71201e69f83": [ + "E2E-5: UTXO, Fee, and Change Integration E2E-5.9: No change address error throws when change address missing and surplus > dust" + ], + "9ec1bc0709d49362": [ + "encoder stress-sweep @regression E1: obfuscation key binds to the ACTUAL first input after a reservation skip OP_RETURN action decodes with ins[0].txid when the largest utxo was reserved", + "encoder stress-sweep @regression E1: obfuscation key binds to the ACTUAL first input after a reservation skip caller-supplied UTXOs are unaffected: key binds to utxos[0] as before" + ], + "9f193c2caff73160": [ + "XChainEncoder concurrent input selection two builds racing for the same outpoints: one succeeds with both, one is refused" + ], + "9f36f327b577990e": [ + "REG-08: API Contract Regression REG-08.1: validateAll \u2192 createTransaction parameter mapping fee=null flows through as null (auto-fee mode)", + "REG-08: API Contract Regression REG-08.1: validateAll \u2192 createTransaction parameter mapping feePerKb bypasses RPC call", + "REG-08: API Contract Regression REG-08.1: validateAll \u2192 createTransaction parameter mapping utxos passed as array are forwarded correctly", + "REG-08: API Contract Regression REG-08.1: validateAll \u2192 createTransaction parameter mapping valid params flow through without error", + "REG-08: API Contract Regression REG-08.2: Validation errors (code -32602 pattern) data > 65536 bytes produces RangeError", + "REG-08: API Contract Regression REG-08.2: Validation errors (code -32602 pattern) invalid encoding value produces TypeError", + "REG-08: API Contract Regression REG-08.2: Validation errors (code -32602 pattern) negative fee produces RangeError", + "REG-08: API Contract Regression REG-08.2: Validation errors (code -32602 pattern) null params produces TypeError", + "REG-08: API Contract Regression REG-08.3: Encoder errors (code -32603 pattern) no UTXOs error is a plain Error", + "REG-08: API Contract Regression REG-08.4: PSBT serialization encoding is one of the four valid encoding strings", + "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" + ], + "a58c0275055cc988": [ + "Encoder input validator validateChange null passes; rejects empty and over-long" + ], + "a5c9e585e49afb1a": [ + "Category D: UTXO & Fee Integration Replace-by-fee sequence sets sequence to 0xfffffffd when rbf=true (RBF armed, BIP68 disabled)", + "Category D: UTXO & Fee Integration Replace-by-fee sequence sets sequence to 0xffffffff when rbf=false" + ], "a6d0a7d54f8fdf52": [ "health()/GET /status carry the window without bending readiness @regression a window does not make a lagging tracker read synced", "health()/GET /status carry the window without bending readiness @regression an expired sentinel reads as no maintenance", @@ -646,6 +1339,42 @@ "suggested fee-rate ceiling @regression @tier1 treats a ceiling equal to the node rate as NOT a breach", "suggested fee-rate ceiling @regression @tier1 treats an unknown or empty network as mainnet, never as a test chain" ], + "a7ccb078a2beb124": [ + "Encoder input validator validateCustomOutputs (+ validateCustomOutput via wrapper) coerces value and rejects malformed entries", + "Encoder input validator validateCustomOutputs (+ validateCustomOutput via wrapper) null passes; non-array and over-cap throw" + ], + "a8137cddea506ba3": [ + "paginated utxo fetch is one chain-state snapshot @regression leaves the single-page path untouched", + "paginated utxo fetch is one chain-state snapshot @regression merges pages that share one snapshot", + "paginated utxo fetch is one chain-state snapshot @regression refuses to merge pages read at different tracker heights", + "paginated utxo fetch is one chain-state snapshot @regression refuses when a later page reports the tracker halted", + "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 5460 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" + ], + "ab3024540b6f92b9": [ + "Category D: UTXO & Fee Integration D-8: Dust floor on fee floors fee to dustAmount when computed fee is lower" + ], "ab75df16ddef33d7": [ "encoder crash handlers a broken logger cannot swallow the exit", "encoder crash handlers a non-Error rejection reason still yields a readable record", @@ -653,6 +1382,36 @@ "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" ], + "b08835d106268b2a": [ + "UTXO Value Boundaries UTXO sorting with mixed values largest UTXO is always used as first input (txidFirstInput)", + "UTXO Value Boundaries UTXO with large value full-precision string value above MAX_SAFE_INTEGER builds exact BigInt change (DOGE consolidation)", + "UTXO Value Boundaries UTXO with large value value = 2.1 trillion sats (21M BTC max supply) works correctly", + "UTXO Value Boundaries UTXO with large value value = MAX_SAFE_INTEGER builds exact change above the library Satoshi type cap", + "UTXO Value Boundaries all-duplicate UTXOs 3 identical UTXOs dedup to 1; if sufficient, single input", + "UTXO Value Boundaries all-duplicate UTXOs 3 identical small UTXOs dedup to 1; insufficient throws INSUFFICIENT_FUNDS", + "UTXO Value Boundaries minimum value UTXOs 1-sat UTXO: throws INSUFFICIENT_FUNDS (cannot cover fee)", + "UTXO Value Boundaries minimum value UTXOs value=0 UTXO: parseInt(0)=0, contributes nothing to inputSatoshis", + "UTXO Value Boundaries string-typed UTXO values (parseInt coercion) \"1.9\" string value is rejected, not truncated to 1", + "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" + ], + "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", + "TxSizeEstimator Boundaries estimateInputSize with missing UTXO data fallback contributes to fee estimation instead of being silently ignored", + "TxSizeEstimator Boundaries estimateInputSize with missing UTXO data returns 350 (conservative fallback) for UTXO with neither witnessUtxo nor nonWitnessUtxo", + "TxSizeEstimator Boundaries estimateInputSize with out-of-bounds vout index returns 180 (P2PKH fallback) when output at vout does not exist", + "TxSizeEstimator Boundaries estimateOpReturnOutput framing boundaries charges a 3-byte script-length varint once the script reaches 253 bytes", + "TxSizeEstimator Boundaries estimateOpReturnOutput framing boundaries is exact for a 100-byte payload (single-byte varint, PUSHDATA1 push)", + "TxSizeEstimator Boundaries estimateOpReturnOutput framing boundaries matches the compiled script at the 75/76 OP_PUSHDATA1 inflection", + "TxSizeEstimator Boundaries estimateOpReturnOutput framing boundaries stays exact just past the varint inflection (253-byte data)", + "TxSizeEstimator Boundaries estimateP2shInputWithRedeem returns 629 for 476-byte redeem script (max P2SH chunk)", + "TxSizeEstimator Boundaries estimateP2shInputWithRedeem returns overhead (149) for 0-byte redeem script" + ], + "b32e58f0542ec761": [ + "Category D: UTXO & Fee Integration D-10: Legacy (non-segwit) UTXO handling fetches raw tx hex for nonWitnessUtxo via connector", + "Category D: UTXO & Fee Integration D-10: Legacy (non-segwit) UTXO handling segwit UTXOs do NOT call getTransactionHex" + ], "b35bd4199facec7a": [ "XChainEncoder.isSegwitUTXO() returns false for P2PKH", "XChainEncoder.isSegwitUTXO() returns false for P2SH", @@ -664,10 +1423,158 @@ "XChainEncoder.isSegwitUTXO() returns true for P2WPKH (version 0, 20-byte hash)", "XChainEncoder.isSegwitUTXO() returns true for P2WSH (version 0, 32-byte hash)" ], - "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" + "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", + "XChainEncoder.createTransaction(): P2WSH tx2 (spending) throws RangeError when p2shHex has no output at the expected voutPsbtIndex (P2SH)" + ], + "b4c7bf0a08619cca": [ + "Encoder input validator validateAll exercises validateFeeQuote validation paths through validateAll", + "Encoder input validator validateAll rbf/unconfirmed absent or null stay undefined so downstream defaults apply", + "Encoder input validator validateAll rejects an absent or null pubkey (openrpc marks it required)", + "Encoder input validator validateAll rejects non-boolean rbf/unconfirmed instead of truthiness-coercing (string \"false\" must not flip policy to true)", + "Encoder input validator validateAll returns a fully-coerced, normalized param set", + "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" + ], + "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", + "Change Address Boundaries changeSatoshis = 1 (minimum positive change) does not throw with null change when changeSatoshis = 1 (1 <= dustAmount)", + "Change Address Boundaries changeSatoshis = 1 (minimum positive change) sub-dust change (value=1) is folded into fee, not emitted, even with a change address", + "Change Address Boundaries dust threshold boundary (BTC: 546) changeSatoshis = 546 (= dustAmount): no throw with null change", + "Change Address Boundaries dust threshold boundary (BTC: 546) changeSatoshis = 547 (dustAmount + 1): throws with null change", + "Change Address Boundaries dust threshold boundary (BTC: 546) changeSatoshis = 547 with change address: creates change output", + "Change Address Boundaries dust threshold boundary (LTC: 5460) changeSatoshis = 5460: no throw with null change", + "Change Address Boundaries dust threshold boundary (LTC: 5460) changeSatoshis = 5461: throws with null change", + "Change Address Boundaries negative changeSatoshis throws INSUFFICIENT_FUNDS when fee exceeds input (change address present)", + "Change Address Boundaries negative changeSatoshis throws INSUFFICIENT_FUNDS with a null change address too" + ], + "b70b109181c9f1fc": [ + "BlockchainConnector.sendRawTransaction() maxfeerate retry does NOT retry on regtest for non-fee-cap errors", + "BlockchainConnector.sendRawTransaction() maxfeerate retry does NOT retry on the fee-cap error when chain is not regtest", + "BlockchainConnector.sendRawTransaction() maxfeerate retry matches the bare \"maxfeerate\" wording variant", + "BlockchainConnector.sendRawTransaction() maxfeerate retry propagates the original fee-cap error when the chain check itself fails", + "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" + ], + "b7598b5920b35f20": [ + "Encoder input validator validateAddress accepts a valid string (incl. exactly 100 chars); rejects empty, non-string, and over-length" + ], + "b77f94d358245fae": [ + "XChainEncoder.createTransaction() fee handling calls connector.getFeePerKilobyte when feePerKb is null", + "XChainEncoder.createTransaction() fee handling floors fee to dustAmount when computed fee is lower", + "XChainEncoder.createTransaction() fee handling throws RangeError when the custom fee exceeds 100x the fair-fee estimate (burn backstop)", + "XChainEncoder.createTransaction() fee handling uses custom fee when provided", + "XChainEncoder.createTransaction() fee handling uses feePerKb parameter when provided (no RPC call)" + ], + "b9beb9ad608568f2": [ + "REG-04: Validator Functions REG-04.10: validateCustomOutputs() returns null for null input", + "REG-04: Validator Functions REG-04.10: validateCustomOutputs() throws RangeError for array > MAX_CUSTOM_OUTPUTS", + "REG-04: Validator Functions REG-04.10: validateCustomOutputs() throws RangeError for negative value", + "REG-04: Validator Functions REG-04.10: validateCustomOutputs() throws TypeError for address > 100 chars", + "REG-04: Validator Functions REG-04.10: validateCustomOutputs() throws TypeError for empty address string", + "REG-04: Validator Functions REG-04.10: validateCustomOutputs() throws TypeError for non-array", + "REG-04: Validator Functions REG-04.10: validateCustomOutputs() validates address and value fields" + ], + "ba5e3c6a15d7666d": [ + "Category B: Encoding Type Integration B-1: OP_RETURN script structure contains obfuscated XCHN-prefixed data", + "Category B: Encoding Type Integration B-1: OP_RETURN script structure output has value=0 and script starts with OP_RETURN (0x6a)", + "Category B: Encoding Type Integration B-2: P2SH tx1 (funding output) creates P2SH output with value >= dustAmount", + "Category B: Encoding Type Integration B-2: P2SH tx1 (funding output) has a change output returning remaining funds", + "Category B: Encoding Type Integration B-3: P2SH tx2 (spending input with redeemScript) creates tx2 with P2SH input and OP_RETURN marker", + "Category B: Encoding Type Integration B-3: P2SH tx2 (spending input with redeemScript) tx2 P2SH input has redeemScript containing ACTION data", + "Category B: Encoding Type Integration B-4: P2WSH tx1 (funding output) creates P2WSH output when explicitly requested", + "Category B: Encoding Type Integration B-5: P2WSH tx2 (witness input) creates tx2 with witnessScript containing ACTION data", + "Category B: Encoding Type Integration B-6: MULTISIGN output structure creates 1-of-3 multisig output with correct structure", + "Category B: Encoding Type Integration B-6: MULTISIGN output structure third pubkey is the real compressed public key", + "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", + "E2E-7: Complex Parameter & Edge Case Handling E2E-7.12: Large BATCH with 10+ actions 10 actions in batch all preserved", + "E2E-7: Complex Parameter & Edge Case Handling E2E-7.13: Custom dust parameter MULTISIGN output uses custom dust value", + "E2E-7: Complex Parameter & Edge Case Handling E2E-7.13: Custom dust parameter fee floor still uses network dustAmount, not custom", + "E2E-7: Complex Parameter & Edge Case Handling E2E-7.14: ISSUE with all locks enabled all lock fields set to 1 are preserved", + "E2E-7: Complex Parameter & Edge Case Handling E2E-7.1: TICK with special characters J-DOG_#1 survives encoding round-trip", + "E2E-7: Complex Parameter & Edge Case Handling E2E-7.2: TICK by numeric ID (caret prefix) ^1234 preserved in SEND", + "E2E-7: Complex Parameter & Edge Case Handling E2E-7.3: Long TICK name 12-character TICK survives encoding", + "E2E-7: Complex Parameter & Edge Case Handling E2E-7.4: Zero amount SEND with amount=0 preserves the zero", + "E2E-7: Complex Parameter & Edge Case Handling E2E-7.5: Large amount with high precision 18-digit amount preserved exactly", + "E2E-7: Complex Parameter & Edge Case Handling E2E-7.5: Large amount with high precision decimal amount with 8 places preserved", + "E2E-7: Complex Parameter & Edge Case Handling E2E-7.6: Unicode in memo accented characters survive encoding", + "E2E-7: Complex Parameter & Edge Case Handling E2E-7.6: Unicode in memo emoji and CJK characters survive UTF-8 encoding", + "E2E-7: Complex Parameter & Edge Case Handling E2E-7.7: Pipe character in memo field pipe in memo extends the field count (protocol limitation)", + "E2E-7: Complex Parameter & Edge Case Handling E2E-7.8: ISSUE with empty optional fields empty lock and callback fields preserved as empty strings", + "E2E-7: Complex Parameter & Edge Case Handling E2E-7.9: data + rawData dual parameter both buffers compiled into script and recoverable" + ], + "bb2e52c2147d06f2": [ + "Encoding Chunk Boundaries: Full Pipeline OP_RETURN chunk-split threshold (forced encoding) 150-char data (compiled=152) \u2192 rejected", + "Encoding Chunk Boundaries: Full Pipeline OP_RETURN chunk-split threshold (forced encoding) 151-char data (compiled=153) \u2192 rejected", + "Encoding Chunk Boundaries: Full Pipeline OP_RETURN chunk-split threshold (forced encoding) 75-char data \u2192 1 OP_RETURN output", + "Encoding Chunk Boundaries: Full Pipeline OP_RETURN chunk-split threshold (forced encoding) 76-char data (compiled=78) \u2192 rejected (would exceed single output)" + ], + "bb903d583344a390": [ + "XChainEncoder.createTransaction() UTXO deduplication removes duplicate UTXOs with same txid+vout" + ], + "bccc1cc83d946a18": [ + "singleInstanceGuard assertSingleInstance names every in-process store in the replica refusal", + "singleInstanceGuard assertSingleInstance passes when ENCODER_REPLICAS is empty", + "singleInstanceGuard assertSingleInstance passes when ENCODER_REPLICAS is unset", + "singleInstanceGuard assertSingleInstance passes when ENCODER_REPLICAS=1", + "singleInstanceGuard assertSingleInstance throws on non-integer values", + "singleInstanceGuard assertSingleInstance throws when ENCODER_REPLICAS > 1" + ], + "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", + "XChainEncoder.createTransaction() no UTXOs available resolves a raw pubkey to P2PKH before querying the tracker on a network without segwit", + "XChainEncoder.createTransaction() no UTXOs available throws when utxos is empty and tracker returns nothing", + "XChainEncoder.createTransaction() no UTXOs available throws when utxos is null and tracker returns null" + ], + "c131768030b78cbc": [ + "ins[0] / obfuscation-key binding @regression MULTISIGN: same invariant on the bare-multisig payload path", + "ins[0] / obfuscation-key binding @regression OP_RETURN: a blocking reservation that lapses mid-build cannot steal ins[0]", + "ins[0] / obfuscation-key binding @regression caller-supplied UTXOs: the key binds to the first UNRESERVED input, which lands at ins[0]", + "ins[0] / obfuscation-key binding @regression does not double-add or double-reserve the spliced outpoint", + "ins[0] / obfuscation-key binding @regression fails closed when no outpoint could be pre-reserved and a different one frees" + ], + "c29f3481850f5d9f": [ + "E2E-9: Round-Trip Verification E2E-9.1: OP_RETURN SEND round-trip encode \u2192 extract \u2192 deobfuscate \u2192 decompile \u2192 exact match", + "E2E-9: Round-Trip Verification E2E-9.2: P2SH ISSUE round-trip (all 25+ fields) all ISSUE fields survive P2SH tx1\u2192tx2 round-trip", + "E2E-9: Round-Trip Verification E2E-9.3: MULTISIGN round-trip data encoded in fake pubkeys recovers original payload", + "E2E-9: Round-Trip Verification E2E-9.4: P2WSH FILE round-trip large file content survives P2WSH encoding", + "E2E-9: Round-Trip Verification E2E-9.5: Oversized OP_RETURN rejected forced OP_RETURN beyond a single output is rejected", + "E2E-9: Round-Trip Verification E2E-9.6: BATCH round-trip semicolon-separated batch survives encoding", + "E2E-9: Round-Trip Verification E2E-9.7: Cross-chain decode equivalence same ACTION on BTC/DOGE/LTC produces identical decoded payloads" + ], + "c438a1275387f62b": [ + "REG-01: Core Encoding Types REG-01.1: OP_RETURN OP_RETURN output has value=0 and script starts with 0x6a", + "REG-01: Core Encoding Types REG-01.1: OP_RETURN auto-selects OP_RETURN when compiled data + magic <= 80 bytes", + "REG-01: Core Encoding Types REG-01.1: OP_RETURN deobfuscated payload starts with XCHN magic", + "REG-01: Core Encoding Types REG-01.1: OP_RETURN small SEND auto-selects OP_RETURN encoding", + "REG-01: Core Encoding Types REG-01.2: P2SH P2SH output has OP_HASH160 <20-byte-hash> OP_EQUAL structure", + "REG-01: Core Encoding Types REG-01.2: P2SH P2SH output value >= dustAmount", + "REG-01: Core Encoding Types REG-01.2: P2SH forced P2SH encoding respected even for small data", + "REG-01: Core Encoding Types REG-01.2: P2SH large ISSUE payload auto-selects P2SH", + "REG-01: Core Encoding Types REG-01.3: P2WSH P2WSH encoding accepted on bitcoin-regtest", + "REG-01: Core Encoding Types REG-01.3: P2WSH P2WSH output has OP_0 <32-byte-hash> structure", + "REG-01: Core Encoding Types REG-01.3: P2WSH P2WSH rejected on dogecoin-regtest (no segwit)", + "REG-01: Core Encoding Types REG-01.4: MULTISIGN MULTISIGN produces 1-of-3 multisig output", + "REG-01: Core Encoding Types REG-01.4: MULTISIGN deobfuscated MULTISIGN payload has XCHN prefix", + "REG-01: Core Encoding Types REG-01.4: MULTISIGN third pubkey is the compressedPubKey argument", + "REG-01: Core Encoding Types REG-01.5: Return shape psbt.toHex() produces a valid hex string", + "REG-01: Core Encoding Types REG-01.5: Return shape result is { psbt, encoding } with Psbt instance" ], "c512e0cbe2bc163a": [ "large satoshi amounts (>2^53-1) createTransaction accepts a >2^53-1-sat input paying small outputs (giant consolidation UTXO)", @@ -684,6 +1591,52 @@ "large satoshi amounts (>2^53-1) validateUtxoEntry / validateCustomOutputs accepts a >2^53-1 customOutputs value string, coercing to BigInt", "large satoshi amounts (>2^53-1) validateUtxoEntry / validateCustomOutputs accepts a >2^53-1 utxo value string, coercing to BigInt" ], + "c547ad41237f5f7b": [ + "Encoding Chunk Boundaries: Full Pipeline P2SH/P2WSH compiled chunk size ceiling every compiled chunk is <= 520 bytes at the chunk boundary (P2SH and P2WSH)" + ], + "c590386a3c3ac1c3": [ + "Encoder input validator validateUtxoArray / validateUtxoEntry coerces numeric-string confirmations and rejects untyped values", + "Encoder input validator validateUtxoArray / validateUtxoEntry coerces vout/value and defaults confirmations", + "Encoder input validator validateUtxoArray / validateUtxoEntry null passes; non-array and over-cap throw", + "Encoder input validator validateUtxoArray / validateUtxoEntry preserves an explicit confirmations value", + "Encoder input validator validateUtxoArray / validateUtxoEntry rejects each malformed field", + "Encoder input validator validateUtxoArray / validateUtxoEntry rejects vout values bare Number() would coerce to a plausible index (uuid:4555d78c)" + ], + "c77bdfff70e2f687": [ + "XChainEncoder.createTransaction() P2SH native-fee customOutputs phase 1 (funding) does NOT emit the fee-destination output", + "XChainEncoder.createTransaction() P2SH native-fee customOutputs phase 1 (funding) over-funds the P2SH output by the fee value plus its byte cost", + "XChainEncoder.createTransaction() P2SH native-fee customOutputs phase 2 (reveal) emits the fee-destination output funded by phase 1" + ], + "c8961a4d3201babd": [ + "Encoding Chunk Boundaries: Full Pipeline P2WSH real-world boundary (bitcoin-regtest only) 473-char data \u2192 1 P2WSH output (last single-chunk size)", + "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" + ], + "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", + "E2E-8: Error Handling & Negative Tests E2E-8.12: UTXO value insufficient for fee handles gracefully when total UTXO value barely covers fee", + "E2E-8: Error Handling & Negative Tests E2E-8.1: No UTXOs available throws when utxos empty and tracker returns empty", + "E2E-8: Error Handling & Negative Tests E2E-8.1: No UTXOs available throws when utxos null and tracker returns null", + "E2E-8: Error Handling & Negative Tests E2E-8.2: RPC fee estimation failure propagates RPC error when fee estimation fails", + "E2E-8: Error Handling & Negative Tests E2E-8.2: RPC fee estimation failure succeeds when feePerKb provided despite RPC failure", + "E2E-8: Error Handling & Negative Tests E2E-8.3: Legacy UTXO raw tx fetch failure propagates error when getTransactionHex fails for legacy UTXO", + "E2E-8: Error Handling & Negative Tests E2E-8.4: UtxoTracker unreachable propagates ECONNREFUSED when tracker down and utxos=null", + "E2E-8: Error Handling & Negative Tests E2E-8.5: Invalid network name throws TypeError during construction", + "E2E-8: Error Handling & Negative Tests E2E-8.6: MULTISIGN without compressedPubKey throws when compressedPubKey is null", + "E2E-8: Error Handling & Negative Tests E2E-8.7: Invalid address for P2SH throws on invalid base58 address", + "E2E-8: Error Handling & Negative Tests E2E-8.8: No change address with surplus funds throws descriptive \"burn satoshis\" error", + "E2E-8: Error Handling & Negative Tests E2E-8.9: Empty data string handles empty string without crashing", + "E2E-8: Error Handling & Negative Tests Return value shape validation returns { psbt: Psbt, encoding: string } with valid enum" + ], + "c9d4ebeec3e35e91": [ + "XChainEncoder.createTransaction() custom outputs adds custom outputs with correct address and value", + "XChainEncoder.createTransaction() custom outputs skips custom outputs when customOutputs is not an array", + "XChainEncoder.createTransaction() custom outputs skips custom outputs when customOutputs is null" + ], + "ca5016508c9204fa": [ + "Category D: UTXO & Fee Integration UTXO sorting (largest first) uses largest UTXO first, needing fewer inputs" + ], "ca8fa416b5e8a763": [ "observability/installObservability buckets an unmatched path by first segment so URLs cannot explode cardinality", "observability/installObservability gates the endpoint behind METRICS_TOKEN when one is configured", @@ -754,6 +1707,34 @@ "observability/patchConsole resolves printf format strings and keeps an Error stack, via util.format", "observability/patchConsole routes console.* through the shim, mapping log to info" ], + "cb66238944b4845c": [ + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types ADDRESS: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types AIRDROP: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types BATCH: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types BROADCAST: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types CALLBACK: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types COINPAY: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types DESTROY: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types DISPENSER: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types DIVIDEND: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types FILE: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types ISSUE full: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types ISSUE minimal: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types ISSUE v1: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types LINK: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types LIST: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types MESSAGE: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types MINT: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types ORDER: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types SEND v0: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types SEND v1: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types SEND v2: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types SEND v3: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types SLEEP: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types SWAP: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types SWEEP: produces valid Psbt with >= 1 input and >= 2 outputs", + "E2E-1: Full ACTION-to-PSBT Pipeline Structural invariants across all ACTION types TICK by ID: produces valid Psbt with >= 1 input and >= 2 outputs" + ], "ccc87ed9c55081d5": [ "XChainEncoder.dataToPubkey() always returns exactly 33 bytes", "XChainEncoder.dataToPubkey() data bytes are preserved starting at offset 1", @@ -763,120 +1744,149 @@ "XChainEncoder.dataToPubkey() no padding when data is exactly 32 bytes", "XChainEncoder.dataToPubkey() pads with 0x00 when data is less than 32 bytes" ], - "d51133e5aaa56d2f": [ - "XChainEncoder TAPROOT envelope TxSizeEstimator envelope sizing estimateEnvelopeRevealTx covers and does not wildly over-fund a real reveal", - "XChainEncoder TAPROOT envelope TxSizeEstimator envelope sizing estimateTaprootOutput matches the serialized P2TR output size", - "XChainEncoder TAPROOT envelope TxSizeEstimator envelope sizing uses compactSize framing for scripts above 65535 bytes (5-byte prefix band)", - "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) a payload at exactly the ceiling builds a reveal within MAX_STANDARD_TX_WEIGHT (\u00a74)", - "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) applies the per-encoding \u00a74 ceiling: over ENVELOPE_MAX_PAYLOAD rejects, over 8192 passes", - "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) emits customOutputs on the COMMIT (fee-destination outputs ride the commit, \u00a73.5)", - "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) pads the reveal over the LTC stripped-size floor and prefunds the pad (\u00a73.5)", - "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) propagates RBF to both transactions of the pair", - "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) refuses TAPROOT on DOGE with the P2WSH gate error shape (\u00a73.6)", - "XChainEncoder TAPROOT envelope createTransaction pair construction (\u00a73.5/\u00a76) refuses non-segwit commit inputs (\u00a73.5, fail closed)", - "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", - "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) accepts the x-only internal key form", - "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) applies the create_tx fee guards to feePerKb and replacebyfee", - "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) builds, signs and extracts a sweep using only the recovery record", - "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) fails closed when the sweep would land under dust", - "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) validates the recovery-record fields", - "XChainEncoder TAPROOT envelope prepareData grammar (\u00a73.2) emits OP_FALSE OP_IF <0x00> OP_ENDIF OP_CHECKSIG", - "XChainEncoder TAPROOT envelope prepareData grammar (\u00a73.2) matches the golden grammar vector", - "XChainEncoder TAPROOT envelope prepareData grammar (\u00a73.2) rebalances a degenerate 1-byte minimal-opcode final chunk (compile would canonicalize it away)", - "XChainEncoder TAPROOT envelope prepareData grammar (\u00a73.2) requires compressedPubKey and validates its shape", - "XChainEncoder TAPROOT envelope prepareData grammar (\u00a73.2) splits the payload into 520-byte pushes, in order", - "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" + "cd6d34bba928b35b": [ + "Obfuscation Boundaries pipeline: all-zero TXID in first UTXO produces valid PSBT with deobfuscatable payload", + "Obfuscation Boundaries pipeline: short TXID in first UTXO createTransaction throws during obfuscation step", + "Obfuscation Boundaries short TXID causes AES failure TXID < 32 hex chars throws invalid IV error", + "Obfuscation Boundaries short TXID causes AES failure empty TXID throws", + "Obfuscation Boundaries unit-level: obfuscate() 1-byte data length is preserved and round-trips", + "Obfuscation Boundaries unit-level: obfuscate() all-f TXID round-trips correctly", + "Obfuscation Boundaries unit-level: obfuscate() all-zero TXID produces valid obfuscated output", + "Obfuscation Boundaries unit-level: obfuscate() all-zero TXID round-trips correctly", + "Obfuscation Boundaries unit-level: obfuscate() empty data returns empty buffer", + "Obfuscation Boundaries unit-level: obfuscate() large data (10KB) round-trips correctly" ], - "d6e2e7913a5f7e7f": [ - "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", - "XChainEncoder.createTransaction() - feeQuote injection adds feeQuote as an extra output when address and amount > 0", - "XChainEncoder.createTransaction() - feeQuote injection does not add feeQuote when amount is 0", - "XChainEncoder.createTransaction() - feeQuote injection does not add feeQuote when feeQuote has no address", - "XChainEncoder.createTransaction() - invalid fee throws RangeError for a NaN fee string", - "XChainEncoder.createTransaction() - invalid fee throws RangeError for a negative fee", - "XChainEncoder.createTransaction() - maxFeeRateKb cap caps the fee rate when connector returns a rate above maxFeeRateKb", + "ce55de477de9f588": [ + "encoder FILE payload compression (spec Part B) createTransaction integration an incompressible payload opted in still builds, just uncompressed", + "encoder FILE payload compression (spec Part B) createTransaction integration compresses by DEFAULT, with no compress flag passed at all", + "encoder FILE payload compression (spec Part B) createTransaction integration compresses when opted in, and the estimator prices the COMPRESSED bytes", + "encoder FILE payload compression (spec Part B) createTransaction integration honours an explicit opt-out", + "encoder FILE payload compression (spec Part B) createTransaction integration refuses at build time when a gated FILE asks for encoder-side compression", + "encoder FILE payload compression (spec Part B) createTransaction integration the compressed transaction carries the marker AND reconstructs byte-exactly" + ], + "cee2a600289a6989": [ + "E2E-5: UTXO, Fee, and Change Integration E2E-5.7: Fee floor enforcement floors fee to dustAmount when computed fee is lower" + ], + "cf4d68f6c7c95947": [ + "Fuzz: prepareData() MULTISIGN chunks are full 64-byte magic-prefixed slots over 1500 payloads", + "Fuzz: prepareData() OP_RETURN produces one magic-prefixed chunk at/under the limit over 1500 payloads", + "Fuzz: prepareData() OP_RETURN rejects oversized payloads over 1500 payloads", + "Fuzz: prepareData() P2SH compilation never throws over 1500 payloads" + ], + "d089099f98093462": [ + "Encoding Chunk Boundaries: Full Pipeline P2WSH on Dogecoin (no bech32 in network config) throws because bitcoin.payments.p2wsh requires bech32 prefix" + ], + "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", + "Category C: Obfuscation Round-Trip C-2: MULTISIGN obfuscation round-trip deobfuscated multisig data has XCHN prefix and original data", + "Category C: Obfuscation Round-Trip C-3: P2SH marker obfuscation tx2 OP_RETURN marker deobfuscates to XCHNp2sh", + "Category C: Obfuscation Round-Trip C-4: TXID sensitivity same ACTION with different TXIDs produces different obfuscated output", + "Category C: Obfuscation Round-Trip C-5: UTXO sorting preserves obfuscation key derivation deobfuscation with wrong TXID produces garbage", + "Category C: Obfuscation Round-Trip C-5: UTXO sorting preserves obfuscation key derivation obfuscation uses largest UTXO txid regardless of input order" + ], + "d23d7c5b635a6794": [ + "Chaos Category A: Network & Dependency Failures A-1: Coin daemon unreachable (ECONNREFUSED) feePerKb parameter bypasses broken getFeePerKilobyte", + "Chaos Category A: Network & Dependency Failures A-1: Coin daemon unreachable (ECONNREFUSED) getFeePerKilobyte failure propagates when feePerKb not provided", + "Chaos Category A: Network & Dependency Failures A-1: Coin daemon unreachable (ECONNREFUSED) getTransactionHex failure propagates for legacy UTXO", + "Chaos Category A: Network & Dependency Failures A-2: Coin daemon returns invalid JSON (502 HTML) SyntaxError from JSON parse propagates", + "Chaos Category A: Network & Dependency Failures A-3: Coin daemon RPC error (node still loading) RPC error from getFeePerKilobyte propagates", + "Chaos Category A: Network & Dependency Failures A-3: Coin daemon RPC error (node still loading) RPC error from getTransactionHex propagates for legacy UTXO", + "Chaos Category A: Network & Dependency Failures A-4: UTXO tracker unreachable propagates ECONNREFUSED when utxos=[]", + "Chaos Category A: Network & Dependency Failures A-4: UTXO tracker unreachable propagates ECONNREFUSED when utxos=null", + "Chaos Category A: Network & Dependency Failures A-5: UTXO tracker malformed responses A-5a: tracker returns null \u2192 throws about no utxos", + "Chaos Category A: Network & Dependency Failures A-5: UTXO tracker malformed responses A-5b: tracker returns {} (no utxos key) \u2192 throws", + "Chaos Category A: Network & Dependency Failures A-5: UTXO tracker malformed responses A-5c: tracker returns { utxos: null } \u2192 throws", + "Chaos Category A: Network & Dependency Failures A-5: UTXO tracker malformed responses A-5d: tracker returns { utxos: \"not-an-array\" } \u2192 throws", + "Chaos Category A: Network & Dependency Failures A-6: Intermittent RPC failures first call succeeds, second call fails (alternating pattern)", + "Chaos Category A: Network & Dependency Failures A-7: Slow RPC responses 150ms delay on getFeePerKilobyte still completes", + "Chaos Category A: Network & Dependency Failures A-7: Slow RPC responses 150ms delay on getTransactionHex (legacy UTXO) still completes" + ], + "d5e9845e4e125e70": [ + "REG-04: Validator Functions REG-04.12: validateCompressedPubKey() accepts 02-prefixed 66-char hex key", + "REG-04: Validator Functions REG-04.12: validateCompressedPubKey() accepts 03-prefixed 66-char hex key", + "REG-04: Validator Functions REG-04.12: validateCompressedPubKey() returns null for null", + "REG-04: Validator Functions REG-04.12: validateCompressedPubKey() throws TypeError for 04-prefixed (uncompressed)", + "REG-04: Validator Functions REG-04.12: validateCompressedPubKey() throws TypeError for non-string", + "REG-04: Validator Functions REG-04.12: validateCompressedPubKey() throws TypeError for wrong length (too short)" + ], + "d650883504da9b2d": [ + "Category D: UTXO & Fee Integration D-4: Unconfirmed filtering with unconfirmed=false excludes mempool UTXOs when unconfirmed=false", + "Category D: UTXO & Fee Integration D-4: Unconfirmed filtering with unconfirmed=false includes mempool UTXOs when unconfirmed=true" + ], + "d683ae27a964eb5b": [ "XChainEncoder.createTransaction() - rawData parameter accepts rawData and includes it in the compiled payload", - "XChainEncoder.createTransaction() - rawData parameter rawData does not affect result when null (false branch of rawData != null)", - "XChainEncoder.createTransaction() - remaining branch coverage throws RangeError when a UTXO value is not a valid satoshi amount", - "XChainEncoder.createTransaction() - remaining branch coverage throws RangeError when customOutputs[i].value is negative", - "XChainEncoder.createTransaction() - remaining branch coverage throws RangeError when customOutputs[i].value is not a valid satoshi amount", - "XChainEncoder.createTransaction() - remaining branch coverage throws when unconfirmed=false strips all UTXOs (line 325 path)", - "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", - "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", - "XChainEncoder.createTransaction(): P2WSH tx2 (spending) throws RangeError when p2shHex has no output at the expected voutPsbtIndex (P2SH)", - "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", - "XChainEncoder.createTransaction(): non-segwit UTXO path uses multiple UTXOs when first is insufficient to cover fee", - "XChainEncoder.createTransaction(): payload size guard throws RangeError when compiled payload exceeds MAX_COMPILED_ACTION_DATA_LENGTH (8192)", - "XChainEncoder.estimateSpendingP2wshTx() does not add an OP_PUSHDATA1 byte in the 76..252 band (len \u2261 1 mod 4)", - "XChainEncoder.estimateSpendingP2wshTx() includes the 8-byte safety margin in the result", - "XChainEncoder.estimateSpendingP2wshTx() increases monotonically with data size", - "XChainEncoder.estimateSpendingP2wshTx() is smaller than estimateSpendingP2shTx for the same data (witness discount)", - "XChainEncoder.estimateSpendingP2wshTx() switches prefix width at 253, not at 76 or 256", - "XChainEncoder.estimateSpendingP2wshTx() uses a 1-byte compactSize prefix for a witness script < 76 bytes", - "XChainEncoder.estimateSpendingP2wshTx() uses a 1-byte compactSize prefix for a witness script in 76..252", - "XChainEncoder.estimateSpendingP2wshTx() uses a 3-byte compactSize prefix for a witness script >= 253 bytes" + "XChainEncoder.createTransaction() - rawData parameter rawData does not affect result when null (false branch of rawData != null)" ], "d8d1e52267ea606f": [ "cross-repo sibling coverage (what this run could NOT verify) declares every sibling it lists in .ci-siblings, so the venue ships them", "cross-repo sibling coverage (what this run could NOT verify) reports every sibling it looked for, so the list itself cannot rot silently", "cross-repo sibling coverage (what this run could NOT verify) resolves every sibling checkout the cross-repo guards depend on" ], - "dfc3f143ee7a2329": [ - "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", - "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 discards a root that confirms between its two RPC calls", - "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 does not leak the RPC password when a mempool call fails", - "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 keeps an earlier root's branch when a later root confirms mid-sequence", - "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 reads the modern Core field layout (vsize and fees.base)", - "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns an empty package for an empty or non-array input", - "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns an empty package when every input is already confirmed", - "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null on an HTTP-500 RPC error body", - "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null on an RPC error rather than throwing", - "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when an entry carries no readable size or fee", - "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when the flat fee field is an empty string", - "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when the flat fee field is false", - "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when the flat fee field is null", - "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when the nested fees.base field is null", - "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when the node is unreachable", - "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when the second call fails for a reason other than absence", - "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 returns null when the size field is a boolean rather than a number", - "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 skips a txid the mempool does not hold (already confirmed)", - "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 still prices a legitimate zero-fee ancestor, in both field layouts", - "BlockchainConnector.getUnconfirmedAncestorPackage() @regression @tier1 sums an unconfirmed parent and everything above it", - "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", - "P2SH two-phase package prefund @regression @tier1 funds the package rate into the leg when the commit pays under target", - "P2SH two-phase package prefund @regression @tier1 the reveal keeps the package money as fee instead of sweeping it back", - "XChainEncoder package-aware fee sizing @regression @tier1 MAX_CPFP_UPLIFT_SAT=0 turns package sizing off entirely", - "XChainEncoder package-aware fee sizing @regression @tier1 clamps the uplift at MAX_CPFP_UPLIFT_SAT and warns that the package stays under target", - "XChainEncoder package-aware fee sizing @regression @tier1 clamps the uplift to the fee-rate cap rather than blowing through it", - "XChainEncoder package-aware fee sizing @regression @tier1 counts a shared ancestor once by handing the node every unconfirmed input txid", - "XChainEncoder package-aware fee sizing @regression @tier1 degrades when the connector has no package method at all", - "XChainEncoder package-aware fee sizing @regression @tier1 does not lower the fee when the ancestors already pay well above the target", - "XChainEncoder package-aware fee sizing @regression @tier1 falls back to the per-transaction fee when the ancestor lookup fails", - "XChainEncoder package-aware fee sizing @regression @tier1 leaves the fee untouched when every input is confirmed, and never asks the node", - "XChainEncoder package-aware fee sizing @regression @tier1 lifts the fee so a cheap ancestor package clears the target rate", - "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", - "packageFeeUpliftSatoshis() @regression @tier1 reproduces the live DOGE shortfall", - "packageFeeUpliftSatoshis() @regression @tier1 returns 0 for an empty package or a missing target", - "packageFeeUpliftSatoshis() @regression @tier1 returns 0 when the package already clears the target", - "packageFeeUpliftSatoshis() @regression @tier1 subtracts what the transaction already pays", - "packageFeeUpliftSatoshis() @regression @tier1 takes the ancestor fee in base units when one is given, ahead of the coin-unit figure", - "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", - "two-phase reveal package prefund @regression @tier1 carries ancestors the commit's own capped uplift could not pay for", - "two-phase reveal package prefund @regression @tier1 clamps at MAX_CPFP_UPLIFT_SAT and warns that the package stays under target", - "two-phase reveal package prefund @regression @tier1 leaves the reveal its change output rather than paying the uplift out of it", - "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" + "da6106a9ba02aab9": [ + "Encoder input validator validateCombinedDataLength accepts a single-push payload at the compiled ceiling (backwards-compatible)", + "Encoder input validator validateCombinedDataLength accepts an explicit OP_RETURN payload at/under the 76-byte ceiling", + "Encoder input validator validateCombinedDataLength accounts for per-push overhead on dual-push payloads at the boundary", + "Encoder input validator validateCombinedDataLength applies the envelope ceiling to AUTO, like TAPROOT", + "Encoder input validator validateCombinedDataLength counts data + rawData and throws over the limit", + "Encoder input validator validateCombinedDataLength counts the OP_PUSHDATA4 band on a push past 65,535 bytes", + "Encoder input validator validateCombinedDataLength does NOT apply the OP_RETURN ceiling when encoding is omitted (P2SH auto-fallback)", + "Encoder input validator validateCombinedDataLength leaves the OP_PUSHDATA2 band alone at its upper edge", + "Encoder input validator validateCombinedDataLength measures rawData-only payloads (data defaults to an OP_0 push)", + "Encoder input validator validateCombinedDataLength rejects an explicit OP_RETURN payload above the 76-byte ceiling", + "Encoder input validator validateCombinedDataLength rejects dual-push payloads whose compiled size exceeds the ceiling", + "Encoder input validator validateCombinedDataLength rejects the documented dual-push undercount case (raw sum within old limit)", + "Encoder input validator validateCombinedDataLength returns early for null data and accepts within-limit payloads" + ], + "dbe9aa0a839fb823": [ + "XChainEncoder.createTransaction() change output adds change output when there is leftover and change address given", + "XChainEncoder.createTransaction() change output throws when change address is falsy and change would be burned" + ], + "dc974e6bd1abb882": [ + "Chaos Category D: Arithmetic & State Corruption D-1: Negative change is refused, not signed 1-sat UTXO + 10000-sat fee \u2192 INSUFFICIENT_FUNDS, no PSBT", + "Chaos Category D: Arithmetic & State Corruption D-1: Negative change is refused, not signed 3x 100-sat UTXOs with 10000-sat fee \u2192 INSUFFICIENT_FUNDS on the whole set", + "Chaos Category D: Arithmetic & State Corruption D-2: Insufficient UTXOs produce no PSBT a funded selection under the same chaos parameters still builds", + "Chaos Category D: Arithmetic & State Corruption D-2: Insufficient UTXOs produce no PSBT a single dust UTXO is refused rather than serialized", + "Chaos Category D: Arithmetic & State Corruption D-2: Insufficient UTXOs produce no PSBT total inputs < fee \u2192 rejected instead of returned", + "Chaos Category D: Arithmetic & State Corruption D-3: Concurrent calls with shared UTXO array two concurrent calls sharing same array both complete", + "Chaos Category D: Arithmetic & State Corruption D-3: Concurrent calls with shared UTXO array two concurrent calls with independent arrays both succeed", + "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" + ], + "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", + "REG-04: Validator Functions REG-04.14: validateAll() integration throws RangeError for combined data > 65536 bytes", + "REG-04: Validator Functions REG-04.14: validateAll() integration throws TypeError for null params", + "REG-04: Validator Functions REG-04.14: validateAll() integration throws TypeError for string params", + "REG-04: Validator Functions REG-04.14: validateAll() integration throws a SHAPE error for positional array params, not a missing-field one", + "REG-04: Validator Functions REG-04.14: validateAll() integration validates P2SH pair atomically (rejects partial pair)" + ], + "df667f23a4c0ba6d": [ + "Category D: UTXO & Fee Integration D-2: Multiple UTXOs needed adds UTXOs until inputs cover outputs + fee" + ], + "dfdfc61603d906bd": [ + "encoder FILE payload compression (spec Part B) validator accepts compress as an optional boolean; absent means \"use the deployment default\"" + ], + "dfeddeb82fafc3ef": [ + "E2E-5: UTXO, Fee, and Change Integration E2E-5.3: UTXO deduplication collapses duplicate (txid, vout) pairs" + ], + "e1423a6af229c1b4": [ + "BlockchainConnector.getNetworkInfo() keeps the node RPC code and message from an HTTP-200 error body", + "BlockchainConnector.getNetworkInfo() keeps the node RPC code and message from an HTTP-500 error body", + "BlockchainConnector.getNetworkInfo() passes auth credentials to axios", + "BlockchainConnector.getNetworkInfo() returns the result object on success", + "BlockchainConnector.getNetworkInfo() sends the correct JSON-RPC method", + "BlockchainConnector.getNetworkInfo() sets a timeout on the request", + "BlockchainConnector.getNetworkInfo() throws when result is missing from response", + "BlockchainConnector.getNetworkInfo() wraps axios transport errors" + ], + "e1c1badc45b5ec79": [ + "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) accepts the x-only internal key form", + "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) applies the create_tx fee guards to feePerKb and replacebyfee", + "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) builds, signs and extracts a sweep using only the recovery record", + "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) fails closed when the sweep would land under dust", + "XChainEncoder TAPROOT envelope key-path cancel from the persisted recovery record (\u00a73.5/\u00a73.7) validates the recovery-record fields" ], "e349cec537f9beb2": [ "assertEnvelopeRecognized() FAIL-CLOSES when the tip is unknowable, rather than assuming", @@ -900,6 +1910,57 @@ "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" ], + "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", + "Category F: Multi-Chain Network Configs CryptoNetworks completeness bitcoin-testnet returns valid config with dustThreshold", + "Category F: Multi-Chain Network Configs CryptoNetworks completeness dogecoin-mainnet returns valid config with dustThreshold", + "Category F: Multi-Chain Network Configs CryptoNetworks completeness dogecoin-regtest returns valid config with dustThreshold", + "Category F: Multi-Chain Network Configs CryptoNetworks completeness dogecoin-testnet returns valid config with dustThreshold", + "Category F: Multi-Chain Network Configs CryptoNetworks completeness litecoin-mainnet returns valid config with dustThreshold", + "Category F: Multi-Chain Network Configs CryptoNetworks completeness litecoin-regtest returns valid config with dustThreshold", + "Category F: Multi-Chain Network Configs CryptoNetworks completeness litecoin-testnet returns valid config with dustThreshold", + "Category F: Multi-Chain Network Configs F-4: Litecoin higher dust threshold Litecoin MULTISIGN output is higher than Bitcoin, driven by its dust threshold", + "Category F: Multi-Chain Network Configs F-4: Litecoin higher dust threshold MULTISIGN output clears both the chain dust floor and the bare-multisig relay floor", + "Category F: Multi-Chain Network Configs F-5: Dogecoin address in P2SH redeem script P2SH output address uses Dogecoin network params", + "Category F: Multi-Chain Network Configs Fee floor uses chain-specific dust threshold Litecoin fee floor is 5460, not 546", + "Category F: Multi-Chain Network Configs bitcoin-regtest: basic PSBT construction creates valid OP_RETURN transaction", + "Category F: Multi-Chain Network Configs bitcoin-regtest: basic PSBT construction creates valid P2SH transaction", + "Category F: Multi-Chain Network Configs bitcoin-regtest: basic PSBT construction uses correct dustThreshold of 546", + "Category F: Multi-Chain Network Configs dogecoin-regtest: basic PSBT construction creates valid OP_RETURN transaction", + "Category F: Multi-Chain Network Configs dogecoin-regtest: basic PSBT construction creates valid P2SH transaction", + "Category F: Multi-Chain Network Configs dogecoin-regtest: basic PSBT construction uses correct dustThreshold of 100000", + "Category F: Multi-Chain Network Configs litecoin-regtest: basic PSBT construction creates valid OP_RETURN transaction", + "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", + "Data/Payload Boundaries empty data string data=\"\" \u2192 no nulldata output at all (payment-only)", + "Data/Payload Boundaries empty data string one-byte data is the smallest payload that still emits (MAGIC_WORD)", + "Data/Payload Boundaries null bytes in data data with embedded null bytes survives round-trip intact", + "Data/Payload Boundaries rawData parameter interactions rawData present: decompiled script has 2 elements", + "Data/Payload Boundaries rawData parameter interactions rawData=null: decompiled script has 1 element", + "Data/Payload Boundaries rawData parameter interactions small data + large rawData pushes combined size over OP_RETURN into P2SH", + "Data/Payload Boundaries rawData parameter interactions small data + small rawData stays within OP_RETURN", + "Data/Payload Boundaries very large payload 1000-byte data forced to OP_RETURN is rejected (exceeds single output)" + ], "e9e98dbc28c54a4f": [ "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", @@ -923,6 +1984,30 @@ "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" ], + "ea49e69971727d3a": [ + "Encoder input validator validateP2shParams both-omitted yields nulls; mismatch throws", + "Encoder input validator validateP2shParams enforces hex shape and the raw-tx length cap on p2shHex", + "Encoder input validator validateP2shParams validates hash hex and non-empty hex; returns the pair" + ], + "ee1005d70e3a9b6a": [ + "Category D: UTXO & Fee Integration D-6: No UTXOs available throws when utxos empty and tracker returns empty", + "Category D: UTXO & Fee Integration D-6: No UTXOs available throws when utxos null and tracker returns null" + ], + "ef8432a4a1ba859e": [ + "REG-09: 2026-07-03 deepdive encoder fixes FIX K: get_utxos validates the address param before the tracker call rejects an object-valued address with code -32602", + "REG-09: 2026-07-03 deepdive encoder fixes FIX K: get_utxos validates the address param before the tracker call rejects an over-length (>100 char) address with code -32602", + "REG-09: 2026-07-03 deepdive encoder fixes L-1: in-memory outpoint reservation caller-supplied UTXOs are reserved too, and the SDK supplies a tracker-fetched set", + "REG-09: 2026-07-03 deepdive encoder fixes L-1: in-memory outpoint reservation once every outpoint is reserved a further call reports insufficient funds", + "REG-09: 2026-07-03 deepdive encoder fixes L-1: in-memory outpoint reservation two sequential tracker-fed selections pick disjoint outpoints", + "REG-09: 2026-07-03 deepdive encoder fixes L-2: API surfaces typed operational errors collapses an unexpected internal error to a generic message (no leak)", + "REG-09: 2026-07-03 deepdive encoder fixes L-2: API surfaces typed operational errors forwards an OperationalError with code -32010 and a machine-readable reason", + "REG-09: 2026-07-03 deepdive encoder fixes M-5: P2SH reveal does not re-query the UTXO tracker builds the reveal from p2shHex alone, even when the tracker would fail", + "REG-09: 2026-07-03 deepdive encoder fixes M-6: sub-dust change folding emits change exactly at the dust threshold, folds one sat below it", + "REG-09: 2026-07-03 deepdive encoder fixes M-6: sub-dust change folding folds 1-sat change into the fee (no unbroadcastable dust output)", + "REG-09: 2026-07-03 deepdive encoder fixes M-7: MAX_UTXO_COUNT applies to selected inputs, not the fetched set builds from a tracker set larger than 500 UTXOs when a few inputs cover", + "REG-09: 2026-07-03 deepdive encoder fixes M-7: MAX_UTXO_COUNT applies to selected inputs, not the fetched set throws RangeError only when the SELECTED input count exceeds the limit", + "REG-09: 2026-07-03 deepdive encoder fixes M-8: insufficient-funds error throws INSUFFICIENT_FUNDS with a required/available payload" + ], "ef95d11a5f979b57": [ "XChainEncoder soft-dust floor on authored outputs P2SH funding legs at the fee rate that stalled testnet authors no funding leg below 0.01 DOGE", "XChainEncoder soft-dust floor on authored outputs P2SH funding legs at the fee rate that stalled testnet clamps a caller dust below the floor up to the floor", @@ -932,10 +2017,102 @@ "XChainEncoder soft-dust floor on authored outputs the floor lets an operator DUST_AMOUNT raise the floor but never lower it", "XChainEncoder soft-dust floor on authored outputs the floor raises the DOGE floor from the pinned hard limit to the 0.01 DOGE soft limit on every network" ], - "f16582a001cac4d2": [ - "BlockchainConnector RPC-credential log sanitization does not leak the RPC password when an axios call fails", - "BlockchainConnector constructor builds the correct URL from host and port", - "BlockchainConnector constructor stores rpcUser and rpcPassword", + "efc2d14c81fd5e58": [ + "E2E-3: Encoding Type Selection & Boundaries E2E-3.1: Minimum OP_RETURN (1-byte ACTION) single byte payload fits OP_RETURN", + "E2E-3: Encoding Type Selection & Boundaries E2E-3.2: Maximum OP_RETURN boundary (75-byte string) 75-byte ACTION compiles to 76 bytes, fits OP_RETURN (76 + 4 magic = 80)", + "E2E-3: Encoding Type Selection & Boundaries E2E-3.3: One byte over OP_RETURN boundary 80-byte ACTION exceeds OP_RETURN, auto-selects P2SH", + "E2E-3: Encoding Type Selection & Boundaries E2E-3.4: Forced OP_RETURN with large data is rejected 200-byte data forced to OP_RETURN is rejected", + "E2E-3: Encoding Type Selection & Boundaries E2E-3.4: Forced OP_RETURN with large data is rejected 200-byte data on the auto path selects P2SH (the valid carrier)", + "E2E-3: Encoding Type Selection & Boundaries E2E-3.5: Forced P2SH despite data fitting OP_RETURN encoding override to P2SH is honored", + "E2E-3: Encoding Type Selection & Boundaries E2E-3.6: Forced P2WSH encoding override to P2WSH produces witness output", + "E2E-3: Encoding Type Selection & Boundaries E2E-3.7: Forced MULTISIGN encoding override to MULTISIGN produces 1-of-3 multisig", + "E2E-3: Encoding Type Selection & Boundaries E2E-3.8: Near P2SH max chunk size 400-byte data fits in single P2SH chunk", + "E2E-3: Encoding Type Selection & Boundaries E2E-3.9: P2WSH handles larger-than-P2SH payload 1000-byte file data splits across multiple P2WSH chunks" + ], + "f1872a85a8671d97": [ + "Security: compiled payload-size ceiling does not reject a small payload at the size guard", + "Security: compiled payload-size ceiling rejects a payload whose compiled push exceeds the on-chain ceiling" + ], + "f2625b374d260d4b": [ + "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.10: DESTROY burn amount preserved", + "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.11: CALLBACK minimal callback payload preserved", + "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.12: SLEEP resume block number preserved", + "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.13: SWEEP destination address preserved", + "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.1: Simple SEND v0 SEND|0|JDOG|100| encodes to valid OP_RETURN PSBT with exact payload", + "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.2: SEND with memo memo field survives full pipeline", + "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.3: SEND v1 multi-output multiple AMOUNT|DEST pairs preserved", + "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.4: SEND v2 multi-token multi-token TICK|AMT|DEST triplets preserved", + "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.5: SEND v3 multi-token with per-transfer memo per-transfer memos preserved", + "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.6: Full ISSUE with all 25+ fields (P2SH) produces valid P2SH PSBT with correctly structured output", + "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.7: Minimal ISSUE ISSUE|0|X auto-selects OP_RETURN", + "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.8: ISSUE v1 description edit version 1 format with description preserved", + "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.9: MINT tick and amount preserved" + ], + "f4d480d0b43ad2e9": [ + "UtxoTracker constructor builds the correct URL from host and port", + "UtxoTracker constructor stores the port" + ], + "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", + "encoder FILE payload compression (spec Part B) the compression report carries the bytes that were written says nothing extra when compression did not fire", + "encoder FILE payload compression (spec Part B) the compression report carries the bytes that were written the marker WITHOUT the stored bytes builds a different carrier (why both halves ride)" + ], + "f6cecc24770508b5": [ + "encoder FILE payload compression (spec Part B) action-string helpers detects FILE v0 and gating correctly", + "encoder FILE payload compression (spec Part B) action-string helpers pads intermediate optional fields to reach index 10", + "encoder FILE payload compression (spec Part B) action-string helpers preserves the gating fields when a client sets the marker", + "encoder FILE payload compression (spec Part B) action-string helpers setting the field empty leaves the action byte-identical" + ], + "f7313ee5d4a1e4d5": [ + "Fuzz: dataToPubkey() holds the pubkey-shape invariant over 2000 random chunks" + ], + "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", + "REG-04: Validator Functions REG-04.1: validatePubkey() returns null for null input", + "REG-04: Validator Functions REG-04.1: validatePubkey() returns null for undefined input", + "REG-04: Validator Functions REG-04.1: validatePubkey() throws TypeError for empty string", + "REG-04: Validator Functions REG-04.1: validatePubkey() throws TypeError for non-string (number)", + "REG-04: Validator Functions REG-04.1: validatePubkey() throws TypeError for string > 100 chars (shares the validateAddress bound)", + "REG-04: Validator Functions REG-04.2: validateDataParam() accepts a string", + "REG-04: Validator Functions REG-04.2: validateDataParam() returns null for null", + "REG-04: Validator Functions REG-04.2: validateDataParam() throws TypeError for boolean", + "REG-04: Validator Functions REG-04.2: validateDataParam() throws TypeError for non-string", + "REG-04: Validator Functions REG-04.3: validateCombinedDataLength() accepts data at exactly MAX_DATA_BYTES", + "REG-04: Validator Functions REG-04.3: validateCombinedDataLength() accepts data within limit", + "REG-04: Validator Functions REG-04.3: validateCombinedDataLength() accounts for rawData in total", + "REG-04: Validator Functions REG-04.3: validateCombinedDataLength() does nothing for null data", + "REG-04: Validator Functions REG-04.3: validateCombinedDataLength() throws RangeError when combined data exceeds MAX_DATA_BYTES", + "REG-04: Validator Functions REG-04.4: validateEncoding() accepts all four valid encoding values", + "REG-04: Validator Functions REG-04.4: validateEncoding() is case-sensitive (rejects lowercase)", + "REG-04: Validator Functions REG-04.4: validateEncoding() returns null for null", + "REG-04: Validator Functions REG-04.4: validateEncoding() throws TypeError for invalid string", + "REG-04: Validator Functions REG-04.4: validateEncoding() throws TypeError for non-string", + "REG-04: Validator Functions REG-04.5: validateFee() accepts 0", + "REG-04: Validator Functions REG-04.5: validateFee() accepts MAX_FEE_SATOSHIS exactly", + "REG-04: Validator Functions REG-04.5: validateFee() accepts numeric input", + "REG-04: Validator Functions REG-04.5: validateFee() parses integer string correctly", + "REG-04: Validator Functions REG-04.5: validateFee() returns null for false", + "REG-04: Validator Functions REG-04.5: validateFee() returns null for null", + "REG-04: Validator Functions REG-04.5: validateFee() throws RangeError for negative value", + "REG-04: Validator Functions REG-04.5: validateFee() throws RangeError for value > MAX_FEE_SATOSHIS", + "REG-04: Validator Functions REG-04.5: validateFee() throws TypeError for non-numeric string", + "REG-04: Validator Functions REG-04.6: validateFeePerKb() accepts positive number", + "REG-04: Validator Functions REG-04.6: validateFeePerKb() returns null for false", + "REG-04: Validator Functions REG-04.6: validateFeePerKb() returns null for null", + "REG-04: Validator Functions REG-04.6: validateFeePerKb() throws RangeError for negative", + "REG-04: Validator Functions REG-04.6: validateFeePerKb() throws RangeError for zero", + "REG-04: Validator Functions REG-04.6: validateFeePerKb() throws TypeError for Infinity", + "REG-04: Validator Functions REG-04.6: validateFeePerKb() throws TypeError for NaN string", + "REG-04: Validator Functions REG-04.7: validateDust() accepts 0", + "REG-04: Validator Functions REG-04.7: validateDust() parses integer correctly", + "REG-04: Validator Functions REG-04.7: validateDust() returns null for false", + "REG-04: Validator Functions REG-04.7: validateDust() returns null for null", + "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", @@ -957,102 +2134,488 @@ "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", - "BlockchainConnector.getNetworkInfo() keeps the node RPC code and message from an HTTP-200 error body", - "BlockchainConnector.getNetworkInfo() keeps the node RPC code and message from an HTTP-500 error body", - "BlockchainConnector.getNetworkInfo() passes auth credentials to axios", - "BlockchainConnector.getNetworkInfo() returns the result object on success", - "BlockchainConnector.getNetworkInfo() sends the correct JSON-RPC method", - "BlockchainConnector.getNetworkInfo() sets a timeout on the request", - "BlockchainConnector.getNetworkInfo() throws when result is missing from response", - "BlockchainConnector.getNetworkInfo() wraps axios transport errors", - "BlockchainConnector.getTransactionHex() includes the txid in the \"not found\" error message", - "BlockchainConnector.getTransactionHex() passes hexFormat=false when requested", - "BlockchainConnector.getTransactionHex() rethrows transport errors directly", - "BlockchainConnector.getTransactionHex() returns the hex string on success", - "BlockchainConnector.getTransactionHex() sends auth credentials", - "BlockchainConnector.getTransactionHex() sends getrawtransaction with correct txid and hexFormat=true", - "BlockchainConnector.getTransactionHex() throws \"not found\" message when error.code is -5", - "BlockchainConnector.getTransactionHex() throws generic error when result is missing and error is not -5", - "BlockchainConnector.isRegtest() keeps the node RPC code and message from an HTTP-200 error body", - "BlockchainConnector.isRegtest() keeps the node RPC code and message from an HTTP-500 error body", - "BlockchainConnector.isRegtest() returns false when chain is \"main\"", - "BlockchainConnector.isRegtest() returns false when chain is \"test\"", - "BlockchainConnector.isRegtest() returns true when chain is \"regtest\"", - "BlockchainConnector.isRegtest() sends getblockchaininfo method", - "BlockchainConnector.isRegtest() throws when result has no chain property", - "BlockchainConnector.isRegtest() throws when result is missing", - "BlockchainConnector.isRegtest() wraps transport errors", - "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() maxfeerate retry does NOT retry on regtest for non-fee-cap errors", - "BlockchainConnector.sendRawTransaction() maxfeerate retry does NOT retry on the fee-cap error when chain is not regtest", - "BlockchainConnector.sendRawTransaction() maxfeerate retry matches the bare \"maxfeerate\" wording variant", - "BlockchainConnector.sendRawTransaction() maxfeerate retry propagates the original fee-cap error when the chain check itself fails", - "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", - "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" + "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" + ], + "fceac8d2d8f0bb07": [ + "E2E-10: JSON-RPC API Layer E2E-10.1: create_tx with full params returns {psbt: hex, encoding: string}", + "E2E-10: JSON-RPC API Layer E2E-10.2: create_tx minimal params applies defaults for missing optional params", + "E2E-10: JSON-RPC API Layer E2E-10.3: PSBT hex validity returned hex can be parsed by Psbt.fromHex()", + "E2E-10: JSON-RPC API Layer E2E-10.4: Error response format invalid method returns JSON-RPC error", + "E2E-10: JSON-RPC API Layer E2E-10.5: CORS headers response includes Access-Control-Allow-Origin", + "E2E-10: JSON-RPC API Layer E2E-10.6: Concurrent requests multiple simultaneous requests do not interfere" + ], + "fea0d5dcb2b90bb0": [ + "UtxoTracker.getSyncStatus() propagates transport errors", + "UtxoTracker.getSyncStatus() returns the result object on success", + "UtxoTracker.getSyncStatus() sends get_sync_status JSON-RPC method", + "UtxoTracker.getSyncStatus() sends params as an empty object", + "UtxoTracker.getSyncStatus() sets a timeout on the request", + "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": 120, + "titleCount": 910, + "files": { + "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", + "test/unit/api/cors_preflight.test.js": "78e833e8f04d5380", + "test/unit/api/encoder_stress_sweep.test.js": "358184203c4ccfba", + "test/unit/api/health_serve_readiness.test.js": "99f46ad865902c51", + "test/unit/api/jsonrpc_body_guard.test.js": "58706b2a54fa0008", + "test/unit/api/openrpc_coverage.test.js": "995c9801f2581aab", + "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": "7fabeeeb5816e16e", + "test/unit/blockchain_connector.test/04_send_raw_transaction.test.js": "e65ba2021038db9f", + "test/unit/blockchain_connector.test/05_send_raw_transaction_maxfeerate_retry.test.js": "b70b109181c9f1fc", + "test/unit/blockchain_connector.test/06_get_fee_per_kilobyte.test.js": "fae26ed352b38e34", + "test/unit/blockchain_connector.test/07_rpc_credential_log_sanitization.test.js": "72c9c5f2d2749796", + "test/unit/build/apply_bufferutils_patch.test.js": "63c55285b6bac324", + "test/unit/build/compression.test.js": "68868c37d76d5eb2", + "test/unit/build/compression.test/01_action_string_helpers.test.js": "f6cecc24770508b5", + "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/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", + "test/unit/build/package_fee_sizing.test/03_two_phase_reveal_package_prefund.test.js": "4dd0d30bb214ff45", + "test/unit/build/package_fee_sizing.test/04_p2sh_two_phase_package_prefund.test.js": "6f8680c56c04f0ef", + "test/unit/build/tx_size_estimator.test.js": "99f2a27c2274c9bc", + "test/unit/build/utxo_tracker.test.js": "f4d480d0b43ad2e9", + "test/unit/build/utxo_tracker.test/01_get_sync_status.test.js": "fea0d5dcb2b90bb0", + "test/unit/build/utxo_tracker.test/02_get_utxos_from_address.test.js": "149a2b69c0c72f7f", + "test/unit/build/utxo_tracker.test/03_get_utxos_from_address_validation.test.js": "27d7bba206f363af", + "test/unit/coins/coins_conformance.test.js": "2442df610ebb6911", + "test/unit/coins/xchain_encoder_consensus_pin_boot.test.js": "6a874601dbd714de", + "test/unit/common/error_sanitize.test.js": "8c8c02bad5e89e5b", + "test/unit/common/validator/action_manifest_conformance.test.js": "babd2db450e2e6a1", + "test/unit/common/validator/large_satoshi_amounts.test.js": "c512e0cbe2bc163a", + "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", + "test/unit/common/validator/validator.test/04_alias_expansion_at_the_compiled_ceiling.test.js": "6d14427e043988d0", + "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/09_validate_dust.test.js": "0e58538e3d6aa84b", + "test/unit/common/validator/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js": "c590386a3c3ac1c3", + "test/unit/common/validator/validator.test/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/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/observability.test.js": "ca8fa416b5e8a763", + "test/unit/repo/coverage_thresholds_sync.test.js": "4405034a199528ec", + "test/unit/repo/docs_openrpc.test.js": "65be62f0e174b504", + "test/unit/repo/sibling_coverage.test.js": "d8d1e52267ea606f", + "test/unit/security/configuration/dependency_advisories.test.js": "0f2abe37f99c5a17", + "test/unit/server/cors_origin.test.js": "121f9fc54d5673a7", + "test/unit/server/maintenance_window.test.js": "a6d0a7d54f8fdf52", + "test/unit/server/single_instance_guard.test.js": "bccc1cc83d946a18", + "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/envelope_recognition_gate.test.js": "e349cec537f9beb2", + "test/unit/xchain_encoder/suggested_fee_ceiling.test.js": "a76fc6f4a4157148", + "test/unit/xchain_encoder/xchain_encoder_auto_encoding.test.js": "0748973400fcace3", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test.js": "23e242d978109d26", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/01_utxo_deduplication.test.js": "bb903d583344a390", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/02_unconfirmed_filtering.test.js": "01301310489a6d18", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/03_utxo_sorting.test.js": "61c7893f33172ff0", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/04_replace_by_fee_sequence.test.js": "779e7e9a758ea87d", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/05_fee_handling.test.js": "b77f94d358245fae", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/06_custom_dust_parameter.test.js": "68626fb6051d2aba", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/07_change_output.test.js": "dbe9aa0a839fb823", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/08_no_utxos_available.test.js": "c08315d45c994876", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/09_custom_outputs.test.js": "c9d4ebeec3e35e91", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/10_op_return_encoding_path.test.js": "08d13b4a948a8f78", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/11_p2sh_encoding_funding.test.js": "13fa2adf6419a698", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/12_p2sh_encoding_spending.test.js": "78ea596173e06da0", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/13_p2sh_native_fee_custom_outputs.test.js": "c77bdfff70e2f687", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/14_multisign_encoding_path.test.js": "13761a81f887787c", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/15_return_value.test.js": "21640a976ee201c8", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/16_concurrent_selection.test.js": "9f193c2caff73160", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/16_estimate_spending_p2sh_tx.test.js": "1c00492f188266d1", + "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_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", + "test/unit/xchain_encoder/xchain_encoder_extra.test/02_p2wsh_tx2_spending.test.js": "b3f82aa329a90693", + "test/unit/xchain_encoder/xchain_encoder_extra.test/03_payload_size_guard.test.js": "1b91de49aeb88df1", + "test/unit/xchain_encoder/xchain_encoder_extra.test/04_fee_quote_injection.test.js": "447682ee980135d8", + "test/unit/xchain_encoder/xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.js": "34a2845bc2d97b6c", + "test/unit/xchain_encoder/xchain_encoder_extra.test/06_change_edge_cases.test.js": "8f324f20d5abfee5", + "test/unit/xchain_encoder/xchain_encoder_extra.test/07_invalid_fee.test.js": "fc5c3c204837ea5e", + "test/unit/xchain_encoder/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js": "59ae18cc4c09d4bd", + "test/unit/xchain_encoder/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js": "630b933b3e1320d0", + "test/unit/xchain_encoder/xchain_encoder_extra.test/10_raw_data_parameter.test.js": "d683ae27a964eb5b", + "test/unit/xchain_encoder/xchain_encoder_fee_rate_cap.test.js": "5ada45b5f0f336ef", + "test/unit/xchain_encoder/xchain_encoder_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", + "test/unit/xchain_encoder/xchain_encoder_prepare_data.test.js": "4a88b66dc7f07157", + "test/unit/xchain_encoder/xchain_encoder_release_reservations.test.js": "923b72fe3989bb3c", + "test/unit/xchain_encoder/xchain_encoder_reveal_funding_binding.test.js": "990dee7527465c8e", + "test/unit/xchain_encoder/xchain_encoder_segwit_input_guard.test.js": "2aa020357e5b268b", + "test/unit/xchain_encoder/xchain_encoder_soft_dust_floor.test.js": "ef95d11a5f979b57", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test.js": "357ca3318e99187c", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js": "27e24996d31ce20e", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js": "880c961e264c0519", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js": "e1c1badc45b5ec79", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js": "5086151532f51b80", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js": "44230034b18955bb", + "test/unit/xchain_encoder/xchain_encoder_tracker_freshness_classifier.test.js": "e9e98dbc28c54a4f", + "test/unit/xchain_encoder/xchain_encoder_utxo_dedup.test.js": "2a1c91a7aceda8eb" + } + }, + "test:boundary": { + "fileCount": 17, + "titleCount": 101, + "files": { + "test/boundary/change_address_boundaries.test.js": "b66602bc925af7a5", + "test/boundary/custom_output_boundaries.test.js": "4526c9e9c70c2999", + "test/boundary/data_payload_boundaries.test.js": "e855d976bf5194b0", + "test/boundary/encoding_chunk_boundaries.test.js": "71168814d61ac771", + "test/boundary/encoding_chunk_boundaries.test/01_op_return_chunk_split_threshold_forced_encoding.test.js": "bb2e52c2147d06f2", + "test/boundary/encoding_chunk_boundaries.test/02_p2sh_chunk_split_threshold.test.js": "48791dc9a10cb986", + "test/boundary/encoding_chunk_boundaries.test/03_p2wsh_real_world_boundary_bitcoin_regtest_only.test.js": "c8961a4d3201babd", + "test/boundary/encoding_chunk_boundaries.test/04_p2wsh_on_dogecoin_no_bech32_in_network_config.test.js": "d089099f98093462", + "test/boundary/encoding_chunk_boundaries.test/05_multisign_encoding_boundaries.test.js": "3200c672043a4ec4", + "test/boundary/encoding_chunk_boundaries.test/06_data_to_pubkey_unit_boundaries.test.js": "07d57a6f8f81fa07", + "test/boundary/encoding_chunk_boundaries.test/07_forced_encoding_override.test.js": "2a2db054ce1a2706", + "test/boundary/encoding_chunk_boundaries.test/08_single_op_return_policy_enforcement_always_fail_closed.test.js": "7a323908028c1835", + "test/boundary/encoding_chunk_boundaries.test/09_p2sh_p2wsh_compiled_chunk_size_ceiling.test.js": "c547ad41237f5f7b", + "test/boundary/fee_calculation_boundaries.test.js": "427e40092d870bf8", + "test/boundary/obfuscation_boundaries.test.js": "cd6d34bba928b35b", + "test/boundary/txsize_estimator_boundaries.test.js": "b2c94f7368f553aa", + "test/boundary/utxo_value_boundaries.test.js": "b08835d106268b2a" + } + }, + "test:chaos": { + "fileCount": 6, + "titleCount": 65, + "files": { + "test/chaos/api_layer.test.js": "16caf10d6b0f828b", + "test/chaos/arithmetic_state.test.js": "dc974e6bd1abb882", + "test/chaos/input_corruption.test.js": "1c5c9824a2732293", + "test/chaos/library_failures.test.js": "156289203491c9e5", + "test/chaos/network_failures.test.js": "d23d7c5b635a6794", + "test/chaos/resource_exhaustion.test.js": "56b53ed53845ff32" + } + }, + "test:e2e": { + "fileCount": 27, + "titleCount": 158, + "files": { + "test/e2e/action_pipeline_e2e.test.js": "f2625b374d260d4b", + "test/e2e/action_pipeline_e2e.test/01_e2e_1_14_airdrop.test.js": "920e3050fd4107ed", + "test/e2e/action_pipeline_e2e.test/02_e2e_1_21_broadcast.test.js": "870962db3bfe1571", + "test/e2e/action_pipeline_e2e.test/03_e2e_1_28_batch.test.js": "04c80607df6faac2", + "test/e2e/action_pipeline_e2e.test/04_structural_invariants_across_all_action_types.test.js": "cb66238944b4845c", + "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/obfuscation_integrity_e2e.test.js": "883e1aa41a43b1c9", + "test/e2e/p2sh_p2wsh_sequence_e2e.test.js": "0d8834d5fc73c75b", + "test/e2e/round_trip_e2e.test.js": "c29f3481850f5d9f", + "test/e2e/utxo_fee_change_e2e.test.js": "64da3d9aebea4a81", + "test/e2e/utxo_fee_change_e2e.test/01_e2e_5_2_multiple_utxos_aggregated.test.js": "76765d0ff666ac82", + "test/e2e/utxo_fee_change_e2e.test/02_e2e_5_3_utxo_deduplication.test.js": "dfeddeb82fafc3ef", + "test/e2e/utxo_fee_change_e2e.test/03_e2e_5_4_unconfirmed_utxos_excluded.test.js": "3d0c3f469f5a49b0", + "test/e2e/utxo_fee_change_e2e.test/04_e2e_5_5_unconfirmed_utxos_included.test.js": "9235b63db069724c", + "test/e2e/utxo_fee_change_e2e.test/05_e2e_5_6_utxo_tracker_fallback.test.js": "0da11bf4c436ca7c", + "test/e2e/utxo_fee_change_e2e.test/06_e2e_5_7_fee_floor_enforcement.test.js": "cee2a600289a6989", + "test/e2e/utxo_fee_change_e2e.test/07_e2e_5_8_fee_cap_enforcement.test.js": "07b9c396d0fbecca", + "test/e2e/utxo_fee_change_e2e.test/08_e2e_5_9_no_change_address_error.test.js": "9ca9d71201e69f83", + "test/e2e/utxo_fee_change_e2e.test/09_e2e_5_10_legacy_utxo_handling.test.js": "13fc0912a0f597f5", + "test/e2e/utxo_fee_change_e2e.test/10_e2e_5_11_segwit_utxo_handling.test.js": "471e523a3c7464b3", + "test/e2e/utxo_fee_change_e2e.test/11_e2e_5_12_rbf_sequence_flag.test.js": "82bd252980196b0b", + "test/e2e/utxo_fee_change_e2e.test/12_e2e_5_13_custom_outputs_coexist_with_action.test.js": "2dd7e128b3fa1284", + "test/e2e/utxo_fee_change_e2e.test/13_e2e_5_14_explicit_fee_per_kb_bypasses_rpc.test.js": "3649849b71c1d16c", + "test/e2e/utxo_fee_change_e2e.test/14_e2e_5_15_null_fee_per_kb_triggers_rpc_call.test.js": "325536fb1b16a149" + } + }, + "test:e2e:service": { + "fileCount": 1, + "titleCount": 6, + "files": { + "test/e2e-service/api_layer_e2e.test.js": "fceac8d2d8f0bb07" + } + }, + "test:fuzz": { + "fileCount": 3, + "titleCount": 6, + "files": { + "test/fuzz/datatopubkey.fuzz.js": "f7313ee5d4a1e4d5", + "test/fuzz/obfuscate.fuzz.js": "65825b2a5099ec3e", + "test/fuzz/preparedata.fuzz.js": "cf4d68f6c7c95947" + } + }, + "test:integration": { + "fileCount": 21, + "titleCount": 115, + "files": { + "test/integration/action_encoding.test.js": "9adb839d3209d4b9", + "test/integration/custom_outputs.test.js": "1a362ab39ee459fd", + "test/integration/encoder_stress_sweep.test.js": "9ec1bc0709d49362", + "test/integration/encoding_types.test.js": "ba5e3c6a15d7666d", + "test/integration/error_handling.test.js": "06a3215a6e86ed2d", + "test/integration/large_satoshi_tracker.test.js": "6b03cb1158d948c7", + "test/integration/multi_chain.test.js": "e4c3435f9bc2b0af", + "test/integration/obfuscation_roundtrip.test.js": "d101790f46691c56", + "test/integration/utxo_fee.test.js": "70440ad9037c909f", + "test/integration/utxo_fee.test/01_d_2_multiple_utxos_needed.test.js": "df667f23a4c0ba6d", + "test/integration/utxo_fee.test/02_d_3_duplicate_utxo_deduplication.test.js": "171d238e570fea73", + "test/integration/utxo_fee.test/03_d_4_unconfirmed_filtering.test.js": "d650883504da9b2d", + "test/integration/utxo_fee.test/04_d_5_utxo_tracker_fallback.test.js": "0e4bd255929ec3b3", + "test/integration/utxo_fee.test/05_d_6_no_utxos_available.test.js": "ee1005d70e3a9b6a", + "test/integration/utxo_fee.test/06_d_7_fee_capped_by_max_fee_per_bytes.test.js": "8f0e3d8435a01701", + "test/integration/utxo_fee.test/07_d_8_dust_floor_on_fee.test.js": "ab3024540b6f92b9", + "test/integration/utxo_fee.test/08_d_9_no_change_address.test.js": "271d27a1e5622be1", + "test/integration/utxo_fee.test/09_d_10_legacy_non_segwit_utxo_handling.test.js": "b32e58f0542ec761", + "test/integration/utxo_fee.test/10_utxo_sorting_largest_first.test.js": "ca5016508c9204fa", + "test/integration/utxo_fee.test/11_replace_by_fee_sequence.test.js": "a5c9e585e49afb1a", + "test/integration/utxo_fee.test/12_fee_source_selection.test.js": "3b83f98eb236d6d9" + } + }, + "test:regression": { + "fileCount": 27, + "titleCount": 307, + "files": { + "test/regression/reg_01_encoding_types.test.js": "c438a1275387f62b", + "test/regression/reg_02_obfuscation.test.js": "6c8feaefe1116923", + "test/regression/reg_03_fee_utxo.test.js": "2b12b033eef48c5a", + "test/regression/reg_04_validator.test.js": "fa31a66c65833973", + "test/regression/reg_04_validator.test/01_validate_utxo_entry.test.js": "5ad7a5c09e3d72bb", + "test/regression/reg_04_validator.test/02_validate_utxo_array.test.js": "4aa31300b417710e", + "test/regression/reg_04_validator.test/03_validate_custom_outputs.test.js": "b9beb9ad608568f2", + "test/regression/reg_04_validator.test/04_validate_p2sh_params.test.js": "361338400dd1a0a7", + "test/regression/reg_04_validator.test/05_validate_compressed_pub_key.test.js": "d5e9845e4e125e70", + "test/regression/reg_04_validator.test/06_validate_change.test.js": "6e6b6189dd2623bf", + "test/regression/reg_04_validator.test/07_validate_all_integration.test.js": "df4be611311f4206", + "test/regression/reg_05_multi_chain.test.js": "0e91f384a4f7fca1", + "test/regression/reg_06_p2sh_p2wsh_sequence.test.js": "68346d71243fcd6b", + "test/regression/reg_07_action_pipeline.test.js": "393ef05040e4ada2", + "test/regression/reg_08_api_contract.test.js": "9f36f327b577990e", + "test/regression/reg_09_deepdive_fixes.test.js": "ef8432a4a1ba859e", + "test/regression/reg_10_utxo_tracker_freshness.test.js": "140130fb419b99c8", + "test/regression/reg_11_minimal_op_canonicalization.test.js": "76410d2234a85853", + "test/regression/reg_12_ins0_key_binding.test.js": "c131768030b78cbc", + "test/regression/reg_13_reservation_release_on_throw.test.js": "4f79a402f81e163a", + "test/regression/reg_14_p2sh_reveal_fee_floor.test.js": "3675428cec810c11", + "test/regression/reg_15_uppercase_txid_key_binding.test.js": "926d15e7ddd15fee", + "test/regression/reg_16_rawdata_only_warning.test.js": "145ea55c07323321", + "test/regression/reg_17_utxo_pagination_snapshot.test.js": "a8137cddea506ba3", + "test/regression/reg_18_chained_send_reservation.test.js": "9716e0ba5467b267", + "test/regression/reg_19_envelope_cancel_reservation.test.js": "7334b6c4e6a33bbe", + "test/regression/reg_31_shortfall_names_reserved_inputs.test.js": "82be72919ffb1b6d" + } + }, + "test:regtest": { + "fileCount": 2, + "titleCount": 2, + "files": { + "test/api.test.js": "52004ee9ee184868", + "test/xchain_encoder.test.js": "64e3f3fd5d6fd7e3" + } + }, + "test:security": { + "fileCount": 7, + "titleCount": 67, + "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/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": "41d964a43ddfab20" + } + }, "test:unit": { - "fileCount": 47, - "titleCount": 906, + "fileCount": 120, + "titleCount": 910, "files": { - "test/unit/ActionManifestConformance.test.js": "babd2db450e2e6a1", - "test/unit/BlockchainConnector.test.js": "f16582a001cac4d2", - "test/unit/CryptoNetworks.test.js": "3f015c065bb424cc", - "test/unit/LargeSatoshiAmounts.test.js": "c512e0cbe2bc163a", - "test/unit/TxSizeEstimator.test.js": "99f2a27c2274c9bc", - "test/unit/UtxoTracker.test.js": "1afcffc2f1fa70d9", - "test/unit/XChainEncoder.autoEncoding.test.js": "0748973400fcace3", - "test/unit/XChainEncoder.consensusPinBoot.test.js": "6a874601dbd714de", - "test/unit/XChainEncoder.createTransaction.test.js": "57c53b6db4d98a77", - "test/unit/XChainEncoder.dataToPubkey.test.js": "ccc87ed9c55081d5", - "test/unit/XChainEncoder.exactInputs.test.js": "0402af4b590d4603", - "test/unit/XChainEncoder.extra.test.js": "d6e2e7913a5f7e7f", - "test/unit/XChainEncoder.feeRateCap.test.js": "5ada45b5f0f336ef", - "test/unit/XChainEncoder.isSegwitUTXO.test.js": "b35bd4199facec7a", - "test/unit/XChainEncoder.obfuscate.test.js": "11a13e37d91017bf", - "test/unit/XChainEncoder.p2shRevealHeadroom.test.js": "e3e957d2c34ec853", - "test/unit/XChainEncoder.prepareData.test.js": "4a88b66dc7f07157", - "test/unit/XChainEncoder.releaseReservations.test.js": "923b72fe3989bb3c", - "test/unit/XChainEncoder.revealFundingBinding.test.js": "990dee7527465c8e", - "test/unit/XChainEncoder.segwitInputGuard.test.js": "2aa020357e5b268b", - "test/unit/XChainEncoder.softDustFloor.test.js": "ef95d11a5f979b57", - "test/unit/XChainEncoder.taprootEnvelope.test.js": "d51133e5aaa56d2f", - "test/unit/XChainEncoder.trackerFreshnessClassifier.test.js": "e9e98dbc28c54a4f", - "test/unit/XChainEncoder.utxoDedup.test.js": "2a1c91a7aceda8eb", - "test/unit/applyBufferutilsPatch.test.js": "63c55285b6bac324", - "test/unit/coins-conformance.test.js": "2442df610ebb6911", - "test/unit/compression.test.js": "140f5ed1c625c9ab", - "test/unit/corsOrigin.test.js": "121f9fc54d5673a7", - "test/unit/corsPreflight.test.js": "78e833e8f04d5380", - "test/unit/coverage-thresholds-sync.test.js": "4405034a199528ec", - "test/unit/crashHandlers.test.js": "ab75df16ddef33d7", - "test/unit/docs-openrpc.test.js": "65be62f0e174b504", - "test/unit/encoderStressSweep.test.js": "358184203c4ccfba", - "test/unit/envelopeRecognitionGate.test.js": "e349cec537f9beb2", - "test/unit/errorSanitize.test.js": "8c8c02bad5e89e5b", - "test/unit/evmAdapter.test.js": "2c605d4ec40ce8e5", - "test/unit/health-serve-readiness.test.js": "99f46ad865902c51", - "test/unit/jsonrpc-body-guard.test.js": "58706b2a54fa0008", - "test/unit/maintenanceWindow.test.js": "a6d0a7d54f8fdf52", + "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", + "test/unit/api/cors_preflight.test.js": "78e833e8f04d5380", + "test/unit/api/encoder_stress_sweep.test.js": "358184203c4ccfba", + "test/unit/api/health_serve_readiness.test.js": "99f46ad865902c51", + "test/unit/api/jsonrpc_body_guard.test.js": "58706b2a54fa0008", + "test/unit/api/openrpc_coverage.test.js": "995c9801f2581aab", + "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": "7fabeeeb5816e16e", + "test/unit/blockchain_connector.test/04_send_raw_transaction.test.js": "e65ba2021038db9f", + "test/unit/blockchain_connector.test/05_send_raw_transaction_maxfeerate_retry.test.js": "b70b109181c9f1fc", + "test/unit/blockchain_connector.test/06_get_fee_per_kilobyte.test.js": "fae26ed352b38e34", + "test/unit/blockchain_connector.test/07_rpc_credential_log_sanitization.test.js": "72c9c5f2d2749796", + "test/unit/build/apply_bufferutils_patch.test.js": "63c55285b6bac324", + "test/unit/build/compression.test.js": "68868c37d76d5eb2", + "test/unit/build/compression.test/01_action_string_helpers.test.js": "f6cecc24770508b5", + "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/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", + "test/unit/build/package_fee_sizing.test/03_two_phase_reveal_package_prefund.test.js": "4dd0d30bb214ff45", + "test/unit/build/package_fee_sizing.test/04_p2sh_two_phase_package_prefund.test.js": "6f8680c56c04f0ef", + "test/unit/build/tx_size_estimator.test.js": "99f2a27c2274c9bc", + "test/unit/build/utxo_tracker.test.js": "f4d480d0b43ad2e9", + "test/unit/build/utxo_tracker.test/01_get_sync_status.test.js": "fea0d5dcb2b90bb0", + "test/unit/build/utxo_tracker.test/02_get_utxos_from_address.test.js": "149a2b69c0c72f7f", + "test/unit/build/utxo_tracker.test/03_get_utxos_from_address_validation.test.js": "27d7bba206f363af", + "test/unit/coins/coins_conformance.test.js": "2442df610ebb6911", + "test/unit/coins/xchain_encoder_consensus_pin_boot.test.js": "6a874601dbd714de", + "test/unit/common/error_sanitize.test.js": "8c8c02bad5e89e5b", + "test/unit/common/validator/action_manifest_conformance.test.js": "babd2db450e2e6a1", + "test/unit/common/validator/large_satoshi_amounts.test.js": "c512e0cbe2bc163a", + "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", + "test/unit/common/validator/validator.test/04_alias_expansion_at_the_compiled_ceiling.test.js": "6d14427e043988d0", + "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/09_validate_dust.test.js": "0e58538e3d6aa84b", + "test/unit/common/validator/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js": "c590386a3c3ac1c3", + "test/unit/common/validator/validator.test/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/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/observability.test.js": "ca8fa416b5e8a763", - "test/unit/openrpc-coverage.test.js": "995c9801f2581aab", - "test/unit/packageFeeSizing.test.js": "dfc3f143ee7a2329", - "test/unit/security/configuration/dependency-advisories.test.js": "0f2abe37f99c5a17", - "test/unit/sibling-coverage.test.js": "d8d1e52267ea606f", - "test/unit/singleInstanceGuard.test.js": "750c1552249846a5", - "test/unit/suggestedFeeCeiling.test.js": "a76fc6f4a4157148", - "test/unit/validator.test.js": "85489f2e3279585d" + "test/unit/repo/coverage_thresholds_sync.test.js": "4405034a199528ec", + "test/unit/repo/docs_openrpc.test.js": "65be62f0e174b504", + "test/unit/repo/sibling_coverage.test.js": "d8d1e52267ea606f", + "test/unit/security/configuration/dependency_advisories.test.js": "0f2abe37f99c5a17", + "test/unit/server/cors_origin.test.js": "121f9fc54d5673a7", + "test/unit/server/maintenance_window.test.js": "a6d0a7d54f8fdf52", + "test/unit/server/single_instance_guard.test.js": "bccc1cc83d946a18", + "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/envelope_recognition_gate.test.js": "e349cec537f9beb2", + "test/unit/xchain_encoder/suggested_fee_ceiling.test.js": "a76fc6f4a4157148", + "test/unit/xchain_encoder/xchain_encoder_auto_encoding.test.js": "0748973400fcace3", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test.js": "23e242d978109d26", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/01_utxo_deduplication.test.js": "bb903d583344a390", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/02_unconfirmed_filtering.test.js": "01301310489a6d18", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/03_utxo_sorting.test.js": "61c7893f33172ff0", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/04_replace_by_fee_sequence.test.js": "779e7e9a758ea87d", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/05_fee_handling.test.js": "b77f94d358245fae", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/06_custom_dust_parameter.test.js": "68626fb6051d2aba", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/07_change_output.test.js": "dbe9aa0a839fb823", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/08_no_utxos_available.test.js": "c08315d45c994876", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/09_custom_outputs.test.js": "c9d4ebeec3e35e91", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/10_op_return_encoding_path.test.js": "08d13b4a948a8f78", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/11_p2sh_encoding_funding.test.js": "13fa2adf6419a698", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/12_p2sh_encoding_spending.test.js": "78ea596173e06da0", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/13_p2sh_native_fee_custom_outputs.test.js": "c77bdfff70e2f687", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/14_multisign_encoding_path.test.js": "13761a81f887787c", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/15_return_value.test.js": "21640a976ee201c8", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/16_concurrent_selection.test.js": "9f193c2caff73160", + "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/16_estimate_spending_p2sh_tx.test.js": "1c00492f188266d1", + "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_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", + "test/unit/xchain_encoder/xchain_encoder_extra.test/02_p2wsh_tx2_spending.test.js": "b3f82aa329a90693", + "test/unit/xchain_encoder/xchain_encoder_extra.test/03_payload_size_guard.test.js": "1b91de49aeb88df1", + "test/unit/xchain_encoder/xchain_encoder_extra.test/04_fee_quote_injection.test.js": "447682ee980135d8", + "test/unit/xchain_encoder/xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.js": "34a2845bc2d97b6c", + "test/unit/xchain_encoder/xchain_encoder_extra.test/06_change_edge_cases.test.js": "8f324f20d5abfee5", + "test/unit/xchain_encoder/xchain_encoder_extra.test/07_invalid_fee.test.js": "fc5c3c204837ea5e", + "test/unit/xchain_encoder/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js": "59ae18cc4c09d4bd", + "test/unit/xchain_encoder/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js": "630b933b3e1320d0", + "test/unit/xchain_encoder/xchain_encoder_extra.test/10_raw_data_parameter.test.js": "d683ae27a964eb5b", + "test/unit/xchain_encoder/xchain_encoder_fee_rate_cap.test.js": "5ada45b5f0f336ef", + "test/unit/xchain_encoder/xchain_encoder_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", + "test/unit/xchain_encoder/xchain_encoder_prepare_data.test.js": "4a88b66dc7f07157", + "test/unit/xchain_encoder/xchain_encoder_release_reservations.test.js": "923b72fe3989bb3c", + "test/unit/xchain_encoder/xchain_encoder_reveal_funding_binding.test.js": "990dee7527465c8e", + "test/unit/xchain_encoder/xchain_encoder_segwit_input_guard.test.js": "2aa020357e5b268b", + "test/unit/xchain_encoder/xchain_encoder_soft_dust_floor.test.js": "ef95d11a5f979b57", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test.js": "357ca3318e99187c", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js": "27e24996d31ce20e", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js": "880c961e264c0519", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js": "e1c1badc45b5ec79", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js": "5086151532f51b80", + "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js": "44230034b18955bb", + "test/unit/xchain_encoder/xchain_encoder_tracker_freshness_classifier.test.js": "e9e98dbc28c54a4f", + "test/unit/xchain_encoder/xchain_encoder_utxo_dedup.test.js": "2a1c91a7aceda8eb" } } } diff --git a/bin/pins/suite-title-splits.json b/bin/pins/suite-title-splits.json index 0baab71..cd8fa8d 100644 --- a/bin/pins/suite-title-splits.json +++ b/bin/pins/suite-title-splits.json @@ -10,183 +10,5 @@ "test/unit/big_suite/writes.test.js" ] }, - "splits": { - "test/unit/blockchain_connector.test.js": [ - "test/unit/blockchain_connector.test.js", - "test/unit/blockchain_connector.test/01_get_network_info.test.js", - "test/unit/blockchain_connector.test/02_is_regtest.test.js", - "test/unit/blockchain_connector.test/03_get_transaction_hex.test.js", - "test/unit/blockchain_connector.test/04_send_raw_transaction.test.js", - "test/unit/blockchain_connector.test/05_send_raw_transaction_maxfeerate_retry.test.js", - "test/unit/blockchain_connector.test/06_get_fee_per_kilobyte.test.js", - "test/unit/blockchain_connector.test/07_rpc_credential_log_sanitization.test.js" - ], - "test/unit/build/compression.test.js": [ - "test/unit/build/compression.test.js", - "test/unit/build/compression.test/01_action_string_helpers.test.js", - "test/unit/build/compression.test/02_create_transaction_integration.test.js", - "test/unit/build/compression.test/03_the_compression_report_carries_the_bytes_that_were_written.test.js", - "test/unit/build/compression.test/04_validator.test.js", - "test/unit/build/compression.test/05_constants_conformance.test.js" - ], - "test/unit/build/package_fee_sizing.test.js": [ - "test/unit/build/package_fee_sizing.test.js", - "test/unit/build/package_fee_sizing.test/01_package_fee_uplift_satoshis.test.js", - "test/unit/build/package_fee_sizing.test/02_xchain_encoder_package_aware_fee_sizing.test.js", - "test/unit/build/package_fee_sizing.test/03_two_phase_reveal_package_prefund.test.js", - "test/unit/build/package_fee_sizing.test/04_p2sh_two_phase_package_prefund.test.js" - ], - "test/unit/server/single_instance_guard.test.js": [ - "test/unit/server/single_instance_guard.test.js", - "test/unit/server/single_instance_guard.test/01_acquire_instance_lock.test.js", - "test/unit/server/single_instance_guard.test/02_is_pid_alive.test.js", - "test/unit/server/single_instance_guard.test/03_release_lock_on_signals.test.js" - ], - "test/unit/build/utxo_tracker.test.js": [ - "test/unit/build/utxo_tracker.test.js", - "test/unit/build/utxo_tracker.test/01_get_sync_status.test.js", - "test/unit/build/utxo_tracker.test/02_get_utxos_from_address.test.js", - "test/unit/build/utxo_tracker.test/03_get_utxos_from_address_validation.test.js" - ], - "test/unit/xchain_encoder/xchain_encoder_create_transaction.test.js": [ - "test/unit/xchain_encoder/xchain_encoder_create_transaction.test.js", - "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/01_utxo_deduplication.test.js", - "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/02_unconfirmed_filtering.test.js", - "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/03_utxo_sorting.test.js", - "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/04_replace_by_fee_sequence.test.js", - "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/05_fee_handling.test.js", - "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/06_custom_dust_parameter.test.js", - "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/07_change_output.test.js", - "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/08_no_utxos_available.test.js", - "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/09_custom_outputs.test.js", - "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/10_op_return_encoding_path.test.js", - "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/11_p2sh_encoding_funding.test.js", - "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/12_p2sh_encoding_spending.test.js", - "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/13_p2sh_native_fee_custom_outputs.test.js", - "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/14_multisign_encoding_path.test.js", - "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/15_return_value.test.js", - "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/16_estimate_spending_p2sh_tx.test.js", - "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/17_payment_only.test.js", - "test/unit/xchain_encoder/xchain_encoder_create_transaction.test/18_payload_guards.test.js" - ], - "test/unit/xchain_encoder/xchain_encoder_extra.test.js": [ - "test/unit/xchain_encoder/xchain_encoder_extra.test.js", - "test/unit/xchain_encoder/xchain_encoder_extra.test/01_p2wsh_tx1_funding.test.js", - "test/unit/xchain_encoder/xchain_encoder_extra.test/02_p2wsh_tx2_spending.test.js", - "test/unit/xchain_encoder/xchain_encoder_extra.test/03_payload_size_guard.test.js", - "test/unit/xchain_encoder/xchain_encoder_extra.test/04_fee_quote_injection.test.js", - "test/unit/xchain_encoder/xchain_encoder_extra.test/05_max_fee_rate_kb_cap.test.js", - "test/unit/xchain_encoder/xchain_encoder_extra.test/06_change_edge_cases.test.js", - "test/unit/xchain_encoder/xchain_encoder_extra.test/07_invalid_fee.test.js", - "test/unit/xchain_encoder/xchain_encoder_extra.test/08_estimate_spending_p2wsh_tx.test.js", - "test/unit/xchain_encoder/xchain_encoder_extra.test/09_remaining_branch_coverage.test.js", - "test/unit/xchain_encoder/xchain_encoder_extra.test/10_raw_data_parameter.test.js" - ], - "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test.js": [ - "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test.js", - "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/01_create_transaction_pair_construction.test.js", - "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/02_sign_finalize_extract_round_trip.test.js", - "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/03_key_path_cancel_from_persisted_recovery_record.test.js", - "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js" - ], - "test/xchain_encoder.test.js": [ - "test/xchain_encoder.test.js", - "test/xchain_encoder.test/01_should_create_a_p2sh_tx.test.js", - "test/xchain_encoder.test/02_should_create_a_multisign_tx.test.js", - "test/xchain_encoder.test/03_should_create_a_p2wsh_tx.test.js" - ], - "test/boundary/encoding_chunk_boundaries.test.js": [ - "test/boundary/encoding_chunk_boundaries.test.js", - "test/boundary/encoding_chunk_boundaries.test/01_op_return_chunk_split_threshold_forced_encoding.test.js", - "test/boundary/encoding_chunk_boundaries.test/02_p2sh_chunk_split_threshold.test.js", - "test/boundary/encoding_chunk_boundaries.test/03_p2wsh_real_world_boundary_bitcoin_regtest_only.test.js", - "test/boundary/encoding_chunk_boundaries.test/04_p2wsh_on_dogecoin_no_bech32_in_network_config.test.js", - "test/boundary/encoding_chunk_boundaries.test/05_multisign_encoding_boundaries.test.js", - "test/boundary/encoding_chunk_boundaries.test/06_data_to_pubkey_unit_boundaries.test.js", - "test/boundary/encoding_chunk_boundaries.test/07_forced_encoding_override.test.js", - "test/boundary/encoding_chunk_boundaries.test/08_single_op_return_policy_enforcement_always_fail_closed.test.js", - "test/boundary/encoding_chunk_boundaries.test/09_p2sh_p2wsh_compiled_chunk_size_ceiling.test.js" - ], - "test/e2e/action_pipeline_e2e.test.js": [ - "test/e2e/action_pipeline_e2e.test.js", - "test/e2e/action_pipeline_e2e.test/01_e2e_1_14_airdrop.test.js", - "test/e2e/action_pipeline_e2e.test/02_e2e_1_21_broadcast.test.js", - "test/e2e/action_pipeline_e2e.test/03_e2e_1_28_batch.test.js", - "test/e2e/action_pipeline_e2e.test/04_structural_invariants_across_all_action_types.test.js" - ], - "test/e2e/utxo_fee_change_e2e.test.js": [ - "test/e2e/utxo_fee_change_e2e.test.js", - "test/e2e/utxo_fee_change_e2e.test/01_e2e_5_2_multiple_utxos_aggregated.test.js", - "test/e2e/utxo_fee_change_e2e.test/02_e2e_5_3_utxo_deduplication.test.js", - "test/e2e/utxo_fee_change_e2e.test/03_e2e_5_4_unconfirmed_utxos_excluded.test.js", - "test/e2e/utxo_fee_change_e2e.test/04_e2e_5_5_unconfirmed_utxos_included.test.js", - "test/e2e/utxo_fee_change_e2e.test/05_e2e_5_6_utxo_tracker_fallback.test.js", - "test/e2e/utxo_fee_change_e2e.test/06_e2e_5_7_fee_floor_enforcement.test.js", - "test/e2e/utxo_fee_change_e2e.test/07_e2e_5_8_fee_cap_enforcement.test.js", - "test/e2e/utxo_fee_change_e2e.test/08_e2e_5_9_no_change_address_error.test.js", - "test/e2e/utxo_fee_change_e2e.test/09_e2e_5_10_legacy_utxo_handling.test.js", - "test/e2e/utxo_fee_change_e2e.test/10_e2e_5_11_segwit_utxo_handling.test.js", - "test/e2e/utxo_fee_change_e2e.test/11_e2e_5_12_rbf_sequence_flag.test.js", - "test/e2e/utxo_fee_change_e2e.test/12_e2e_5_13_custom_outputs_coexist_with_action.test.js", - "test/e2e/utxo_fee_change_e2e.test/13_e2e_5_14_explicit_fee_per_kb_bypasses_rpc.test.js", - "test/e2e/utxo_fee_change_e2e.test/14_e2e_5_15_null_fee_per_kb_triggers_rpc_call.test.js" - ], - "test/integration/utxo_fee.test.js": [ - "test/integration/utxo_fee.test.js", - "test/integration/utxo_fee.test/01_d_2_multiple_utxos_needed.test.js", - "test/integration/utxo_fee.test/02_d_3_duplicate_utxo_deduplication.test.js", - "test/integration/utxo_fee.test/03_d_4_unconfirmed_filtering.test.js", - "test/integration/utxo_fee.test/04_d_5_utxo_tracker_fallback.test.js", - "test/integration/utxo_fee.test/05_d_6_no_utxos_available.test.js", - "test/integration/utxo_fee.test/06_d_7_fee_capped_by_max_fee_per_bytes.test.js", - "test/integration/utxo_fee.test/07_d_8_dust_floor_on_fee.test.js", - "test/integration/utxo_fee.test/08_d_9_no_change_address.test.js", - "test/integration/utxo_fee.test/09_d_10_legacy_non_segwit_utxo_handling.test.js", - "test/integration/utxo_fee.test/10_utxo_sorting_largest_first.test.js", - "test/integration/utxo_fee.test/11_replace_by_fee_sequence.test.js", - "test/integration/utxo_fee.test/12_fee_source_selection.test.js" - ], - "test/regression/reg_04_validator.test.js": [ - "test/regression/reg_04_validator.test.js", - "test/regression/reg_04_validator.test/01_validate_utxo_entry.test.js", - "test/regression/reg_04_validator.test/02_validate_utxo_array.test.js", - "test/regression/reg_04_validator.test/03_validate_custom_outputs.test.js", - "test/regression/reg_04_validator.test/04_validate_p2sh_params.test.js", - "test/regression/reg_04_validator.test/05_validate_compressed_pub_key.test.js", - "test/regression/reg_04_validator.test/06_validate_change.test.js", - "test/regression/reg_04_validator.test/07_validate_all_integration.test.js" - ], - "test/security/concurrency_gate.test.js": [ - "test/security/concurrency_gate.test.js", - "test/security/concurrency_gate.test/01_resolve_limit.test.js", - "test/security/concurrency_gate.test/02_api_js_wiring.test.js" - ], - "test/smoke/smoke.test.js": [ - "test/smoke/smoke.test.js", - "test/smoke/smoke.test/01_tx_size_estimator.test.js", - "test/smoke/smoke.test/02_segwit_utxo_detection.test.js", - "test/smoke/smoke.test/03_data_to_pubkey.test.js", - "test/smoke/smoke.test/04_api_server_startup.test.js" - ], - "test/unit/common/validator/validator.test.js": [ - "test/unit/common/validator/validator.test.js", - "test/unit/common/validator/validator.test/01_validate_data_param.test.js", - "test/unit/common/validator/validator.test/02_validate_action_name_encoder_action_name_gate.test.js", - "test/unit/common/validator/validator.test/03_validate_combined_data_length.test.js", - "test/unit/common/validator/validator.test/04_alias_expansion_at_the_compiled_ceiling.test.js", - "test/unit/common/validator/validator.test/05_validate_encoding.test.js", - "test/unit/common/validator/validator.test/06_max_fee_satoshis.test.js", - "test/unit/common/validator/validator.test/07_validate_fee.test.js", - "test/unit/common/validator/validator.test/08_validate_fee_per_kb.test.js", - "test/unit/common/validator/validator.test/09_validate_dust.test.js", - "test/unit/common/validator/validator.test/10_validate_utxo_array_validate_utxo_entry.test.js", - "test/unit/common/validator/validator.test/11_validate_custom_outputs_validate_custom_output_via_wrapper.test.js", - "test/unit/common/validator/validator.test/12_validate_p2sh_params.test.js", - "test/unit/common/validator/validator.test/13_validate_raw_tx_hex.test.js", - "test/unit/common/validator/validator.test/14_validate_compressed_pub_key.test.js", - "test/unit/common/validator/validator.test/15_validate_change.test.js", - "test/unit/common/validator/validator.test/16_validate_address.test.js", - "test/unit/common/validator/validator.test/17_validate_all.test.js" - ] - } + "splits": {} } diff --git a/bin/pins/test-rename-map.json b/bin/pins/test-rename-map.json index e170484..0967ef4 100644 --- a/bin/pins/test-rename-map.json +++ b/bin/pins/test-rename-map.json @@ -1,118 +1 @@ -{ - "test/XChainEncoder.test.js": "test/xchain_encoder.test.js", - "test/boundary/change-address-boundaries.test.js": "test/boundary/change_address_boundaries.test.js", - "test/boundary/custom-output-boundaries.test.js": "test/boundary/custom_output_boundaries.test.js", - "test/boundary/data-payload-boundaries.test.js": "test/boundary/data_payload_boundaries.test.js", - "test/boundary/encoding-chunk-boundaries.test.js": "test/boundary/encoding_chunk_boundaries.test.js", - "test/boundary/fee-calculation-boundaries.test.js": "test/boundary/fee_calculation_boundaries.test.js", - "test/boundary/obfuscation-boundaries.test.js": "test/boundary/obfuscation_boundaries.test.js", - "test/boundary/txsize-estimator-boundaries.test.js": "test/boundary/txsize_estimator_boundaries.test.js", - "test/boundary/utxo-value-boundaries.test.js": "test/boundary/utxo_value_boundaries.test.js", - "test/chaos/api-layer.test.js": "test/chaos/api_layer.test.js", - "test/chaos/arithmetic-state.test.js": "test/chaos/arithmetic_state.test.js", - "test/chaos/input-corruption.test.js": "test/chaos/input_corruption.test.js", - "test/chaos/library-failures.test.js": "test/chaos/library_failures.test.js", - "test/chaos/network-failures.test.js": "test/chaos/network_failures.test.js", - "test/chaos/resource-exhaustion.test.js": "test/chaos/resource_exhaustion.test.js", - "test/conformance/generateRoundtripFixture.js": "test/conformance/helpers/generate_roundtrip_fixture.js", - "test/conformance/roundtripConformance.test.js": "test/conformance/roundtrip_conformance.test.js", - "test/conformance/utxoRecordConformance.test.js": "test/conformance/utxo_record_conformance.test.js", - "test/e2e-service/api-layer.e2e.js": "test/e2e-service/api_layer_e2e.test.js", - "test/e2e/action-pipeline.e2e.js": "test/e2e/action_pipeline_e2e.test.js", - "test/e2e/edge-cases.e2e.js": "test/e2e/edge_cases_e2e.test.js", - "test/e2e/encoding-boundaries.e2e.js": "test/e2e/encoding_boundaries_e2e.test.js", - "test/e2e/error-rejection.e2e.js": "test/e2e/error_rejection_e2e.test.js", - "test/e2e/multi-chain.e2e.js": "test/e2e/multi_chain_e2e.test.js", - "test/e2e/obfuscation-integrity.e2e.js": "test/e2e/obfuscation_integrity_e2e.test.js", - "test/e2e/p2sh-p2wsh-sequence.e2e.js": "test/e2e/p2sh_p2wsh_sequence_e2e.test.js", - "test/e2e/round-trip.e2e.js": "test/e2e/round_trip_e2e.test.js", - "test/e2e/utxo-fee-change.e2e.js": "test/e2e/utxo_fee_change_e2e.test.js", - "test/integration/action-encoding.test.js": "test/integration/action_encoding.test.js", - "test/integration/custom-outputs.test.js": "test/integration/custom_outputs.test.js", - "test/integration/encoderStressSweep.test.js": "test/integration/encoder_stress_sweep.test.js", - "test/integration/encoding-types.test.js": "test/integration/encoding_types.test.js", - "test/integration/error-handling.test.js": "test/integration/error_handling.test.js", - "test/integration/large-satoshi-tracker.test.js": "test/integration/large_satoshi_tracker.test.js", - "test/integration/multi-chain.test.js": "test/integration/multi_chain.test.js", - "test/integration/obfuscation-roundtrip.test.js": "test/integration/obfuscation_roundtrip.test.js", - "test/integration/utxo-fee.test.js": "test/integration/utxo_fee.test.js", - "test/nodeHelper.js": "test/helpers/node_helper.js", - "test/performance/bench.js": "test/performance/helpers/bench.js", - "test/performance/k6/concurrent.js": "test/performance/helpers/k6/concurrent.js", - "test/performance/k6/spike.js": "test/performance/helpers/k6/spike.js", - "test/performance/k6/sustained.js": "test/performance/helpers/k6/sustained.js", - "test/performance/soak.js": "test/performance/helpers/soak.js", - "test/prepareRegtest.test.js": "test/prepare_regtest.test.js", - "test/regression/reg-01-encoding-types.test.js": "test/regression/reg_01_encoding_types.test.js", - "test/regression/reg-02-obfuscation.test.js": "test/regression/reg_02_obfuscation.test.js", - "test/regression/reg-03-fee-utxo.test.js": "test/regression/reg_03_fee_utxo.test.js", - "test/regression/reg-04-validator.test.js": "test/regression/reg_04_validator.test.js", - "test/regression/reg-05-multi-chain.test.js": "test/regression/reg_05_multi_chain.test.js", - "test/regression/reg-06-p2sh-p2wsh-sequence.test.js": "test/regression/reg_06_p2sh_p2wsh_sequence.test.js", - "test/regression/reg-07-action-pipeline.test.js": "test/regression/reg_07_action_pipeline.test.js", - "test/regression/reg-08-api-contract.test.js": "test/regression/reg_08_api_contract.test.js", - "test/regression/reg-09-deepdive-fixes.test.js": "test/regression/reg_09_deepdive_fixes.test.js", - "test/regression/reg-10-utxo-tracker-freshness.test.js": "test/regression/reg_10_utxo_tracker_freshness.test.js", - "test/regression/reg-11-minimal-op-canonicalization.test.js": "test/regression/reg_11_minimal_op_canonicalization.test.js", - "test/regression/reg-12-ins0-key-binding.test.js": "test/regression/reg_12_ins0_key_binding.test.js", - "test/regression/reg-13-reservation-release-on-throw.test.js": "test/regression/reg_13_reservation_release_on_throw.test.js", - "test/regression/reg-14-p2sh-reveal-fee-floor.test.js": "test/regression/reg_14_p2sh_reveal_fee_floor.test.js", - "test/regression/reg-15-uppercase-txid-key-binding.test.js": "test/regression/reg_15_uppercase_txid_key_binding.test.js", - "test/regression/reg-16-rawdata-only-warning.test.js": "test/regression/reg_16_rawdata_only_warning.test.js", - "test/regression/reg-17-utxo-pagination-snapshot.test.js": "test/regression/reg_17_utxo_pagination_snapshot.test.js", - "test/regression/reg-18-chained-send-reservation.test.js": "test/regression/reg_18_chained_send_reservation.test.js", - "test/regression/reg-19-envelope-cancel-reservation.test.js": "test/regression/reg_19_envelope_cancel_reservation.test.js", - "test/regression/reg-31-shortfall-names-reserved-inputs.test.js": "test/regression/reg_31_shortfall_names_reserved_inputs.test.js", - "test/security/concurrency-gate.test.js": "test/security/concurrency_gate.test.js", - "test/security/input-validation.test.js": "test/security/input_validation.test.js", - "test/security/obfuscation-key.test.js": "test/security/obfuscation_key.test.js", - "test/security/payload-size.test.js": "test/security/payload_size.test.js", - "test/security/rate-limit-log.test.js": "test/security/rate_limit_log.test.js", - "test/setup.js": "test/setup/index.js", - "test/unit/ActionManifestConformance.test.js": "test/unit/common/validator/action_manifest_conformance.test.js", - "test/unit/BlockchainConnector.test.js": "test/unit/blockchain_connector.test.js", - "test/unit/CryptoNetworks.test.js": "test/unit/build/crypto_networks.test.js", - "test/unit/LargeSatoshiAmounts.test.js": "test/unit/common/validator/large_satoshi_amounts.test.js", - "test/unit/TxSizeEstimator.test.js": "test/unit/build/tx_size_estimator.test.js", - "test/unit/UtxoTracker.test.js": "test/unit/build/utxo_tracker.test.js", - "test/unit/XChainEncoder.autoEncoding.test.js": "test/unit/xchain_encoder/xchain_encoder_auto_encoding.test.js", - "test/unit/XChainEncoder.consensusPinBoot.test.js": "test/unit/coins/xchain_encoder_consensus_pin_boot.test.js", - "test/unit/XChainEncoder.createTransaction.test.js": "test/unit/xchain_encoder/xchain_encoder_create_transaction.test.js", - "test/unit/XChainEncoder.dataToPubkey.test.js": "test/unit/xchain_encoder/xchain_encoder_data_to_pubkey.test.js", - "test/unit/XChainEncoder.exactInputs.test.js": "test/unit/xchain_encoder/xchain_encoder_exact_inputs.test.js", - "test/unit/XChainEncoder.extra.test.js": "test/unit/xchain_encoder/xchain_encoder_extra.test.js", - "test/unit/XChainEncoder.feeRateCap.test.js": "test/unit/xchain_encoder/xchain_encoder_fee_rate_cap.test.js", - "test/unit/XChainEncoder.isSegwitUTXO.test.js": "test/unit/xchain_encoder/xchain_encoder_is_segwit_u_t_x_o.test.js", - "test/unit/XChainEncoder.obfuscate.test.js": "test/unit/xchain_encoder/xchain_encoder_obfuscate.test.js", - "test/unit/XChainEncoder.p2shRevealHeadroom.test.js": "test/unit/xchain_encoder/xchain_encoder_p2sh_reveal_headroom.test.js", - "test/unit/XChainEncoder.prepareData.test.js": "test/unit/xchain_encoder/xchain_encoder_prepare_data.test.js", - "test/unit/XChainEncoder.releaseReservations.test.js": "test/unit/xchain_encoder/xchain_encoder_release_reservations.test.js", - "test/unit/XChainEncoder.revealFundingBinding.test.js": "test/unit/xchain_encoder/xchain_encoder_reveal_funding_binding.test.js", - "test/unit/XChainEncoder.segwitInputGuard.test.js": "test/unit/xchain_encoder/xchain_encoder_segwit_input_guard.test.js", - "test/unit/XChainEncoder.softDustFloor.test.js": "test/unit/xchain_encoder/xchain_encoder_soft_dust_floor.test.js", - "test/unit/XChainEncoder.taprootEnvelope.test.js": "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test.js", - "test/unit/XChainEncoder.trackerFreshnessClassifier.test.js": "test/unit/xchain_encoder/xchain_encoder_tracker_freshness_classifier.test.js", - "test/unit/XChainEncoder.utxoDedup.test.js": "test/unit/xchain_encoder/xchain_encoder_utxo_dedup.test.js", - "test/unit/applyBufferutilsPatch.test.js": "test/unit/build/apply_bufferutils_patch.test.js", - "test/unit/coins-conformance.test.js": "test/unit/coins/coins_conformance.test.js", - "test/unit/compression.test.js": "test/unit/build/compression.test.js", - "test/unit/corsOrigin.test.js": "test/unit/server/cors_origin.test.js", - "test/unit/corsPreflight.test.js": "test/unit/api/cors_preflight.test.js", - "test/unit/coverage-thresholds-sync.test.js": "test/unit/repo/coverage_thresholds_sync.test.js", - "test/unit/crashHandlers.test.js": "test/unit/crash_handlers.test.js", - "test/unit/docs-openrpc.test.js": "test/unit/repo/docs_openrpc.test.js", - "test/unit/encoderStressSweep.test.js": "test/unit/api/encoder_stress_sweep.test.js", - "test/unit/envelopeRecognitionGate.test.js": "test/unit/xchain_encoder/envelope_recognition_gate.test.js", - "test/unit/errorSanitize.test.js": "test/unit/common/error_sanitize.test.js", - "test/unit/evmAdapter.test.js": "test/unit/adapters/evm_adapter.test.js", - "test/unit/health-serve-readiness.test.js": "test/unit/api/health_serve_readiness.test.js", - "test/unit/jsonrpc-body-guard.test.js": "test/unit/api/jsonrpc_body_guard.test.js", - "test/unit/maintenanceWindow.test.js": "test/unit/server/maintenance_window.test.js", - "test/unit/openrpc-coverage.test.js": "test/unit/api/openrpc_coverage.test.js", - "test/unit/packageFeeSizing.test.js": "test/unit/build/package_fee_sizing.test.js", - "test/unit/security/configuration/dependency-advisories.test.js": "test/unit/security/configuration/dependency_advisories.test.js", - "test/unit/sibling-coverage.test.js": "test/unit/repo/sibling_coverage.test.js", - "test/unit/singleInstanceGuard.test.js": "test/unit/server/single_instance_guard.test.js", - "test/unit/suggestedFeeCeiling.test.js": "test/unit/xchain_encoder/suggested_fee_ceiling.test.js", - "test/unit/validator.test.js": "test/unit/common/validator/validator.test.js" -} +{} From 99ff364a2cea6102d816a288f47904d18b5386d7 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 12:26:22 -0700 Subject: [PATCH 146/148] ci: run the suite-title pin compare as its own ci:full tier --- bin/ci-full.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bin/ci-full.sh b/bin/ci-full.sh index 6bd236b..2803515 100755 --- a/bin/ci-full.sh +++ b/bin/ci-full.sh @@ -107,6 +107,14 @@ identity_pin_check() { } run_tier "identity pin (vendored coins, conformance fixture)" identity_pin_check +# --- suite-title pin (this gate only; no ci.yml job runs it) ----------- +# Guards that every npm test script still collects the same test titles it +# did at the pin, through the declared rename and split maps. +run_tier "suite-title pin (at1)" node bin/suite-title-map.js \ + --compare bin/pins/at1-suite-titles.json \ + --rename-map bin/pins/test-rename-map.json \ + --split-map bin/pins/suite-title-splits.json + # --- job: coverage (needs: ci) ------------------------------------------ # The workflow checks out every repo in .ci-siblings before this job so the # ratchet re-run measures coverage with the same cross-repo tests exercised From 23216d1bb256f0f1e177dfbb06048b0550407314 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 15 Sep 2026 16:10:22 -0700 Subject: [PATCH 147/148] ci: run the envelope recognition gate against a checked-out xchain-decoder The drift-guards job never checked out the decoder, so the test that byte-compares ENVELOPE_RECOGNITION_ACTIVATION against the decoder copy skipped on every GitHub run. The job now checks out the (public) decoder beside the encoder and runs the test with XCHAIN_REQUIRE_SIBLINGS=1, so a missing sibling fails instead of skipping (activation-registry row 29, section 6.4 guard 3). --- .github/workflows/ci.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aec40c1..1b080d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,11 +56,28 @@ jobs: ssh-key: ${{ secrets.XCHAIN_HUB_DEPLOY_KEY }} path: xchain-hub + - name: Check out the sibling xchain-decoder (envelope gate twin) + uses: actions/checkout@v4 + with: + repository: XChain-Platform/xchain-decoder + ref: ${{ github.ref == 'refs/heads/master' && 'master' || 'develop' }} + path: xchain-decoder + - name: Use Node.js 22 uses: actions/setup-node@v4 with: node-version: "22" + - name: Install dependencies + working-directory: xchain-encoder + run: if [ -f package-lock.json ]; then npm ci; else npm install; fi + + - name: Envelope recognition gate equals the decoder copy (hard) + working-directory: xchain-encoder + env: + XCHAIN_REQUIRE_SIBLINGS: "1" + run: npx mocha test/unit/xchain_encoder/envelope_recognition_gate.test.js --exit + - name: Coin-registry byte-identity vs canonical run: xchain-hub/bin/sync-coins.sh --check --only xchain-encoder From 29320498eb1de5cdba9f47c2c2130b4e0ab83a7a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Wed, 16 Sep 2026 05:25:38 -0700 Subject: [PATCH 148/148] release: v0.19.0 Version 0.19.0, the 0.19.0 changelog section and the README badges and script-table counts measured on Linux. --- CHANGELOG.md | 9 +++++++++ README.md | 12 ++++++------ package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61d34f3..b67a935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.19.0] - 2026-09-16 + +### Added +- Consensus: the XBRIDGE action name and its escrow role addresses are recognized. + +### Changed +- Audited transitive packages move to their patched releases (lockfile only). +- Restructured under the platform code-structure standard (feature directories, snake_case files, split test suites, restored comments); consensus identity byte-identical and pinned. + ## [0.18.0] - 2026-09-11 ### Fixed diff --git a/README.md b/README.md index a5e0d78..ffef213 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ # XChain Platform Encoder

- Version - Tests + Version + Tests Node License

@@ -165,20 +165,20 @@ neither source sets one, so these defaults hold on an unconfigured box: | `npm run build` | Production browser bundle (minified) -> `dist/xchain_encoder.min.js` | | `npm run build:dev` | Development browser bundle (unminified) | | `npm run smoke-test` | Smoke tests (~52 tests, <1s) | -| `npm run test:unit` | Unit tests (870 tests) | +| `npm run test:unit` | Unit tests (910 tests) | | `npm run test:integration` | Integration tests (115 tests) | | `npm run test:boundary` | Boundary condition tests (~101 tests) | -| `npm run test:security` | Security tests (63 tests) | +| `npm run test:security` | Security tests (67 tests) | | `npm run test:fuzz` | Property-based fuzz tests (6 suites, 6 tests) | | `npm run test:chaos` | Chaos engineering tests (65 tests) | | `npm run test:e2e` | End-to-end tests (~158 tests) | -| `npm run test:regression` | Regression tests (302 tests) | +| `npm run test:regression` | Regression tests (307 tests) | | `npm run mutate` | Full mutation testing via StrykerJS | | `npm run mutate:quick` | Quick mutation check (XChainEncoder.js only) | | `npm run bench` | Performance benchmarks | | `npm run bench:full` | Extended benchmarks with JSON output | | `npm run bench:soak` | Soak test (sustained load) | -| `npm test` | Unit tests (hermetic, no external services, 870 tests) | +| `npm test` | Unit tests (hermetic, no external services, 910 tests) | | `npm run test:regtest` | Regtest integration tests (requires local bitcoind) | ## Test Suite diff --git a/package-lock.json b/package-lock.json index 999d6cd..229d974 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-encoder", - "version": "0.18.0", + "version": "0.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-encoder", - "version": "0.18.0", + "version": "0.19.0", "license": "AGPL-3.0-or-later", "dependencies": { "axios": "^1.18.1", diff --git a/package.json b/package.json index ee68799..8c44764 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.18.0", + "version": "0.19.0", "license": "AGPL-3.0-or-later", "repository": { "type": "git",