diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..1f37424 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,17 @@ +{ + "attribution": false, + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "git config user.name 'J-Dog' && git config user.email 'j-dog@j-dog.net' || true", + "timeout": 10, + "statusMessage": "Setting the repo git identity" + } + ] + } + ] + } +} diff --git a/.env.example b/.env.example index 2d6220e..46699e8 100644 --- a/.env.example +++ b/.env.example @@ -19,14 +19,19 @@ ENCODER_API_PORT=3003 UTXO_TRACKER_URL=127.0.0.1 UTXO_TRACKER_API_PORT=3001 +# Optional: "remote" makes the tracker mandatory and fails closed. A missing +# URL or port, an unreachable tracker, or a missing, halted, stale or not-ready +# sync state each refuse with a typed error. Default keeps the lenient behaviour. +# UTXO_TRACKER_PROFILE=remote + # Optional: cap fee-rate estimates at this sat/kB ceiling. # MAX_FEE_RATE_KB=200000 # Optional: relative fee-rate ceiling as a multiple of the node's # estimatesmartfee(1) estimate (default 100). Caps caller-supplied fee/feePerKb # so a hostile request cannot drain inputs into miner fee. Set to 0 to disable -# (not recommended; a fixed 100x burn backstop still applies when -# MAX_FEE_RATE_KB is unset); an unset/unparseable value keeps the default. +# (not recommended; a fixed 100x burn backstop still applies whatever this +# or MAX_FEE_RATE_KB says); an unset/unparseable value keeps the default. # MAX_FEE_RATE_MULTIPLIER=100 # Optional: ceiling (base units) on the extra fee a transaction spending diff --git a/CHANGELOG.md b/CHANGELOG.md index ddd9619..16dd4b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.23.0] - 2026-10-11 + +### Added +- Added the remote tracker profile that fails closed. +- Answered node rejections from broadcast_tx as error -32010 with the node code. + +### Changed +- Bounded fragmented input selection at the transaction cap and refused capped unconfirmed selections. +- Rounded fees up and shared one caller fee-rate cap with a node-anchored burn backstop. + +### Fixed +- Returned retryable errors for capped dust selection and deferred capped selections that contain mempool inputs. +- Mirrored exact PSBT fee totals past 2^53 from the SDK copy. + ## [0.21.0] - 2026-09-29 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 91d94da..3162a8b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,6 +105,7 @@ CI is the smoke + unit gate. Before opening a PR: 2. Update `CHANGELOG.md` with a terse entry for your change. 3. Make sure `git status` is clean apart from intended changes (no `node_modules/`, no editor leftovers, no `.env`). 4. Open the PR with a clear title and a description of what changed and why. +5. Agree to the Contributor License Agreement when the CLA Assistant bot links it on your first PR. The bot records your signature once for every XChain Platform repository, and its `license/cla` check must pass before the PR can merge. For non-security bugs, open an issue at . For security bugs, see [`SECURITY.md`](./SECURITY.md). diff --git a/NOTICE.md b/NOTICE.md index ed913df..bbf40b8 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -21,8 +21,10 @@ For commercial licensing, contact **legal@dankest.llc**. ## Attribution -Any redistribution or modification of this software must retain the following -attribution, per the AGPL and the project's trademark policy: +As an additional term under section 7(b) of the GNU Affero General Public +License v3.0, any redistribution or modification of this software must preserve +the following author attribution in its source and in the Appropriate Legal +Notices displayed by any work containing it: > Based on XChain Platform by Dankest, LLC - https://dankest.llc diff --git a/README.md b/README.md index 0684c47..5b6f338 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ PSBT encoding service for the XChain Platform. Takes an ACTION string, a set of - **Two-transaction P2SH/P2WSH**: automatic tx1 (fund) -> tx2 (spend/reveal) orchestration with marker OP_RETURN - **UTXO selection**: largest-first selection, duplicate removal, optional unconfirmed filtering, automatic change output - **Fee estimation**: byte-accurate transaction size estimation per format via `TxSizeEstimator`; dust floor enforcement -- **Fee rate caps**: caller-supplied `fee`/`feePerKb` is capped at `MAX_FEE_RATE_MULTIPLIER` x the node's own fee estimate (default 100x), so a hostile or buggy request cannot drain inputs into miner fee; `MAX_FEE_RATE_KB` adds an optional absolute cap. The envelope cancel (`create_envelope_cancel_tx`) applies the same caps, and both anchor on the node's relayfee when it has no fee estimate +- **Fee rate caps**: caller-supplied `fee`/`feePerKb` is capped at `MAX_FEE_RATE_MULTIPLIER` x the node's own fee estimate (default 100x), so a hostile or buggy request cannot drain inputs into miner fee; `MAX_FEE_RATE_KB` adds an optional absolute cap, and a fixed 100x burn backstop holds even when either cap is looser or disabled. The envelope cancel (`create_envelope_cancel_tx`) applies the same caps, and both anchor on the node's relayfee when it has no fee estimate - **Input validation**: centralized parameter validation (`validator.js`) with typed errors for all 15 `createTransaction` parameters - **Multi-chain support**: Bitcoin, Litecoin, and Dogecoin today on mainnet, testnet, and regtest (9 network configs) - **Replace-By-Fee**: optional RBF signaling via sequence number @@ -77,12 +77,12 @@ npm run api | `NODE_USER` | Yes | (none) | RPC username | | `NODE_PASSWORD` | Yes | (none) | RPC password | | `ENCODER_API_PORT` | No | `3000` | JSON-RPC API port | -| `NODE_RPC_TIMEOUT` | No | `30000` | Coin-node RPC call timeout in milliseconds | +| `NODE_RPC_TIMEOUT` | No | `30000` | Coin-node RPC call timeout in milliseconds; must be a positive integer, and an empty or invalid value falls back to `30000` with a warning | | `UTXO_TRACKER_URL` | No | (none) | xchain-utxo-tracker service host | | `UTXO_TRACKER_API_PORT` | No | (none) | xchain-utxo-tracker service port | | `UTXO_TRACKER_MAX_LAG_BLOCKS` | No | `2` | Max blocks the utxo-tracker's reported sync lag may be before `create_tx` refuses to select UTXOs from it. `GET /status` publishes the effective value as `tracker_max_lag_blocks`, so a status board can rank lag against the other unready causes without mirroring a constant it cannot see (the tracker's own `SYNCED_THRESHOLD` is looser and is not this gate) | | `MAX_FEE_RATE_KB` | No | Uncapped | Absolute maximum fee rate in sat/kB | -| `MAX_FEE_RATE_MULTIPLIER` | No | `100` | Caps caller-supplied fee/feePerKb at this multiple of the node's fee estimate (`0` disables this cap; with `MAX_FEE_RATE_KB` also unset, a fixed 100x burn backstop still clamps feePerKb and refuses an explicit fee) | +| `MAX_FEE_RATE_MULTIPLIER` | No | `100` | Caps caller-supplied fee/feePerKb at this multiple of the node's fee estimate (`0` disables this cap). Whatever this and `MAX_FEE_RATE_KB` are set to, a fixed 100x burn backstop still clamps feePerKb and refuses an explicit fee, so a value above `100` loosens nothing past 100x | | `MAX_CPFP_UPLIFT_SAT` | No | `10000000` | Most a transaction spending unconfirmed inputs may add to its fee so the whole mempool package reaches the target rate (`0` disables package-aware sizing) | | `FEE_NO_ESTIMATE_RELAY_MULTIPLIER` | No | `10` | Multiple of the node's relay floor charged on a non-mainnet chain when `estimatesmartfee` has no data. Raise it where miners ignore the documented rate (`100` gives 0.1 DOGE/kB). Mainnet is unaffected | | `DUST_AMOUNT` | No | Coin default | Floor in base units on every value output the encoder authors (funding legs, data outputs, change). Only raises the floor: the coin's consensus dust threshold and its relay-policy soft-dust floor (Dogecoin: 0.01 DOGE, below which each output adds the whole limit to the required relay fee) already apply | diff --git a/bin/ci-full.sh b/bin/ci-full.sh index dad8583..e95c93c 100755 --- a/bin/ci-full.sh +++ b/bin/ci-full.sh @@ -80,6 +80,7 @@ ci_tier_deferred() { # >>> ci-tier timer (generated block; re-run the tier wirer to update) >>> run_tier() { ci_tier_deferred "$1" && return 0 # ci-tier guard (generated) + if [ "${CI_TIER:-full}" = "fast" ] && [ -n "${FAILED:-}" ]; then echo; echo "ci:full ===== $1 NOT RUN (a push stops at its first red tier, its verdict already red; the full sweep runs it) ====="; return 0; fi # ci-tier stop (generated) local name="$1"; shift local __ci_tier_t0=$SECONDS echo; echo "ci:full ===== $name =====" diff --git a/bin/pins/at1-identity.json b/bin/pins/at1-identity.json index 5c96e62..0c2565e 100644 --- a/bin/pins/at1-identity.json +++ b/bin/pins/at1-identity.json @@ -1,9 +1,9 @@ { - "capturedAt": "2026-10-01T05:39:07.006Z", + "capturedAt": "2026-10-09T19:55:43.652Z", "coins": { - "src/coins/BTC.js": "92f1e7d143f844886700f40deb53cb02bb7c6c270ab4bb837488f5d8535629b6", - "src/coins/LTC.js": "d7cf410caea44a18c3c5cac07ab7f47507af8680ec3cbbec4f54ab8cd09fc0b9", - "src/coins/DOGE.js": "3b1aac3d95ca96fd6a7f53c4af169e4e845effb77d00058473bda4d4786e397b", + "src/coins/BTC.js": "182baf5173e3846b94a5edc1ae15913a54fb266addfc45a3cf9a013bd9ec16c6", + "src/coins/LTC.js": "6155195bfa5fdfeab77dec3802ce67b5cc47a77695e81a4a5545d980e4c421dd", + "src/coins/DOGE.js": "4659b45fb7a5c959ce2777ab9d5e7c193f3515f03cdd8cd23bb6b9c4d012fc11", "src/coins/index.js": "08b678d934fa647d3c7d14dc5ccdaeb86722ee32a3662de69e4a31cd7b09234c", "src/coins/consensus_pin.js": "342bf7af224a19ea0a98ff9cfb2c9a70b3fd5be75de7ecef2db05936f82baa63" }, @@ -13,7 +13,7 @@ "src/observability/index.js": "6d29637dc3a815d896076642823393866e4f74a68b59dc363ff1cd828aff422a", "src/observability/logShipper.js": "fbb1e5b3f01229417117c718da24ee2d62fefd9740adbbec2a4e941a67dbd027", "src/observability/metrics.js": "b29a0f2855e0dc4d8d97dde22c36daa6ab0408391945eda1b47b47398c0cd261", - "test/fixtures/action-manifest.json": "e4b386bf166203f65d0cd8a246bc0820a2518ed570ab2112367169dd8a5c1e69", + "test/fixtures/action-manifest.json": "2be99c8a8adbcfbeb9d545e8dc42663323cdb15351a39373ccd38fabf48e58ff", "test/fixtures/roundtrip-conformance.json": "d9963ac7c35bdcfab99595fec040d7791d73119cb62fef20db136f8ea56236ed", "test/fixtures/utxo-record-conformance.json": "ca9051b2dcacbcdded68d53089eea4b6a2ba0d498d1ee14eb95342ab0f7da5ff", "tools/release/release-signing-fingerprint.txt": "a216bc460bc978da7011d8ed0a0e203d87f97d3bd14ef6c79283a05bb263d1db", diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index ec561af..75ad953 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -628,31 +628,6 @@ "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" ], - "46251d813cd6068d": [ - "classifyTrackerFreshness(): the halt reason is tracker-authored bounds a long halt reason and keeps an absent one null", - "classifyTrackerFreshness(): the halt reason is tracker-authored collapses a halt reason carrying an endpoint in both the message and the details", - "classifyTrackerFreshness(): the single tracker-freshness verdict does not mutate the sync object it is handed", - "classifyTrackerFreshness(): the single tracker-freshness verdict fails open on every field it is not explicitly told is bad", - "classifyTrackerFreshness(): the single tracker-freshness verdict names the halt, not the staleness, when a halted tracker is ALSO stale", - "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a halted tracker even at lag 0, and carries the halt reason", - "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a lag above the supplied ceiling and names the ceiling it used", - "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a tracker that de-asserts synced, whatever the lag", - "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a tracker whose mempool has not reconverged", - "classifyTrackerFreshness(): the single tracker-freshness verdict refuses an orphaned view (tracker committed above the node) and says so", - "classifyTrackerFreshness(): the single tracker-freshness verdict reports no refusal, and present:false, for a tracker with no freshness surface", - "classifyTrackerFreshness(): the single tracker-freshness verdict separates the tracker's positive synced claim from the refusal verdict", - "classifyTrackerFreshness(): the single tracker-freshness verdict serves at exactly the ceiling (lag == max is not \"above\")", - "classifyTrackerFreshness(): the single tracker-freshness verdict takes the ceiling from its argument, not from a baked-in constant", - "classifyTrackerFreshness(): the single tracker-freshness verdict treats a non-numeric lag as unknown rather than comparing it", - "create_tx and health() reach the same verdict on the same tracker @regression agrees on: halted", - "create_tx and health() reach the same verdict on the same tracker @regression agrees on: healthy", - "create_tx and health() reach the same verdict on the same tracker @regression agrees on: lag at the ceiling", - "create_tx and health() reach the same verdict on the same tracker @regression agrees on: lag one over the ceiling", - "create_tx and health() reach the same verdict on the same tracker @regression agrees on: mempool not reconverged", - "create_tx and health() reach the same verdict on the same tracker @regression agrees on: orphaned view", - "create_tx and health() reach the same verdict on the same tracker @regression agrees on: tracker de-asserts synced", - "create_tx and health() reach the same verdict on the same tracker @regression reports the halt and the mempool state alongside the verdict" - ], "471e523a3c7464b3": [ "E2E-5: UTXO, Fee, and Change Integration E2E-5.11: SegWit UTXO handling P2WPKH UTXO uses witnessUtxo, no raw tx fetch" ], @@ -2024,6 +1999,45 @@ "XChainEncoder P2SH reveal headroom scales the leg with the fee rate and with the payload size", "XChainEncoder P2SH reveal headroom the DOGE reveal is no longer a full burn: it leaves a >= dust output to the caller" ], + "e3f640b2cf4c0689": [ + "UTXO_TRACKER_PROFILE=remote fails closed UtxoTracker.assertTrackerReady types a transport failure in the remote profile", + "UTXO_TRACKER_PROFILE=remote fails closed classifies an absent sync as SYNC_MISSING only in the remote profile", + "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a empty status tracker with UTXO_TRACKER_SYNC_MISSING", + "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a halted tracker with UTXO_TRACKER_HALTED", + "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a not ready tracker with UTXO_TRACKER_NOT_READY", + "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a stale tracker with UTXO_TRACKER_STALE", + "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a status without lag tracker with UTXO_TRACKER_SYNC_MISSING", + "UTXO_TRACKER_PROFILE=remote fails closed getUtxos refuses a unreachable tracker with UTXO_TRACKER_UNREACHABLE", + "UTXO_TRACKER_PROFILE=remote fails closed keeps the default profile failing open on a missing sync field", + "UTXO_TRACKER_PROFILE=remote fails closed passes a healthy remote tracker", + "UTXO_TRACKER_PROFILE=remote fails closed refuses a get_utxos response that carries no sync field", + "UTXO_TRACKER_PROFILE=remote fails closed refuses construction without a tracker url or port", + "UTXO_TRACKER_PROFILE=remote fails closed resolves the profile and rejects unknown values", + "UTXO_TRACKER_PROFILE=remote fails closed wraps a get_utxos transport failure as UNREACHABLE and leaks no host", + "classifyTrackerFreshness(): the halt reason is tracker-authored bounds a long halt reason and keeps an absent one null", + "classifyTrackerFreshness(): the halt reason is tracker-authored collapses a halt reason carrying an endpoint in both the message and the details", + "classifyTrackerFreshness(): the single tracker-freshness verdict does not mutate the sync object it is handed", + "classifyTrackerFreshness(): the single tracker-freshness verdict fails open on every field it is not explicitly told is bad", + "classifyTrackerFreshness(): the single tracker-freshness verdict names the halt, not the staleness, when a halted tracker is ALSO stale", + "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a halted tracker even at lag 0, and carries the halt reason", + "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a lag above the supplied ceiling and names the ceiling it used", + "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a tracker that de-asserts synced, whatever the lag", + "classifyTrackerFreshness(): the single tracker-freshness verdict refuses a tracker whose mempool has not reconverged", + "classifyTrackerFreshness(): the single tracker-freshness verdict refuses an orphaned view (tracker committed above the node) and says so", + "classifyTrackerFreshness(): the single tracker-freshness verdict reports no refusal, and present:false, for a tracker with no freshness surface", + "classifyTrackerFreshness(): the single tracker-freshness verdict separates the tracker's positive synced claim from the refusal verdict", + "classifyTrackerFreshness(): the single tracker-freshness verdict serves at exactly the ceiling (lag == max is not \"above\")", + "classifyTrackerFreshness(): the single tracker-freshness verdict takes the ceiling from its argument, not from a baked-in constant", + "classifyTrackerFreshness(): the single tracker-freshness verdict treats a non-numeric lag as unknown rather than comparing it", + "create_tx and health() reach the same verdict on the same tracker @regression agrees on: halted", + "create_tx and health() reach the same verdict on the same tracker @regression agrees on: healthy", + "create_tx and health() reach the same verdict on the same tracker @regression agrees on: lag at the ceiling", + "create_tx and health() reach the same verdict on the same tracker @regression agrees on: lag one over the ceiling", + "create_tx and health() reach the same verdict on the same tracker @regression agrees on: mempool not reconverged", + "create_tx and health() reach the same verdict on the same tracker @regression agrees on: orphaned view", + "create_tx and health() reach the same verdict on the same tracker @regression agrees on: tracker de-asserts synced", + "create_tx and health() reach the same verdict on the same tracker @regression reports the halt and the mempool state alongside the verdict" + ], "e4c3435f9bc2b0af": [ "Category F: Multi-Chain Network Configs CryptoNetworks completeness bitcoin-mainnet returns valid config with dustThreshold", "Category F: Multi-Chain Network Configs CryptoNetworks completeness bitcoin-regtest returns valid config with dustThreshold", @@ -2285,7 +2299,7 @@ "scripts": { "test": { "fileCount": 135, - "titleCount": 1006, + "titleCount": 1020, "files": { "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", @@ -2420,7 +2434,7 @@ "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js": "5086151532f51b80", "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js": "16b6105b0a4d877e", "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/native_fee_on_commit.test.js": "cc54aa46235cfca0", - "test/unit/xchain_encoder/xchain_encoder_tracker_freshness_classifier.test.js": "46251d813cd6068d", + "test/unit/xchain_encoder/trackerless_profile.test.js": "e3f640b2cf4c0689", "test/unit/xchain_encoder/xchain_encoder_utxo_dedup.test.js": "2a1c91a7aceda8eb" } }, @@ -2571,7 +2585,7 @@ }, "test:unit": { "fileCount": 135, - "titleCount": 1006, + "titleCount": 1020, "files": { "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", @@ -2706,7 +2720,7 @@ "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/04_tx_size_estimator_envelope_sizing.test.js": "5086151532f51b80", "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/05_key_path_cancel_suspension_count.test.js": "16b6105b0a4d877e", "test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/native_fee_on_commit.test.js": "cc54aa46235cfca0", - "test/unit/xchain_encoder/xchain_encoder_tracker_freshness_classifier.test.js": "46251d813cd6068d", + "test/unit/xchain_encoder/trackerless_profile.test.js": "e3f640b2cf4c0689", "test/unit/xchain_encoder/xchain_encoder_utxo_dedup.test.js": "2a1c91a7aceda8eb" } } diff --git a/bin/suite-title-map.js b/bin/suite-title-map.js index 67caf84..af1f369 100644 --- a/bin/suite-title-map.js +++ b/bin/suite-title-map.js @@ -46,7 +46,7 @@ * 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 + * exit 1 on dropped coverage * node bin/suite-title-map.js --compare --rename-map * the same, with the moving * commit's {old: new} paths @@ -269,6 +269,17 @@ function compare(pin, fresh, renames, only) { return differences; } +const ADDITIVE_DIFFERENCES = new Set(['file_added', 'title_added']); + +function reportDifferences(differences, against, log = console.log) { + log(`${differences.length} difference(s) against ${against}:`); + for (const d of differences.slice(0, 200)) { + log(` [${d.script}] ${d.kind} ${d.file || ''} ${d.title ? `:: ${d.title}` : d.detail || ''}`); + } + if (differences.length > 200) log(` ... and ${differences.length - 200} more`); + return differences.some((d) => !ADDITIVE_DIFFERENCES.has(d.kind)); +} + function parseArgs(argv) { const opts = { json: false }; for (let i = 0; i < argv.length; i += 1) { @@ -302,12 +313,7 @@ function main() { + `${opts.splitMap ? ' through the declared split 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; + if (reportDifferences(differences, path.relative(REPO_ROOT, opts.compare))) process.exitCode = 1; return; } @@ -340,4 +346,6 @@ function main() { if (require.main === module) main(); -module.exports = { buildMap, collect, mochaArgsFor, splitCommand, compare, expand, NOT_RUN }; +module.exports = { + buildMap, collect, mochaArgsFor, splitCommand, compare, expand, reportDifferences, NOT_RUN, +}; diff --git a/claude/reports/2026-08-30_parked-wip-xchain-encoder-f781868.patch b/claude/reports/2026-08-30_parked-wip-xchain-encoder-f781868.patch new file mode 100644 index 0000000..cb4eff8 --- /dev/null +++ b/claude/reports/2026-08-30_parked-wip-xchain-encoder-f781868.patch @@ -0,0 +1,623 @@ +From f7818681a72acc329ed893ef732594c8f830d7da Mon Sep 17 00:00:00 2001 +From: J-Dog +Date: Sun, 30 Aug 2026 21:10:52 -0700 +Subject: [PATCH] wip: extend outpoint reservation to caller-supplied sets + +--- + src/XChainEncoder.js | 130 ++++++++++---- + .../boundary/custom-output-boundaries.test.js | 4 + + test/chaos/library-failures.test.js | 2 + + test/chaos/network-failures.test.js | 4 +- + test/integration/custom-outputs.test.js | 6 +- + test/regression/reg-09-deepdive-fixes.test.js | 20 ++- + .../reg-12-ins0-key-binding.test.js | 16 +- + .../reg-18-chained-send-reservation.test.js | 163 ++++++++++++++++++ + test/unit/XChainEncoder.autoEncoding.test.js | 2 + + .../XChainEncoder.createTransaction.test.js | 3 + + .../XChainEncoder.p2shRevealHeadroom.test.js | 3 + + test/unit/compression.test.js | 3 + + test/unit/packageFeeSizing.test.js | 3 + + 13 files changed, 313 insertions(+), 46 deletions(-) + create mode 100644 test/regression/reg-18-chained-send-reservation.test.js + +diff --git a/src/XChainEncoder.js b/src/XChainEncoder.js +index db42d4e..0a8cb1c 100644 +--- a/src/XChainEncoder.js ++++ b/src/XChainEncoder.js +@@ -369,11 +369,52 @@ class XChainEncoder { + // 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 concurrent create_tx calls for the same address both selecting the +- // same tracker-fetched UTXOs and emitting conflicting double-spends. Only +- // engaged for tracker-fetched selections (caller-supplied UTXOs are the +- // caller's own coin-control). See RESERVATION_TTL_MS. ++ // 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 (XC -1796). ++ // 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() ++ } ++ ++ // 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) + } + + // A reserved outpoint is one an in-flight selection has claimed and not yet +@@ -426,10 +467,12 @@ class XChainEncoder { + } + } + +- // Explicit release of all reservations. The encoder holds no durable state, +- // so this is primarily a test seam; production relies on the TTL. ++ // 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() + } + + isSegwitUTXO(utxo) { +@@ -1198,7 +1241,7 @@ class XChainEncoder { + // 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 +- // (tracker-fetched sets only), so sorted utxos[0] is NOT necessarily the first input. ++ // (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]. +@@ -1213,10 +1256,15 @@ class XChainEncoder { + // 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 (XC -1796). + let firstReservedOutpoint = null + let txidFirstInput = null + if (utxos.length){ +- if (fetchedFromTracker){ ++ { + const nowFirst = Date.now() + this._evictExpiredReservations(nowFirst) + for (let i = 0; i < utxos.length; i++){ +@@ -1242,9 +1290,6 @@ class XChainEncoder { + // 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"] +- } else { +- // Caller-supplied UTXOs are the caller's own coin-control; ins[0] is utxos[0]. +- 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, +@@ -1747,6 +1792,10 @@ class XChainEncoder { + // 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) +@@ -1771,25 +1820,29 @@ class XChainEncoder { + `commit txid at signing time and strand the pre-built reveal.`) + } + +- // Best-effort double-spend guard: when this set was fetched from +- // the tracker for the sender address, skip any outpoint another +- // in-flight create_tx just claimed, and reserve the ones we take. +- // Two concurrent calls for one address would otherwise both pick +- // the largest UTXOs and build conflicting double-spends. Reserve +- // synchronously here (before the getTransactionHex await below) +- // so a concurrent call observes the claim. Caller-supplied UTXOs +- // are the caller's own coin-control and are left unreserved. ++ // 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. + const outpointKey = nextUtxo.txid + ':' + nextUtxo.vout +- if (fetchedFromTracker){ +- // Skip outpoints reserved by OTHER in-flight 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)){ +- nextUtxoIndex = nextUtxoIndex + 1 +- continue +- } +- if (outpointKey !== firstReservedOutpoint){ +- this._claimOutpoint(callReservations, outpointKey, now) +- } ++ // 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 }) +@@ -1868,10 +1921,19 @@ class XChainEncoder { + // 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', +- 'insufficient funds: no spendable inputs available (all candidates reserved or empty)', +- { required: jsonSafeSat(outputSatoshis + BigInt(estimatedFee)), available: 0, outputs: jsonSafeSat(outputSatoshis), fee: estimatedFee } ++ 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 } + ) + } + +@@ -2241,6 +2303,12 @@ class XChainEncoder { + // 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. ++ // Runs on the funding/single tx of every encoding (the TAPROOT reveal is ++ // derived from this commit, so guarding the commit guards the pair). ++ this._refuseDuplicateBuild(psbt, Date.now()) ++ + let result = {"psbt":psbt,"encoding":preparedData["encoding"]} + + // Non-fatal advisory for the fee-payer; see rawDataOnlyPayload above. Additive +diff --git a/test/boundary/custom-output-boundaries.test.js b/test/boundary/custom-output-boundaries.test.js +index 167abe0..c6e5a35 100644 +--- a/test/boundary/custom-output-boundaries.test.js ++++ b/test/boundary/custom-output-boundaries.test.js +@@ -129,6 +129,8 @@ describe('Custom Output Boundaries', () => { + null, null, null, true, 100000 // sat/kB: 100 sat/byte, moderate fee rate + ) + ++ // Same input on purpose: release the first build's reservation. ++ encoder.clearReservations() + const resultWithout = await encoder.createTransaction( + [utxo], address, null, + 'SEND|0|X|1|a', null, null, false, null, address, +@@ -208,6 +210,8 @@ describe('Custom Output Boundaries', () => { + null, null, null, true, 0.00001 + ) + ++ // Same input on purpose: release the first build's reservation. ++ encoder.clearReservations() + const resultNull = await encoder.createTransaction( + [utxo], address, null, + 'SEND|0|X|1|a', null, 10000, false, null, address, +diff --git a/test/chaos/library-failures.test.js b/test/chaos/library-failures.test.js +index b41145e..259b206 100644 +--- a/test/chaos/library-failures.test.js ++++ b/test/chaos/library-failures.test.js +@@ -208,6 +208,8 @@ describe('Chaos Category C: Library & Crypto Failures', () => { + actions.makeIssueFull().data, null, 10000, false, null, DOGE_ADDR, + null, null, null, true, 0.00001 + ) ++ // Same input on purpose: release the first build's reservation. ++ encoder.clearReservations() + const viaPubkey = await encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], PUBKEY_BUF.toString('hex'), null, + actions.makeIssueFull().data, null, 10000, false, null, DOGE_ADDR, +diff --git a/test/chaos/network-failures.test.js b/test/chaos/network-failures.test.js +index 78aee4d..cd02992 100644 +--- a/test/chaos/network-failures.test.js ++++ b/test/chaos/network-failures.test.js +@@ -263,7 +263,9 @@ describe('Chaos Category A: Network & Dependency Failures', () => { + ) + assert.ok(result1.psbt) + +- // Call 2 (even) → failure ++ // Call 2 (even) → failure. Every call here respends the one fixture ++ // input, so release call 1's reservation or the RPC never gets asked. ++ encoder.clearReservations() + await assert.rejects( + () => encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], ADDRESS, null, +diff --git a/test/integration/custom-outputs.test.js b/test/integration/custom-outputs.test.js +index 6748cf1..c115646 100644 +--- a/test/integration/custom-outputs.test.js ++++ b/test/integration/custom-outputs.test.js +@@ -75,7 +75,9 @@ describe('Category E: Custom Outputs (COINPAY Integration)', () => { + null, null, null, true, 0.00001 + ) + +- // With custom output of 500000 sats ++ // With custom output of 500000 sats. Same input on purpose: release the ++ // first build's reservation so this is a comparison, not a double-spend. ++ encoder.clearReservations() + const customOutputs = [{ address: address, value: '500000' }] + const resultWithCustom = await encoder.createTransaction( + [utxo], address, customOutputs, +@@ -149,6 +151,8 @@ describe('Category E: Custom Outputs (COINPAY Integration)', () => { + async function buildFunding (encoder, address, customOutputs) { + const utxo = makeSegwitUtxo(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, +diff --git a/test/regression/reg-09-deepdive-fixes.test.js b/test/regression/reg-09-deepdive-fixes.test.js +index 55c77a3..d8f13d7 100644 +--- a/test/regression/reg-09-deepdive-fixes.test.js ++++ b/test/regression/reg-09-deepdive-fixes.test.js +@@ -100,7 +100,9 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { + assert.ok(atDust.psbt.txOutputs.find(o => o.value === 546), + 'change equal to the dust threshold must be emitted') + +- // change == dust - 1 (545): folded. ++ // change == dust - 1 (545): folded. Same outpoint as above, so release the ++ // first build's reservation before rebuilding. ++ encoder.clearReservations() + const belowDust = await encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 10545)], address, null, + 'test', null, 10000, false, null, address, +@@ -222,18 +224,22 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { + assert.deepStrictEqual(r.psbt.txInputs.map(i => i.index), [0]) + }) + +- it('caller-supplied UTXOs are never reserved (caller owns coin-control)', async () => { ++ it('caller-supplied UTXOs are reserved too (XC -1796: the SDK supplies a tracker-fetched set)', async () => { + const encoder = makeEncoder('bitcoin-regtest') + const address = getTestAddress('bitcoin-regtest') + const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + +- // Two back-to-back calls with the SAME explicit UTXO both succeed and both +- // select it: explicit coin-control is the caller's responsibility. ++ // Two back-to-back calls with the SAME explicit UTXO: the first selects it ++ // and reserves it, the second is refused with the reservation named as ++ // the cause. Before XC -1796 both succeeded and built the same transaction. + const r1 = await encoder.createTransaction([utxo], address, null, 'test', null, 10000, false, null, address, null, null, null, true, 0.00001) +- const r2 = await encoder.createTransaction([utxo], address, null, 'test', null, 10000, false, null, address, null, null, null, true, 0.00001) + assert.deepStrictEqual(r1.psbt.txInputs.map(i => i.index), [0]) +- assert.deepStrictEqual(r2.psbt.txInputs.map(i => i.index), [0]) +- assert.strictEqual(encoder.outpointReservations.size, 0, 'explicit UTXOs must not be reserved') ++ assert.strictEqual(encoder.outpointReservations.size, 1, 'explicit UTXOs are reserved on selection') ++ await assert.rejects( ++ () => encoder.createTransaction([utxo], address, null, 'test', null, 10000, false, null, address, null, null, null, true, 0.00001), ++ (err) => err.operational === true && err.xchainCode === 'INSUFFICIENT_FUNDS' && ++ err.details.reservedCandidates === 1 && /reserved by a transaction built/.test(err.message) ++ ) + }) + }) + +diff --git a/test/regression/reg-12-ins0-key-binding.test.js b/test/regression/reg-12-ins0-key-binding.test.js +index 22f41c9..458d47c 100644 +--- a/test/regression/reg-12-ins0-key-binding.test.js ++++ b/test/regression/reg-12-ins0-key-binding.test.js +@@ -195,22 +195,26 @@ describe('ins[0] / obfuscation-key binding @regression', function () { + `reservation map should not leak entries, saw ${encoder.outpointReservations.size}`) + }) + +- it('caller-supplied UTXOs are untouched: no reservations, ins[0] is utxos[0]', async function () { ++ it('caller-supplied UTXOs: the key binds to the first UNRESERVED input, which lands at ins[0]', async function () { + const encoder = twoUtxoEncoder() + const address = getTestAddress(NETWORK) + const action = actions.makeSend() + ++ // XC -1796: caller-supplied sets go through the same reservation path as ++ // tracker-fetched ones. A foreign hold on TXID_A must push selection to ++ // TXID_B AND bind the obfuscation key there, or the action decodes to nothing. ++ encoder.outpointReservations.set(TXID_A + ':0', Date.now() + FIVE_MINUTES) + const result = await encoder.createTransaction( +- [makeSegwitUtxo(TXID_A, 0, 100000000)], address, null, ++ [makeSegwitUtxo(TXID_A, 0, 100000000), makeSegwitUtxo(TXID_B, 0, 100000000)], address, null, + action.data, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) + +- assert.strictEqual(ins0Txid(result), TXID_A) +- assert.strictEqual(encoder.outpointReservations.size, 0, +- 'caller coin-control must not engage the reservation map') ++ assert.strictEqual(ins0Txid(result), TXID_B) ++ assert.ok(encoder.outpointReservations.has(TXID_B + ':0'), ++ 'the selected caller-supplied input must be reserved') + const opReturn = result.psbt.txOutputs.find(o => o.value === 0) + const obf = bitcoin.script.decompile(opReturn.script)[1] +- assert.strictEqual(deobfuscate(obf, TXID_A).subarray(0, 4).toString('utf8'), MAGIC) ++ assert.strictEqual(deobfuscate(obf, TXID_B).subarray(0, 4).toString('utf8'), MAGIC) + }) + }) +diff --git a/test/regression/reg-18-chained-send-reservation.test.js b/test/regression/reg-18-chained-send-reservation.test.js +new file mode 100644 +index 0000000..71b2c5b +--- /dev/null ++++ b/test/regression/reg-18-chained-send-reservation.test.js +@@ -0,0 +1,163 @@ ++// 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. ++ ++// Regression for XC -1796: chained sends from one wallet session built the SAME ++// transaction twice. ++// ++// Measured live on BTC testnet4, 2026-08-27: three MINTs fired 1.2s apart from ++// one validator address. The first built and broadcast; the second, ~800ms ++// later, was handed the same input by the utxo-tracker (which had not yet seen ++// the spend) and the encoder built a byte-identical transaction with the same ++// txid, which the caller counted as a second success; the third failed. ++// ++// Root cause: the outpoint reservation map was only engaged for sets the ++// encoder fetched from the tracker itself. The SDK fetches the funding set via ++// get_utxos and passes it as `utxos`, so the mainstream wallet path was treated ++// as "caller coin-control" and never reserved anything. ++// ++// Contract now: every selected input is reserved regardless of who fetched the ++// set, a chained send that re-supplies a reserved input is refused with the ++// reservation named as the cause, and a build identical to one produced inside ++// the reservation window is refused even if the outpoint map was bypassed. ++ ++const assert = require('assert') ++const { ++ makeEncoder, makeSegwitUtxo, getTestAddress, TXID_A, TXID_B, TXID_C ++} = require('../integration/helpers/utxoFactory') ++ ++const NETWORK = 'bitcoin-regtest' ++const MINT = 'MINT|0|XCHAIN|10000' ++ ++function build (encoder, utxos, action = MINT) { ++ const address = getTestAddress(NETWORK) ++ return encoder.createTransaction( ++ utxos, address, null, action, null, 10000, false, null, address, ++ null, null, null, true, 0.00001 ++ ) ++} ++ ++function txidOf (result) { ++ const bitcoin = require('bitcoinjs-lib') ++ return bitcoin.Transaction.fromBuffer(result.psbt.data.globalMap.unsignedTx.toBuffer()).getId() ++} ++ ++describe('REG-18: chained sends reserve caller-supplied inputs @regression', function () { ++ this.timeout(10000) ++ ++ it('the measured shape: a second send re-supplying the spent input is refused, not rebuilt', async () => { ++ const encoder = makeEncoder(NETWORK) ++ // The tracker view a wallet hands over on a chained send: the previous ++ // build's input is still listed because the spend has not reached it yet. ++ const staleView = () => [makeSegwitUtxo(TXID_A, 0, 100000000)] ++ ++ const first = await build(encoder, staleView()) ++ const firstTxid = txidOf(first) ++ ++ await assert.rejects( ++ () => build(encoder, staleView()), ++ (err) => { ++ assert.strictEqual(err.operational, true) ++ assert.strictEqual(err.xchainCode, 'INSUFFICIENT_FUNDS') ++ assert.strictEqual(err.details.reservedCandidates, 1) ++ assert.match(err.message, /reserved by a transaction built in the last 5 minutes/) ++ return true ++ }, ++ 'the second build must be refused with the reservation named, never returned as a second success' ++ ) ++ ++ // Nothing about the first build leaked or changed. ++ 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') ++ }) ++ ++ it('three chained sends over a fresh view each produce three DISTINCT transactions', async () => { ++ // The verify clause of XC -1796 at unit scale: chained sends whose views ++ // reflect each prior spend select different inputs and hash differently. ++ const encoder = makeEncoder(NETWORK) ++ const r1 = await build(encoder, [makeSegwitUtxo(TXID_A, 0, 100000000)]) ++ const r2 = await build(encoder, [makeSegwitUtxo(TXID_B, 0, 100000000)]) ++ const r3 = await build(encoder, [makeSegwitUtxo(TXID_C, 0, 100000000)]) ++ const txids = new Set([txidOf(r1), txidOf(r2), txidOf(r3)]) ++ assert.strictEqual(txids.size, 3, 'three chained sends must be three distinct transactions') ++ assert.strictEqual(encoder.outpointReservations.size, 3, 'each selected input stays reserved') ++ }) ++ ++ it('a stale view that still lists the spent input alongside the change selects the change', async () => { ++ const encoder = makeEncoder(NETWORK) ++ await build(encoder, [makeSegwitUtxo(TXID_A, 0, 100000000)]) ++ // Tracker now lists the old input AND the new change; the old one is reserved. ++ const r2 = await build(encoder, [ ++ makeSegwitUtxo(TXID_A, 0, 100000000), ++ makeSegwitUtxo(TXID_B, 1, 99000000) ++ ]) ++ 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') ++ }) ++ ++ it('a different transaction over an unreserved input is not mistaken for a duplicate', async () => { ++ const encoder = makeEncoder(NETWORK) ++ await build(encoder, [makeSegwitUtxo(TXID_A, 0, 100000000)], MINT) ++ const r2 = await build(encoder, [makeSegwitUtxo(TXID_B, 0, 100000000)], 'MINT|0|XCHAIN|5000') ++ assert.ok(r2.psbt, 'a genuinely different transaction builds') ++ }) ++ ++ 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 ++ // would) and rebuild byte-for-byte. The recent-build record still refuses it. ++ const encoder = makeEncoder(NETWORK) ++ const first = await build(encoder, [makeSegwitUtxo(TXID_A, 0, 100000000)]) ++ encoder.outpointReservations.clear() ++ ++ await assert.rejects( ++ () => build(encoder, [makeSegwitUtxo(TXID_A, 0, 100000000)]), ++ (err) => { ++ assert.strictEqual(err.operational, true) ++ assert.strictEqual(err.xchainCode, 'DUPLICATE_TRANSACTION') ++ assert.strictEqual(err.details.txid, txidOf(first)) ++ return true ++ } ++ ) ++ // The refused rebuild hands back the reservation it took, so the map does ++ // not squat on the outpoint for a build that never happened. ++ assert.strictEqual(encoder.outpointReservations.size, 0) ++ }) ++ ++ it('an RBF bump of the same input is a different transaction and passes the duplicate gate', async () => { ++ const encoder = makeEncoder(NETWORK) ++ const address = getTestAddress(NETWORK) ++ const utxo = () => makeSegwitUtxo(TXID_A, 0, 100000000) ++ const first = await encoder.createTransaction([utxo()], address, null, MINT, null, 10000, true, null, address, null, null, null, true, 0.00001) ++ // The bump deliberately respends the same input: only the outpoint map is ++ // released (the operator's "wait out the TTL"), the recent-build record stays. ++ encoder.outpointReservations.clear() ++ 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') ++ }) ++ ++ it('clearReservations releases both the outpoint map and the recent-build record', async () => { ++ const encoder = makeEncoder(NETWORK) ++ await build(encoder, [makeSegwitUtxo(TXID_A, 0, 100000000)]) ++ encoder.clearReservations() ++ assert.strictEqual(encoder.outpointReservations.size, 0) ++ assert.strictEqual(encoder.recentBuilds.size, 0) ++ const again = await build(encoder, [makeSegwitUtxo(TXID_A, 0, 100000000)]) ++ assert.ok(again.psbt) ++ }) ++ ++ it('recent-build records expire with the reservation TTL', async () => { ++ const encoder = makeEncoder(NETWORK) ++ const first = await build(encoder, [makeSegwitUtxo(TXID_A, 0, 100000000)]) ++ encoder.outpointReservations.clear() ++ encoder.recentBuilds.set(txidOf(first), Date.now() - 1) ++ const again = await build(encoder, [makeSegwitUtxo(TXID_A, 0, 100000000)]) ++ assert.strictEqual(txidOf(again), txidOf(first)) ++ assert.strictEqual(encoder.recentBuilds.size, 1, 'the expired record is evicted and the new one written') ++ }) ++}) +diff --git a/test/unit/XChainEncoder.autoEncoding.test.js b/test/unit/XChainEncoder.autoEncoding.test.js +index 0cfa759..fd9a1f9 100644 +--- a/test/unit/XChainEncoder.autoEncoding.test.js ++++ b/test/unit/XChainEncoder.autoEncoding.test.js +@@ -172,6 +172,8 @@ describe('size-aware encoding selection (§6)', function () { + assert.strictEqual(small.encoding, 'OP_RETURN') + assert.strictEqual(small.revealPsbt, undefined) + ++ // Same fixture input again: release the first build's reservation. ++ e.clearReservations() + const large = await e.createTransaction( + [segwitUtxo(network)], callerAddress(network), null, FILE_ACTION, + compressibleText(6000), null, false, null, callerAddress(network), +diff --git a/test/unit/XChainEncoder.createTransaction.test.js b/test/unit/XChainEncoder.createTransaction.test.js +index abb69d8..461c46f 100644 +--- a/test/unit/XChainEncoder.createTransaction.test.js ++++ b/test/unit/XChainEncoder.createTransaction.test.js +@@ -755,6 +755,9 @@ describe('XChainEncoder.createTransaction()', () => { + // 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, +diff --git a/test/unit/XChainEncoder.p2shRevealHeadroom.test.js b/test/unit/XChainEncoder.p2shRevealHeadroom.test.js +index 279224b..3eab325 100644 +--- a/test/unit/XChainEncoder.p2shRevealHeadroom.test.js ++++ b/test/unit/XChainEncoder.p2shRevealHeadroom.test.js +@@ -72,6 +72,9 @@ function callerAddress (network) { + + async function buildFunding (encoder, network, encoding, feePerKb, payloadLen) { + const addr = callerAddress(network) ++ // Each probe respends the one fixture input on the same encoder; release the ++ // previous build's reservation so this is a comparison, not a double-spend. ++ encoder.clearReservations() + const res = await encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], addr, null, + 'x'.repeat(payloadLen), null, null, false, encoding, addr, +diff --git a/test/unit/compression.test.js b/test/unit/compression.test.js +index 5e5496d..a4d76f1 100644 +--- a/test/unit/compression.test.js ++++ b/test/unit/compression.test.js +@@ -259,6 +259,9 @@ describe('encoder FILE payload compression (spec Part B)', function () { + 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'), +diff --git a/test/unit/packageFeeSizing.test.js b/test/unit/packageFeeSizing.test.js +index 0e4d461..5dac68d 100644 +--- a/test/unit/packageFeeSizing.test.js ++++ b/test/unit/packageFeeSizing.test.js +@@ -270,6 +270,9 @@ describe('XChainEncoder package-aware fee sizing @regression @tier1', () => { + } + + 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 +-- +2.50.1 (Apple Git-155) diff --git a/docs/openrpc.build.js b/docs/openrpc.build.js index 7c1ee24..3b24339 100644 --- a/docs/openrpc.build.js +++ b/docs/openrpc.build.js @@ -53,7 +53,7 @@ const METHODS = [ + 'sign both, broadcast commit then reveal. TAPROOT requires compressedPubKey (the envelope internal ' + 'key), segwit-only inputs, and accepts payloads up to the 400,000-byte envelope ceiling.', params: [ - { name: 'pubkey', required: true, schema: str('sender address (or public key for compressed flows)') }, + { name: 'pubkey', required: true, schema: str('caller identity: sender address (base58 or bech32) or compressed public key hex (02/03 + 64 hex); uncompressed (04...) keys are rejected') }, { name: 'data', schema: str('ACTION payload string (pipe-delimited, from xchain-sdk createAction)') }, { name: 'rawData', schema: str('additional raw bytes, Latin-1 decoded (gated-FILE ciphertext, ECIES envelopes)') }, { name: 'utxos', schema: { type: 'array', description: 'explicit UTXOs; fetched from the UTXO tracker when omitted (max 500)', items: { type: 'object' } } }, diff --git a/docs/openrpc.json b/docs/openrpc.json index af94962..0a5552a 100644 --- a/docs/openrpc.json +++ b/docs/openrpc.json @@ -150,7 +150,7 @@ "required": true, "schema": { "type": "string", - "description": "sender address (or public key for compressed flows)" + "description": "caller identity: sender address (base58 or bech32) or compressed public key hex (02/03 + 64 hex); uncompressed (04...) keys are rejected" } }, { diff --git a/package-lock.json b/package-lock.json index 1b8d9ed..5547dc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-encoder", - "version": "0.21.0", + "version": "0.23.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-encoder", - "version": "0.21.0", + "version": "0.23.0", "license": "AGPL-3.0-or-later", "dependencies": { "axios": "^1.18.1", diff --git a/package.json b/package.json index e32c63b..2c177f2 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.21.0", + "version": "0.23.0", "license": "AGPL-3.0-or-later", "repository": { "type": "git", diff --git a/src/XChainEncoder.js b/src/XChainEncoder.js index b0856bd..a316b4a 100644 --- a/src/XChainEncoder.js +++ b/src/XChainEncoder.js @@ -30,6 +30,7 @@ const coins = require('./coins') const BlockchainConnector = require('./build/blockchain_connector') const CryptoNetworks = require('./build/crypto_networks') const UtxoTracker = require('./build/utxo_tracker') +const { resolveTrackerProfile, assertRemoteTrackerConfigured } = require('./XChainEncoder/trackerless_profile.js') const TxSizeEstimator = require("./build/tx_size_estimator") const { OperationalError } = require('./build/errors') const { upstreamErrorMessage } = require('./common/error_sanitize') @@ -119,7 +120,7 @@ function initReservationMaps(){ class XChainEncoder { - constructor(network, nodeUrl, nodePort, nodeUser, nodePassword, utxoTrackerUrl, utxoTrackerPort, maxFeeRateKb=null, maxFeeRateMultiplier=DEFAULT_MAX_FEE_RATE_MULTIPLIER, maxUtxoTrackerLagBlocks=DEFAULT_MAX_UTXO_TRACKER_LAG_BLOCKS, dustAmount=null) { + constructor(network, nodeUrl, nodePort, nodeUser, nodePassword, utxoTrackerUrl, utxoTrackerPort, maxFeeRateKb=null, maxFeeRateMultiplier=DEFAULT_MAX_FEE_RATE_MULTIPLIER, maxUtxoTrackerLagBlocks=DEFAULT_MAX_UTXO_TRACKER_LAG_BLOCKS, dustAmount=null, trackerProfile=null) { this.network = CryptoNetworks.getBitcoinJsNetwork(network) // The raw "-" key. getBitcoinJsNetwork returns only the // bitcoinjs params, which carry no chain identity, and the envelope @@ -139,8 +140,7 @@ class XChainEncoder { // api.js builds the singleton encoder at module load, so a later check would // let the HTTP surface bind and serve builds first. coins.verifyConsensusPin(this.consensusNetwork) - this.connector = new BlockchainConnector(nodeUrl, nodePort, nodeUser, nodePassword) - this.utxoTrackerConnector = new UtxoTracker(utxoTrackerUrl, utxoTrackerPort) + this.connector = new BlockchainConnector(nodeUrl, nodePort, nodeUser, nodePassword, this.consensusNetwork) // Two floors: dustAmount is the pinned consensus threshold (fee floor, fee-drain // caps, burn guard); outputFloor bounds every output this encoder authors and is // the same threshold raised to the coin relay floor and again to an operator DUST_AMOUNT. @@ -154,15 +154,24 @@ class XChainEncoder { // Maximum fee rate in BTC/byte (null = no cap). Prevents runaway estimates // (e.g. regtest feedback loop) from producing fees that the node will reject. // MAX_FEE_RATE_KB is in sat/kB, convert to BTC/byte to match feePerBytes units. - this.maxFeePerBytes = maxFeeRateKb ? maxFeeRateKb / 1000 / SATOSHI_UNIT : null + const capKb = Number(maxFeeRateKb) + // Treat a zero, negative or unparseable cap as no cap: one below zero would + // clamp every rate, the node's own included, down to a dust-sized fee. + this.maxFeePerBytes = (Number.isFinite(capKb) && capKb > 0) ? capKb / 1000 / SATOSHI_UNIT : null // Relative fee-rate ceiling: caller-supplied fee/feePerKb may not exceed // this multiple of the node's current estimate (0/null disables). - this.maxFeeRateMultiplier = maxFeeRateMultiplier || null + const multiplier = Number(maxFeeRateMultiplier) + // Keep the default for a negative or unparseable multiplier rather than invert the cap below zero. + this.maxFeeRateMultiplier = (maxFeeRateMultiplier == null || multiplier === 0) ? null + : (multiplier > 0 ? multiplier : DEFAULT_MAX_FEE_RATE_MULTIPLIER) // See DEFAULT_MAX_UTXO_TRACKER_LAG_BLOCKS above. `undefined`/`null` from an // unset or unparseable env var falls through to the class default via the // 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 + this.trackerProfile = resolveTrackerProfile(trackerProfile) + assertRemoteTrackerConfigured(this.trackerProfile, utxoTrackerUrl, utxoTrackerPort) + this.utxoTrackerConnector = new UtxoTracker(utxoTrackerUrl, utxoTrackerPort, this.trackerProfile, this.maxUtxoTrackerLagBlocks) initReservationMaps.call(this) } diff --git a/src/XChainEncoder/build_transaction/data_outputs.js b/src/XChainEncoder/build_transaction/data_outputs.js index 79da6d6..8149cdd 100644 --- a/src/XChainEncoder/build_transaction/data_outputs.js +++ b/src/XChainEncoder/build_transaction/data_outputs.js @@ -25,6 +25,7 @@ const { SATOSHI_UNIT, Encoding } = require('../constants.js') const { resolveCallerAddress } = require('../request_resolution.js') const { emitP2shChunk, emitP2wshChunk } = require('./script_hash_legs.js') const { feeForVsize } = require('../fee_policy.js') +const { ParamRangeError } = require('../../build/errors') function prepareDataChunks(build){ let { p2shHash, psbt, hasActionPayload, finalDataBuffer, encoding, pubkey, compressedPubKey } = build @@ -302,7 +303,7 @@ function emitCustomOutputs(build){ // 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)`) + throw new ParamRangeError(`customOutputs[${i}].value must be a positive integer (satoshis)`) } psbt.addOutput({ address: output.address, diff --git a/src/XChainEncoder/build_transaction/fee_rates.js b/src/XChainEncoder/build_transaction/fee_rates.js index 77f2761..7a88c07 100644 --- a/src/XChainEncoder/build_transaction/fee_rates.js +++ b/src/XChainEncoder/build_transaction/fee_rates.js @@ -20,7 +20,7 @@ const util = require('node:util'); const { logger, SATOSHI_UNIT } = require('../constants.js') -const { suggestedFeeCeilingPerByte, suggestedFeeCeilingFloorPerByte, capCallerFeeRate } = require('../fee_policy.js') +const { suggestedFeeCeilingPerByte, suggestedFeeCeilingFloorPerByte, capCallerFeeRate, clampLimitLabel } = require('../fee_policy.js') // The relay floor, per-byte rate this build charges, node rate the drain caps // anchor to, effective cap, and dust floor, settled in that order. @@ -137,12 +137,13 @@ function applyFeeCapAndDustFloor(build){ let { feePerBytes, nodeFeePerBytes, dust } = build // Bound the rate that prices the fee AND sizes the P2SH/P2WSH legs, so a // hostile feePerKb cannot drain inputs to the miner. capFeePerBytes stays - // null under the burn backstop, so refuseExcessiveFee keeps its wording. + // the pure operator cap (null when both are off), so refuseExcessiveFee + // keeps its wording even when the burn backstop is the limit that binds. const capped = capCallerFeeRate({ feePerBytes, nodeFeePerBytes, relayFeePerKb: build.relayFeePerKb, maxFeePerBytes: this.maxFeePerBytes, maxFeeRateMultiplier: this.maxFeeRateMultiplier }) const capFeePerBytes = capped.capFeePerBytes if (capped.clamped) { - const limit = capFeePerBytes != null ? 'the fee-rate cap' : 'the fixed burn backstop (fee-rate cap disabled)' + const limit = clampLimitLabel(capped) logger.warn(`Fee rate ${feePerBytes * 1000 * SATOSHI_UNIT} sat/kB exceeds ${limit}, clamping to ${capped.feePerBytes * 1000 * SATOSHI_UNIT} sat/kB`) feePerBytes = capped.feePerBytes } diff --git a/src/XChainEncoder/build_transaction/fee_settlement.js b/src/XChainEncoder/build_transaction/fee_settlement.js index 0f1f35a..8c51fc0 100644 --- a/src/XChainEncoder/build_transaction/fee_settlement.js +++ b/src/XChainEncoder/build_transaction/fee_settlement.js @@ -19,7 +19,7 @@ ********************************************************************/ const util = require('node:util'); -const { OperationalError } = require('../../build/errors') +const { OperationalError, ParamRangeError } = require('../../build/errors') const { logger, SATOSHI_UNIT, RESERVATION_TTL_MS, BURN_BACKSTOP_MULTIPLIER } = require('../constants.js') const { jsonSafeSat } = require('../script_amount_helpers.js') const { maxCpfpUpliftSat, packageFeeUpliftSatoshis, feeForVsize } = require('../fee_policy.js') @@ -36,7 +36,7 @@ function refuseExcessiveFee(build){ 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)`) + throw new ParamRangeError(`fee ${estimatedFee} exceeds the maximum allowed ${maxFeeSatoshis} satoshis for a ~${estimatedTxSize}-byte transaction (fee-rate cap)`) } } @@ -61,7 +61,7 @@ function refuseExcessiveFee(build){ const fairFee = Math.ceil(estimatedTxSize * referenceFeePerBytes * SATOSHI_UNIT) const hardCeiling = Math.max(this.dustAmount, fairFee * BURN_BACKSTOP_MULTIPLIER) if (estimatedFee > hardCeiling){ - throw new RangeError(`fee ${estimatedFee} exceeds ${BURN_BACKSTOP_MULTIPLIER}x the estimated fair fee (${fairFee} satoshis) for a ~${estimatedTxSize}-byte transaction`) + throw new ParamRangeError(`fee ${estimatedFee} exceeds ${BURN_BACKSTOP_MULTIPLIER}x the estimated fair fee (${fairFee} satoshis) for a ~${estimatedTxSize}-byte transaction`) } } } @@ -83,9 +83,9 @@ function refuseInsufficientRelayFee(build){ if (suppliedFee >= minimumRelayFee) return if (hasExplicitFee){ - throw new RangeError(`fee ${suppliedFee} is below the node relay minimum ${minimumRelayFee} base units for a ~${estimatedTxSize}-byte transaction`) + throw new ParamRangeError(`fee ${suppliedFee} is below the node relay minimum ${minimumRelayFee} base units for a ~${estimatedTxSize}-byte transaction`) } - throw new RangeError(`feePerKb ${feePerKb} base units/kB produces fee ${suppliedFee}, below the node relay minimum ${minimumRelayFee} base units for a ~${estimatedTxSize}-byte transaction`) + throw new ParamRangeError(`feePerKb ${feePerKb} base units/kB produces fee ${suppliedFee}, below the node relay minimum ${minimumRelayFee} base units for a ~${estimatedTxSize}-byte transaction`) } function* upliftForAncestors(build){ diff --git a/src/XChainEncoder/build_transaction/input_candidates.js b/src/XChainEncoder/build_transaction/input_candidates.js index bc947f5..84cddd7 100644 --- a/src/XChainEncoder/build_transaction/input_candidates.js +++ b/src/XChainEncoder/build_transaction/input_candidates.js @@ -19,9 +19,10 @@ ********************************************************************/ const { validateUtxoEntry } = require('../../common/validator') -const { OperationalError } = require('../../build/errors') +const { OperationalError, ParamTypeError } = require('../../build/errors') const { upstreamErrorMessage } = require('../../common/error_sanitize') -const { classifyTrackerFreshness, resolveCallerAddress } = require('../request_resolution.js') +const { resolveCallerAddress } = require('../request_resolution.js') +const { isRemoteProfile, throwIfRefused, trackerUnreachableError } = require('../trackerless_profile.js') function initInputState(build){ let { replacebyfee, p2shHash } = build @@ -72,12 +73,12 @@ function checkExactInputs(build){ const exactInputs = !!(options && options.exactInputs) if (exactInputs){ if (isReveal){ - throw new TypeError( + throw new ParamTypeError( '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( + throw new ParamTypeError( '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') } @@ -105,11 +106,14 @@ function* fetchTrackerUtxos(build){ try { fetched = (yield this.utxoTrackerConnector.getUtxosFromAddress(resolveCallerAddress(pubkey, this.network))) } catch (err) { + if (isRemoteProfile(this.trackerProfile)){ + throw err.operational ? err : trackerUnreachableError(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. + // address-too-large) pass through, already sanitized and capped by readUtxoResult. throw new OperationalError('UTXO_TRACKER_ERROR', upstreamErrorMessage(err, 'UTXO tracker unavailable')) } @@ -126,10 +130,7 @@ function* fetchTrackerUtxos(build){ // 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) - } + throwIfRefused(this.trackerProfile, fetched && fetched.sync, this.maxUtxoTrackerLagBlocks) acceptTrackerUtxos(build, fetched) } @@ -191,7 +192,7 @@ function refuseShrunkExactInputs(build){ if (!unconfirmed){ const mempoolInput = utxos.find((u) => u.confirmations == 0) if (mempoolInput){ - throw new TypeError( + throw new ParamTypeError( `options.exactInputs names unconfirmed utxo ${mempoolInput.txid}:${mempoolInput.vout}, ` + 'but unconfirmed=false would drop it; pass unconfirmed: true to spend it') } @@ -200,7 +201,7 @@ function refuseShrunkExactInputs(build){ for (const u of utxos){ const k = u.txid + ':' + u.vout if (seenOutpoints.has(k)){ - throw new TypeError( + throw new ParamTypeError( `options.exactInputs names outpoint ${k} more than once; a transaction cannot spend the same output twice`) } seenOutpoints.add(k) diff --git a/src/XChainEncoder/build_transaction/input_selection.js b/src/XChainEncoder/build_transaction/input_selection.js index b2ff0a3..2dc2418 100644 --- a/src/XChainEncoder/build_transaction/input_selection.js +++ b/src/XChainEncoder/build_transaction/input_selection.js @@ -21,7 +21,7 @@ const bitcoin = require('bitcoinjs-lib') const TxSizeEstimator = require('../../build/tx_size_estimator') const { MAX_UTXO_COUNT, parseSatoshiAmount } = require('../../common/validator') -const { OperationalError } = require('../../build/errors') +const { OperationalError, ParamTypeError, ParamRangeError } = require('../../build/errors') const { SATOSHI_UNIT, RESERVATION_TTL_MS, Encoding } = require('../constants.js') const { jsonSafeSat } = require('../script_amount_helpers.js') const { feeForVsize } = require('../fee_policy.js') @@ -74,6 +74,7 @@ function* selectInputs(build){ this.evictExpiredReservations(now) let nextUtxoIndex = 0 while (nextUtxoIndex < utxos.length){ + refuseCappedSelection.call(this, build, nextUtxoIndex, inputSatoshis, estimatedFee, selectedInputCount, now) let nextUtxo = utxos[nextUtxoIndex] refuseUnsignableInput.call(this, preparedData, nextUtxo) @@ -115,6 +116,37 @@ function* selectInputs(build){ Object.assign(build, { estimatedTxSize, estimatedFee, inputSatoshis, selectedInputCount, reservedCandidates }) } +function refuseCappedSelection(build, nextUtxoIndex, inputSatoshis, estimatedFee, selectedInputCount, now){ + if (selectedInputCount < MAX_UTXO_COUNT) return + if (remainingCandidatesCanFund.call(this, build, nextUtxoIndex, inputSatoshis, estimatedFee, now)){ + throw new ParamRangeError( + `selected input count would exceed the maximum (${MAX_UTXO_COUNT}) inputs for a single transaction` + ) + } + throw new OperationalError( + 'NO_CONFIRMED_UTXO', + `no input selection can fund the transaction within the ${MAX_UTXO_COUNT}-input maximum without ` + + 'waiting for a larger output to confirm or consolidating the confirmed outputs', + { selectedInputCount, maximum: MAX_UTXO_COUNT } + ) +} + +function remainingCandidatesCanFund(build, nextUtxoIndex, inputSatoshis, estimatedFee, now){ + const { utxos, exactInputs, firstReservedOutpoint, outputSatoshis } = build + const required = outputSatoshis + BigInt(estimatedFee) + let available = inputSatoshis + for (let index = nextUtxoIndex; index < utxos.length; index++){ + const candidate = utxos[index] + const outpointKey = candidate.txid + ':' + candidate.vout + if (!exactInputs && outpointKey !== firstReservedOutpoint && this.isOutpointReserved(outpointKey, now)){ + continue + } + available += BigInt(parseSatoshiAmount(candidate.value, `utxos[${index}].value`, { allowBig: true })) + if (available > required) return true + } + return false +} + function refuseUnsignableInput(preparedData, nextUtxo){ // Envelope commit inputs MUST be segwit. The // reveal is pre-built against the UNSIGNED commit's txid, which @@ -127,7 +159,7 @@ function refuseUnsignableInput(preparedData, nextUtxo){ // 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( + throw new ParamTypeError( `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.`) @@ -137,7 +169,7 @@ function refuseUnsignableInput(preparedData, nextUtxo){ // 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( + throw new ParamTypeError( `Input ${nextUtxo.txid}:${nextUtxo.vout} carries a witness-program scriptPubKey, ` + `which this network does not support (no segwit). Spend legacy inputs on this chain.`) } @@ -196,7 +228,7 @@ function assertPrevOutValueMatches(prevTxHex, utxo, fetchedFromTracker){ if (problem === null) return // Name who supplied the bad figure: a tracker row is not a caller parameter. if (fetchedFromTracker) throw new OperationalError('UTXO_TRACKER_ERROR', `utxo-tracker returned a wrong utxo: ${problem}`) - throw new RangeError(problem) + throw new ParamRangeError(problem) } function* addSelectedInput(psbt, nextUtxo, utxoSequence, attachPrevTx, estimatedTxSize, inputSatoshis, fetchedFromTracker){ @@ -255,7 +287,7 @@ function refuseEmptySelection(build){ // 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`) + throw new ParamRangeError(`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 diff --git a/src/XChainEncoder/build_transaction/payload_checks.js b/src/XChainEncoder/build_transaction/payload_checks.js index b8fa0a5..0618512 100644 --- a/src/XChainEncoder/build_transaction/payload_checks.js +++ b/src/XChainEncoder/build_transaction/payload_checks.js @@ -23,7 +23,8 @@ const { MAX_COMPILED_ACTION_DATA_LENGTH, ENVELOPE_MAX_PAYLOAD, validateDataParam const { compressPayloadForAction } = require('../../build/compression') const { Encoding } = require('../constants.js') const { ensureEccLib } = require('../script_amount_helpers.js') -const { defaultCompressionEnabled } = require('../request_resolution.js') +const { defaultCompressionEnabled, assertAddressOnNetwork } = require('../request_resolution.js') +const { ParamTypeError, ParamRangeError } = require('../../build/errors') // The caller's payload as handed over: wire-safety re-checks, the ACTION-name // read, and the fee-quote output injected ahead of every other output. @@ -73,6 +74,8 @@ function checkPayloadInput(build){ // decoder tokenizes the compiled push whatever shape the caller handed over. const unknownAction = unknownActionName(data) + checkOutputAddresses.call(this, build) + // If feeQuote is provided, inject it as a custom output if(feeQuote && feeQuote.address && feeQuote.amount > 0){ if(!customOutputs) customOutputs = []; @@ -81,6 +84,19 @@ function checkPayloadInput(build){ Object.assign(build, { unknownAction, customOutputs }) } +// Verify every caller output address decodes on this network, inside the first +// build step, so a typo is a -32602 before any fee, tracker or reservation work. +// pubkey is left out: it may be a raw pubkey hex, resolved to an address later. +function checkOutputAddresses(build){ + const { customOutputs, feeQuote, change } = build + if (Array.isArray(customOutputs)){ + customOutputs.forEach((output, i) => assertAddressOnNetwork(output && output.address, this.network, `customOutputs[${i}].address`)) + } + // Check feeQuote under the same condition that injects it as an output. + if (feeQuote && feeQuote.address && feeQuote.amount > 0) assertAddressOnNetwork(feeQuote.address, this.network, 'feeQuote.address') + if (change) assertAddressOnNetwork(change, this.network, 'change') +} + function* compressPayload(build){ let { compress, data, rawData } = build // Transparent FILE payload compression, ON by default. @@ -231,11 +247,11 @@ function chooseEncoding(build){ // 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'})`) + throw new ParamRangeError(`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)') + throw new ParamTypeError('P2WSH encoding is not supported on this network (no segwit support)') } Object.assign(build, { encoding }) } @@ -249,10 +265,10 @@ function* checkEnvelopeEncoding(build){ // 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)') + throw new ParamTypeError('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') + throw new ParamTypeError('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 diff --git a/src/XChainEncoder/build_transaction/script_hash_legs.js b/src/XChainEncoder/build_transaction/script_hash_legs.js index 78b1356..d077e5c 100644 --- a/src/XChainEncoder/build_transaction/script_hash_legs.js +++ b/src/XChainEncoder/build_transaction/script_hash_legs.js @@ -24,6 +24,7 @@ const { MAGIC_WORD, SATOSHI_UNIT } = require('../constants.js') const { asSatValue } = require('../script_amount_helpers.js') const { assertRevealFundingTxMatches } = require('../request_resolution.js') const { feeForVsize } = require('../fee_policy.js') +const { ParamRangeError } = require('../../build/errors') // A P2SH chunk: a funding output on the commit, or an input spending it on the reveal. function* emitP2shChunk(build, nextDataBuffer){ @@ -66,7 +67,7 @@ function* spendP2shLeg(build, nextDataBuffer){ } if (!p2shTx || !p2shTx.outs || voutPsbtIndex >= p2shTx.outs.length) { - throw new RangeError(`p2shHex transaction does not have output at index ${voutPsbtIndex}`) + throw new ParamRangeError(`p2shHex transaction does not have output at index ${voutPsbtIndex}`) } let nextInput = { sequence: utxoSequence, @@ -228,7 +229,7 @@ function* spendP2wshLeg(build, nextDataBuffer){ } if (!p2shTx || !p2shTx.outs || voutPsbtIndex >= p2shTx.outs.length) { - throw new RangeError(`p2shHex transaction does not have output at index ${voutPsbtIndex}`) + throw new ParamRangeError(`p2shHex transaction does not have output at index ${voutPsbtIndex}`) } let nextInput = { sequence: utxoSequence, diff --git a/src/XChainEncoder/envelope_cancel.js b/src/XChainEncoder/envelope_cancel.js index b58c056..8b70760 100644 --- a/src/XChainEncoder/envelope_cancel.js +++ b/src/XChainEncoder/envelope_cancel.js @@ -22,11 +22,12 @@ 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, validateOutpoint } = require('../common/validator') -const { OperationalError } = require('../build/errors') +const { parseSatoshiAmount, validateFeePerKb, validateOptionalBoolean, validateOutpoint, assertPubkeyOnCurve } = require('../common/validator') +const { OperationalError, ParamTypeError, ParamRangeError } = require('../build/errors') const { logger, SATOSHI_UNIT, RESERVATION_TTL_MS, Encoding } = require('./constants.js') const { ensureEccLib } = require('./script_amount_helpers.js') -const { capCallerFeeRate, feeForVsize } = require('./fee_policy.js') +const { assertAddressOnNetwork } = require('./request_resolution.js') +const { capCallerFeeRate, clampLimitLabel, feeForVsize } = require('./fee_policy.js') // The cancel's recovery record as handed over: validated, decoded, and read // into the values the build uses. @@ -42,18 +43,22 @@ function readCancelRecord(commitTxid, commitVout, commitValue, internalPubkey, t } 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') + throw new ParamTypeError('internalPubkey must be a 66-character compressed or 64-character x-only pubkey hex string') } + // Refuse an off-curve key here as -32602, before any claim or fee RPC. + assertPubkeyOnCurve(internalKeyBuf, 'internalPubkey') if (typeof tapleafHash !== 'string' || !/^[0-9a-fA-F]{64}$/.test(tapleafHash)) { - throw new TypeError('tapleafHash must be a 64-character hex string') + throw new ParamTypeError('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') + // assertAddressOnNetwork runs that shape check first, then refuses an + // address that does not decode on this network, before any claim or fee RPC. + assertAddressOnNetwork(destination, this.network, 'destination') if (this.network.supportsSegwit === false) { - throw new TypeError('TAPROOT encoding is not supported on this network (no segwit support)') + throw new ParamTypeError('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 @@ -148,7 +153,7 @@ function* cancelFeeRate(feeRatePerKb){ const capped = capCallerFeeRate({ feePerBytes, nodeFeePerBytes, relayFeePerKb, maxFeePerBytes: this.maxFeePerBytes, maxFeeRateMultiplier: this.maxFeeRateMultiplier }) if (capped.clamped){ - const limit = capped.capFeePerBytes != null ? 'the fee-rate cap' : 'the fixed burn backstop (fee-rate cap disabled)' + const limit = clampLimitLabel(capped) logger.warn(`Envelope-cancel fee rate ${feePerBytes * 1000 * SATOSHI_UNIT} sat/kB exceeds ${limit}, clamping to ${capped.feePerBytes * 1000 * SATOSHI_UNIT} sat/kB`) } return { feePerBytes: capped.feePerBytes, relayFeePerKb } @@ -173,7 +178,7 @@ function sizeCancelSweep(destination, value, feePerBytes, relayFeePerKb, callerF const relayFeeBaseUnitsPerKb = Math.round(relayFeePerKb * SATOSHI_UNIT) const minimumRelayFee = Math.ceil(cancelVsize * relayFeeBaseUnitsPerKb / 1000) if (cancelFee < minimumRelayFee){ - throw new RangeError(`feePerKb ${callerFeePerKb} base units/kB produces fee ${cancelFee}, below the node relay minimum ${minimumRelayFee} base units for a ~${cancelVsize}-byte transaction`) + throw new ParamRangeError(`feePerKb ${callerFeePerKb} base units/kB produces fee ${cancelFee}, below the node relay minimum ${minimumRelayFee} base units for a ~${cancelVsize}-byte transaction`) } } if (cancelFee < this.dustAmount){ diff --git a/src/XChainEncoder/fee_policy.js b/src/XChainEncoder/fee_policy.js index 9e78b13..308bd04 100644 --- a/src/XChainEncoder/fee_policy.js +++ b/src/XChainEncoder/fee_policy.js @@ -83,8 +83,8 @@ function packageFeeUpliftSatoshis({ currentFee, txSize, ancestorSize, ancestorFe return (uplift > 0) ? uplift : 0 } -// Bound a fee rate (coin per byte) by the operator cap, or by the fixed burn -// backstop when no cap is set, both anchored on the node rate or relayfee. +// Bound a fee rate (coin per byte) by the tighter of the operator cap and the +// fixed burn backstop, both anchored on the node rate or relayfee. // Synchronous on purpose: the envelope cancel's suspension count is pinned. function capCallerFeeRate({ feePerBytes, nodeFeePerBytes, relayFeePerKb, maxFeePerBytes, maxFeeRateMultiplier }){ // Anchor on the relay floor when the node has no estimate, so no cap goes missing. @@ -96,12 +96,26 @@ function capCallerFeeRate({ feePerBytes, nodeFeePerBytes, relayFeePerKb, maxFeeP const relativeCap = anchorFeePerBytes * maxFeeRateMultiplier capFeePerBytes = (capFeePerBytes != null) ? Math.min(capFeePerBytes, relativeCap) : relativeCap } - // With both operator caps disabled, the burn backstop still bounds the rate. - const backstopFeePerBytes = (capFeePerBytes == null && anchorFeePerBytes != null) - ? anchorFeePerBytes * BURN_BACKSTOP_MULTIPLIER : null - const ceiling = capFeePerBytes != null ? capFeePerBytes : backstopFeePerBytes + // The burn backstop bounds the rate whatever the operator caps say, so a + // cap looser than 100x (or a loose MAX_FEE_RATE_KB alone) cannot lift it. + const backstopFeePerBytes = anchorFeePerBytes != null ? anchorFeePerBytes * BURN_BACKSTOP_MULTIPLIER : null + // Name the limit that binds; a cap equal to the backstop counts as the cap, + // so default deployments log exactly what they did before. + let limit = null + if (capFeePerBytes != null && (backstopFeePerBytes == null || capFeePerBytes <= backstopFeePerBytes)) limit = 'cap' + else if (backstopFeePerBytes != null) limit = 'backstop' + const ceiling = limit === 'cap' ? capFeePerBytes : (limit === 'backstop' ? backstopFeePerBytes : null) const clamped = ceiling != null && feePerBytes > ceiling - return { anchorFeePerBytes, capFeePerBytes, backstopFeePerBytes, clamped, feePerBytes: clamped ? ceiling : feePerBytes } + // capFeePerBytes stays the pure operator cap: refuseExcessiveFee and the + // package uplift read it and apply the backstop on their own. + return { anchorFeePerBytes, capFeePerBytes, backstopFeePerBytes, limit, clamped, feePerBytes: clamped ? ceiling : feePerBytes } +} + +// Describe the limit a clamp hit, for the warning the two callers log. +function clampLimitLabel(capped){ + if (capped.limit === 'cap') return 'the fee-rate cap' + if (capped.capFeePerBytes == null) return 'the fixed burn backstop (fee-rate cap disabled)' + return `the fixed ${BURN_BACKSTOP_MULTIPLIER}x burn backstop (tighter than the configured fee-rate cap)` } // The fee in base units for `vsize` bytes at `feePerBytes` (coin per byte), @@ -122,4 +136,4 @@ function feeForVsize(vsize, feePerBytes, satoshiUnit){ return Math.ceil(exact - Math.abs(exact) * 1e-12) } -module.exports = { isTestNetworkKey, suggestedFeeCeilingPerByte, suggestedFeeCeilingFloorPerByte, maxCpfpUpliftSat, packageFeeUpliftSatoshis, capCallerFeeRate, feeForVsize } +module.exports = { isTestNetworkKey, suggestedFeeCeilingPerByte, suggestedFeeCeilingFloorPerByte, maxCpfpUpliftSat, packageFeeUpliftSatoshis, capCallerFeeRate, clampLimitLabel, feeForVsize } diff --git a/src/XChainEncoder/outpoint_reservations.js b/src/XChainEncoder/outpoint_reservations.js index 7beb658..dff98fe 100644 --- a/src/XChainEncoder/outpoint_reservations.js +++ b/src/XChainEncoder/outpoint_reservations.js @@ -20,7 +20,7 @@ const crypto = require('crypto'); const bitcoin = require('bitcoinjs-lib'); -const { OperationalError } = require('../build/errors') +const { OperationalError, ParamTypeError } = require('../build/errors') const { RESERVATION_TTL_MS } = require('./constants.js') module.exports = { @@ -244,7 +244,7 @@ module.exports = { // 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') + throw new ParamTypeError('reservationId must be a 32-character lowercase hex string, as returned in create_tx result.reservation.id') } const now = Date.now() this.evictExpiredReservations(now) diff --git a/src/XChainEncoder/payload_encodings.js b/src/XChainEncoder/payload_encodings.js index 470e288..411e5e7 100644 --- a/src/XChainEncoder/payload_encodings.js +++ b/src/XChainEncoder/payload_encodings.js @@ -21,6 +21,8 @@ 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') +const { ParamTypeError, ParamRangeError } = require('../build/errors') +const { validateCompressedPubKey } = require('../common/validator') // The per-encoding payload layouts prepareData dispatches to. Each returns the // chunk buffers the emission loop turns into outputs, with the encoding used. @@ -47,7 +49,7 @@ function opReturnPayload(data, encoding, magicWordBuffer){ // 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( + throw new ParamRangeError( `OP_RETURN encoding requires compiled payload <= ${chunksSize} bytes; ` + `got ${data.length}. Use P2SH for larger payloads.` ) @@ -204,11 +206,10 @@ function envelopePayload(data, encoding, compressedPubKey, magicWordBuffer){ // 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') + throw new ParamTypeError('compressedPubKey is required for TAPROOT encoding (it becomes the envelope internal key)') } + // Shape and curve check, so a library caller that skips validateAll still gets -32602. + validateCompressedPubKey(compressedPubKey) const internalPubkey = Buffer.from(compressedPubKey, 'hex').subarray(1) let envelopeChunks = sliceEnvelopeChunks(data) diff --git a/src/XChainEncoder/payload_preparation.js b/src/XChainEncoder/payload_preparation.js index 947aa7c..382b444 100644 --- a/src/XChainEncoder/payload_preparation.js +++ b/src/XChainEncoder/payload_preparation.js @@ -25,6 +25,7 @@ const { MAX_COMPILED_ACTION_DATA_LENGTH, ENVELOPE_MAX_PAYLOAD } = require('../co 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') +const { ParamTypeError } = require('../build/errors') module.exports = { isSegwitUTXO(utxo) { @@ -67,7 +68,7 @@ module.exports = { return envelopePayload(data, encoding, compressedPubKey, magicWordBuffer) } default: - throw new TypeError(`Unknown encoding: "${encoding}". Valid values: OP_RETURN, P2SH, MULTISIGN, P2WSH, TAPROOT`) + throw new ParamTypeError(`Unknown encoding: "${encoding}". Valid values: OP_RETURN, P2SH, MULTISIGN, P2WSH, TAPROOT`) } }, @@ -113,7 +114,7 @@ module.exports = { async assertEnvelopeRecognized(){ const height = CryptoNetworks.getEnvelopeRecognitionHeight(this.networkKey) if (height === null || height === undefined) { - throw new TypeError('TAPROOT encoding is not recognized on this network; ' + + throw new ParamTypeError('TAPROOT encoding is not recognized on this network; ' + 'no envelope recognition height is defined for it') } if (height === 0) return // genesis-active (testnet/regtest) diff --git a/src/XChainEncoder/request_resolution.js b/src/XChainEncoder/request_resolution.js index b64bacd..3b9ac91 100644 --- a/src/XChainEncoder/request_resolution.js +++ b/src/XChainEncoder/request_resolution.js @@ -21,6 +21,9 @@ const bitcoin = require('bitcoinjs-lib'); const config = require('../common/config'); const { safeUpstreamReason } = require('../common/error_sanitize'); +const { validateAddress } = require('../common/validator'); +const { ensureEccLib } = require('./script_amount_helpers.js'); +const { ParamTypeError } = require('../build/errors'); // 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. @@ -88,7 +91,7 @@ function classifyTrackerFreshness(sync, maxLagBlocks){ } if (halted){ // The reason is tracker-authored, so it is gated before it reaches the - // forwarded message (src/build/errors.js forwards encoder-authored text only). + // forwarded message (src/build/errors.js forwards upstream text only once sanitized). const haltReason = safeUpstreamReason(sync.halt_reason) verdict.code = 'UTXO_TRACKER_HALTED' verdict.message = `utxo-tracker is halted (${haltReason || 'unrecoverable reorg'}); ` + @@ -204,11 +207,30 @@ function defaultCompressionEnabled(){ function assertRevealFundingTxMatches(p2shHash, fundingTxid){ if (!p2shHash) return if (String(p2shHash).toLowerCase() !== String(fundingTxid).toLowerCase()){ - throw new TypeError( + throw new ParamTypeError( `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 } +// Refuse an address that does not decode on this network with a TypeError, so +// the API answers -32602 before node, tracker or reservation work. bitcoinjs throws +// a plain Error late (a retryable -32603), or never on a P2SH/P2WSH funding call. +function assertAddressOnNetwork(address, network, label){ + validateAddress(address, label) + // Decoding a bech32m (Taproot) address needs the ECC library, which this + // encoder only loads lazily; without it a valid bc1p address is refused. + ensureEccLib() + try { + bitcoin.address.toOutputScript(address, network) + } catch (err) { + // bitcoinjs reports an undecodable address as a plain Error; any other + // error class is a library fault and keeps its own identity. + if (err && err.constructor === Error) throw new ParamTypeError(`${label} is not a valid address for this network`) + throw err + } + return address +} + +module.exports = { classifyTrackerFreshness, resolveCallerHash160, resolveCallerAddress, defaultCompressionEnabled, assertRevealFundingTxMatches, assertAddressOnNetwork } diff --git a/src/XChainEncoder/trackerless_profile.js b/src/XChainEncoder/trackerless_profile.js new file mode 100644 index 0000000..0ecb239 --- /dev/null +++ b/src/XChainEncoder/trackerless_profile.js @@ -0,0 +1,94 @@ +'use strict'; + +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * UTXO_TRACKER_PROFILE + * + * 'default' keeps the historical behaviour: a tracker that predates the + * freshness surface fails open. 'remote' makes a remote tracker mandatory and + * fails closed: every missing or negative freshness signal refuses with a typed + * error instead of selecting from a view nobody vouched for. + * + ********************************************************************/ + +const { OperationalError } = require('../build/errors') +const { upstreamErrorMessage } = require('../common/error_sanitize') +const { classifyTrackerFreshness } = require('./request_resolution.js') + +const PROFILE_DEFAULT = 'default' +const PROFILE_REMOTE = 'remote' + +function resolveTrackerProfile(raw){ + if (raw == null || String(raw).trim() === '') return PROFILE_DEFAULT + const profile = String(raw).trim().toLowerCase() + if (profile === PROFILE_DEFAULT || profile === PROFILE_REMOTE) return profile + throw new Error(`UTXO_TRACKER_PROFILE must be "${PROFILE_DEFAULT}" or "${PROFILE_REMOTE}" (got ${JSON.stringify(String(raw))})`) +} + +function isRemoteProfile(profile){ + return profile === PROFILE_REMOTE +} + +function assertRemoteTrackerConfigured(profile, url, port){ + if (!isRemoteProfile(profile)) return + const missing = [] + if (url == null || String(url).trim() === '') missing.push('UTXO_TRACKER_URL') + if (port == null || String(port).trim() === '') missing.push('UTXO_TRACKER_API_PORT') + if (missing.length){ + throw new OperationalError('UTXO_TRACKER_NOT_CONFIGURED', + `UTXO_TRACKER_PROFILE=remote requires ${missing.join(' and ')}`, { missing }) + } +} + +function trackerUnreachableError(err){ + return new OperationalError('UTXO_TRACKER_UNREACHABLE', + upstreamErrorMessage(err, 'utxo-tracker is unreachable; the remote profile has no fallback')) +} + +function trackerSyncMissingError(){ + return new OperationalError('UTXO_TRACKER_SYNC_MISSING', + 'utxo-tracker did not report its sync state; the remote profile refuses an unattested view') +} + +// The remote profile's verdict on one `sync` payload: a missing payload, a +// missing or non-numeric lag and an absent positive `synced` are all refusals. +// Returns the shared classifier's verdict when it refuses, else null. +function classifyRemoteSync(profile, sync, maxLagBlocks){ + const verdict = classifyTrackerFreshness(sync, maxLagBlocks) + if (!isRemoteProfile(profile)) return verdict.code ? verdict : null + if (!verdict.present || verdict.lag === null){ + const err = trackerSyncMissingError() + return { code: err.xchainCode, message: err.message, details: null } + } + if (verdict.code) return verdict + if (!verdict.syncedClaimed){ + const err = new OperationalError('UTXO_TRACKER_STALE', + 'utxo-tracker did not assert that it is synced; refusing to select utxos from it') + // Carry the same heights as the shared classifier's STALE, as the error registry promises. + const details = { lag: verdict.lag, tracker_height: sync.tracker_height, node_height: sync.node_height } + return { code: err.xchainCode, message: err.message, details } + } + return null +} + +function throwIfRefused(profile, sync, maxLagBlocks){ + const refusal = classifyRemoteSync(profile, sync, maxLagBlocks) + if (refusal) throw new OperationalError(refusal.code, refusal.message, refusal.details || undefined) +} + +module.exports = { + PROFILE_DEFAULT, PROFILE_REMOTE, resolveTrackerProfile, isRemoteProfile, + assertRemoteTrackerConfigured, trackerUnreachableError, trackerSyncMissingError, + classifyRemoteSync, throwIfRefused +} diff --git a/src/api.js b/src/api.js index 60a04cb..4f89b0a 100644 --- a/src/api.js +++ b/src/api.js @@ -20,6 +20,7 @@ const dotenv = require('dotenv') dotenv.config() +const config = require('./common/config') // Before anything else logs. The API_KEY notice and env-validation lines // immediately below are exactly the ones an operator needs levelled and @@ -29,7 +30,7 @@ const { patchConsole } = require('./observability'); patchConsole({ service: 'xchain-encoder', version: require('../package.json').version, - network: process.env.NETWORK || '' + network: config.NETWORK }); const bitcoin = require('bitcoinjs-lib'); @@ -43,7 +44,7 @@ const { limitedHandler } = require('./server/rate_limit_log.js') const XChainEncoder = require('./XChainEncoder'); const jsonRouter = require('express-json-rpc-router') const concurrencyGate = require('./server/concurrency_gate.js') -const { isProbe } = require('./server/probe_request.js') +const { isProbe, isSpecRequest } = require('./server/probe_request.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 @@ -66,35 +67,44 @@ const { installObservability } = require('./observability'); // default-off /m const { installCrashHandlers } = require('./server/crash_handlers') -const NETWORK = process.env.NETWORK -const NODE_URL = process.env.NODE_URL -const NODE_PORT = process.env.NODE_PORT -const NODE_USER = process.env.NODE_USER -const NODE_PASSWORD = process.env.NODE_PASSWORD -const UTXO_TRACKER_URL = process.env.UTXO_TRACKER_URL -const UTXO_TRACKER_API_PORT = process.env.UTXO_TRACKER_API_PORT -const ENCODER_API_PORT = process.env.ENCODER_API_PORT -const MAX_FEE_RATE_KB = process.env.MAX_FEE_RATE_KB ? parseInt(process.env.MAX_FEE_RATE_KB, 10) : null +const NETWORK = config.NETWORK_RAW +const NODE_URL = config.NODE_URL +const NODE_PORT = config.NODE_PORT +const NODE_USER = config.NODE_USER +const NODE_PASSWORD = config.NODE_PASSWORD +const UTXO_TRACKER_URL = config.UTXO_TRACKER_URL +const UTXO_TRACKER_API_PORT = config.UTXO_TRACKER_API_PORT +const ENCODER_API_PORT = config.ENCODER_API_PORT +// Absolute fee-rate cap in sat/kB; only a positive value caps, anything else leaves it off. +const _maxFeeRateKb = parseInt(config.MAX_FEE_RATE_KB, 10) +const MAX_FEE_RATE_KB = (Number.isFinite(_maxFeeRateKb) && _maxFeeRateKb > 0) ? _maxFeeRateKb : null // Relative fee-rate ceiling as a multiple of the node's estimatesmartfee(1) // estimate (default 100, DEFAULT_MAX_FEE_RATE_MULTIPLIER in XChainEncoder.js). // Caps caller-supplied fee/feePerKb so a hostile request cannot drain inputs // into miner fee. Set to 0 to disable (not recommended). -// An unset or unparseable value keeps the encoder default (fail-safe). -const _maxFeeRateMultiplier = parseFloat(process.env.MAX_FEE_RATE_MULTIPLIER) -const MAX_FEE_RATE_MULTIPLIER = Number.isFinite(_maxFeeRateMultiplier) ? _maxFeeRateMultiplier : undefined +// An unset, unparseable or negative value keeps the encoder default (fail-safe). +const _maxFeeRateMultiplier = parseFloat(config.MAX_FEE_RATE_MULTIPLIER) +const MAX_FEE_RATE_MULTIPLIER = (Number.isFinite(_maxFeeRateMultiplier) && _maxFeeRateMultiplier >= 0) ? _maxFeeRateMultiplier : undefined +// Name a set-but-refused fee cap at boot, since the fallback above is otherwise silent. +if (config.MAX_FEE_RATE_KB && MAX_FEE_RATE_KB === null && _maxFeeRateKb !== 0) { + console.warn('WARNING: MAX_FEE_RATE_KB=' + config.MAX_FEE_RATE_KB + ' is not a positive sat/kB rate; the absolute fee-rate cap stays off.') +} +if (config.MAX_FEE_RATE_MULTIPLIER && MAX_FEE_RATE_MULTIPLIER === undefined) { + console.warn('WARNING: MAX_FEE_RATE_MULTIPLIER=' + config.MAX_FEE_RATE_MULTIPLIER + ' is not a number >= 0; the default multiplier applies.') +} // Max blocks the utxo-tracker's per-response freshness `sync` field may report // as lag before create_tx refuses to select UTXOs from it // (UTXO_TRACKER_STALE). Unset/unparseable falls through to XChainEncoder's own default // (DEFAULT_MAX_UTXO_TRACKER_LAG_BLOCKS) via the `undefined` fallback, same // pattern as MAX_FEE_RATE_MULTIPLIER above. -const _utxoTrackerMaxLagBlocks = parseInt(process.env.UTXO_TRACKER_MAX_LAG_BLOCKS, 10) +const _utxoTrackerMaxLagBlocks = parseInt(config.UTXO_TRACKER_MAX_LAG_BLOCKS, 10) const UTXO_TRACKER_MAX_LAG_BLOCKS = Number.isFinite(_utxoTrackerMaxLagBlocks) ? _utxoTrackerMaxLagBlocks : undefined // Operator floor on every value output the encoder authors; it only raises the floor // above the coin dust threshold and relay-policy soft-dust floor (see XChainEncoder). -const _dustAmount = parseInt(process.env.DUST_AMOUNT, 10) +const _dustAmount = parseInt(config.DUST_AMOUNT, 10) const DUST_AMOUNT = (Number.isFinite(_dustAmount) && _dustAmount > 0) ? _dustAmount : undefined -const API_KEY = process.env.API_KEY -const CORS_ORIGIN = process.env.CORS_ORIGIN +const API_KEY = config.API_KEY +const CORS_ORIGIN = config.CORS_ORIGIN // Constant-time API-key comparison. A plain `!==` short-circuits at the first // mismatching byte, leaking the key through response-time differences; @@ -112,7 +122,7 @@ if (!API_KEY) { console.warn('NOTICE: API_KEY not set. Encoder API authentication is DISABLED (open access).') } -const encoder = new XChainEncoder(NETWORK, NODE_URL, NODE_PORT, NODE_USER, NODE_PASSWORD, UTXO_TRACKER_URL, UTXO_TRACKER_API_PORT, MAX_FEE_RATE_KB, MAX_FEE_RATE_MULTIPLIER, UTXO_TRACKER_MAX_LAG_BLOCKS, DUST_AMOUNT); +const encoder = new XChainEncoder(NETWORK, NODE_URL, NODE_PORT, NODE_USER, NODE_PASSWORD, UTXO_TRACKER_URL, UTXO_TRACKER_API_PORT, MAX_FEE_RATE_KB, MAX_FEE_RATE_MULTIPLIER, UTXO_TRACKER_MAX_LAG_BLOCKS, DUST_AMOUNT, config.UTXO_TRACKER_PROFILE); const app = express(); @@ -132,7 +142,7 @@ const app = express(); // ENCODER_TRUST_PROXY overrides for other topologies: `false`, a hop count // (e.g. `1`), or an address/CIDR list per the Express docs. Mirrors // xchain-hub's HUB_TRUST_PROXY (src/api.js). -let trustProxy = process.env.ENCODER_TRUST_PROXY || 'loopback, uniquelocal'; +let trustProxy = config.ENCODER_TRUST_PROXY || 'loopback, uniquelocal'; if (trustProxy === 'true') trustProxy = true; else if (trustProxy === 'false') trustProxy = false; else if (/^\d+$/.test(trustProxy)) trustProxy = parseInt(trustProxy, 10); @@ -158,8 +168,9 @@ app.use(cors({ origin: parseCorsOrigin(CORS_ORIGIN) })); // API key authentication (only enforced when API_KEY is configured). if (API_KEY) { app.use((req, res, next) => { - // The machine-readable spec stays public even on keyed deploys. - if (req.method === 'GET' && req.path === '/openrpc.json') return next() + // The machine-readable spec stays public even on keyed deploys, for every + // request shape Express routes to it (HEAD, any case, a trailing slash). + if (isSpecRequest(req)) return next() const key = req.headers['x-api-key'] if (!keyEquals(key, API_KEY)) { return res.status(401).json({ @@ -172,7 +183,7 @@ if (API_KEY) { } const ENCODER_RATE_LIMIT_WINDOW_MS = 60 * 1000 -const ENCODER_RATE_LIMIT_RPM = parseInt(process.env.ENCODER_RATE_LIMIT_RPM, 10) || 60 +const ENCODER_RATE_LIMIT_RPM = parseInt(config.ENCODER_RATE_LIMIT_RPM, 10) || 60 const limiter = rateLimit({ windowMs: ENCODER_RATE_LIMIT_WINDOW_MS, limit: ENCODER_RATE_LIMIT_RPM, @@ -227,7 +238,7 @@ app.use(bodyParser.json({ limit: '3mb' })); const BUSY_BODY = { jsonrpc: '2.0', id: null, error: { code: -32029, message: 'Server busy, retry shortly' } } const probeGate = concurrencyGate.createConcurrencyGate({ - limit: concurrencyGate.resolveLimit(process.env.ENCODER_MAX_CONCURRENT_PROBES, 16), + limit: concurrencyGate.resolveLimit(config.ENCODER_MAX_CONCURRENT_PROBES, 16), retryAfter: 1, skip: (req) => !isProbe(req), body: BUSY_BODY @@ -235,7 +246,7 @@ const probeGate = concurrencyGate.createConcurrencyGate({ app.use(probeGate) const requestGate = concurrencyGate.createConcurrencyGate({ - limit: concurrencyGate.resolveLimit(process.env.ENCODER_MAX_CONCURRENT_REQUESTS, 50), + limit: concurrencyGate.resolveLimit(config.ENCODER_MAX_CONCURRENT_REQUESTS, 50), retryAfter: 1, skip: isProbe, body: BUSY_BODY @@ -313,7 +324,7 @@ app.get('/openrpc.json', (req, res) => { // each estimate_fee/create_tx/get_utxos does node or tracker RPCs with no concurrency limit, // exhausting the shared coin-node RPC pool from one unauthenticated request. Cap the batch // length (default 20, ENCODER_MAX_RPC_BATCH). Must run after bodyParser and before the router. -app.use(makeRpcBatchGuard(parseInt(process.env.ENCODER_MAX_RPC_BATCH, 10) || 20)) +app.use(makeRpcBatchGuard(parseInt(config.ENCODER_MAX_RPC_BATCH, 10) || 20)) // Express 5 / body-parser 2.x leaves req.body undefined when a request carries // no JSON body (a GET, or a POST without application/json), whereas body-parser diff --git a/src/api/json_rpc_methods.js b/src/api/json_rpc_methods.js index 0de56a6..3160e65 100644 --- a/src/api/json_rpc_methods.js +++ b/src/api/json_rpc_methods.js @@ -24,6 +24,7 @@ const bitcoin = require('bitcoinjs-lib') const XChainEncoder = require('../XChainEncoder'); const validator = require('../common/validator') const { upstreamErrorMessage } = require('../common/error_sanitize') +const { ParamTypeError, isInvalidParams } = require('../build/errors') const { getLogger } = require('../observability'); const { version: ENCODER_VERSION } = require('../../package.json') const { readMaintenanceWindow } = require('../server/maintenance_window') // operator-declared scheduled outage, reported beside readiness @@ -122,6 +123,7 @@ return { // 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)); + await assertNodeTierOrRpcError(encoder); try { for (const tier of Object.keys(targets)) { const feerate = await encoder.connector.getFeePerKilobyte(targets[tier]); // coin/kB @@ -140,6 +142,37 @@ return { } } +// Refuse a coin node on the wrong network tier before any fee, build or broadcast work. +// (Inside the build a fee-path throw is swallowed when the caller supplies feePerKb.) +async function assertNodeTierOrRpcError(encoder) { + try { await encoder.connector.assertNodeTier() } catch (err) { throw toRpcError(err) } +} + +// Map a build-path error to its JSON-RPC code: OperationalError to -32010 with its +// xchainCode, an encoder-raised ParamTypeError/ParamRangeError to -32602, and anything +// else (a Node or library TypeError included) to a logged, generic -32603. +// The SDK treats every JSON-RPC body error as final; callers own any retries. +function toRpcError(err) { + // Forward an operational error's credential-free message and stable code so the wallet and SDK can branch on it + if (err && err.operational === true) { + const e = new Error(err.message) + e.code = -32010 + e.data = Object.assign({ reason: err.xchainCode }, err.details || {}) + return e + } + // Return a caller-input rejection's own message: fix the params, do not retry + if (isInvalidParams(err)) { + const e = new Error(err.message) + e.code = -32602 + return e + } + // Log anything else and send a generic message, so no host:port, stack or RPC credential leaks + logger.error(util.format('Encoder error:', err)) + const e = new Error('Internal encoder error') + e.code = -32603 + return e +} + // The build itself; every error class it can raise maps to one JSON-RPC code. function buildTransactionMethods({ encoder }) { return { @@ -153,6 +186,7 @@ return { throw e } + await assertNodeTierOrRpcError(encoder) let psbt try { psbt = await encoder.createTransaction( @@ -162,32 +196,8 @@ return { 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). - // Map a validation error to -32602 (fix the params, do not retry) and - // everything else to -32603 (retry with backoff). Node and tracker faults - // inside the build throw plain Error so they stay on the retryable side. - const isKnown = err instanceof TypeError || err instanceof RangeError - if (!isKnown) { - logger.error(util.format('Encoder error:', err)) - } - const e = new Error(isKnown ? err.message : 'Internal encoder error') - e.code = isKnown ? -32602 : -32603 - throw e + // Node and tracker faults inside the build throw plain Error, so they stay retryable + throw toRpcError(err) } psbt["psbt"] = psbt["psbt"].toHex() @@ -206,8 +216,8 @@ 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). + // record alone. Validation lives in the encoder method (ParamTypeError/ + // ParamRangeError -> -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. @@ -216,23 +226,12 @@ return { e.code = -32602 throw e } + await assertNodeTierOrRpcError(encoder) 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 + throw toRpcError(err) } result.psbt = result.psbt.toHex() return result @@ -260,7 +259,7 @@ return { let reservationId try { if (typeof rawParams !== 'object' || rawParams === null || Array.isArray(rawParams)) { - throw new TypeError('Request params must be an object') + throw new ParamTypeError('Request params must be an object') } reservationId = validator.validateReservationId(rawParams.reservationId) } catch (err) { @@ -271,18 +270,20 @@ return { 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 + throw toRpcError(err) } }, } } +// The node's answer for a transaction it already holds: RPC -27 (already in +// the chain) or the mempool duplicate wording. +const ALREADY_KNOWN_NODE_CODE = -27 +const ALREADY_KNOWN_PATTERN = /already in block ?chain|already known|already in mempool|txn-already/i +function isAlreadyKnown(err) { + return err.nodeCode === ALREADY_KNOWN_NODE_CODE || ALREADY_KNOWN_PATTERN.test(err.message || '') +} + // Node-facing broadcast; upstream error text is sanitized before it leaves. function buildBroadcastMethods({ encoder }) { return { @@ -306,13 +307,22 @@ return { throw e } + await assertNodeTierOrRpcError(encoder) try { let txid = await encoder.connector.sendRawTransaction(tx_hex) return { txid: txid } } catch (err) { logger.error(util.format(`Broadcast error for txid ${localTxid}:`, err)) const e = new Error(upstreamErrorMessage(err, 'Transaction broadcast failed')) - e.code = -32603 + if (Number.isInteger(err && err.nodeCode)) { + e.code = -32010 + e.data = { + reason: isAlreadyKnown(err) ? 'TX_ALREADY_IN_CHAIN' : 'NODE_REJECTED', + node_code: err.nodeCode, + } + } else { + e.code = -32603 + } throw e } }, diff --git a/src/build/blockchain_connector.js b/src/build/blockchain_connector.js index 23881cc..4911017 100644 --- a/src/build/blockchain_connector.js +++ b/src/build/blockchain_connector.js @@ -23,10 +23,12 @@ const broadcast = require('./blockchain_connector/broadcast.js') const feeEstimation = require('./blockchain_connector/fee_estimation.js') class BlockchainConnector { - constructor(url, port, rpcUser, rpcPassword) { + // consensusNetwork ('mainnet'|'testnet'|'regtest') arms assertNodeTier; omitted, the check is skipped. + constructor(url, port, rpcUser, rpcPassword, consensusNetwork = null) { this.url = "http://"+url+":"+port this.rpcUser = rpcUser this.rpcPassword = rpcPassword + this.consensusNetwork = consensusNetwork } } diff --git a/src/build/blockchain_connector/broadcast.js b/src/build/blockchain_connector/broadcast.js index c169ae7..71647d2 100644 --- a/src/build/blockchain_connector/broadcast.js +++ b/src/build/blockchain_connector/broadcast.js @@ -17,6 +17,14 @@ const util = require('node:util'); const { logger, RPC_TIMEOUT } = require('./constants'); const { sanitizeRpcError } = require('./rpc_helpers'); +// An Error carrying the node's own message and, when it sent one, its numeric +// RPC code as nodeCode, so callers can tell a rejection from a transport fault. +function nodeRejection(rpcError) { + const err = new Error(rpcError.message || JSON.stringify(rpcError)); + if (Number.isInteger(rpcError.code)) err.nodeCode = rpcError.code; + return err; +} + module.exports = { async sendRawTransaction(txHex) { try { @@ -68,7 +76,7 @@ module.exports = { const responseData = response.data; if (responseData && responseData.error) { - throw new Error(responseData.error.message || JSON.stringify(responseData.error)); + throw nodeRejection(responseData.error); } if (responseData && responseData.result) { @@ -82,9 +90,10 @@ module.exports = { // 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". + if (error && error.nodeCode !== undefined) throw error; const body = error.response?.data; if (body && body.error) { - throw new Error(body.error.message || JSON.stringify(body.error)); + throw nodeRejection(body.error); } logger.error(util.format('Error:', sanitizeRpcError(error))); throw error; diff --git a/src/build/blockchain_connector/chain_identity.js b/src/build/blockchain_connector/chain_identity.js new file mode 100644 index 0000000..89b1eea --- /dev/null +++ b/src/build/blockchain_connector/chain_identity.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. + * + ********************************************************************** + * + * XChain Encoder - coin-node chain-tier identity + * + * The tier half of xchain-decoder's src/protocol/chain_identity.js, kept in + * step with it. getblockchaininfo's `chain` names the network TIER, never the + * coin, and `test`/`testnet3`/`testnet4` collapse to one tier, so this can + * prove "wrong tier" and nothing more. + * + ********************************************************************/ + +'use strict'; + +// getblockchaininfo chain strings mapped to the encoder's consensus network name. +const CHAIN_TO_NETWORK = { + main: 'mainnet', + test: 'testnet', + testnet3: 'testnet', + testnet4: 'testnet', + regtest: 'regtest', + signet: 'signet', +}; + +// Return a reason string when reportedChain is a recognized tier other than consensusNetwork, else null. +// (Fails open on an absent or unknown chain string, as the decoder does.) +function chainTierMismatch(consensusNetwork, reportedChain){ + if (typeof reportedChain !== 'string' || reportedChain === '') return null; + const mapped = CHAIN_TO_NETWORK[reportedChain]; + if (!mapped) return null; + if (mapped === String(consensusNetwork)) return null; + return 'node reports chain="' + reportedChain + '" (' + mapped + + ') but this encoder is configured for ' + consensusNetwork; +} + +module.exports = { CHAIN_TO_NETWORK, chainTierMismatch }; diff --git a/src/build/blockchain_connector/constants.js b/src/build/blockchain_connector/constants.js index 8902bc1..fc116ea 100644 --- a/src/build/blockchain_connector/constants.js +++ b/src/build/blockchain_connector/constants.js @@ -16,7 +16,23 @@ const { getLogger } = require('../../observability'); const config = require('../../common/config'); const logger = getLogger(); -const RPC_TIMEOUT = parseInt(config.NODE_RPC_TIMEOUT ?? '30000', 10) +// Read an integer env value, falling back with a warning on anything but a clean integer >= min. +// (An empty value would parse to NaN, which axios treats as "no timeout"; '30s' would parse to 30.) +function envInt(raw, fallback, name, min = 1) { + const s = (raw === undefined || raw === null) ? '' : String(raw).trim() + if (s === '') { + if (raw !== undefined && raw !== null) logger.warn(`[config] ${name} is set but empty; using ${fallback}`) + return fallback + } + const n = /^-?\d+$/.test(s) ? Number(s) : NaN + if (!Number.isInteger(n) || n < min) { + logger.warn(`[config] ${name}="${s}" is not an integer >= ${min}; using ${fallback}`) + return fallback + } + return n +} + +const RPC_TIMEOUT = envInt(config.NODE_RPC_TIMEOUT, 30000, 'NODE_RPC_TIMEOUT') // Absolute per-chain sanity ceiling (COIN/kB, the same unit estimatesmartfee // returns) on the RAW non-regtest estimate before anything else sees it. @@ -41,4 +57,4 @@ const DEFAULT_FEE_ESTIMATE_SANITY_CEILING = { dogecoin: 10, // 10 DOGE/kB (~1000x the 0.01 DOGE/kB documented recommended rate) } -module.exports = { logger, RPC_TIMEOUT, DEFAULT_FEE_ESTIMATE_SANITY_CEILING } +module.exports = { logger, envInt, 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 index 0a5cca5..966b8f2 100644 --- a/src/build/blockchain_connector/fee_estimation.js +++ b/src/build/blockchain_connector/fee_estimation.js @@ -156,8 +156,18 @@ function usableSmartFee(responseData) { async function requireNoEstimateFallback(connector, responseData) { const fallback = await connector.noEstimateRelayFallback(); if (fallback !== null) return fallback; - // Keep the node's reason from an HTTP 200 error body (BTC v28's shape). - throw new Error('Error getting smart fee from node' + rpcErrorDetail(responseData)); + // Keep the node's reason from either HTTP 200 shape: an error body (BTC v28) or + // modern Core's success body that lists it under result.errors. + throw new Error('Error getting smart fee from node' + smartFeeResultErrors(responseData) + + rpcErrorDetail(responseData)); +} + +// The non-empty strings in result.errors as a compact suffix, or '' when there are none. +function smartFeeResultErrors(responseData) { + const errors = responseData && responseData.result && responseData.result.errors; + if (!Array.isArray(errors)) return ''; + const reasons = errors.filter((e) => typeof e === 'string' && e !== ''); + return reasons.length ? ' (node: ' + reasons.join('; ') + ')' : ''; } // RPC implementations may report missing estimate data as an error body. Run diff --git a/src/build/blockchain_connector/node_queries.js b/src/build/blockchain_connector/node_queries.js index 46fea60..6d07228 100644 --- a/src/build/blockchain_connector/node_queries.js +++ b/src/build/blockchain_connector/node_queries.js @@ -16,8 +16,28 @@ const axios = require('axios') const util = require('node:util'); const { logger, RPC_TIMEOUT } = require('./constants'); const { sanitizeRpcError, rpcErrorDetail } = require('./rpc_helpers'); +const { chainTierMismatch } = require('./chain_identity'); +const { OperationalError } = require('../errors'); module.exports = { + // Refuse a node whose getblockchaininfo chain is a recognized tier other than the configured network. + // (Fails open, warning once, when no network was configured or the chain cannot be read; a pass is cached.) + async assertNodeTier(){ + if (!this.consensusNetwork || this.nodeTierOk) return; + let chain; + try { + chain = await this.chainName(); + } catch (error) { + if (!this.nodeTierWarned) logger.warn(`Node chain tier unchecked: ${error && error.message}`); + this.nodeTierWarned = true; + return; + } + const reason = chainTierMismatch(this.consensusNetwork, chain); + if (reason) throw new OperationalError('NODE_CHAIN_MISMATCH', 'Coin node is on the wrong network: ' + reason); + this.nodeTierOk = true; + }, + + async getNetworkInfo(){ const data = { jsonrpc: '2.0', diff --git a/src/build/compression.js b/src/build/compression.js index 20a39b3..3d469c0 100644 --- a/src/build/compression.js +++ b/src/build/compression.js @@ -52,6 +52,8 @@ const { COMPRESSION_MAX_RATIO, COMPRESSION_MAX_INPUT_BYTES } = require('../common/validator.js') +// The guards refuse a caller request, so they raise the -32602 Param classes. +const { ParamTypeError, ParamRangeError } = require('./errors') // 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 @@ -112,7 +114,7 @@ function guardCompressible(actionString, rawDataBuffer, maxInputBytes, refuse){ // 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', + return refuse(ParamTypeError, 'not-a-file-action', 'Compression requested, but COMPRESSION is a FILE v0 field: ' + 'refusing to compress a payload whose action cannot carry the marker.') @@ -130,7 +132,7 @@ function guardCompressible(actionString, rawDataBuffer, maxInputBytes, refuse){ // 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', + return refuse(ParamTypeError, '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. ' + @@ -141,13 +143,13 @@ function guardCompressible(actionString, rawDataBuffer, maxInputBytes, refuse){ // would double-encode. const declared = compressionFieldOf(actionString) if (declared.length > 0) - return refuse(TypeError, 'codec-already-declared', + return refuse(ParamTypeError, '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', + return refuse(ParamRangeError, 'over-input-cap', `Payload of ${rawDataBuffer.length} bytes exceeds the ` + `${maxInputBytes}-byte compression input cap.`) @@ -180,7 +182,7 @@ function guardCompressible(actionString, rawDataBuffer, maxInputBytes, refuse){ * @returns {Promise<{data: string, rawData: Buffer, compressed: boolean, * 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 + * @throws ParamTypeError or ParamRangeError when compression was explicitly requested but cannot be * 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 diff --git a/src/build/crypto_networks.js b/src/build/crypto_networks.js index db27468..bc8671e 100644 --- a/src/build/crypto_networks.js +++ b/src/build/crypto_networks.js @@ -24,6 +24,7 @@ const coins = require('../coins'); const { ENVELOPE_RECOGNITION_ACTIVATION } = require('../protocol/changes'); +const { ParamTypeError } = require('./errors'); const SUPPORTED = 'bitcoin-mainnet, bitcoin-testnet, bitcoin-regtest, dogecoin-mainnet, ' + 'dogecoin-testnet, dogecoin-regtest, litecoin-mainnet, litecoin-testnet, litecoin-regtest'; @@ -44,7 +45,7 @@ class CryptoNetworks { // bitcoinjs-lib network object (+ XChain relay overlays) for a network key. static getBitcoinJsNetwork(networkName){ const p = parseNetworkName(networkName); - if(!p) throw new TypeError(`Unknown network: "${networkName}". Supported: ${SUPPORTED}`); + if(!p) throw new ParamTypeError(`Unknown network: "${networkName}". Supported: ${SUPPORTED}`); return coins.getCoinConfig(p.tick, p.net).net; } diff --git a/src/build/errors.js b/src/build/errors.js index 8e247b3..6af3370 100644 --- a/src/build/errors.js +++ b/src/build/errors.js @@ -20,8 +20,9 @@ * transport failure that embeds host:port). api.js forwards an OperationalError * message + its stable `xchainCode` to the caller, but collapses everything * else to a generic message so internals and credentials never reach a - * response. The message here is always encoder-authored (never an upstream - * string), so it is safe to forward by construction. + * response. The message here is encoder-authored; any upstream text it embeds + * (a tracker error or halt reason) has first passed safeUpstreamReason + * (leak-checked, printable ASCII, length-capped), so it is safe to forward. * ********************************************************************/ @@ -38,4 +39,27 @@ class OperationalError extends Error { } } -module.exports = { OperationalError } +// A caller-input rejection the encoder raised itself, so its message is safe to +// return as -32602. A bare TypeError/RangeError from Node or a library is a bug and +// stays unmarked. `name` is inherited, so instanceof and name checks still match. +class ParamTypeError extends TypeError { + constructor(message) { + super(message) + this.invalidParams = true + } +} + +// The RangeError twin of ParamTypeError: a caller value of the right type out of bounds. +class ParamRangeError extends RangeError { + constructor(message) { + super(message) + this.invalidParams = true + } +} + +// Whether an error is an encoder-authored caller-input rejection. +function isInvalidParams(err) { + return Boolean(err) && err.invalidParams === true +} + +module.exports = { OperationalError, ParamTypeError, ParamRangeError, isInvalidParams } diff --git a/src/build/utxo_tracker.js b/src/build/utxo_tracker.js index 99a57c1..7d2c558 100644 --- a/src/build/utxo_tracker.js +++ b/src/build/utxo_tracker.js @@ -23,6 +23,7 @@ const util = require('node:util'); const { getLogger } = require('../observability'); const { safeUpstreamReason } = require('../common/error_sanitize'); const logger = getLogger(); +const { isRemoteProfile, throwIfRefused, trackerUnreachableError, trackerSyncMissingError } = require('../XChainEncoder/trackerless_profile.js') // How long to wait on the tracker before giving up on a request. const TRACKER_TIMEOUT = 15000 @@ -72,6 +73,7 @@ function snapshotDivergence(first, later){ // silently. Converting this into an explicit, catchable error lets // callers (e.g. the oracle PRICE submission path) surface or retry. async function assertTrackerReady(tracker){ + if (isRemoteProfile(tracker.profile)) return assertRemoteTrackerReady(tracker) try { const syncStatus = await tracker.getSyncStatus() const lag = syncStatus.lag @@ -118,6 +120,24 @@ async function assertTrackerReady(tracker){ } } +// Remote profile: the tracker is mandatory, so a transport failure, an empty +// status and every missing or negative freshness signal refuse with a typed code. +async function assertRemoteTrackerReady(tracker){ + let status + try { + status = await tracker.getSyncStatus() + } catch (error) { + logger.error(util.format('Error checking UTXO tracker sync status:', error)); + throw error.syncMissing ? trackerSyncMissingError() : trackerUnreachableError(error) + } + try { + throwIfRefused(tracker.profile, status, tracker.maxLagBlocks) + } 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){ @@ -159,8 +179,12 @@ function readUtxoResult(responseData){ // 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'}`) + // The tracker's text is untrusted and reaches public errors, so it gets the halt_reason sanitizer + // (leak-checked, printable ASCII, capped); an integer JSON-RPC code is safe as it stands. + const rawCode = rpcError.data?.code || rpcError.code + const code = Number.isSafeInteger(rawCode) ? String(rawCode) : safeUpstreamReason(rawCode) + const message = safeUpstreamReason(rpcError.message) || 'unknown error' + throw new Error(`Error getting utxos: ${code ? `[${code}] ` : ''}${message}`) } throw new Error('Error getting utxos: empty result') } @@ -222,9 +246,11 @@ function assertFreshCursor(nextCursor, seenCursors, address){ } class UtxoTracker { - constructor(url, port) { + constructor(url, port, profile, maxLagBlocks) { this.url = "http://"+url+":"+port this.port = port + this.profile = profile + this.maxLagBlocks = maxLagBlocks } // Probe the tracker's lag behind the chain tip. Returns the parsed @@ -250,7 +276,9 @@ class UtxoTracker { if (responseData.result && typeof responseData.result === 'object' && responseData.result !== null) { return responseData.result } else { - throw new Error('Error getting sync status: empty result') + const empty = new Error('Error getting sync status: empty result') + empty.syncMissing = true + throw empty } } diff --git a/src/coins/BTC.js b/src/coins/BTC.js index 31f7e01..b50f3d4 100644 --- a/src/coins/BTC.js +++ b/src/coins/BTC.js @@ -301,7 +301,7 @@ module.exports = { GAS_PRICE: '0.00001', // XCHAIN per gas unit UNIFIED_EXPIRATION_FEE_FREE_DAYS: 90, - FEE_PAYMENT_MODE: 'xchain', // informational only; not read at runtime (see detectFeePaymentMode) + FEE_PAYMENT_MODE: 'xchain', // declared mode; the indexer classifies by coin (BTC xchain) and a registry test pins the two together FEE_TOLERANCE_MIN: '0.95', FEE_TOLERANCE_MAX: '1.10', ORACLE_MAX_PRICE_AGE_SECONDS: 1800, diff --git a/src/coins/DOGE.js b/src/coins/DOGE.js index 3cd041c..787327f 100644 --- a/src/coins/DOGE.js +++ b/src/coins/DOGE.js @@ -218,7 +218,7 @@ module.exports = { GAS_PRICE: '0.00001', UNIFIED_EXPIRATION_FEE_FREE_DAYS: 90, - FEE_PAYMENT_MODE: 'native', // DOGE: native-only; informational, not read at runtime + FEE_PAYMENT_MODE: 'native', // DOGE: native-only; declared mode, classified by coin in the indexer and pinned by a registry test FEE_TOLERANCE_MIN: '0.95', FEE_TOLERANCE_MAX: '1.10', ORACLE_MAX_PRICE_AGE_SECONDS: 1800, diff --git a/src/coins/LTC.js b/src/coins/LTC.js index 1dcbab4..2715fc6 100644 --- a/src/coins/LTC.js +++ b/src/coins/LTC.js @@ -212,7 +212,7 @@ module.exports = { GAS_PRICE: '0.00001', UNIFIED_EXPIRATION_FEE_FREE_DAYS: 90, - FEE_PAYMENT_MODE: 'native', // LTC: native-only; informational, not read at runtime + FEE_PAYMENT_MODE: 'native', // LTC: native-only; declared mode, classified by coin in the indexer and pinned by a registry test FEE_TOLERANCE_MIN: '0.95', FEE_TOLERANCE_MAX: '1.10', ORACLE_MAX_PRICE_AGE_SECONDS: 1800, diff --git a/src/common/config.js b/src/common/config.js index 5b36458..0fc7e01 100644 --- a/src/common/config.js +++ b/src/common/config.js @@ -47,6 +47,26 @@ const config = { // 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; }, + // Unset-preserving network value for call sites that distinguish undefined from empty. + get NETWORK_RAW() { return process.env.NETWORK; }, + get NODE_URL() { return process.env.NODE_URL; }, + get NODE_PORT() { return process.env.NODE_PORT; }, + get NODE_USER() { return process.env.NODE_USER; }, + get NODE_PASSWORD() { return process.env.NODE_PASSWORD; }, + get UTXO_TRACKER_URL() { return process.env.UTXO_TRACKER_URL; }, + get UTXO_TRACKER_API_PORT() { return process.env.UTXO_TRACKER_API_PORT; }, + get UTXO_TRACKER_PROFILE() { return process.env.UTXO_TRACKER_PROFILE; }, + get MAX_FEE_RATE_KB() { return process.env.MAX_FEE_RATE_KB; }, + get MAX_FEE_RATE_MULTIPLIER() { return process.env.MAX_FEE_RATE_MULTIPLIER; }, + get UTXO_TRACKER_MAX_LAG_BLOCKS() { return process.env.UTXO_TRACKER_MAX_LAG_BLOCKS; }, + get DUST_AMOUNT() { return process.env.DUST_AMOUNT; }, + get API_KEY() { return process.env.API_KEY; }, + get CORS_ORIGIN() { return process.env.CORS_ORIGIN; }, + get ENCODER_TRUST_PROXY() { return process.env.ENCODER_TRUST_PROXY; }, + get ENCODER_RATE_LIMIT_RPM() { return process.env.ENCODER_RATE_LIMIT_RPM; }, + get ENCODER_MAX_CONCURRENT_PROBES() { return process.env.ENCODER_MAX_CONCURRENT_PROBES; }, + get ENCODER_MAX_CONCURRENT_REQUESTS() { return process.env.ENCODER_MAX_CONCURRENT_REQUESTS; }, + get ENCODER_MAX_RPC_BATCH() { return process.env.ENCODER_MAX_RPC_BATCH; }, }; module.exports = config; diff --git a/src/common/validator.js b/src/common/validator.js index a561cac..2f9a5d8 100644 --- a/src/common/validator.js +++ b/src/common/validator.js @@ -64,6 +64,7 @@ const { validateP2shParams, validateRawTxHex, validateCompressedPubKey, + assertPubkeyOnCurve, validateChange, validateAddress, validateReservationId, @@ -144,6 +145,7 @@ module.exports = { validateP2shParams, validateRawTxHex, validateCompressedPubKey, + assertPubkeyOnCurve, validateChange, validateAddress, validateReservationId, diff --git a/src/common/validator/action_data_checks.js b/src/common/validator/action_data_checks.js index 4d54540..9121e07 100644 --- a/src/common/validator/action_data_checks.js +++ b/src/common/validator/action_data_checks.js @@ -22,6 +22,7 @@ const { VALID_ENCODINGS } = require('./constants') const { firstNonLatin1 } = require('./value_checks') +const { ParamTypeError, ParamRangeError } = require('../../build/errors') // 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') @@ -36,17 +37,17 @@ const { firstNonLatin1 } = require('./value_checks') function validateDataParam(value, fieldName) { if (value == null) return null if (typeof value !== 'string') { - throw new TypeError(`${fieldName} must be a string`) + throw new ParamTypeError(`${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`) + throw new ParamRangeError(`${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`) + throw new ParamRangeError(`${fieldName} is not well-formed Unicode (unpaired surrogate) and cannot round-trip through UTF-8`) } return value } @@ -104,12 +105,12 @@ function isMinimalOpSingleByte(buf) { // out of scope here (see isMinimalOpSingleByte). function validateActionPushDecodability(data, rawData) { if (data != null && isMinimalOpSingleByte(Buffer.from(data, 'utf8'))) { - throw new RangeError( + throw new ParamRangeError( '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( + throw new ParamRangeError( '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') } @@ -138,7 +139,7 @@ function validateActionPushDecodability(data, rawData) { function validateActionName(data) { const rawActionName = unknownActionName(data) if (rawActionName == null) return - throw new RangeError( + throw new ParamRangeError( `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') @@ -230,7 +231,7 @@ function validateCombinedDataLength(data, rawData, encoding) { 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' : ''})`) + throw new ParamRangeError(`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 @@ -239,7 +240,7 @@ function validateCombinedDataLength(data, rawData, encoding) { // 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( + throw new ParamRangeError( `OP_RETURN encoding requires compiled payload <= ${MAX_OP_RETURN_COMPILED_LENGTH} bytes; ` + `got ${compiled}. Use P2SH for larger payloads.`) } @@ -248,7 +249,7 @@ function validateCombinedDataLength(data, rawData, encoding) { 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(', ')}`) + throw new ParamTypeError(`Invalid encoding: "${encoding}". Valid values: ${[...VALID_ENCODINGS].join(', ')}`) } return encoding } diff --git a/src/common/validator/constants.js b/src/common/validator/constants.js index f25c96b..d58e968 100644 --- a/src/common/validator/constants.js +++ b/src/common/validator/constants.js @@ -70,9 +70,9 @@ const MAX_FEE_SATOSHIS = 2_100_000_000_000 // 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. +// Transaction.fromHex / Buffer.from or a coin-node round-trip. The 3mb body +// limit in src/api.js is far looser (sized for TAPROOT envelopes), so this cap, +// not the transport, is the precise gate and gives a named rejection. 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 diff --git a/src/common/validator/fee_and_utxo_checks.js b/src/common/validator/fee_and_utxo_checks.js index 9e5cae4..3eb2f23 100644 --- a/src/common/validator/fee_and_utxo_checks.js +++ b/src/common/validator/fee_and_utxo_checks.js @@ -27,15 +27,16 @@ const { parseSatoshiAmount } = require('./value_checks') const validateAddress = (...args) => require('./transaction_checks').validateAddress(...args) +const { ParamTypeError, ParamRangeError } = require('../../build/errors') function validateCreateTxOptions(options) { if (options == null) return null if (typeof options !== 'object' || Array.isArray(options)) { - throw new TypeError('options must be an object') + throw new ParamTypeError('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(', ')}`) + throw new ParamTypeError(`Unknown options key: "${key}". Valid keys: ${[...VALID_CREATE_TX_OPTIONS].join(', ')}`) } } if (options.signerSupportsTapscript !== undefined) { @@ -54,13 +55,13 @@ 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}`) + throw new ParamTypeError(`fee must be a valid integer, got: ${typeof fee === 'string' ? fee : typeof fee}`) } if (num < 0) { - throw new RangeError('fee must be non-negative') + throw new ParamRangeError('fee must be non-negative') } if (num > MAX_FEE_SATOSHIS) { - throw new RangeError(`fee (${num}) exceeds maximum (${MAX_FEE_SATOSHIS})`) + throw new ParamRangeError(`fee (${num}) exceeds maximum (${MAX_FEE_SATOSHIS})`) } return num } @@ -71,7 +72,7 @@ function validateFeePerKb(feePerKb) { // 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') + throw new ParamTypeError('feePerKb must be a finite number') } let num if (typeof feePerKb === 'number') { @@ -83,15 +84,15 @@ function validateFeePerKb(feePerKb) { // 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') + throw new ParamTypeError('feePerKb must be a finite number') } num = Number(s) } if (isNaN(num) || !isFinite(num)) { - throw new TypeError('feePerKb must be a finite number') + throw new ParamTypeError('feePerKb must be a finite number') } if (num <= 0) { - throw new RangeError('feePerKb must be positive') + throw new ParamRangeError('feePerKb must be positive') } return num } @@ -100,13 +101,13 @@ 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') + throw new ParamTypeError('dust must be a valid integer') } if (num < 0) { - throw new RangeError('dust must be non-negative') + throw new ParamRangeError('dust must be non-negative') } if (num > MAX_FEE_SATOSHIS) { - throw new RangeError(`dust (${num}) exceeds maximum (${MAX_FEE_SATOSHIS})`) + throw new ParamRangeError(`dust (${num}) exceeds maximum (${MAX_FEE_SATOSHIS})`) } return num } @@ -117,7 +118,7 @@ function validateOutpoint(txid, vout, txidLabel, voutLabel) { // 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 txid !== 'string' || !HEX_64_RE.test(txid)) { - throw new TypeError(`${txidLabel} must be a 64-character hex string`) + throw new ParamTypeError(`${txidLabel} must be a 64-character hex string`) } // Route vout through toExactInt (rejecting NaN), not bare Number(): on the // money path a JSON null/''/false/[] all coerce via Number() to a plausible @@ -128,7 +129,7 @@ function validateOutpoint(txid, vout, txidLabel, voutLabel) { // Cap vout at the uint32 wire width, so an oversized index is a -32602 here // and never an opaque bitcoinjs typeforce error inside psbt.addInput. if (!Number.isInteger(exact) || exact < 0 || exact > 0xffffffff) { - throw new TypeError(`${voutLabel} must be a non-negative integer no greater than 4294967295`) + throw new ParamTypeError(`${voutLabel} must be a non-negative integer no greater than 4294967295`) } return { txid: txid.toLowerCase(), vout: exact } } @@ -138,7 +139,7 @@ function validateUtxoOutpoint(entry, index) { // 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`) + throw new ParamTypeError(`utxos[${index}] must be an object`) } const outpoint = validateOutpoint(entry.txid, entry.vout, `utxos[${index}].txid`, `utxos[${index}].vout`) // Canonicalize case, because on the OP_RETURN/MULTISIGN path this string IS the @@ -160,7 +161,7 @@ function validateUtxoValueAndScript(entry, index) { 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`) + throw new ParamTypeError(`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. @@ -169,10 +170,10 @@ function validateUtxoValueAndScript(entry, index) { // 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})`) + throw new ParamRangeError(`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`) + throw new ParamTypeError(`utxos[${index}].scriptPubKey must be an even-length hex string`) } if (entry.confirmations == null) { entry.confirmations = 0 @@ -184,7 +185,7 @@ function validateUtxoValueAndScript(entry, index) { // 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`) + throw new ParamTypeError(`utxos[${index}].confirmations must be a non-negative integer`) } entry.confirmations = confirmations } @@ -199,10 +200,10 @@ function validateUtxoEntry(entry, index) { function validateUtxoArray(utxos) { if (utxos == null) return null if (!Array.isArray(utxos)) { - throw new TypeError('utxos must be an array') + throw new ParamTypeError('utxos must be an array') } if (utxos.length > MAX_UTXO_COUNT) { - throw new RangeError(`utxos array length (${utxos.length}) exceeds maximum (${MAX_UTXO_COUNT})`) + throw new ParamRangeError(`utxos array length (${utxos.length}) exceeds maximum (${MAX_UTXO_COUNT})`) } for (let i = 0; i < utxos.length; i++) { validateUtxoEntry(utxos[i], i) @@ -212,7 +213,7 @@ function validateUtxoArray(utxos) { function validateCustomOutput(output, index) { if (typeof output !== 'object' || output === null || Array.isArray(output)) { - throw new TypeError(`customOutputs[${index}] must be an object`) + throw new ParamTypeError(`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 @@ -227,7 +228,7 @@ function validateCustomOutput(output, index) { // 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)`) + throw new ParamRangeError(`customOutputs[${index}].value must be a positive integer (satoshis)`) } return output } @@ -235,10 +236,10 @@ function validateCustomOutput(output, index) { function validateCustomOutputs(customOutputs) { if (customOutputs == null) return null if (!Array.isArray(customOutputs)) { - throw new TypeError('customOutputs must be an array') + throw new ParamTypeError('customOutputs must be an array') } if (customOutputs.length > MAX_CUSTOM_OUTPUTS) { - throw new RangeError(`customOutputs length (${customOutputs.length}) exceeds maximum (${MAX_CUSTOM_OUTPUTS})`) + throw new ParamRangeError(`customOutputs length (${customOutputs.length}) exceeds maximum (${MAX_CUSTOM_OUTPUTS})`) } for (let i = 0; i < customOutputs.length; i++) { validateCustomOutput(customOutputs[i], i) @@ -251,18 +252,19 @@ function validateFeeQuote(feeQuote) { // 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. + // validators carry (validateCreateTxOptions, validateUtxoOutpoint, + // validateCustomOutput, and validateAll in transaction_checks.js). 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') + throw new ParamTypeError('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)') + throw new ParamRangeError('feeQuote.amount must be a positive integer (satoshis)') } if (amount > MAX_FEE_SATOSHIS) { - throw new RangeError('feeQuote.amount exceeds maximum (' + MAX_FEE_SATOSHIS + ')') + throw new ParamRangeError('feeQuote.amount exceeds maximum (' + MAX_FEE_SATOSHIS + ')') } feeQuote.amount = amount return feeQuote diff --git a/src/common/validator/transaction_checks.js b/src/common/validator/transaction_checks.js index 2461870..aaa75d0 100644 --- a/src/common/validator/transaction_checks.js +++ b/src/common/validator/transaction_checks.js @@ -39,6 +39,7 @@ const { validateCustomOutputs, validateFeeQuote } = require('./fee_and_utxo_checks') +const { ParamTypeError, ParamRangeError } = require('../../build/errors') function validateP2shParams(p2shHash, p2shHex) { const hasHash = (p2shHash != null && p2shHash !== false) @@ -46,19 +47,19 @@ function validateP2shParams(p2shHash, p2shHex) { if (!hasHash && !hasHex) return { p2shHash: null, p2shHex: null } if (hasHash !== hasHex) { - throw new TypeError('p2shHash and p2shHex must both be provided or both omitted') + throw new ParamTypeError('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') + throw new ParamTypeError('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') + throw new ParamTypeError('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 + ')') + throw new ParamTypeError('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') + throw new ParamTypeError('p2shHex must be an even-length hex string') } return { p2shHash, p2shHex } } @@ -68,39 +69,52 @@ function validateP2shParams(p2shHash, p2shHex) { // 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') + throw new ParamTypeError('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 + ')') + throw new ParamTypeError('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') + throw new ParamTypeError('tx_hex must be an even-length hex string') } return txHex } +let secp = null + +// Refuse a key that is not a secp256k1 point (33-byte compressed or 32-byte +// x-only). The hex regexes check shape only, and about half of all x values +// have no point: one lands unspendable in a multisig or fails late in a p2tr tweak. +function assertPubkeyOnCurve(buf, label) { + if (!secp) secp = require('tiny-secp256k1') // lazy: wasm-backed, as ensureEccLib + const onCurve = buf.length === 32 ? secp.isXOnlyPoint(buf) : secp.isPoint(buf) + if (!onCurve) throw new ParamTypeError(`${label} is not a valid secp256k1 point`) +} + 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') + throw new ParamTypeError('compressedPubKey must be a 66-character hex string starting with 02 or 03') } + assertPubkeyOnCurve(Buffer.from(compressedPubKey, 'hex'), 'compressedPubKey') 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 +// validateChange, validatePubkey (params.pubkey is a sender address or a +// compressed pubkey hex), and customOutputs[].address / feeQuote.address. +// Network validity (base58/bech32 decoding) is checked at the encoder entry by +// assertAddressOnNetwork, since this module stays network-agnostic; 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`) + throw new ParamTypeError(`${label} must be a non-empty string`) } if (address.length > 100) { - throw new TypeError(`${label} exceeds maximum length (100)`) + throw new ParamTypeError(`${label} exceeds maximum length (100)`) } return address } @@ -112,7 +126,7 @@ function validateAddress(address, label = 'address') { // 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') + throw new ParamTypeError('reservationId must be a 32-character lowercase hex string, as returned in create_tx result.reservation.id') } return reservationId } @@ -159,14 +173,14 @@ function validateEncodingRequirements(encoding, compressedPubKey, p2shHash, pubk // 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') + throw new ParamTypeError('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)') + throw new ParamTypeError('compressedPubKey is required for TAPROOT encoding (it becomes the envelope internal key)') } // TAPROOT is a single-call flow: create_tx returns the {commit, reveal} @@ -174,7 +188,7 @@ function validateEncodingRequirements(encoding, compressedPubKey, p2shHash, pubk // 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') + throw new ParamTypeError('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 @@ -183,7 +197,7 @@ function validateEncodingRequirements(encoding, compressedPubKey, p2shHash, pubk // 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') + throw new ParamRangeError('pubkey is required') } } @@ -236,9 +250,10 @@ 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). + // object-shaped validators in fee_and_utxo_checks.js carry (validateCreateTxOptions, + // validateUtxoOutpoint, validateCustomOutput, validateFeeQuote). if (typeof params !== 'object' || params === null || Array.isArray(params)) { - throw new TypeError('Request params must be an object') + throw new ParamTypeError('Request params must be an object') } const { data, rawData, encoding } = validateActionFields(params) @@ -262,6 +277,7 @@ module.exports = { validateP2shParams, validateRawTxHex, validateCompressedPubKey, + assertPubkeyOnCurve, validateAddress, validateReservationId, validateChange, diff --git a/src/common/validator/value_checks.js b/src/common/validator/value_checks.js index a442010..7998460 100644 --- a/src/common/validator/value_checks.js +++ b/src/common/validator/value_checks.js @@ -13,6 +13,7 @@ **********************************************************************/ const { MAX_SATOSHI_U64 } = require('./constants') +const { ParamTypeError, ParamRangeError } = require('../../build/errors') const validateAddress = (...args) => require('./transaction_checks').validateAddress(...args) // Exact-integer parse for satoshi/fee money fields. Unlike parseInt, this @@ -38,7 +39,7 @@ function toExactInt(raw) { 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`) + throw new ParamTypeError(`${label} must be a boolean (got ${typeof raw}${typeof raw === 'string' ? ` "${raw}"` : ''}); string "false" would coerce to true`) } return raw } @@ -60,22 +61,22 @@ function parseSatoshiAmount(raw, label, opts) { // 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`) + throw new ParamRangeError(`${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`) + throw new ParamRangeError(`${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})`) + throw new ParamRangeError(`${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`) + throw new ParamRangeError(`${label} must be a non-negative integer`) } if (Number.isSafeInteger(num)) { return num @@ -85,23 +86,27 @@ function parseSatoshiAmount(raw, label, opts) { // guaranteed non-negative, so this BigInt parse cannot throw. const big = BigInt(raw.trim()) if (big > MAX_SATOSHI_U64) { - throw new RangeError(`${label} (${raw.trim()}) exceeds the maximum 64-bit satoshi amount (${MAX_SATOSHI_U64})`) + throw new ParamRangeError(`${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' : ''}`) + throw new ParamRangeError(`${label} (${typeof raw === 'string' ? raw : num}) exceeds the maximum safe satoshi amount (${Number.MAX_SAFE_INTEGER}) and cannot be represented without precision loss${allowBig ? '; pass amounts above it as an exact decimal string' : ''}`) } -// 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. +// Uncompressed public key hex: 04 then the 64-byte X and Y coordinates. +const UNCOMPRESSED_PUBKEY_HEX = /^04[0-9a-fA-F]{128}$/ + +// Check params.pubkey, the caller identity: a sender address or a compressed +// 02/03 pubkey hex, both resolved downstream by resolveCallerAddress and +// resolveCallerHash160. Null passes through (presence is checked in validateAll). function validatePubkey(pubkey) { if (pubkey == null) return null + // Refuse an uncompressed key by name: the network-default P2WPKH address takes + // only a 33-byte key, and the length cap below would misreport it as oversized. + if (typeof pubkey === 'string' && UNCOMPRESSED_PUBKEY_HEX.test(pubkey)) { + throw new ParamTypeError('pubkey: uncompressed public keys (04 + 128 hex) are not supported; pass the compressed 02/03 + 64 hex form or a sender address') + } + // Hold everything else to the same shared bound as every address-shaped field. return validateAddress(pubkey, 'pubkey') } diff --git a/src/server/cors_origin.js b/src/server/cors_origin.js index c4bd592..7991f65 100644 --- a/src/server/cors_origin.js +++ b/src/server/cors_origin.js @@ -33,6 +33,15 @@ * no browser sends, so a fat-fingered `*,https://x` grants x and nothing more * rather than silently opening the service to everyone. * + * Identical by intent across these six copies (this file is one of them); + * an edit to one is an edit to all six: + * xchain-encoder/src/server/cors_origin.js + * xchain-hub/src/api/cors_origin.js + * xchain-indexer/src/api/cors_origin.js + * xchain-sdk/src/utils/cors_origin.js + * xchain-sync/src/http/cors_origin.js + * xchain-utxo-tracker/src/server/cors_origin.js + * * @param {string|undefined|null} raw - the raw CORS_ORIGIN value * @returns {false|string|string[]} `false` (disabled), `'*'` (any), one origin, or an allowlist */ diff --git a/src/server/probe_request.js b/src/server/probe_request.js index 8756aa2..5c589a2 100644 --- a/src/server/probe_request.js +++ b/src/server/probe_request.js @@ -21,12 +21,29 @@ // finds no slot of its own to keep. const PROBE_PATH = /^\/(status|openrpc\.json)\/?$/i +// Match only the spec route, with the same case and trailing-slash tolerance. +// The API-key gate exempts this one; /status still needs the key. +const SPEC_PATH = /^\/openrpc\.json\/?$/i + +// Accept the methods Express sends to a GET route (HEAD dispatches to it). +function isGetRouted (req) { + return req.method === 'GET' || req.method === 'HEAD' +} + /** * @param {{method: string, path: string}} req * @returns {boolean} true when the request belongs to the probe reserve */ function isProbe (req) { - return (req.method === 'GET' || req.method === 'HEAD') && PROBE_PATH.test(req.path) + return isGetRouted(req) && PROBE_PATH.test(req.path) +} + +/** + * @param {{method: string, path: string}} req + * @returns {boolean} true when the request reaches the public OpenRPC spec handler + */ +function isSpecRequest (req) { + return isGetRouted(req) && SPEC_PATH.test(req.path) } -module.exports = { isProbe, PROBE_PATH } +module.exports = { isProbe, isSpecRequest, PROBE_PATH, SPEC_PATH } diff --git a/test/fixtures/action-manifest.json b/test/fixtures/action-manifest.json index 8aa9e15..028d4c8 100644 --- a/test/fixtures/action-manifest.json +++ b/test/fixtures/action-manifest.json @@ -14,13 +14,14 @@ "validator": "validator-broadcast on-chain action: decoded + indexed but NOT user-encodable (ANCHOR/ATTEST/NODEPROOF/ROLLCALL/SLASH)", "mirror-injected": "indexer-injected from the hub mirror, NOT chain-decoded (XCALL/XEXEC/CROSS_SETTLE)", "lifecycle": "system-generated sub-action, never a decoded wire tx (matches/expiries/dispense)", - "explorer-legacy-render": "render-only in the explorer (legacy order/dispenser cancel+edit views); no decoder/indexer twin" + "explorer-legacy-render": "live sub-action name the indexer writes by renaming its parent's actions row (ORDER/SWAP/DISPENSER format 1 -> _CANCEL, format 2 -> _EDIT, via updateActionIndex in xchain-indexer src/actions/{order,swap,dispenser}/settle.js; BET format 4 -> BET_EDIT via updateActionIndex in src/actions/bet/edit_lists_apply.js), rendered by the explorer and keyed by the indexer's escrow attribution (src/consensus/escrow_journal_writer/attribution.js); the wire tx decodes and dispatches as its parent, so the name is never wireDecoded or indexerHandled. The category key predates this definition: the formats are live, not legacy", + "settlement-anchor": "indexer-minted anchor row for a bridge-policy or shared-list settlement record (XPOLICY/LIST_SHARE); never a decoded wire tx and never dispatched, only rendered by the explorer" }, "notes": [ "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. 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.", + "userEncodableVersions is NOT a copy of the SDK Formats keys, it is the audit those keys are checked against. Every entry was read off the indexer handler for that action, xchain-indexer/src/actions//index.js (or the flat src/actions/.js for the few single-file handlers): 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. Four versions the indexer accepts, across three actions, are therefore absent here. Two are parsed and then gated: VOTE v2 (finalize) rejects a user broadcast outright via `if(!data['IS_SYNTHETIC'])` in xchain-indexer/src/actions/vote/index.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 v2 and v5 are the other two, and they have no user format at all: 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' / 'invalid: XBRIDGE v5 is system-injected', in xchain-indexer/src/actions/xbridge/verdicts.js), the ATTEST precedent of one action name carrying mixed formats. That is why two entries carry gapped version lists, VOTE [0, 1, 3] and XBRIDGE [0, 1, 3, 4]. 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": { @@ -74,11 +75,15 @@ "category": "wire-user", "wireDecoded": true, "userEncodable": true, - "userEncodableVersions": [0, 1, 2, 3], + "userEncodableVersions": [0, 1, 2, 3, 4], "indexerHandled": true, "explorerRender": true, "walletForm": true }, + "BET_EDIT": { + "category": "explorer-legacy-render", + "explorerRender": true + }, "BET_EXPIRE": { "category": "lifecycle", "indexerHandled": true, @@ -252,6 +257,10 @@ "explorerRender": true, "walletForm": true }, + "LIST_SHARE": { + "category": "settlement-anchor", + "explorerRender": true + }, "MESSAGE": { "category": "wire-user", "wireDecoded": true, @@ -432,6 +441,10 @@ "category": "mirror-injected", "indexerHandled": true, "explorerRender": true + }, + "XPOLICY": { + "category": "settlement-anchor", + "explorerRender": true } } } diff --git a/test/helpers/sibling_checkout.js b/test/helpers/sibling_checkout.js index afcbac8..3cf5c6d 100644 --- a/test/helpers/sibling_checkout.js +++ b/test/helpers/sibling_checkout.js @@ -41,6 +41,7 @@ const fs = require('fs'); const path = require('path'); +const cp = require('child_process'); const OWN_ROOT = path.resolve(__dirname, '..', '..'); @@ -63,6 +64,26 @@ function checkoutRootOf(dir) { } } +function pinnedMainCheckout(root, name, env) { + let pins; + try { pins = JSON.parse(env.XCHAIN_SIBLING_COMMITS || '{}'); } + catch (e) { return false; } + const expected = pins && pins[name]; + if (typeof expected !== 'string' || !/^[0-9a-f]{40}$/.test(expected)) return false; + + let actual; + try { + actual = cp.execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] + }).trim(); + } catch (e) { return false; } + if (actual !== expected) return false; + + const clean = (args) => cp.spawnSync('git', ['-C', root, ...args], { stdio: 'ignore' }).status === 0; + return clean(['diff', '--quiet', '--ignore-submodules', '--']) + && clean(['diff', '--cached', '--quiet', '--ignore-submodules', '--']); +} + /** * Judge one sibling path. * @@ -74,6 +95,7 @@ function checkoutRootOf(dir) { */ function siblingCheckout(fromDir, target, opts = {}) { const ownRoot = opts.ownRoot || OWN_ROOT; + const env = opts.env || process.env; const abs = path.resolve(fromDir, target); if (!fs.existsSync(abs)) @@ -94,12 +116,15 @@ function siblingCheckout(fromDir, target, opts = {}) { const targetRoot = checkoutRootOf(real); const targetIsMain = targetRoot !== null && fs.lstatSync(path.join(targetRoot, '.git')).isDirectory(); - if (targetIsMain) + if (targetIsMain) { + if (pinnedMainCheckout(targetRoot, path.basename(entry), env)) + return { usable: true, path: abs, reason: null }; return { usable: false, path: abs, reason: 'sibling ' + path.basename(entry) + ' resolves through a symlink into the live main checkout ' + targetRoot + ', which no commit pins; cut a real sibling worktree to test against it', }; + } return { usable: true, path: abs, reason: null }; } @@ -120,4 +145,4 @@ function skipOrFail(ctx, verdict, what) { return false; } -module.exports = { siblingCheckout, skipOrFail, siblingsRequired, isLinkedWorktree }; +module.exports = { siblingCheckout, skipOrFail, siblingsRequired, isLinkedWorktree, pinnedMainCheckout }; diff --git a/test/integration/helpers/utxoFactory.js b/test/integration/helpers/utxoFactory.js index 46d093e..8ba8c2e 100644 --- a/test/integration/helpers/utxoFactory.js +++ b/test/integration/helpers/utxoFactory.js @@ -238,7 +238,8 @@ function makeEncoder (networkName = 'dogecoin-regtest') { getFeePerKilobyte: async () => 0.00001, getNetworkInfo: async () => ({ relayfee: 0.00001 }), getTransactionHex: async () => rawTxHex, - isRegtest: async () => true + isRegtest: async () => true, + assertNodeTier: async () => {} } encoder.utxoTrackerConnector = { getUtxosFromAddress: async () => makeTrackerEnvelope([makeUtxo(networkName, TXID_A, 0, 100000000)]) 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 index b27c8f8..a9a0592 100644 --- 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 @@ -23,7 +23,7 @@ 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) +const VALID_COMPRESSED_03 = '0379BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798' // on the curve: the generator's x describe('REG-04: Validator Functions', function () { describe('REG-04.12: validateCompressedPubKey()', function () { diff --git a/test/regression/reg_08_api_contract.test.js b/test/regression/reg_08_api_contract.test.js index d70995b..6b26c3a 100644 --- a/test/regression/reg_08_api_contract.test.js +++ b/test/regression/reg_08_api_contract.test.js @@ -34,9 +34,9 @@ const NETWORK = 'dogecoin-regtest' /** * Simulate the api.js create_tx handler flow: - * 1. validateAll(rawParams): may throw TypeError/RangeError (→ -32602) - * 2. encoder.createTransaction(validated): may throw TypeError/RangeError - * (→ -32602), an operational error (→ -32010) or plain Error (→ -32603) + * 1. validateAll(rawParams): may throw ParamTypeError/ParamRangeError (→ -32602) + * 2. encoder.createTransaction(validated): may throw ParamTypeError/ParamRangeError + * (→ -32602), an operational error (→ -32010) or anything else (→ -32603) * 3. return { psbt: psbt.toHex(), encoding } */ async function simulateCreateTx (rawParams, encoder) { diff --git a/test/regression/reg_32_create_tx_error_code_split.test.js b/test/regression/reg_32_create_tx_error_code_split.test.js index 4016160..1b63135 100644 --- a/test/regression/reg_32_create_tx_error_code_split.test.js +++ b/test/regression/reg_32_create_tx_error_code_split.test.js @@ -14,6 +14,7 @@ const assert = require('assert') const { createJsonRpcController } = require('../../src/api/json_rpc_methods') +const { ParamTypeError } = require('../../src/build/errors') const { TXID_A, PUBKEY_BUF, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') const actions = require('../integration/helpers/actionFactory') @@ -50,9 +51,16 @@ describe('REG-32: create_tx answers a caller fault with -32602', () => { assert.ok(/below the node relay minimum/.test(caught.message), caught.message) }) + // The compression guards raise through refuse(ErrorClass, ...), never a literal `new`. + it('keeps the reason for an explicit compress request on a non-FILE action', async () => { + const caught = await rejectionOf('create_tx', null, sendParams({ compress: true, rawData: 'a'.repeat(64) })) + assert.strictEqual(caught.code, -32602) + assert.ok(/COMPRESSION is a FILE v0 field/.test(caught.message), caught.message) + }) + it('keeps the message of a TypeError the build raises', async () => { const caught = await rejectionOf('create_tx', (encoder) => { - encoder.createTransaction = async () => { throw new TypeError('Unknown encoding: "FOO"') } + encoder.createTransaction = async () => { throw new ParamTypeError('Unknown encoding: "FOO"') } }, sendParams()) assert.strictEqual(caught.code, -32602) assert.strictEqual(caught.message, 'Unknown encoding: "FOO"') @@ -113,3 +121,32 @@ describe('REG-32: the other build paths keep node faults off -32602', () => { (err) => err.constructor === Error && /output uplift must be a positive integer/.test(err.message)) }) }) + +// A native or library TypeError/RangeError is a bug, not a caller fault: logged, retryable, generic. +describe('REG-32: an unmarked TypeError or RangeError from the build is an internal error', () => { + const NATIVE = "Cannot read properties of undefined (reading 'x')" + const cancelParams = { + commitTxid: TXID_A, + commitVout: 0, + commitValue: 100000, + internalPubkey: PUBKEY_BUF.toString('hex'), + tapleafHash: 'c'.repeat(64), + destination: address + } + const cases = [ + ['create_tx', (enc, err) => { enc.createTransaction = async () => { throw err } }, sendParams()], + ['create_envelope_cancel_tx', (enc, err) => { enc.createEnvelopeCancelTransaction = async () => { throw err } }, cancelParams], + ['release_inputs', (enc, err) => { enc.releaseReservation = () => { throw err } }, { reservationId: 'f'.repeat(32) }] + ] + + for (const [method, stub, params] of cases) { + for (const make of [() => new TypeError(NATIVE), () => new RangeError('Invalid array length')]) { + const kind = make().name + it(method + ' answers -32603, generic, for a native ' + kind, async () => { + const caught = await rejectionOf(method, (enc) => stub(enc, make()), params) + assert.strictEqual(caught.code, -32603) + assert.strictEqual(caught.message, 'Internal encoder error') + }) + } + } +}) diff --git a/test/regression/reg_33_network_invalid_address.test.js b/test/regression/reg_33_network_invalid_address.test.js new file mode 100644 index 0000000..9d514d7 --- /dev/null +++ b/test/regression/reg_33_network_invalid_address.test.js @@ -0,0 +1,113 @@ +// 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 well-formed address that does not decode on the encoder's network is a +// caller fault: -32602, named by field, refused before any tracker or fee work. + +const assert = require('assert') +const { createJsonRpcController } = require('../../src/api/json_rpc_methods') +const { assertAddressOnNetwork } = require('../../src/XChainEncoder/request_resolution') +const CryptoNetworks = require('../../src/build/crypto_networks') +const { TXID_A, PUBKEY_BUF, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') +const actions = require('../integration/helpers/actionFactory') + +const NETWORK = 'bitcoin-regtest' +const REGTEST = CryptoNetworks.getBitcoinJsNetwork(NETWORK) +const address = getTestAddress(NETWORK) +const MAINNET_P2PKH = '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa' +const REGTEST_P2TR = 'bcrt1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqc8gma6' +// Flip the last character so the base58 checksum no longer matches. +const BAD_CHECKSUM = address.slice(0, -1) + (address.endsWith('A') ? 'B' : 'A') + +// Count every tracker fetch and node fee read, so a test can prove none ran. +function recordUpstream (encoder, calls) { + const tracker = encoder.utxoTrackerConnector.getUtxosFromAddress + const fee = encoder.connector.getFeePerKilobyte + const info = encoder.connector.getNetworkInfo + encoder.utxoTrackerConnector.getUtxosFromAddress = (...a) => { calls.push('tracker'); return tracker(...a) } + encoder.connector.getFeePerKilobyte = (...a) => { calls.push('fee'); return fee(...a) } + encoder.connector.getNetworkInfo = (...a) => { calls.push('networkinfo'); return info(...a) } +} + +// Run one JSON-RPC method against a fresh stubbed encoder; return its outcome and upstream calls. +async function run (method, params) { + const encoder = makeEncoder(NETWORK) + const calls = [] + recordUpstream(encoder, calls) + const controller = createJsonRpcController({ encoder, NETWORK }) + try { + return { result: await controller[method](params), calls } + } catch (caught) { + return { caught, calls } + } +} + +function sendParams (extra) { + return Object.assign({ pubkey: address, data: actions.makeSend().data, change: address, unconfirmed: true }, extra) +} + +function cancelParams (destination) { + return { + commitTxid: TXID_A, commitVout: 0, commitValue: 100000, + internalPubkey: PUBKEY_BUF.toString('hex'), tapleafHash: 'c'.repeat(64), destination + } +} + +// Assert a -32602 naming the field, with no tracker or node fee work done first. +function assertCallerFault (outcome, field) { + assert.ok(outcome.caught, 'the request should have been refused') + assert.strictEqual(outcome.caught.code, -32602, outcome.caught.message) + assert.ok(outcome.caught.message.includes(field), outcome.caught.message) + assert.deepStrictEqual(outcome.calls, [], 'refused only after upstream work: ' + outcome.calls.join(',')) +} + +describe('REG-33: create_tx refuses a network-invalid address as a caller fault', () => { + it('a mainnet customOutputs address on regtest', async () => { + assertCallerFault(await run('create_tx', sendParams({ customOutputs: [{ address: MAINNET_P2PKH, value: 10000 }] })), + 'customOutputs[0].address') + }) + + it('a mainnet feeQuote address on regtest', async () => { + assertCallerFault(await run('create_tx', sendParams({ feeQuote: { address: MAINNET_P2PKH, amount: 1000 } })), + 'feeQuote.address') + }) + + it('a change address with a bad checksum', async () => { + assertCallerFault(await run('create_tx', sendParams({ change: BAD_CHECKSUM })), 'change') + }) + + it('a P2SH funding call, where the custom output is only priced and never emitted', async () => { + assertCallerFault(await run('create_tx', sendParams({ + encoding: 'P2SH', customOutputs: [{ address: MAINNET_P2PKH, value: 10000 }] + })), 'customOutputs[0].address') + }) +}) + +describe('REG-33: create_envelope_cancel_tx refuses a network-invalid destination', () => { + it('a mainnet destination on regtest', async () => { + assertCallerFault(await run('create_envelope_cancel_tx', cancelParams(MAINNET_P2PKH)), 'destination') + }) +}) + +describe('REG-33: valid addresses still build', () => { + it('a regtest Taproot custom output on a default create_tx', async () => { + const outcome = await run('create_tx', sendParams({ customOutputs: [{ address: REGTEST_P2TR, value: 10000 }] })) + assert.ok(!outcome.caught, outcome.caught && outcome.caught.message) + }) + + it('assertAddressOnNetwork accepts regtest P2PKH and P2TR and labels a refusal', () => { + assert.strictEqual(assertAddressOnNetwork(address, REGTEST, 'x'), address) + assert.strictEqual(assertAddressOnNetwork(REGTEST_P2TR, REGTEST, 'x'), REGTEST_P2TR) + assert.throws(() => assertAddressOnNetwork(MAINNET_P2PKH, REGTEST, 'payTo'), + (err) => err instanceof TypeError && /payTo is not a valid address for this network/.test(err.message)) + assert.throws(() => assertAddressOnNetwork('', REGTEST, 'payTo'), + (err) => err instanceof TypeError && /payTo must be a non-empty string/.test(err.message)) + }) +}) 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 index a89d485..8052716 100644 --- a/test/security/concurrency_gate.test/02_api_js_wiring.test.js +++ b/test/security/concurrency_gate.test/02_api_js_wiring.test.js @@ -44,10 +44,16 @@ describe('Security: global in-flight concurrency cap', function () { assert.ok(apiSource.includes('ENCODER_MAX_CONCURRENT_PROBES')) assert.ok(/app\.use\(probeGate\)/.test(apiSource)) // The security suite drives this module's predicate; an inline copy here would escape it. - assert.ok(apiSource.includes("const { isProbe } = require('./server/probe_request.js')")) + assert.ok(apiSource.includes("const { isProbe, isSpecRequest } = require('./server/probe_request.js')")) assert.ok(!/const isProbe\s*=/.test(apiSource), 'api.js must not redefine the probe predicate') }) + it('exempts the spec from the API key through the shared predicate, not an exact match', function () { + assert.ok(/if \(isSpecRequest\(req\)\) return next\(\)/.test(apiSource)) + assert.ok(!apiSource.includes("req.path === '/openrpc.json'"), 'an exact-match exemption refuses HEAD, case and slash variants') + assert.ok(!/const isSpecRequest\s*=/.test(apiSource), 'api.js must not redefine the spec predicate') + }) + 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/unit/XChainEncoder/build_transaction/first_input_key.test.js b/test/unit/XChainEncoder/build_transaction/first_input_key.test.js new file mode 100644 index 0000000..acdb4b1 --- /dev/null +++ b/test/unit/XChainEncoder/build_transaction/first_input_key.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 + +const assert = require('assert') +const { OperationalError } = require('../../../../src/build/errors') +const { RESERVATION_TTL_MS } = require('../../../../src/XChainEncoder/constants') +const reservations = require('../../../../src/XChainEncoder/outpoint_reservations') +const { bindObfuscationKey } = require( + '../../../../src/XChainEncoder/build_transaction/first_input_key' +) + +const TXID_FIRST = 'A'.repeat(64) +const TXID_AVAILABLE = 'B'.repeat(64) +const NOW = 1700000000000 + +function makeContext () { + return Object.assign({ outpointReservations: new Map() }, reservations) +} + +function withFixedNow (run) { + const realNow = Date.now + Date.now = () => NOW + try { + return run() + } finally { + Date.now = realNow + } +} + +describe('bindObfuscationKey', () => { + it('binds the key to the available outpoint moved into the first-input slot', () => { + const context = makeContext() + const firstKey = `${TXID_FIRST}:0` + context.outpointReservations.set(firstKey, NOW + 1) + const build = { + utxos: [ + { txid: TXID_FIRST, vout: 0 }, + { txid: TXID_AVAILABLE, vout: 1 } + ], + exactInputs: false, + callReservations: [] + } + + withFixedNow(() => bindObfuscationKey.call(context, build)) + + assert.strictEqual(build.utxos[0].txid, TXID_AVAILABLE) + assert.strictEqual(build.txidFirstInput, TXID_AVAILABLE.toLowerCase()) + assert.strictEqual(build.firstReservedOutpoint, `${TXID_AVAILABLE}:1`) + }) +}) + +describe('bindObfuscationKey exact inputs', () => { + it('raises OperationalError when exact mode cannot claim its first input', () => { + const context = makeContext() + const firstKey = `${TXID_FIRST}:0` + context.outpointReservations.set(firstKey, NOW + 1) + const build = { + utxos: [{ txid: TXID_FIRST, vout: 0 }], + exactInputs: true, + callReservations: [] + } + + assert.throws( + () => withFixedNow(() => bindObfuscationKey.call(context, build)), + (error) => { + assert.ok(error instanceof OperationalError) + assert.strictEqual(error.xchainCode, 'INPUT_RESERVED') + assert.deepStrictEqual(error.details, { reserved: [firstKey] }) + assert.match(error.message, new RegExp( + `${Math.round(RESERVATION_TTL_MS / 60000)} minutes` + )) + return true + } + ) + }) +}) + +describe('bindObfuscationKey reservation lifetime', () => { + it('reserves the first input for the configured TTL', () => { + const context = makeContext() + const callReservations = [] + const key = `${TXID_FIRST}:2` + const build = { + utxos: [{ txid: TXID_FIRST, vout: 2 }], + exactInputs: false, + callReservations + } + + withFixedNow(() => bindObfuscationKey.call(context, build)) + + assert.strictEqual(context.outpointReservations.get(key), NOW + RESERVATION_TTL_MS) + assert.deepStrictEqual(callReservations, [ + { key, expiry: NOW + RESERVATION_TTL_MS } + ]) + }) +}) diff --git a/test/unit/XChainEncoder/fee_policy.test.js b/test/unit/XChainEncoder/fee_policy.test.js new file mode 100644 index 0000000..9e2395a --- /dev/null +++ b/test/unit/XChainEncoder/fee_policy.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. + +// capCallerFeeRate clamps a caller rate to the tighter of the operator cap and +// the fixed 100x burn backstop, and keeps capFeePerBytes as the pure operator cap. + +const assert = require('assert') +const { capCallerFeeRate, clampLimitLabel } = require('../../../src/XChainEncoder/fee_policy.js') + +// 1 sat/byte expressed as coin per byte. +const ANCHOR = 1e-8 +const DRAIN = ANCHOR * 500 + +function cap (overrides) { + return capCallerFeeRate(Object.assign({ + feePerBytes: DRAIN, nodeFeePerBytes: ANCHOR, relayFeePerKb: 0.00001, + maxFeePerBytes: null, maxFeeRateMultiplier: 100 + }, overrides)) +} + +describe('capCallerFeeRate: operator cap against the burn backstop', () => { + it('default multiplier 100: clamps to the cap and names the cap, as before', () => { + const r = cap({}) + assert.strictEqual(r.limit, 'cap') + assert.strictEqual(r.clamped, true) + assert.strictEqual(r.feePerBytes, ANCHOR * 100) + assert.strictEqual(r.capFeePerBytes, ANCHOR * 100) + assert.strictEqual(clampLimitLabel(r), 'the fee-rate cap') + }) + + it('multiplier 1000: the backstop binds while capFeePerBytes keeps the operator value', () => { + const r = cap({ maxFeeRateMultiplier: 1000 }) + assert.strictEqual(r.limit, 'backstop') + assert.strictEqual(r.feePerBytes, ANCHOR * 100) + assert.strictEqual(r.capFeePerBytes, ANCHOR * 1000) + assert.match(clampLimitLabel(r), /tighter than the configured fee-rate cap/) + }) + + it('loose MAX_FEE_RATE_KB with the multiplier off: the backstop binds', () => { + const r = cap({ maxFeeRateMultiplier: null, maxFeePerBytes: ANCHOR * 1000 }) + assert.strictEqual(r.limit, 'backstop') + assert.strictEqual(r.feePerBytes, ANCHOR * 100) + assert.strictEqual(r.capFeePerBytes, ANCHOR * 1000) + }) + + it('MAX_FEE_RATE_KB tighter than 100x: that cap binds', () => { + const r = cap({ maxFeeRateMultiplier: null, maxFeePerBytes: ANCHOR * 50 }) + assert.strictEqual(r.limit, 'cap') + assert.strictEqual(r.feePerBytes, ANCHOR * 50) + }) + + it('both caps off: the backstop binds and capFeePerBytes stays null', () => { + const r = cap({ maxFeeRateMultiplier: null }) + assert.strictEqual(r.limit, 'backstop') + assert.strictEqual(r.capFeePerBytes, null) + assert.strictEqual(r.feePerBytes, ANCHOR * 100) + assert.strictEqual(clampLimitLabel(r), 'the fixed burn backstop (fee-rate cap disabled)') + }) + + it('a rate under every limit is honored unchanged', () => { + const r = cap({ maxFeeRateMultiplier: 1000, feePerBytes: ANCHOR * 50 }) + assert.strictEqual(r.clamped, false) + assert.strictEqual(r.feePerBytes, ANCHOR * 50) + }) + + it('no anchor and no cap: nothing to clamp against', () => { + const r = cap({ nodeFeePerBytes: null, relayFeePerKb: 0, maxFeeRateMultiplier: null }) + assert.strictEqual(r.limit, null) + assert.strictEqual(r.clamped, false) + assert.strictEqual(r.feePerBytes, DRAIN) + }) +}) diff --git a/test/unit/XChainEncoder/fee_rate_cap_sign_guard.test.js b/test/unit/XChainEncoder/fee_rate_cap_sign_guard.test.js new file mode 100644 index 0000000..717c376 --- /dev/null +++ b/test/unit/XChainEncoder/fee_rate_cap_sign_guard.test.js @@ -0,0 +1,73 @@ +// 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 negative fee cap (an operator typo such as MAX_FEE_RATE_KB=-1) must fall +// back to the safe default, never clamp every build's rate below zero. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') + +const pubkeyBuf = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex' +) +const LTC_REGTEST = require('../../../src/build/crypto_networks').getBitcoinJsNetwork('litecoin-regtest') +const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: LTC_REGTEST }).address +const INPUT_VALUE = 100000000 +const SEGWIT_SCRIPT = bitcoin.payments.p2wpkh({ pubkey: pubkeyBuf, network: bitcoin.networks.regtest }).output.toString('hex') + +// Node estimate mocked at 20 sat/byte, so the node-suggested fee sits far above the dust floor. +function makeEncoder (...capArgs) { + const encoder = new XChainEncoder('litecoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '', ...capArgs) + encoder.connector = { + getFeePerKilobyte: async () => 0.0002, + getNetworkInfo: async () => ({ relayfee: 0.00001 }) + } + encoder.dustAmount = 546 + return encoder +} + +// Build with no caller fee or rate, so the node-suggested rate prices it, and return the fee paid. +async function nodeRatedFee (encoder) { + const result = await encoder.createTransaction( + [{ txid: 'a'.repeat(64), vout: 0, value: INPUT_VALUE, confirmations: 6, scriptPubKey: SEGWIT_SCRIPT }], + TEST_ADDRESS, null, 'test', null, null, false, null, TEST_ADDRESS, + null, null, null, true, null + ) + return INPUT_VALUE - result.psbt.txOutputs.reduce((sum, o) => sum + o.value, 0) +} + +describe('XChainEncoder fee-rate cap sign guard', () => { + it('treats a zero or negative MAX_FEE_RATE_KB as no absolute cap', () => { + assert.strictEqual(makeEncoder(-1).maxFeePerBytes, null) + assert.strictEqual(makeEncoder(0).maxFeePerBytes, null) + assert.strictEqual(makeEncoder(NaN).maxFeePerBytes, null) + assert.ok(makeEncoder(50000).maxFeePerBytes > 0, 'a positive cap still applies') + }) + + it('keeps the default multiplier for a negative or NaN MAX_FEE_RATE_MULTIPLIER', () => { + assert.strictEqual(makeEncoder(null, -1).maxFeeRateMultiplier, 100) + assert.strictEqual(makeEncoder(null, NaN).maxFeeRateMultiplier, 100) + assert.strictEqual(makeEncoder(null, 0).maxFeeRateMultiplier, null, '0 still disables the relative cap') + assert.strictEqual(makeEncoder(null, null).maxFeeRateMultiplier, null, 'null still disables the relative cap') + assert.strictEqual(makeEncoder(null, 5).maxFeeRateMultiplier, 5) + assert.strictEqual(makeEncoder(null).maxFeeRateMultiplier, 100) + }) + + it('prices a node-rated build the same under a negative cap as under the defaults', async () => { + const baseline = await nodeRatedFee(makeEncoder()) + assert.ok(baseline > 546, `baseline fee ${baseline} must sit above the dust floor to be a real probe`) + assert.strictEqual(await nodeRatedFee(makeEncoder(-1)), baseline, + 'MAX_FEE_RATE_KB=-1 must not clamp the node rate down to a dust-sized fee') + assert.strictEqual(await nodeRatedFee(makeEncoder(null, -1)), baseline, + 'MAX_FEE_RATE_MULTIPLIER=-1 must not clamp the node rate down to a dust-sized fee') + }) +}) diff --git a/test/unit/api/broadcast_tx_logging.test.js b/test/unit/api/broadcast_tx_logging.test.js index 7fbeec3..9295bbc 100644 --- a/test/unit/api/broadcast_tx_logging.test.js +++ b/test/unit/api/broadcast_tx_logging.test.js @@ -23,7 +23,8 @@ describe('broadcast_tx rejection logging', function () { const txid = tx.getId() const encoder = { connector: { - sendRawTransaction: async () => { throw new Error('66: insufficient priority') } + sendRawTransaction: async () => { throw new Error('66: insufficient priority') }, + assertNodeTier: async () => {} } } const controller = createJsonRpcController({ encoder, NETWORK: 'dogecoin-testnet' }) diff --git a/test/unit/api/broadcast_tx_node_rejected.test.js b/test/unit/api/broadcast_tx_node_rejected.test.js new file mode 100644 index 0000000..8271320 --- /dev/null +++ b/test/unit/api/broadcast_tx_node_rejected.test.js @@ -0,0 +1,109 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +const assert = require('assert') +const axios = require('axios') +const bitcoin = require('bitcoinjs-lib') +const BlockchainConnector = require('../../../src/build/blockchain_connector') +const { createJsonRpcController } = require('../../../src/api/json_rpc_methods') + +function txHex () { + const tx = new bitcoin.Transaction() + tx.addInput(Buffer.alloc(32, 1), 0) + tx.addOutput(Buffer.from([bitcoin.opcodes.OP_RETURN]), 0) + return tx.toHex() +} + +function controllerFailing (err) { + const encoder = { connector: { sendRawTransaction: async () => { throw err }, assertNodeTier: async () => {} } } + return createJsonRpcController({ encoder, NETWORK: 'dogecoin-testnet' }) +} + +function nodeError (message, nodeCode) { + const err = new Error(message) + if (nodeCode !== undefined) err.nodeCode = nodeCode + return err +} + +async function rejection (controller) { + try { + await controller.broadcast_tx({ tx_hex: txHex() }) + } catch (e) { + return e + } + throw new Error('expected broadcast_tx to reject') +} + +describe('broadcast_tx node rejection codes', function () { + let originalError + beforeEach(() => { originalError = console.error; console.error = () => {} }) + afterEach(() => { console.error = originalError }) + + it('answers a node rejection as -32010 NODE_REJECTED with the node code', async function () { + const e = await rejection(controllerFailing(nodeError('min relay fee not met', -26))) + assert.strictEqual(e.code, -32010) + assert.deepStrictEqual(e.data, { reason: 'NODE_REJECTED', node_code: -26 }) + assert.match(e.message, /min relay fee not met/) + }) + + it('answers an already-known tx as TX_ALREADY_IN_CHAIN by node code', async function () { + const e = await rejection(controllerFailing(nodeError('Transaction already in block chain', -27))) + assert.strictEqual(e.code, -32010) + assert.deepStrictEqual(e.data, { reason: 'TX_ALREADY_IN_CHAIN', node_code: -27 }) + }) + + it('answers a mempool duplicate as TX_ALREADY_IN_CHAIN by message', async function () { + const e = await rejection(controllerFailing(nodeError('txn-already-known', -26))) + assert.strictEqual(e.data.reason, 'TX_ALREADY_IN_CHAIN') + assert.strictEqual(e.data.node_code, -26) + }) + + it('keeps a transport fault at -32603 with no data', async function () { + const err = Object.assign(new Error('connect ECONNREFUSED 10.0.0.5:8332'), { code: 'ECONNREFUSED' }) + const e = await rejection(controllerFailing(err)) + assert.strictEqual(e.code, -32603) + assert.strictEqual(e.data, undefined) + assert.ok(!/10\.0\.0\.5/.test(e.message)) + }) + + it('keeps a node error without a numeric code at -32603', async function () { + const e = await rejection(controllerFailing(nodeError('empty result'))) + assert.strictEqual(e.code, -32603) + }) +}) + +describe('broadcast_tx node rejection codes through the real connector', function () { + let originalError + let originalPost + beforeEach(() => { originalPost = axios.post; originalError = console.error; console.error = () => {} }) + afterEach(() => { axios.post = originalPost; console.error = originalError }) + + function realController () { + const connector = new BlockchainConnector('127.0.0.1', 18332, 'rpcuser', 'rpcpass') + return createJsonRpcController({ encoder: { connector }, NETWORK: 'dogecoin-testnet' }) + } + + it('propagates the node code from a node error body to -32010', async function () { + axios.post = async () => ({ data: { error: { message: 'dust', code: -26 } } }) + const e = await rejection(realController()) + assert.strictEqual(e.code, -32010) + assert.deepStrictEqual(e.data, { reason: 'NODE_REJECTED', node_code: -26 }) + }) + + it('propagates the node code from an HTTP 500 body', async function () { + axios.post = async () => { throw Object.assign(new Error('status 500'), { response: { data: { error: { message: 'Transaction already in block chain', code: -27 } } } }) } + const e = await rejection(realController()) + assert.strictEqual(e.data.reason, 'TX_ALREADY_IN_CHAIN') + assert.strictEqual(e.data.node_code, -27) + }) + + it('keeps a transport fault at -32603', async function () { + axios.post = async () => { throw Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' }) } + const e = await rejection(realController()) + assert.strictEqual(e.code, -32603) + }) +}) diff --git a/test/unit/api/no_bare_param_errors.test.js b/test/unit/api/no_bare_param_errors.test.js new file mode 100644 index 0000000..0828d6a --- /dev/null +++ b/test/unit/api/no_bare_param_errors.test.js @@ -0,0 +1,76 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. +// +// Pins the -32602 marker: JSON-RPC answers "fix your params" only for a +// ParamTypeError/ParamRangeError, so a bare TypeError or RangeError raised in +// src would silently become a logged, generic -32603 instead. + +const assert = require('assert') +const fs = require('fs') +const path = require('path') + +const SRC = path.join(__dirname, '..', '..', '..', 'src') +// Skip the hub-vendored trees: they are byte copies this repo may not edit +const VENDORED = ['coins', 'observability'] + +// Every .js file under src, recursively. +function sourceFiles (dir) { + const out = [] + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) out.push(...sourceFiles(full)) + else if (entry.name.endsWith('.js')) out.push(full) + } + return out +} + +// The code part of one line: comment lines dropped, string literals blanked, a trailing // comment cut. +function codeOf (line) { + if (/^\s*(\*|\/\*)/.test(line)) return '' + return line.replace(/'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"|`(?:\\.|[^`\\])*`/g, "''").replace(/\/\/.*$/, '') +} + +// Every src line that names TypeError or RangeError as a value, not only as `new X(`. +// (`instanceof X` and `extends X` only read the class, so they are allowed.) +function bareLines (text) { + const hits = [] + text.split('\n').forEach((line, i) => { + const code = codeOf(line).replace(/\b(instanceof|extends)\s+(TypeError|RangeError)\b/g, '') + if (/\b(TypeError|RangeError)\b/.test(code)) hits.push(i + 1) + }) + return hits +} + +describe('caller-input errors carry the -32602 marker', () => { + it('src raises no bare TypeError or RangeError, only the Param classes or a plain Error', () => { + const files = sourceFiles(SRC).filter(f => !VENDORED.includes(path.relative(SRC, f).split(path.sep)[0])) + assert.ok(files.length > 20, 'walked ' + files.length + ' files; src was not found') + const bare = [] + for (const file of files) { + for (const n of bareLines(fs.readFileSync(file, 'utf8'))) bare.push(path.relative(SRC, file) + ':' + n) + } + assert.deepStrictEqual(bare, [], 'raise ParamTypeError/ParamRangeError for a caller fault, plain Error for an internal one') + }) + + // A class handed to a helper that constructs it (refuse(TypeError, ...)) is a raise too. + it('flags a class passed by reference, and ignores comments, strings and instanceof', () => { + const probe = [ + "refuse(TypeError, 'x')", + 'pick(a, RangeError)', + "// new TypeError('in a comment')", + "throw new Error('RangeError: in a string')", + 'if (!(err instanceof TypeError)) throw err', + "throw new TypeError('direct')" + ].join('\n') + assert.deepStrictEqual(bareLines(probe), [1, 2, 6]) + }) +}) diff --git a/test/unit/api/node_tier_gate.test.js b/test/unit/api/node_tier_gate.test.js new file mode 100644 index 0000000..d8bd80b --- /dev/null +++ b/test/unit/api/node_tier_gate.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. + +// The JSON-RPC entry points refuse a coin node on the wrong network tier before any build work. +// (A caller-supplied feePerKb skips the fee lookup, so only an entry gate can catch it.) + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const { createJsonRpcController } = require('../../../src/api/json_rpc_methods') +const { assertNodeTier } = require('../../../src/build/blockchain_connector/node_queries') +const { TXID_A, PUBKEY_BUF, makeEncoder, getTestAddress } = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') + +const NETWORK = 'bitcoin-testnet' +const address = getTestAddress(NETWORK) + +// A testnet encoder whose stubbed node reports `chain`, with the real tier gate and build-entry counters. +function testnetEncoderOn (chain) { + const encoder = makeEncoder(NETWORK) + const entered = [] + encoder.connector.consensusNetwork = 'testnet' + encoder.connector.chainName = async () => chain + encoder.connector.assertNodeTier = assertNodeTier + encoder.connector.getFeePerKilobyte = async () => { entered.push('fee'); return 0.00001 } + encoder.connector.sendRawTransaction = async () => { entered.push('broadcast'); return 'ab'.repeat(32) } + const build = encoder.createTransaction.bind(encoder) + encoder.createTransaction = (...a) => { entered.push('build'); return build(...a) } + const cancel = encoder.createEnvelopeCancelTransaction.bind(encoder) + encoder.createEnvelopeCancelTransaction = (...a) => { entered.push('cancel'); return cancel(...a) } + return { encoder, entered } +} + +function sendParams () { + return { pubkey: address, data: actions.makeSend().data, change: address, unconfirmed: true, feePerKb: 5000 } +} + +function rawTxHex () { + const tx = new bitcoin.Transaction() + tx.addInput(Buffer.alloc(32, 1), 0) + tx.addOutput(Buffer.from([bitcoin.opcodes.OP_RETURN]), 0) + return tx.toHex() +} + +const CALLS = { + create_tx: () => sendParams(), + create_envelope_cancel_tx: () => ({ + commitTxid: TXID_A, commitVout: 0, commitValue: 100000, + internalPubkey: PUBKEY_BUF.toString('hex'), tapleafHash: 'c'.repeat(64), destination: address + }), + estimate_fee: () => undefined, + broadcast_tx: () => ({ tx_hex: rawTxHex() }), +} + +describe('JSON-RPC node tier gate', () => { + for (const [method, params] of Object.entries(CALLS)) { + it(`${method} refuses a mainnet node under a testnet encoder as -32010 NODE_CHAIN_MISMATCH`, async () => { + const { encoder, entered } = testnetEncoderOn('main') + const controller = createJsonRpcController({ encoder, NETWORK }) + await assert.rejects(controller[method](params()), (err) => { + assert.strictEqual(err.code, -32010, err.message) + assert.strictEqual(err.data.reason, 'NODE_CHAIN_MISMATCH') + return true + }) + assert.deepStrictEqual(entered, [], `${method} reached ${entered.join(',')} before the gate refused`) + }) + } + + it('create_tx with a caller-supplied feePerKb still builds on a matching testnet node', async () => { + const { encoder, entered } = testnetEncoderOn('testnet4') + const controller = createJsonRpcController({ encoder, NETWORK }) + const result = await controller.create_tx(sendParams()) + assert.strictEqual(typeof result.psbt, 'string') + assert.ok(entered.includes('build')) + }) +}) diff --git a/test/unit/api/openrpc_key_exemption.test.js b/test/unit/api/openrpc_key_exemption.test.js new file mode 100644 index 0000000..0c23496 --- /dev/null +++ b/test/unit/api/openrpc_key_exemption.test.js @@ -0,0 +1,127 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * + * On a keyed deploy the OpenRPC spec stays public for every request shape + * Express routes to its handler: HEAD, any letter case and a trailing slash. + * Everything else, /status included, still needs the key. + * + ********************************************************************/ + +'use strict' + +const assert = require('assert') + +const { isSpecRequest, isProbe } = require('../../../src/server/probe_request.js') + +const API_PATH = require.resolve('../../../src/api.js') +const API_KEY = 'openrpc-exemption-test-key' + +// Require src/api.js fresh under the given env, restoring env and module cache. +function loadApi (env) { + const cached = require.cache[API_PATH] + const keys = Object.keys(env).concat('NETWORK') + const prior = {} + for (const k of keys) prior[k] = process.env[k] + if (!process.env.NETWORK) process.env.NETWORK = 'bitcoin-regtest' + for (const [k, v] of Object.entries(env)) { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + delete require.cache[API_PATH] + try { + return require(API_PATH) + } finally { + delete require.cache[API_PATH] + if (cached) require.cache[API_PATH] = cached + for (const k of keys) { + if (prior[k] === undefined) delete process.env[k] + else process.env[k] = prior[k] + } + } +} + +async function withServer (app, fn) { + const server = await new Promise(resolve => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)) + }) + try { + return await fn(`http://127.0.0.1:${server.address().port}`) + } finally { + if (typeof server.closeAllConnections === 'function') server.closeAllConnections() + await new Promise(resolve => server.close(resolve)) + } +} + +const PUBLIC_SHAPES = [ + { method: 'GET', path: '/openrpc.json' }, + { method: 'HEAD', path: '/openrpc.json' }, + { method: 'GET', path: '/openrpc.json/' }, + { method: 'GET', path: '/OpenRPC.json' } +] + +const KEYED_SHAPES = [ + { method: 'GET', path: '/status' }, + { method: 'POST', path: '/openrpc.json' }, + { method: 'GET', path: '/openrpc.jsonx' }, + { method: 'POST', path: '/' } +] + +describe('isSpecRequest matches only the spec route', function () { + it('accepts GET and HEAD on the spec path in any case, with or without a trailing slash', function () { + for (const req of PUBLIC_SHAPES) assert.strictEqual(isSpecRequest(req), true, req.method + ' ' + req.path) + }) + + it('refuses /status, other methods and lookalike paths', function () { + const refused = [ + { method: 'GET', path: '/status' }, { method: 'HEAD', path: '/status' }, + { method: 'POST', path: '/openrpc.json' }, { method: 'PUT', path: '/openrpc.json' }, + { method: 'OPTIONS', path: '/openrpc.json' }, { method: 'GET', path: '/openrpc.jsonx' }, + { method: 'GET', path: '/x/openrpc.json' } + ] + for (const req of refused) assert.strictEqual(isSpecRequest(req), false, req.method + ' ' + req.path) + }) + + it('leaves the probe predicate covering /status as before', function () { + assert.strictEqual(isProbe({ method: 'HEAD', path: '/Status/' }), true) + assert.strictEqual(isProbe({ method: 'GET', path: '/openrpc.json' }), true) + }) +}) + +describe('API-key gate exempts every spec request shape @regression', function () { + this.timeout(10000) + let keyed + + before(function () { + keyed = loadApi({ API_KEY, ENCODER_RATE_LIMIT_RPM: undefined }) + }) + + it('serves the spec without a key for GET, HEAD, a trailing slash and any case', async function () { + await withServer(keyed.app, async base => { + for (const shape of PUBLIC_SHAPES) { + const res = await fetch(base + shape.path, { method: shape.method }) + assert.strictEqual(res.status, 200, shape.method + ' ' + shape.path) + } + }) + }) + + it('still answers 401 without a key everywhere else, /status included', async function () { + await withServer(keyed.app, async base => { + for (const shape of KEYED_SHAPES) { + const res = await fetch(base + shape.path, { method: shape.method }) + assert.strictEqual(res.status, 401, shape.method + ' ' + shape.path) + assert.strictEqual((await res.json()).error.code, -32001) + } + }) + }) +}) diff --git a/test/unit/bin_tools/pin_identity.test.js b/test/unit/bin_tools/pin_identity.test.js new file mode 100644 index 0000000..7e5243b --- /dev/null +++ b/test/unit/bin_tools/pin_identity.test.js @@ -0,0 +1,77 @@ +'use strict'; + +const assert = require('node:assert/strict'); + +const { + buildPin, + comparable, + differences, + COINS_FILES, + VENDORED_TWIN_FILES, +} = require('../../../bin/pin-identity.js'); + +describe('identity pin file lists', () => { + it('exports the complete ordered identity file lists', () => { + assert.deepEqual(COINS_FILES, [ + 'src/coins/BTC.js', + 'src/coins/LTC.js', + 'src/coins/DOGE.js', + 'src/coins/index.js', + 'src/coins/consensus_pin.js', + ]); + assert.deepEqual(VENDORED_TWIN_FILES, [ + '.github/workflows/verify-tag.yml', + 'src/observability/README.md', + 'src/observability/index.js', + 'src/observability/logShipper.js', + 'src/observability/metrics.js', + 'test/fixtures/action-manifest.json', + 'test/fixtures/roundtrip-conformance.json', + 'test/fixtures/utxo-record-conformance.json', + 'tools/release/release-signing-fingerprint.txt', + 'tools/release/release-signing-key.asc', + ]); + }); +}); + +describe('identity pin values', () => { + it('builds hashes for every tracked identity file', () => { + const pin = buildPin(); + assert.deepEqual(Object.keys(pin.coins), COINS_FILES); + assert.deepEqual(Object.keys(pin.vendoredTwins), VENDORED_TWIN_FILES); + assert.match(pin.capturedAt, /^\d{4}-\d{2}-\d{2}T/); + for (const digest of [...Object.values(pin.coins), ...Object.values(pin.vendoredTwins)]) { + assert.match(digest, /^[a-f0-9]{64}$/); + } + }); + + it('compares a freshly built pin equal to itself', () => { + const pin = comparable(buildPin()); + assert.deepEqual(differences(pin, pin), []); + }); + + it('reports exactly one changed coin digest', () => { + const pin = buildPin(); + const changed = structuredClone(pin); + const changedFile = COINS_FILES[0]; + changed.coins[changedFile] = '0'.repeat(64); + assert.deepEqual( + differences(comparable(pin), comparable(changed)), + [`coins:${changedFile}:hash`], + ); + }); + + it('normalizes comparable values in deterministic key order', () => { + const normalized = comparable({ vendoredTwins: { z: 'last', a: 'first' }, capturedAt: 'ignored' }); + assert.deepEqual(Object.keys(normalized), [ + 'coins', + 'coinsNote', + 'vendoredTwins', + 'vendoredTwinsNote', + ]); + assert.equal( + JSON.stringify(normalized), + '{"coins":{},"coinsNote":null,"vendoredTwins":{"z":"last","a":"first"},"vendoredTwinsNote":null}', + ); + }); +}); diff --git a/test/unit/bin_tools/suite_title_additive.test.js b/test/unit/bin_tools/suite_title_additive.test.js new file mode 100644 index 0000000..74b1103 --- /dev/null +++ b/test/unit/bin_tools/suite_title_additive.test.js @@ -0,0 +1,95 @@ +/********************************************************************* + * + * Copyright © 2025-2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC - https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. + * + ********************************************************************/ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const REPO_ROOT = path.resolve(__dirname, '../../..'); +const CLI = path.join(REPO_ROOT, 'bin', 'suite-title-map.js'); +const PIN = path.join(REPO_ROOT, 'bin', 'pins', 'at1-suite-titles.json'); +const TMP_ROOT = path.join(REPO_ROOT, 'tmp'); + +function loadPin() { + return JSON.parse(fs.readFileSync(PIN, 'utf8')); +} + +function titlesFor(pin, file) { + return pin.titleSets[pin.scripts.test.files[file]]; +} + +function setTitles(pin, file, titles, suffix) { + const key = `suite-title-additive-${suffix}`; + pin.titleSets[key] = titles; + pin.scripts.test.files[file] = key; +} + +function pinnedFileWithTitles(pin) { + return Object.keys(pin.scripts.test.files) + .find((file) => file.startsWith('test/unit/') && titlesFor(pin, file).length); +} + +function compareThroughCli(pin) { + fs.mkdirSync(TMP_ROOT, { recursive: true }); + const dir = fs.mkdtempSync(path.join(TMP_ROOT, 'suite-title-additive-')); + const pinPath = path.join(dir, 'pin.json'); + try { + fs.writeFileSync(pinPath, `${JSON.stringify(pin)}\n`); + const result = spawnSync(process.execPath, [CLI, '--script', 'test', '--compare', pinPath], { + cwd: REPO_ROOT, + encoding: 'utf8', + timeout: 15000, + }); + assert.ifError(result.error); + return { status: result.status, output: `${result.stdout}${result.stderr}` }; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +describe('suite title comparison additive growth', () => { + it('prints added files and titles without failing the compare CLI', () => { + const pin = loadPin(); + const file = pinnedFileWithTitles(pin); + assert.ok(file); + const [addedTitle, ...remainingTitles] = titlesFor(pin, file); + setTitles(pin, file, remainingTitles, 'additive'); + + const result = compareThroughCli(pin); + + assert.strictEqual(result.status, 0, result.output); + assert.ok(result.output.includes( + '[test] file_added test/unit/bin_tools/suite_title_additive.test.js', + ), result.output); + assert.ok(result.output.includes(`[test] title_added ${file} :: ${addedTitle}`), result.output); + }); + + it('prints dropped files and titles and fails the compare CLI', () => { + const pin = loadPin(); + const file = pinnedFileWithTitles(pin); + assert.ok(file); + const droppedFile = 'test/unit/removed-from-tree.test.js'; + const droppedFileTitle = 'suite removed from tree'; + const droppedTitle = 'title removed from tree'; + setTitles(pin, droppedFile, [droppedFileTitle], 'dropped-file'); + setTitles(pin, file, [...titlesFor(pin, file), droppedTitle], 'dropped-title'); + + const result = compareThroughCli(pin); + + assert.strictEqual(result.status, 1, result.output); + assert.ok(result.output.includes(`[test] file_dropped ${droppedFile}`), result.output); + assert.ok(result.output.includes(`[test] title_dropped ${file} :: ${droppedTitle}`), result.output); + }); +}); 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 index c3998a0..dcc9197 100644 --- a/test/unit/blockchain_connector.test/04_send_raw_transaction.test.js +++ b/test/unit/blockchain_connector.test/04_send_raw_transaction.test.js @@ -68,6 +68,24 @@ describe('BlockchainConnector.sendRawTransaction()', () => { }) }) +describe('BlockchainConnector.sendRawTransaction() node code', () => { + registerAxiosHooks() + it('carries the node RPC code as nodeCode on a rejection', async () => { + stubAxiosPost({ data: { error: { message: 'Transaction already in block chain', code: -27 } } }) + await assert.rejects(() => makeConnector().sendRawTransaction(TX_HEX), (e) => e.nodeCode === -27) + }) + + it('carries nodeCode from an HTTP 500 error body', async () => { + stubAxiosPostThrow(Object.assign(new Error('status 500'), { response: { data: { error: { message: 'dust', code: -26 } } } })) + await assert.rejects(() => makeConnector().sendRawTransaction(TX_HEX), (e) => e.nodeCode === -26 && /dust/.test(e.message)) + }) + + it('leaves nodeCode unset on a transport fault', async () => { + stubAxiosPostThrow(Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' })) + await assert.rejects(() => makeConnector().sendRawTransaction(TX_HEX), (e) => e.nodeCode === undefined) + }) +}) + describe('BlockchainConnector.sendRawTransaction()', () => { registerAxiosHooks() it('uses error.message from JSON-RPC error when present', async () => { diff --git a/test/unit/blockchain_connector.test/08_fee_estimate_rpc_error_detail.test.js b/test/unit/blockchain_connector.test/08_fee_estimate_rpc_error_detail.test.js index 926ecec..c90b44a 100644 --- a/test/unit/blockchain_connector.test/08_fee_estimate_rpc_error_detail.test.js +++ b/test/unit/blockchain_connector.test/08_fee_estimate_rpc_error_detail.test.js @@ -46,6 +46,33 @@ describe('BlockchainConnector.getFeePerKilobyte() node RPC error detail', () => await assert.rejects(() => c.getFeePerKilobyte(6), /Error getting smart fee from node \(RPC error -32601: Method not found\)/) }) + it('carries a modern node result.errors reason into the thrown message', async () => { + stubMainnet(() => ({ data: { result: { errors: ['Insufficient data or no feerate found'], blocks: 2 }, error: null } })) + const c = makeConnector() + await assert.rejects(() => c.getFeePerKilobyte(6), + /Error getting smart fee from node \(node: Insufficient data or no feerate found\)/) + }) + + it('adds no suffix for a feerate -1 body or result.errors with no usable string', async () => { + const bodies = [{ feerate: -1, blocks: 25 }, { errors: [], blocks: 2 }, { errors: [null, 42, ''], blocks: 2 }] + for (const result of bodies) { + stubMainnet(() => ({ data: { result: result } })) + const c = makeConnector() + await assert.rejects(() => c.getFeePerKilobyte(6), (err) => err.message === 'Error getting smart fee from node') + } + }) + + it('still takes the testnet relay fallback for a modern no-estimate body', 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') return { data: { result: { errors: ['Insufficient data or no feerate found'], blocks: 2 }, error: null } } + return { data: { result: {} } } + } + const c = makeConnector() + assert.strictEqual(await c.getFeePerKilobyte(6), 0.01) + }) + it('rethrows a bodiless transport failure as the original error object', async () => { const original = new Error('socket hang up') stubMainnet(() => { throw original }) diff --git a/test/unit/blockchain_connector.test/11_rpc_timeout_env.test.js b/test/unit/blockchain_connector.test/11_rpc_timeout_env.test.js new file mode 100644 index 0000000..9cbed06 --- /dev/null +++ b/test/unit/blockchain_connector.test/11_rpc_timeout_env.test.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. + +const assert = require('assert') + +const CONSTANTS_PATH = require.resolve('../../../src/build/blockchain_connector/constants.js') +const constants = require(CONSTANTS_PATH) + +// Capture warnings on the shared logger so a discarded value is proven visible. +function captureWarnings (fn) { + const warnings = [] + const original = constants.logger.warn + constants.logger.warn = (msg) => { warnings.push(String(msg)) } + try { + return { value: fn(), warnings } + } finally { + constants.logger.warn = original + } +} + +// Load a fresh constants module under one NODE_RPC_TIMEOUT value and read RPC_TIMEOUT. +function rpcTimeoutUnder (raw) { + const cached = require.cache[CONSTANTS_PATH] + const saved = process.env.NODE_RPC_TIMEOUT + if (raw === undefined) delete process.env.NODE_RPC_TIMEOUT + else process.env.NODE_RPC_TIMEOUT = raw + delete require.cache[CONSTANTS_PATH] + try { + return require(CONSTANTS_PATH).RPC_TIMEOUT + } finally { + if (saved === undefined) delete process.env.NODE_RPC_TIMEOUT + else process.env.NODE_RPC_TIMEOUT = saved + require.cache[CONSTANTS_PATH] = cached + } +} + +describe('NODE_RPC_TIMEOUT parsing', () => { + describe('envInt', () => { + it('returns the fallback without a warning when the value is unset', () => { + const { value, warnings } = captureWarnings(() => constants.envInt(undefined, 30000, 'NODE_RPC_TIMEOUT')) + assert.strictEqual(value, 30000) + assert.strictEqual(warnings.length, 0) + }) + + for (const raw of ['', ' ', '30s', '0', '-5', 'abc', '1.5', 'NaN']) { + it(`falls back to 30000 with a warning for ${JSON.stringify(raw)}`, () => { + const { value, warnings } = captureWarnings(() => constants.envInt(raw, 30000, 'NODE_RPC_TIMEOUT')) + assert.strictEqual(value, 30000) + assert.strictEqual(warnings.length, 1) + assert.match(warnings[0], /NODE_RPC_TIMEOUT/) + }) + } + + it('accepts a clean positive integer, trimmed', () => { + assert.strictEqual(constants.envInt('45000', 30000, 'NODE_RPC_TIMEOUT'), 45000) + assert.strictEqual(constants.envInt(' 45000 ', 30000, 'NODE_RPC_TIMEOUT'), 45000) + assert.strictEqual(constants.envInt('1', 30000, 'NODE_RPC_TIMEOUT'), 1) + }) + }) + + describe('RPC_TIMEOUT', () => { + it('defaults to 30000 when NODE_RPC_TIMEOUT is unset', () => { + assert.strictEqual(rpcTimeoutUnder(undefined), 30000) + }) + + it('keeps the 30000 default for an empty NODE_RPC_TIMEOUT instead of NaN', () => { + assert.strictEqual(rpcTimeoutUnder(''), 30000) + }) + + it('keeps the 30000 default for a unit-suffixed NODE_RPC_TIMEOUT instead of truncating', () => { + assert.strictEqual(rpcTimeoutUnder('30s'), 30000) + }) + + it('honours a valid NODE_RPC_TIMEOUT', () => { + assert.strictEqual(rpcTimeoutUnder('45000'), 45000) + }) + + it('is always a positive integer, so axios always installs a timer', () => { + for (const raw of [undefined, '', ' ', '30s', '0', '-1', 'x', '45000']) { + const t = rpcTimeoutUnder(raw) + assert.ok(Number.isInteger(t) && t >= 1, `${JSON.stringify(raw)} gave ${t}`) + } + }) + }) +}) diff --git a/test/unit/blockchain_connector.test/12_node_tier.test.js b/test/unit/blockchain_connector.test/12_node_tier.test.js new file mode 100644 index 0000000..a8e5b2d --- /dev/null +++ b/test/unit/blockchain_connector.test/12_node_tier.test.js @@ -0,0 +1,110 @@ +// 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 { logger } = require('../../../src/build/blockchain_connector/constants') + +function makeConnector (consensusNetwork) { + return new BlockchainConnector('127.0.0.1', 18332, 'rpcuser', 'rpcpass', consensusNetwork) +} + +// Answer every axios.post with one getblockchaininfo chain and count the calls. +function stubChain (chain, counter) { + axios.post = async () => { counter.calls++; return { data: { result: chain === undefined ? {} : { chain } } } } +} + +function registerHooks () { + let originalPost + let originalWarn + beforeEach(() => { + originalPost = axios.post + originalWarn = logger.warn + logger.warn = () => {} + }) + afterEach(() => { + axios.post = originalPost + logger.warn = originalWarn + }) +} + +describe('BlockchainConnector.assertNodeTier() chain matching', () => { + registerHooks() + + const matches = [ + ['mainnet', 'main'], ['testnet', 'test'], ['testnet', 'testnet3'], ['testnet', 'testnet4'], ['regtest', 'regtest'], + ] + for (const [network, chain] of matches) { + it(`passes a ${network} encoder on a node reporting chain "${chain}"`, async () => { + stubChain(chain, { calls: 0 }) + await makeConnector(network).assertNodeTier() + }) + } + + const mismatches = [ + ['testnet', 'main'], ['mainnet', 'test'], ['mainnet', 'regtest'], ['mainnet', 'signet'], ['regtest', 'testnet4'], + ] + for (const [network, chain] of mismatches) { + it(`refuses a ${network} encoder on a node reporting chain "${chain}" with NODE_CHAIN_MISMATCH`, async () => { + stubChain(chain, { calls: 0 }) + await assert.rejects(makeConnector(network).assertNodeTier(), (err) => { + assert.strictEqual(err.operational, true) + assert.strictEqual(err.xchainCode, 'NODE_CHAIN_MISMATCH') + assert.ok(err.message.includes(chain) && err.message.includes(network), err.message) + assert.ok(!/127\.0\.0\.1|18332|rpcuser|rpcpass/.test(err.message), err.message) + return true + }) + }) + } +}) + +describe('BlockchainConnector.assertNodeTier() pass-through and caching', () => { + registerHooks() + + it('lets an unrecognized chain string through', async () => { + stubChain('futurenet', { calls: 0 }) + await makeConnector('mainnet').assertNodeTier() + }) + + it('lets a response with no chain field through, warning once across calls', async () => { + const counter = { calls: 0 } + stubChain(undefined, counter) + const warnings = [] + logger.warn = (msg) => { warnings.push(String(msg)) } + const c = makeConnector('mainnet') + await c.assertNodeTier() + await c.assertNodeTier() + assert.strictEqual(warnings.length, 1) + assert.strictEqual(counter.calls, 2, 'an unread chain is retried, never cached as a pass') + }) + + it('lets a transport error through', async () => { + axios.post = async () => { throw Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' }) } + await makeConnector('mainnet').assertNodeTier() + }) + + it('skips the check when no consensus network was given', async () => { + const counter = { calls: 0 } + stubChain('main', counter) + await makeConnector().assertNodeTier() + assert.strictEqual(counter.calls, 0) + }) + + it('costs no further RPC once a matching node has passed', async () => { + const counter = { calls: 0 } + stubChain('test', counter) + const c = makeConnector('testnet') + await c.assertNodeTier() + await c.assertNodeTier() + await c.isRegtest() + assert.strictEqual(counter.calls, 1) + }) +}) diff --git a/test/unit/build/compression.test.js b/test/unit/build/compression.test.js index 42d580b..1d83279 100644 --- a/test/unit/build/compression.test.js +++ b/test/unit/build/compression.test.js @@ -136,6 +136,25 @@ describe('encoder FILE payload compression (spec Part B)', function () { }) }) +describe('encoder FILE payload compression: refusal error class', function () { + describe('compressPayloadForAction', function () { + // Each refusal is a caller fault, so it must carry the marker the API maps to -32602. + it('marks every explicit refusal as invalid params', async function () { + const { isInvalidParams } = require('../../../src/build/errors') + const refusals = [ + ['SEND|0|XCHAIN|1000', compressibleBytes(), {}], + [GATED_FILE, compressibleBytes(), {}], + [PUBLIC_FILE + '|||||1', compressibleBytes(), {}], + [PUBLIC_FILE, Buffer.alloc(2048), { maxInputBytes: 1024 }] + ] + for (const [action, bytes, opts] of refusals) { + await assert.rejects(() => compression.compressPayloadForAction(action, bytes, opts), + (err) => isInvalidParams(err) && (err instanceof TypeError || err instanceof RangeError)) + } + }) + }) +}) + describe('encoder FILE payload compression (spec Part B)', function () { describe('compressPayloadForAction', function () { it('a gated FILE passes its own COMPRESSION field through untouched', function () { diff --git a/test/unit/build/multi_chunk_single_fund_tx.test.js b/test/unit/build/multi_chunk_single_fund_tx.test.js new file mode 100644 index 0000000..72531f8 --- /dev/null +++ b/test/unit/build/multi_chunk_single_fund_tx.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. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../src/XChainEncoder') + +const DOGE = 'dogecoin-regtest' +const DOGE_NET = require('../../../src/build/crypto_networks').getBitcoinJsNetwork(DOGE) +const RATE_KB = 1000000 +const FUNDING_INPUT = 2000000000 +const pubkeyBuf = Buffer.from('0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', 'hex') +const CALLER = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_NET }).address +const callerOutput = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: bitcoin.networks.regtest }).output + +// One chunk fits a single leg; this payload needs several, all carried by the same funding tx. +const SINGLE_CHUNK_PAYLOAD = 'x'.repeat(400) +const MULTI_CHUNK_PAYLOAD = 'x'.repeat(3000) + +function prevTxHex () { + const tx = new bitcoin.Transaction() + tx.addInput(Buffer.alloc(32, 0x11), 0) + for (let i = 0; i < 4; i++) tx.addOutput(callerOutput, FUNDING_INPUT) + return tx.toHex() +} + +function makeEncoder () { + const encoder = new XChainEncoder(DOGE, '127.0.0.1', '8333', 'rpc', 'rpc', '', '') + encoder.connector = { + getFeePerKilobyte: async () => 0.01, + getNetworkInfo: async () => ({ relayfee: 0.00001 }), + getTransactionHex: async () => prevTxHex() + } + return encoder +} + +function utxos () { + return [0, 1, 2, 3].map(vout => ({ + txid: 'a'.repeat(64), vout, value: FUNDING_INPUT, confirmations: 6, scriptPubKey: callerOutput.toString('hex') + })) +} + +async function buildFunding (encoder, payload) { + encoder.clearReservations() + const res = await encoder.createTransaction( + utxos(), CALLER, null, payload, null, null, false, 'P2SH', CALLER, + null, null, null, true, RATE_KB) + return res.psbt.__CACHE.__TX +} + +async function buildReveal (encoder, fundingTx, payload) { + 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 isLeg = (o) => o.script.toString('hex').startsWith('a914') +const legs = (tx) => tx.outs.map((o, vout) => ({ o, vout })).filter(({ o }) => isLeg(o)) +const sum = (outs) => outs.reduce((s, o) => s + o.value, 0) + +describe('multi-chunk payload through a single P2SH fund tx @regression @tier1', () => { + + it('carries every chunk as its own leg of one funding transaction', async () => { + const single = await buildFunding(makeEncoder(), SINGLE_CHUNK_PAYLOAD) + const multi = await buildFunding(makeEncoder(), MULTI_CHUNK_PAYLOAD) + assert.strictEqual(legs(single).length, 1, 'the one-chunk control must hold exactly one leg') + assert.ok(legs(multi).length > 1, `a multi-chunk payload must hold several legs (${legs(multi).length})`) + assert.strictEqual(multi.outs.filter(o => !isLeg(o)).length, 1, 'one change output, no second funding tx') + }) + + it('funds a distinct script per chunk so each leg can be revealed on its own', async () => { + const multi = await buildFunding(makeEncoder(), MULTI_CHUNK_PAYLOAD) + const scripts = new Set(legs(multi).map(({ o }) => o.script.toString('hex'))) + assert.ok(scripts.size > 1, 'chunks of differing content must not collapse onto one script') + }) + + it('is deterministic for the same inputs', async () => { + const first = await buildFunding(makeEncoder(), MULTI_CHUNK_PAYLOAD) + const second = await buildFunding(makeEncoder(), MULTI_CHUNK_PAYLOAD) + assert.strictEqual(first.getId(), second.getId()) + }) + + it('reveals by spending every funded leg in one transaction and keeping a value output', async () => { + const encoder = makeEncoder() + const funding = await buildFunding(encoder, MULTI_CHUNK_PAYLOAD) + const reveal = await buildReveal(encoder, funding, MULTI_CHUNK_PAYLOAD) + const fundedLegs = legs(funding) + + assert.strictEqual(reveal.ins.length, fundedLegs.length, 'one reveal input per funded leg') + const spent = reveal.ins.map(i => `${Buffer.from(i.hash).reverse().toString('hex')}:${i.index}`).sort() + const expected = fundedLegs.map(({ vout }) => `${funding.getId()}:${vout}`).sort() + assert.deepStrictEqual(spent, expected, 'the reveal must spend exactly the funding tx legs') + + const legTotal = sum(fundedLegs.map(({ o }) => o)) + const swept = sum(reveal.outs) + assert.ok(swept > 0, 'the reveal must leave the caller a value output') + assert.ok(swept < legTotal, 'the reveal must pay a fee out of the legs') + }) + + it('refuses a payload over the compiled size ceiling instead of splitting without bound', async () => { + await assert.rejects(() => buildFunding(makeEncoder(), 'x'.repeat(9000)), /Payload too large/) + }) + +}) diff --git a/test/unit/build/utxo_tracker.test/04_tracker_error_text_sanitized.test.js b/test/unit/build/utxo_tracker.test/04_tracker_error_text_sanitized.test.js new file mode 100644 index 0000000..0b45397 --- /dev/null +++ b/test/unit/build/utxo_tracker.test/04_tracker_error_text_sanitized.test.js @@ -0,0 +1,78 @@ +// 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 } = require('./helpers/support') + +const ADDRESS = 'mhqpGkU1tUYKFrmtFDXEcBiMqzaZTbEPxX' + +// The tracker's JSON-RPC error text reaches public create_tx and get_utxos errors, +// so it must arrive leak-checked, printable and capped, like halt_reason. +async function trackerErrorMessage(error) { + let callCount = 0 + axios.post = async () => { + callCount++ + if (callCount === 1) return { data: { result: { lag: 0, synced: true } } } + return { data: { error } } + } + try { + await makeTracker().getUtxosFromAddress(ADDRESS) + } catch (err) { + return err.message + } + throw new Error('expected getUtxosFromAddress to reject') +} + +describe('UtxoTracker.getUtxosFromAddress() tracker error text', () => { + installAxiosHooks() + + it('keeps a normal tracker code and message unchanged', async () => { + const msg = await trackerErrorMessage({ message: 'address has too many utxos', data: { code: 'ADDRESS_TOO_LARGE' } }) + assert.strictEqual(msg, 'Error getting utxos: [ADDRESS_TOO_LARGE] address has too many utxos') + }) + + it('keeps a numeric JSON-RPC code', async () => { + const msg = await trackerErrorMessage({ code: -32000, message: 'boom' }) + assert.strictEqual(msg, 'Error getting utxos: [-32000] boom') + }) + + it('caps a long tracker message at 120 characters', async () => { + const msg = await trackerErrorMessage({ message: 'x'.repeat(1000) }) + assert.strictEqual(msg, 'Error getting utxos: ' + 'x'.repeat(120)) + }) + + it('drops an oversized tracker message to the generic text', async () => { + const msg = await trackerErrorMessage({ message: 'y'.repeat(5000) }) + assert.strictEqual(msg, 'Error getting utxos: unknown error') + }) + + it('replaces control characters in the tracker message', async () => { + const msg = await trackerErrorMessage({ message: 'line1\nline2\r\u0000end' }) + assert.ok(!/[^\x20-\x7e]/.test(msg), 'control characters survived: ' + JSON.stringify(msg)) + assert.match(msg, /^Error getting utxos: line1 line2/) + }) + + it('cleans or drops a hostile string code and keeps the message', async () => { + const msg = await trackerErrorMessage({ message: 'busy', data: { code: 'z'.repeat(5000) } }) + assert.strictEqual(msg, 'Error getting utxos: busy') + const ctl = await trackerErrorMessage({ message: 'busy', data: { code: 'BAD\nCODE' } }) + assert.strictEqual(ctl, 'Error getting utxos: [BAD CODE] busy') + }) + + it('reads a non-string message as unknown error, not [object Object]', async () => { + const msg = await trackerErrorMessage({ code: -1, message: { nested: true } }) + assert.strictEqual(msg, 'Error getting utxos: [-1] unknown error') + }) + + it('drops a tracker message that names an internal endpoint', async () => { + const msg = await trackerErrorMessage({ message: 'upstream tracker-db:3306 refused' }) + assert.strictEqual(msg, 'Error getting utxos: unknown error') + }) +}) diff --git a/test/unit/coins/coins_conformance.test.js b/test/unit/coins/coins_conformance.test.js index e518ba7..65cc399 100644 --- a/test/unit/coins/coins_conformance.test.js +++ b/test/unit/coins/coins_conformance.test.js @@ -34,12 +34,11 @@ const path = require('path'); const coins = require('../../../src/coins'); const { CONSENSUS_CONFIG_PIN } = require('../../../src/coins/consensus_pin.js'); +const { siblingCheckout, skipOrFail } = require('../../helpers/sibling_checkout.js'); 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'; // Keep in lockstep with FILES in xchain-hub/bin/sync-coins.sh. const VENDORED_FILES = ['BTC.js', 'LTC.js', 'DOGE.js', 'index.js', 'consensus_pin.js']; @@ -64,11 +63,9 @@ describe('coin-registry conformance (vendored copy) @regression', function(){ describe('byte-identity to canonical xchain-hub/src/coins', function(){ before(function(){ - if(!CANON_PRESENT){ - if(REQUIRE_SIBLINGS) - throw new Error('XCHAIN_REQUIRE_SIBLINGS=1 but canonical xchain-hub coins dir not found at ' + CANON_DIR); - this.skip(); - } + const hub = siblingCheckout(__dirname, CANON_DIR); + if(!hub.usable) + skipOrFail(this, hub, 'the canonical xchain-hub coins byte-identity guard'); }); VENDORED_FILES.forEach(function(f){ diff --git a/test/unit/common/validator/action_manifest_conformance.test.js b/test/unit/common/validator/action_manifest_conformance.test.js index 8466468..8fc3819 100644 --- a/test/unit/common/validator/action_manifest_conformance.test.js +++ b/test/unit/common/validator/action_manifest_conformance.test.js @@ -54,6 +54,22 @@ describe('ACTION manifest conformance: encoder validateActionName gate @regressi 'Keep src/common/validator.js ACTION_ALIASES byte-identical to xchain-decoder\'s.'); }); + it('records BET version 4 as user-encodable', function () { + assert.deepStrictEqual(MANIFEST.actions.BET.userEncodableVersions, [0, 1, 2, 3, 4]); + }); + + it('keeps settlement anchors outside the encoder ACTION-name gate', function () { + const anchors = Object.entries(MANIFEST.actions) + .filter(([, action]) => action.category === 'settlement-anchor') + .map(([name]) => name) + .sort(); + assert.deepStrictEqual(anchors, ['LIST_SHARE', 'XPOLICY']); + assert.deepStrictEqual( + anchors.filter(name => MANIFEST.actions[name].wireDecoded || v.VALID_ACTION_NAMES.has(name)), + [], + 'settlement anchors are indexer-minted records and must not be accepted as wire ACTION names'); + }); + // IDENTITY: the vendored copy must match the canonical source. Refuses an // absent docs checkout and a lane symlink into a live main checkout alike. describe('byte-identity to canonical manifest', function () { @@ -62,8 +78,10 @@ describe('ACTION manifest conformance: encoder validateActionName gate @regressi const { siblingCheckout, skipOrFail } = require('../../../helpers/sibling_checkout.js'); before(function () { const docs = siblingCheckout(__dirname, CANON); if (!docs.usable) skipOrFail(this, docs, 'the canonical action-manifest.json byte-identity guard'); }); it('vendored test/fixtures/action-manifest.json is byte-identical to canonical', function () { - assert.strictEqual(fs.readFileSync(VENDORED, 'utf8'), fs.readFileSync(CANON, 'utf8'), - 'vendored action-manifest.json drifted from canonical; edit ' + + const vendored = fs.readFileSync(VENDORED); + const canonical = fs.readFileSync(CANON); + assert.deepStrictEqual(vendored, canonical, + 'vendored action-manifest.json differs byte-for-byte from the canonical source; edit ' + 'xchain-documentation/protocol/action-manifest.json and re-vendor all copies.'); }); }); diff --git a/test/unit/common/validator/validator.test.js b/test/unit/common/validator/validator.test.js index 14790f0..47c83d2 100644 --- a/test/unit/common/validator/validator.test.js +++ b/test/unit/common/validator/validator.test.js @@ -31,5 +31,14 @@ describe('Encoder input validator', function () { assert.throws(() => v.validatePubkey(123), TypeError); assert.throws(() => v.validatePubkey('x'.repeat(101)), /maximum length/); }); + + it('refuses an uncompressed key by its form, not as an oversized address', function () { + assert.throws(() => v.validatePubkey('04' + 'a'.repeat(128)), + (err) => err instanceof TypeError && /uncompressed/.test(err.message) && !/maximum length/.test(err.message)); + const compressed = '02' + 'a'.repeat(64); + assert.strictEqual(v.validatePubkey(compressed), compressed); + assert.throws(() => v.validatePubkey('05' + 'a'.repeat(128)), /maximum length/, + 'only the exact uncompressed shape gets the named refusal'); + }); }); }); 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 567eade..3bf6c7f 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 @@ -31,5 +31,17 @@ describe('Encoder input validator', function () { assert.throws(() => v.validateCompressedPubKey('04' + HEX64), /02 or 03/); assert.throws(() => v.validateCompressedPubKey('nope'), TypeError); }); + + it('refuses a well-shaped key whose x is not on the curve, for both prefixes', function () { + // BIP340 lists this x as not on the curve; the fixture checks itself. + const OFF_CURVE_X = 'EEFDEA4CDB677750A420FEE807EACF21EB9898AE79B9768766E4FAA04A2D4A34'; + assert.strictEqual(require('tiny-secp256k1').isXOnlyPoint(Buffer.from(OFF_CURVE_X, 'hex')), false); + for (const prefix of ['02', '03']) { + assert.throws(() => v.validateCompressedPubKey(prefix + OFF_CURVE_X), + (e) => e instanceof TypeError && e.invalidParams === true && /secp256k1 point/.test(e.message)); + } + const G = '79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798'; + assert.strictEqual(v.validateCompressedPubKey('03' + G), '03' + G); + }); }); }); diff --git a/test/unit/common/validator/value_checks.test.js b/test/unit/common/validator/value_checks.test.js new file mode 100644 index 0000000..5f5b1a5 --- /dev/null +++ b/test/unit/common/validator/value_checks.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. + +const assert = require('assert') +const { + toExactInt, + validateOptionalBoolean, + parseSatoshiAmount, + firstNonLatin1 +} = require('../../../../src/common/validator/value_checks') +const { MAX_SATOSHI_U64 } = require('../../../../src/common/validator/constants') + +describe('toExactInt', () => { + it('accepts exact integer numbers and decimal strings', () => { + assert.strictEqual(toExactInt(5), 5) + assert.strictEqual(toExactInt(' -7 '), -7) + }) + + it('rejects fractional and non-numeric values', () => { + assert.ok(Number.isNaN(toExactInt(5.5))) + assert.ok(Number.isNaN(toExactInt('1.5'))) + assert.ok(Number.isNaN(toExactInt('abc'))) + assert.ok(Number.isNaN(toExactInt(null))) + }) +}) + +describe('validateOptionalBoolean', () => { + it('passes through omitted values and booleans', () => { + assert.strictEqual(validateOptionalBoolean(undefined, 'enabled'), undefined) + assert.strictEqual(validateOptionalBoolean(null, 'enabled'), undefined) + assert.strictEqual(validateOptionalBoolean(false, 'enabled'), false) + }) + + it('rejects a string false with a precise coercion warning', () => { + assert.throws( + () => validateOptionalBoolean('false', 'enabled'), + { name: 'TypeError', message: /enabled.*string "false" would coerce to true/ } + ) + }) +}) + +describe('parseSatoshiAmount', () => { + it('returns numbers for safe integer strings and bigints', () => { + assert.strictEqual(parseSatoshiAmount('12', 'amount'), 12) + assert.strictEqual(parseSatoshiAmount(7n, 'amount'), 7) + }) + + it('requires allowBig for a bigint above the safe integer limit', () => { + const value = 2n ** 60n + assert.throws( + () => parseSatoshiAmount(value, 'amount'), + { name: 'RangeError', message: /exceeds the maximum safe satoshi amount/ } + ) + assert.strictEqual(parseSatoshiAmount(value, 'amount', { allowBig: true }), value) + }) + + it('returns an exact bigint for a large decimal string with allowBig', () => { + assert.strictEqual( + parseSatoshiAmount('9007199254740993', 'amount', { allowBig: true }), + 9007199254740993n + ) + }) + + it('rejects negative and non-integer values', () => { + for (const value of [-1, -5n, 'x']) { + assert.throws( + () => parseSatoshiAmount(value, 'amount'), + { name: 'RangeError', message: 'amount must be a non-negative integer' } + ) + } + }) + + it('rejects values above the unsigned 64-bit maximum', () => { + assert.throws( + () => parseSatoshiAmount(2n ** 64n, 'amount', { allowBig: true }), + { name: 'RangeError', message: new RegExp(`maximum 64-bit satoshi amount \\(${MAX_SATOSHI_U64}\\)`) } + ) + }) +}) + +describe('firstNonLatin1', () => { + it('returns minus one when every character fits in Latin-1', () => { + assert.strictEqual(firstNonLatin1('abc'), -1) + assert.strictEqual(firstNonLatin1(''), -1) + assert.strictEqual(firstNonLatin1('\u00ff'), -1) + }) + + it('returns the index of the first character outside Latin-1', () => { + assert.strictEqual(firstNonLatin1('ab\u0100'), 2) + }) +}) diff --git a/test/unit/xchain_encoder/trackerless_profile.test.js b/test/unit/xchain_encoder/trackerless_profile.test.js new file mode 100644 index 0000000..52fcc78 --- /dev/null +++ b/test/unit/xchain_encoder/trackerless_profile.test.js @@ -0,0 +1,323 @@ +'use strict' + +process.env.NETWORK = process.env.NETWORK || 'bitcoin-regtest' +process.env.NODE_URL = process.env.NODE_URL || '127.0.0.1' +process.env.NODE_PORT = process.env.NODE_PORT || '8332' +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 UtxoTracker = require('../../../src/build/utxo_tracker') +const { resolveTrackerProfile, classifyRemoteSync } = require('../../../src/XChainEncoder/trackerless_profile.js') +const { jsonRpcController, encoder: apiEncoder } = require('../../../src/api') +const { + TXID_A, + makeUtxo, + makeEncoder: makeDefaultEncoder, + getTestAddress +} = require('../../integration/helpers/utxoFactory') + +const PUBKEY = '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798' +const goodSync = { tracker_height: 100, node_height: 100, lag: 0, synced: true, mempool_ready: true } +const classify = XChainEncoder.classifyTrackerFreshness + +function makeEncoder(profile, url = '127.0.0.1', port = '3001') { + return new XChainEncoder('bitcoin-regtest', '127.0.0.1', '18443', 'u', 'p', url, port, null, null, undefined, null, profile) +} + +async function code(promise) { + try { await promise } catch (e) { return e.xchainCode || e.message } + return null +} + +function drive(encoder) { + const steps = require('../../../src/XChainEncoder/build_transaction/input_candidates.js') + const build = { utxos: null, isReveal: false, p2shHex: null, pubkey: PUBKEY } + const gen = steps.gatherUtxos.call(encoder, build) + return (async () => { + let next = gen.next() + while (!next.done) { + let settled + try { settled = await next.value } catch (err) { next = gen.throw(err); continue } + next = gen.next(settled) + } + return build + })() +} + +function registerProfileTests() { + it('resolves the profile and rejects unknown values', () => { + assert.strictEqual(resolveTrackerProfile(undefined), 'default') + assert.strictEqual(resolveTrackerProfile(' Remote '), 'remote') + assert.throws(() => resolveTrackerProfile('local'), /UTXO_TRACKER_PROFILE/) + }) + + it('refuses construction without a tracker url or port', () => { + assert.throws(() => makeEncoder('remote', null, '3001'), (e) => e.xchainCode === 'UTXO_TRACKER_NOT_CONFIGURED') + assert.throws(() => makeEncoder('remote', '127.0.0.1', ''), (e) => e.xchainCode === 'UTXO_TRACKER_NOT_CONFIGURED') + assert.doesNotThrow(() => makeEncoder(null, undefined, undefined).trackerProfile) + }) + + it('classifies an absent sync as SYNC_MISSING only in the remote profile', () => { + assert.strictEqual(classifyRemoteSync('default', undefined, 2), null) + assert.strictEqual(classifyRemoteSync('remote', undefined, 2).code, 'UTXO_TRACKER_SYNC_MISSING') + assert.strictEqual(classifyRemoteSync('remote', { synced: true }, 2).code, 'UTXO_TRACKER_SYNC_MISSING') + assert.strictEqual(classifyRemoteSync('remote', { lag: 0 }, 2).code, 'UTXO_TRACKER_STALE') + assert.strictEqual(classifyRemoteSync('remote', goodSync, 2), null) + }) + + it('sends the registered lag and heights on a remote STALE for an unasserted synced', () => { + const refusal = classifyRemoteSync('remote', { tracker_height: 100, node_height: 100, lag: 0 }, 2) + assert.strictEqual(refusal.code, 'UTXO_TRACKER_STALE') + assert.deepStrictEqual(refusal.details, { lag: 0, tracker_height: 100, node_height: 100 }) + }) +} + +function registerStatusTests() { + const cases = [ + ['unreachable', () => { throw Object.assign(new Error('connect ECONNREFUSED 10.1.2.3:3001'), { code: 'ECONNREFUSED' }) }, 'UTXO_TRACKER_UNREACHABLE'], + ['empty status', () => { throw Object.assign(new Error('empty result'), { syncMissing: true }) }, 'UTXO_TRACKER_SYNC_MISSING'], + ['halted', () => ({ ...goodSync, halted: true, halt_reason: 'deep reorg' }), 'UTXO_TRACKER_HALTED'], + ['stale', () => ({ ...goodSync, lag: 9, synced: false }), 'UTXO_TRACKER_STALE'], + ['not ready', () => ({ ...goodSync, mempool_ready: false }), 'UTXO_TRACKER_NOT_READY'], + ['status without lag', () => ({ synced: true }), 'UTXO_TRACKER_SYNC_MISSING'] + ] + for (const [name, status, expected] of cases) { + it(`getUtxos refuses a ${name} tracker with ${expected}`, async () => { + const enc = makeEncoder('remote') + enc.utxoTrackerConnector.getSyncStatus = async () => status() + assert.strictEqual(await code(drive(enc)), expected) + }) + } +} + +function registerUtxoResponseTests() { + it('refuses a get_utxos response that carries no sync field', async () => { + const enc = makeEncoder('remote') + enc.utxoTrackerConnector.getUtxosFromAddress = async () => ({ utxos: [] }) + assert.strictEqual(await code(drive(enc)), 'UTXO_TRACKER_SYNC_MISSING') + }) + + it('wraps a get_utxos transport failure as UNREACHABLE and leaks no host', async () => { + const enc = makeEncoder('remote') + enc.utxoTrackerConnector.getUtxosFromAddress = async () => { throw Object.assign(new Error('connect ECONNREFUSED 10.1.2.3:3001'), { code: 'ECONNREFUSED' }) } + try { await drive(enc); assert.fail('expected refusal') } catch (e) { + assert.strictEqual(e.xchainCode, 'UTXO_TRACKER_UNREACHABLE') + assert.ok(!/10\.1\.2\.3/.test(e.message)) + } + }) + + it('keeps the default profile failing open on a missing sync field', async () => { + const enc = makeEncoder(null) + enc.utxoTrackerConnector.getUtxosFromAddress = async () => ({ utxos: [{ txid: 'a'.repeat(64), vout: 0, value: 100000, scriptPubKey: '0014' + 'b'.repeat(40), confirmations: 3 }] }) + const build = await drive(enc) + assert.strictEqual(build.utxos.length, 1) + }) + + it('passes a healthy remote tracker', async () => { + const enc = makeEncoder('remote') + enc.utxoTrackerConnector.getUtxosFromAddress = async () => ({ sync: goodSync, utxos: [{ txid: 'a'.repeat(64), vout: 0, value: 100000, scriptPubKey: '0014' + 'b'.repeat(40), confirmations: 3 }] }) + assert.strictEqual((await drive(enc)).utxos.length, 1) + }) +} + +function registerTrackerTests() { + it('UtxoTracker.assertTrackerReady types a transport failure in the remote profile', async () => { + const t = new UtxoTracker('127.0.0.1', 1, 'remote', 2) + t.getSyncStatus = async () => { throw new Error('socket hang up') } + assert.strictEqual(await code(t.getUtxosFromAddress('x')), 'UTXO_TRACKER_UNREACHABLE') + }) +} + +describe('UTXO_TRACKER_PROFILE=remote fails closed', () => { + registerProfileTests() + registerStatusTests() + registerUtxoResponseTests() + registerTrackerTests() +}) + +describe('classifyTrackerFreshness(): the single tracker-freshness verdict', function () { + it('reports no refusal, and present:false, for a tracker with no freshness surface', function () { + for (const absent of [undefined, null, 'not an object', 42]) { + const v = classify(absent, 2) + assert.strictEqual(v.code, null, 'a pre-freshness tracker must fail open') + assert.strictEqual(v.present, false) + assert.strictEqual(v.lag, null) + } + }) + + it('refuses a halted tracker even at lag 0, and carries the halt reason', function () { + const v = classify({ tracker_height: 100, node_height: 100, lag: 0, synced: true, halted: true, + halt_reason: 'rolled back past the recovery window' }, 2) + assert.strictEqual(v.code, 'UTXO_TRACKER_HALTED') + assert.strictEqual(v.halted, true) + assert.strictEqual(v.details.halt_reason, 'rolled back past the recovery window') + assert.strictEqual(v.details.tracker_height, 100) + }) + + it('names the halt, not the staleness, when a halted tracker is ALSO stale', function () { + const v = classify({ lag: 99, synced: false, halted: true, mempool_ready: false }, 2) + assert.strictEqual(v.code, 'UTXO_TRACKER_HALTED') + }) + + it('refuses a tracker that de-asserts synced, whatever the lag', function () { + const v = classify({ tracker_height: 100, node_height: 100, lag: 0, synced: false }, 2) + assert.strictEqual(v.code, 'UTXO_TRACKER_STALE') + assert.strictEqual(v.details.lag, 0) + }) + + it('refuses a lag above the supplied ceiling and names the ceiling it used', function () { + const v = classify({ lag: 7, synced: true }, 2) + assert.strictEqual(v.code, 'UTXO_TRACKER_STALE') + assert.strictEqual(v.overLag, true) + assert.ok(/exceeds 2-block threshold/.test(v.message), v.message) + }) + + it('takes the ceiling from its argument, not from a baked-in constant', function () { + 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 halt reason is tracker-authored', function () { + const halted = (reason) => classify({ lag: 0, synced: true, halted: true, halt_reason: reason }, 2) + + it('collapses a halt reason carrying an endpoint in both the message and the details', function () { + const v = halted('reorg read failed: connect ECONNREFUSED 10.0.0.5:8332') + assert.strictEqual(v.code, 'UTXO_TRACKER_HALTED') + assert.ok(v.message.includes('(unrecoverable reorg)'), v.message) + assert.ok(!/10\.0\.0\.5|8332/.test(v.message), v.message) + assert.strictEqual(v.details.halt_reason, null) + }) + + it('bounds a long halt reason and keeps an absent one null', function () { + const v = halted('r'.repeat(1000)) + assert.ok(v.details.halt_reason.length <= 120) + assert.ok(v.message.length < 200, v.message) + assert.strictEqual(halted(undefined).details.halt_reason, 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) + assert.strictEqual(v.overLag, false) + }) + + it('refuses an orphaned view (tracker committed above the node) and says so', function () { + const v = classify({ tracker_height: 1000, node_height: 900, lag: -100, synced: true }, 2) + assert.strictEqual(v.code, 'UTXO_TRACKER_STALE') + assert.strictEqual(v.behindNode, true) + assert.ok(/orphaned/.test(v.message), v.message) + }) + + it('refuses a tracker whose mempool has not reconverged', function () { + const v = classify({ lag: 0, synced: true, mempool_ready: false }, 2) + assert.strictEqual(v.code, 'UTXO_TRACKER_NOT_READY') + assert.strictEqual(v.mempoolReady, false) + }) + + it('fails open on every field it is not explicitly told is bad', function () { + const v = classify({ tracker_height: 100, node_height: 100, lag: 0, synced: true }, 2) + assert.strictEqual(v.code, null) + assert.strictEqual(v.halted, false) + assert.strictEqual(v.mempoolReady, true) + }) + + it('treats a non-numeric lag as unknown rather than comparing it', function () { + for (const lag of [undefined, null, '7', NaN]) { + const v = classify({ lag, synced: true }, 2) + assert.strictEqual(v.overLag, false, `lag ${String(lag)} must not read as over-lag`) + assert.strictEqual(v.behindNode, false) + assert.strictEqual(v.lag, null) + assert.strictEqual(v.code, null) + } + }) + + it('separates the tracker\'s positive synced claim from the refusal verdict', function () { + const omitted = classify({ lag: 0 }, 2) + assert.strictEqual(omitted.syncedClaimed, false, 'an omitted synced is not an assertion') + assert.strictEqual(omitted.code, null, 'but it is not an explicit negative either') + }) + + it('does not mutate the sync object it is handed', function () { + const sync = { tracker_height: 100, node_height: 100, lag: 0, synced: true } + const before = JSON.stringify(sync) + classify(sync, 2) + assert.strictEqual(JSON.stringify(sync), before) + }) +}) + +const PARITY_CASES = [ + { name: 'healthy', sync: { tracker_height: 100, node_height: 100, lag: 0, synced: true, mempool_ready: true }, + expectCode: null }, + { name: 'lag at the ceiling', sync: { tracker_height: 98, node_height: 100, lag: 2, synced: true, mempool_ready: true }, + expectCode: null }, + { name: 'lag one over the ceiling', sync: { tracker_height: 97, node_height: 100, lag: 3, synced: true, mempool_ready: true }, + expectCode: 'UTXO_TRACKER_STALE' }, + { name: 'tracker de-asserts synced', sync: { tracker_height: 100, node_height: 100, lag: 0, synced: false, mempool_ready: true }, + expectCode: 'UTXO_TRACKER_STALE' }, + { name: 'orphaned view', sync: { tracker_height: 1000, node_height: 900, lag: -100, synced: true, mempool_ready: true }, + expectCode: 'UTXO_TRACKER_STALE' }, + { name: 'halted', sync: { tracker_height: 100, node_height: 100, lag: 0, synced: true, mempool_ready: true, halted: true, + halt_reason: 'unrecoverable reorg' }, expectCode: 'UTXO_TRACKER_HALTED' }, + { name: 'mempool not reconverged', sync: { tracker_height: 100, node_height: 100, lag: 0, synced: true, mempool_ready: false }, + expectCode: 'UTXO_TRACKER_NOT_READY' } +] + +describe('create_tx and health() reach the same verdict on the same tracker @regression', function () { + async function createTxVerdict(sync) { + const enc = makeDefaultEncoder('bitcoin-regtest') + const address = getTestAddress('bitcoin-regtest') + enc.utxoTrackerConnector.getUtxosFromAddress = async () => ({ + utxos: [makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000)], + sync + }) + try { + const result = await enc.createTransaction( + null, address, null, 'test', null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) + assert.ok(result.psbt) + return null + } catch (err) { + if (!err.operational) throw err + return err.xchainCode + } + } + + async function healthVerdict(sync) { + const orig = apiEncoder.utxoTrackerConnector.getSyncStatus + apiEncoder.utxoTrackerConnector.getSyncStatus = async () => sync + try { + return await jsonRpcController.health() + } finally { + apiEncoder.utxoTrackerConnector.getSyncStatus = orig + } + } + + for (const testCase of PARITY_CASES) { + it(`agrees on: ${testCase.name}`, async function () { + const code = await createTxVerdict(testCase.sync) + assert.strictEqual(code, testCase.expectCode, + `create_tx verdict for "${testCase.name}"`) + + const health = await healthVerdict(testCase.sync) + assert.strictEqual(health.tracker_synced, testCase.expectCode === null, + `health() must read ${testCase.expectCode === null ? 'serveable' : 'un-serveable'} ` + + `for "${testCase.name}", the same way create_tx does`) + }) + } + + it('reports the halt and the mempool state alongside the verdict', async function () { + const halted = await healthVerdict({ lag: 0, synced: true, halted: true, halt_reason: 'unrecoverable reorg' }) + assert.strictEqual(halted.tracker_halted, true) + assert.strictEqual(halted.tracker_synced, false) + + const unready = await healthVerdict({ lag: 0, synced: true, mempool_ready: false }) + assert.strictEqual(unready.tracker_mempool_ready, false) + assert.strictEqual(unready.tracker_synced, false) + }) +}) diff --git a/test/unit/xchain_encoder/xchain_encoder_dust_cap.test/01_unconfirmed_at_cap.test.js b/test/unit/xchain_encoder/xchain_encoder_dust_cap.test/01_unconfirmed_at_cap.test.js new file mode 100644 index 0000000..e1c5231 --- /dev/null +++ b/test/unit/xchain_encoder/xchain_encoder_dust_cap.test/01_unconfirmed_at_cap.test.js @@ -0,0 +1,77 @@ +'use strict'; + +const assert = require('assert') +const { + makeUtxo, + makeMempoolUtxo, + makeTrackerEnvelope, + makeEncoder, + getTestAddress +} = require('../../../integration/helpers/utxoFactory') + +const NETWORK = 'bitcoin-regtest' +const INPUT_CAP = 500 + +function dustSet(count, value = 20) { + const utxos = [] + for (let i = 0; i < count; i++) { + const txid = (i + 1).toString(16).padStart(64, '0') + utxos.push(makeUtxo(NETWORK, txid, 0, value)) + } + return utxos +} + +function buildFromTracker(utxos, unconfirmed, fee) { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + encoder.utxoTrackerConnector.getUtxosFromAddress = async () => makeTrackerEnvelope(utxos) + return encoder.createTransaction( + null, address, null, 'test', null, fee, false, null, address, + null, null, null, unconfirmed, 0.00001 + ) +} + +function isCappedSelection(error) { + return error.operational === true && + error.xchainCode === 'NO_CONFIRMED_UTXO' && + error.details.selectedInputCount === INPUT_CAP && + error.details.maximum === INPUT_CAP +} + +describe('tracker-funded selection at the input cap', function () { + this.timeout(30000) + + it('uses a large unconfirmed output before 1,082 confirmed dust outputs when allowed', async function () { + const utxos = dustSet(1082) + utxos.push(makeMempoolUtxo('f'.repeat(64), 0, 100000000)) + + const result = await buildFromTracker(utxos, true, 10000) + + assert.strictEqual(result.psbt.data.inputs.length, 1) + assert.strictEqual(Buffer.from(result.psbt.txInputs[0].hash).reverse().toString('hex'), 'f'.repeat(64)) + }) + + it('returns a retryable operational error for an all-confirmed selection at the cap', async function () { + await assert.rejects( + () => buildFromTracker(dustSet(1082), true, 25000), + isCappedSelection + ) + }) + + it('returns the same operational error when unconfirmed outputs are excluded', async function () { + const utxos = dustSet(1082) + utxos.push(makeMempoolUtxo('f'.repeat(64), 0, 100000000)) + + await assert.rejects( + () => buildFromTracker(utxos, false, 25000), + isCappedSelection + ) + }) + + it('preserves the range error when additional inputs could fund the transaction', async function () { + await assert.rejects( + () => buildFromTracker(dustSet(600, 50), true, 25000), + (error) => error instanceof RangeError && /selected input count/.test(error.message) + ) + }) +}) 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 571e2e3..7aabd0a 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 @@ -251,6 +251,33 @@ describe('XChainEncoder fee-rate cap', () => { }) }) +// An operator cap looser than 100x never lifts the burn backstop off feePerKb. +describe('XChainEncoder fee-rate cap', () => { + it('loose multiplier: the burn backstop clamps a 500x feePerKb to 100x the node rate', async () => { + // Multiplier 1000 would allow 1000 sat/byte; 500 sat/byte (~65500) must hold at ~13100. + const result = await create(makeEncoder(null, 1000), { feePerKb: 500000 }) + assert.ok(paidFee(result) <= 20000, `fee ${paidFee(result)} should have been clamped by the burn backstop`) + }) + + it('loose multiplier: a plausible feePerKb is honored, not clamped', async () => { + const result = await create(makeEncoder(null, 1000), { feePerKb: 50000 }) + assert.ok(paidFee(result) >= 5000 && paidFee(result) <= 8000, `fee ${paidFee(result)} should be ~50 sat/byte`) + }) + + it('loose MAX_FEE_RATE_KB alone: the burn backstop clamps a 500x feePerKb', async () => { + // 1000000 sat/kB is 1000x the node rate; multiplier 0 leaves it the only cap. + const result = await create(makeEncoder(1000000, 0), { feePerKb: 500000 }) + assert.ok(paidFee(result) <= 20000, `fee ${paidFee(result)} should have been clamped by the burn backstop`) + }) + + it('loose multiplier: an explicit 500x fee is still refused, matching the feePerKb clamp', async () => { + await assert.rejects( + create(makeEncoder(null, 1000), { fee: 65500 }), + (err) => err instanceof RangeError && /100x the estimated fair fee/.test(err.message) + ) + }) +}) + // The envelope key-path cancel prices its sweep through the same caps. bitcoin.initEccLib(ecc) const XONLY = '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798' @@ -308,4 +335,10 @@ describe('XChainEncoder fee-rate cap', () => { const result = await cancel(makeCancelEncoder(0), 10000) assert.ok(result.fee >= 900 && result.fee <= 1200, `cancel fee ${result.fee} should be ~10 sat/byte`) }) + + it('envelope cancel: loose multiplier, the burn backstop keeps a drain-shaped feePerKb off the commit', async () => { + const result = await cancel(makeCancelEncoder(1000), 9700000) + assert.ok(result.fee <= 20000, `cancel fee ${result.fee} should have been clamped by the burn backstop`) + assert.ok(result.psbt.txOutputs[0].value >= COMMIT_VALUE - 20000) + }) }) 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 8f6890e..9f92a7b 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 @@ -288,3 +288,33 @@ describe('XChainEncoder TAPROOT envelope', function () { }) }) }) + +describe('XChainEncoder TAPROOT envelope', function () { + describe('caller public keys must be secp256k1 points', function () { + // BIP340 lists this x as not on the curve; the fixture checks itself. + const OFF_CURVE_X = 'eefdea4cdb677750a420fee807eacf21eb9898ae79b9768766e4faa04a2d4a34' + const isInvalidParam = (label) => (err) => + err instanceof TypeError && err.invalidParams === true && new RegExp(label + ' is not a valid secp256k1 point').test(err.message) + + it('refuses an off-curve cancel internalPubkey in both forms before any reservation or fee RPC', async function () { + assert.strictEqual(ecc.isXOnlyPoint(Buffer.from(OFF_CURVE_X, 'hex')), false) + const encoder = makeEncoder() + let feeCalls = 0 + encoder.connector.getFeePerKilobyte = async () => { feeCalls++; return 0.00001 } + for (const internalPubkey of ['02' + OFF_CURVE_X, OFF_CURVE_X]) { + await assert.rejects(encoder.createEnvelopeCancelTransaction({ + commitTxid: TXID_A, commitVout: 0, commitValue: 100000, + internalPubkey, tapleafHash: 'c'.repeat(64), destination: callerAddress(encoder.network) + }), isInvalidParam('internalPubkey')) + } + assert.strictEqual(feeCalls, 0) + assert.strictEqual(encoder.outpointReservations.size, 0) + }) + + it('refuses an off-curve TAPROOT compressedPubKey at the envelope builder', function () { + const { envelopePayload } = require('../../../../src/XChainEncoder/payload_encodings.js') + assert.throws(() => envelopePayload(Buffer.from('00', 'hex'), 'TAPROOT', '03' + OFF_CURVE_X, Buffer.from('xchain')), + isInvalidParam('compressedPubKey')) + }) + }) +}) diff --git a/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/published_vectors.test.js b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/published_vectors.test.js new file mode 100644 index 0000000..2967aea --- /dev/null +++ b/test/unit/xchain_encoder/xchain_encoder_taproot_envelope.test/published_vectors.test.js @@ -0,0 +1,146 @@ +// 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 fs = require('fs') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../../src/XChainEncoder') +const { siblingCheckout, skipOrFail } = require('../../../helpers/sibling_checkout') + +const vectorsFile = process.env.TAPROOT_VECTORS_FILE || + '../../../../../xchain-documentation/protocol/test-vectors/taproot_envelope.json' +let vectors +let internalPubkey + +const encoder = new XChainEncoder('bitcoin-regtest', '', '', '', '', '', '') +const cases = [ + { + name: 'envelope_chunking', + action: 'FILE|0|chunks.bin|application/octet-stream|||||||', + rawData: makeRawData(1200), + payloadGeneration: 'action "FILE|0|chunks.bin|application/octet-stream|||||||" + 1200 rawData bytes where byte[i] = (i*7+13) & 0xff' + }, + { + name: 'chunk_rebalance', + action: 'FILE|0|rebalance.bin|application/octet-stream|||||||', + rawData: makeRawData(985, 0x07), + payloadGeneration: 'action "FILE|0|rebalance.bin|application/octet-stream|||||||" + 985 rawData bytes where byte[i] = (i*7+13) & 0xff for i < 984 and byte[984] = 0x07' + } +] + +function sha256 (buffer) { + return crypto.createHash('sha256').update(buffer).digest('hex') +} + +function makeRawData (length, finalByte) { + const rawData = Buffer.alloc(length) + for (let i = 0; i < length; i++) rawData[i] = (i * 7 + 13) & 0xff + if (finalByte != null) rawData[rawData.length - 1] = finalByte + return rawData +} + +function compilePayload (action, rawData) { + return bitcoin.script.compile([Buffer.from(action, 'utf8'), rawData]) +} + +function payloadFor (testCase, vector) { + if (vector.payload_generation) { + assert.strictEqual(vector.payload_generation, testCase.payloadGeneration) + return compilePayload(testCase.action, testCase.rawData) + } + + const payload = Buffer.alloc(vector.compiled_payload_length, 0x41) + payload[payload.length - 1] = Number(vector.final_byte) + return payload +} + +function decompileEnvelope (envelopeScript) { + const decompiled = bitcoin.script.decompile(envelopeScript) + assert.ok(decompiled, 'envelope script must decompile') + return decompiled +} + +function loadPublishedVectors () { + const verdict = siblingCheckout(__dirname, vectorsFile) + + if (!verdict.usable) { + return skipOrFail(this, verdict, 'the published Taproot envelope vector checks') + } + + vectors = JSON.parse(fs.readFileSync(verdict.path, 'utf8')) + internalPubkey = vectors.envelope_grammar.internal_pubkey_compressed +} + +function assertPayload (payload, vector) { + assert.strictEqual(payload.length, vector.compiled_payload_length) + if (vector.final_byte) { + assert.strictEqual(payload.at(-1), Number(vector.final_byte)) + } + if (vector.payload_generation) { + assert.strictEqual(sha256(payload), vector.compiled_payload_sha256) + } +} + +function assertEnvelopeGrammar (decompiled) { + assert.deepStrictEqual(decompiled.slice(0, 4), [ + bitcoin.opcodes.OP_0, + bitcoin.opcodes.OP_IF, + Buffer.from('XCHN', 'utf8'), + Buffer.from([0]) + ]) + assert.strictEqual(decompiled.at(-3), bitcoin.opcodes.OP_ENDIF) + assert.strictEqual( + decompiled.at(-2).toString('hex'), + vectors.envelope_grammar.internal_pubkey_xonly + ) + assert.strictEqual(decompiled.at(-1), bitcoin.opcodes.OP_CHECKSIG) +} + +function assertEnvelope (envelopeScript, payload, vector) { + const decompiled = decompileEnvelope(envelopeScript) + assertEnvelopeGrammar(decompiled) + + const pushes = decompiled.slice(4, -3) + assert.ok(pushes.every(Buffer.isBuffer)) + assert.deepStrictEqual(pushes.map((push) => push.length), vector.push_lengths) + assert.deepStrictEqual(Buffer.concat(pushes), payload) + if (vector.payload_generation) { + assert.strictEqual(sha256(envelopeScript), vector.envelope_script_sha256) + } + if (vector.envelope_script_length != null) { + assert.strictEqual(envelopeScript.length, vector.envelope_script_length) + } +} + +function assertPublishedVector (testCase) { + const vector = vectors[testCase.name] + const payload = payloadFor(testCase, vector) + assertPayload(payload, vector) + + const prepared = encoder.prepareData(payload, 'TAPROOT', null, internalPubkey) + assert.strictEqual(prepared.encoding, 'TAPROOT') + assert.strictEqual(prepared.dataBufferArray.length, 1) + assert.strictEqual( + prepared.internalPubkey.toString('hex'), + vectors.envelope_grammar.internal_pubkey_xonly + ) + assertEnvelope(prepared.dataBufferArray[0], payload, vector) +} + +describe('XChainEncoder published Taproot envelope vectors', function () { + before(loadPublishedVectors) + + for (const testCase of cases) { + it(`matches ${testCase.name} through prepareData`, () => { + assertPublishedVector(testCase) + }) + } +}) 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 deleted file mode 100644 index 8c7a2b4..0000000 --- a/test/unit/xchain_encoder/xchain_encoder_tracker_freshness_classifier.test.js +++ /dev/null @@ -1,252 +0,0 @@ -'use strict'; - -/********************************************************************* - * - * Copyright © 2025–2026 Dankest, LLC - * Based on XChain Platform by Dankest, LLC – https://dankest.llc - * - * SPDX-License-Identifier: AGPL-3.0-or-later - * - * This file is part of XChain Platform. Licensed under the GNU Affero - * General Public License v3.0 or later; see LICENSE.md. A commercial - * license (without AGPL source-disclosure terms) is available - - * contact legal@dankest.llc. - * - ********************************************************************** - * ONE tracker-freshness classifier. - * - * There is exactly one place the verdict is written. Split across two - * endpoints, _buildTransaction reading the `sync` sibling on get_utxos and - * api.js getServeReadiness() reading getSyncStatus(), each re-derives halted / - * over-lag / behind-node / mempool-ready with its own strict-equality chain. - * Two chains drift, and the drift shows up as a status board painting Online on - * an encoder that refuses every create_tx. - * - * The first block pins the classifier's own behaviour. The second block is the - * one that actually matters: it drives BOTH surfaces with the same payload and - * asserts they reach the same verdict, so a future edit to one of them alone - * goes red. - * - ********************************************************************/ - -// api.js constructs an encoder from env at load time; set the minimum viable -// env BEFORE requiring it, exactly as reg-10 does. -process.env.NETWORK = process.env.NETWORK || 'bitcoin-regtest' -process.env.NODE_URL = process.env.NODE_URL || '127.0.0.1' -process.env.NODE_PORT = process.env.NODE_PORT || '8332' -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 { - TXID_A, - makeUtxo, - makeEncoder, - getTestAddress -} = require('../../integration/helpers/utxoFactory') - -const classify = XChainEncoder.classifyTrackerFreshness - -describe('classifyTrackerFreshness(): the single tracker-freshness verdict', function () { - - it('reports no refusal, and present:false, for a tracker with no freshness surface', function () { - for (const absent of [undefined, null, 'not an object', 42]) { - const v = classify(absent, 2) - assert.strictEqual(v.code, null, 'a pre-freshness tracker must fail open') - assert.strictEqual(v.present, false) - assert.strictEqual(v.lag, null) - } - }) - - it('refuses a halted tracker even at lag 0, and carries the halt reason', function () { - const v = classify({ tracker_height: 100, node_height: 100, lag: 0, synced: true, halted: true, - halt_reason: 'rolled back past the recovery window' }, 2) - assert.strictEqual(v.code, 'UTXO_TRACKER_HALTED') - assert.strictEqual(v.halted, true) - assert.strictEqual(v.details.halt_reason, 'rolled back past the recovery window') - assert.strictEqual(v.details.tracker_height, 100) - }) - - it('names the halt, not the staleness, when a halted tracker is ALSO stale', function () { - // Ordering is load-bearing: a halt froze the tracker mid-rollback, and the - // lag it froze at is a consequence, not the cause the operator needs. - const v = classify({ lag: 99, synced: false, halted: true, mempool_ready: false }, 2) - assert.strictEqual(v.code, 'UTXO_TRACKER_HALTED') - }) - - it('refuses a tracker that de-asserts synced, whatever the lag', function () { - const v = classify({ tracker_height: 100, node_height: 100, lag: 0, synced: false }, 2) - assert.strictEqual(v.code, 'UTXO_TRACKER_STALE') - assert.strictEqual(v.details.lag, 0) - }) - - it('refuses a lag above the supplied ceiling and names the ceiling it used', function () { - const v = classify({ lag: 7, synced: true }, 2) - assert.strictEqual(v.code, 'UTXO_TRACKER_STALE') - assert.strictEqual(v.overLag, true) - assert.ok(/exceeds 2-block threshold/.test(v.message), v.message) - }) - - it('takes the ceiling from its argument, not from a baked-in constant', function () { - // Same payload, two deployments: UTXO_TRACKER_MAX_LAG_BLOCKS moves the gate. - 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 halt reason is tracker-authored', function () { - const halted = (reason) => classify({ lag: 0, synced: true, halted: true, halt_reason: reason }, 2) - - it('collapses a halt reason carrying an endpoint in both the message and the details', function () { - const v = halted('reorg read failed: connect ECONNREFUSED 10.0.0.5:8332') - assert.strictEqual(v.code, 'UTXO_TRACKER_HALTED') - assert.ok(v.message.includes('(unrecoverable reorg)'), v.message) - assert.ok(!/10\.0\.0\.5|8332/.test(v.message), v.message) - assert.strictEqual(v.details.halt_reason, null) - }) - - it('bounds a long halt reason and keeps an absent one null', function () { - const v = halted('r'.repeat(1000)) - assert.ok(v.details.halt_reason.length <= 120) - assert.ok(v.message.length < 200, v.message) - assert.strictEqual(halted(undefined).details.halt_reason, 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) - assert.strictEqual(v.overLag, false) - }) - - it('refuses an orphaned view (tracker committed above the node) and says so', function () { - const v = classify({ tracker_height: 1000, node_height: 900, lag: -100, synced: true }, 2) - assert.strictEqual(v.code, 'UTXO_TRACKER_STALE') - assert.strictEqual(v.behindNode, true) - assert.ok(/orphaned/.test(v.message), v.message) - }) - - it('refuses a tracker whose mempool has not reconverged', function () { - const v = classify({ lag: 0, synced: true, mempool_ready: false }, 2) - assert.strictEqual(v.code, 'UTXO_TRACKER_NOT_READY') - assert.strictEqual(v.mempoolReady, false) - }) - - it('fails open on every field it is not explicitly told is bad', function () { - // An older tracker publishes lag/synced only. Nothing here is an explicit - // negative, so nothing refuses; this is the property that let the gate ship - // ahead of the fleet. - const v = classify({ tracker_height: 100, node_height: 100, lag: 0, synced: true }, 2) - assert.strictEqual(v.code, null) - assert.strictEqual(v.halted, false) - assert.strictEqual(v.mempoolReady, true) - }) - - it('treats a non-numeric lag as unknown rather than comparing it', function () { - // A lag that is null, absent or a string bounds nothing; comparing it would - // make `'7' > 2` decide whether money moves. - for (const lag of [undefined, null, '7', NaN]) { - const v = classify({ lag, synced: true }, 2) - assert.strictEqual(v.overLag, false, `lag ${String(lag)} must not read as over-lag`) - assert.strictEqual(v.behindNode, false) - assert.strictEqual(v.lag, null) - assert.strictEqual(v.code, null) - } - }) - - it('separates the tracker\'s positive synced claim from the refusal verdict', function () { - // The one real asymmetry between the two surfaces: a readiness probe needs a - // POSITIVE assertion of health (an omitted `synced` is not one), while the - // create_tx gate refuses only on an explicit negative. - const omitted = classify({ lag: 0 }, 2) - assert.strictEqual(omitted.syncedClaimed, false, 'an omitted synced is not an assertion') - assert.strictEqual(omitted.code, null, 'but it is not an explicit negative either') - }) - - it('does not mutate the sync object it is handed', function () { - const sync = { tracker_height: 100, node_height: 100, lag: 0, synced: true } - const before = JSON.stringify(sync) - classify(sync, 2) - assert.strictEqual(JSON.stringify(sync), before) - }) -}) - -// The anti-drift test. Each row is one tracker state; both surfaces are driven -// with it and must agree. Before the shared classifier these two derivations -// lived 1200 lines apart and were edited independently. -const PARITY_CASES = [ - { name: 'healthy', sync: { tracker_height: 100, node_height: 100, lag: 0, synced: true, mempool_ready: true }, - expectCode: null }, - { name: 'lag at the ceiling', sync: { tracker_height: 98, node_height: 100, lag: 2, synced: true, mempool_ready: true }, - expectCode: null }, - { name: 'lag one over the ceiling', sync: { tracker_height: 97, node_height: 100, lag: 3, synced: true, mempool_ready: true }, - expectCode: 'UTXO_TRACKER_STALE' }, - { name: 'tracker de-asserts synced', sync: { tracker_height: 100, node_height: 100, lag: 0, synced: false, mempool_ready: true }, - expectCode: 'UTXO_TRACKER_STALE' }, - { name: 'orphaned view', sync: { tracker_height: 1000, node_height: 900, lag: -100, synced: true, mempool_ready: true }, - expectCode: 'UTXO_TRACKER_STALE' }, - { name: 'halted', sync: { tracker_height: 100, node_height: 100, lag: 0, synced: true, mempool_ready: true, halted: true, - halt_reason: 'unrecoverable reorg' }, - expectCode: 'UTXO_TRACKER_HALTED' }, - { name: 'mempool not reconverged', sync: { tracker_height: 100, node_height: 100, lag: 0, synced: true, mempool_ready: false }, - expectCode: 'UTXO_TRACKER_NOT_READY' } -] - -describe('create_tx and health() reach the same verdict on the same tracker @regression', function () { - - async function createTxVerdict(sync) { - const enc = makeEncoder('bitcoin-regtest') - const address = getTestAddress('bitcoin-regtest') - enc.utxoTrackerConnector.getUtxosFromAddress = async () => ({ - utxos: [makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000)], - sync - }) - try { - const result = await enc.createTransaction( - null, address, null, 'test', null, 10000, false, null, address, - null, null, null, true, 0.00001 - ) - assert.ok(result.psbt) - return null - } catch (err) { - if (!err.operational) throw err - return err.xchainCode - } - } - - async function healthVerdict(sync) { - const orig = apiEncoder.utxoTrackerConnector.getSyncStatus - apiEncoder.utxoTrackerConnector.getSyncStatus = async () => sync - try { - return await jsonRpcController.health() - } finally { - apiEncoder.utxoTrackerConnector.getSyncStatus = orig - } - } - - for (const testCase of PARITY_CASES) { - it(`agrees on: ${testCase.name}`, async function () { - const code = await createTxVerdict(testCase.sync) - assert.strictEqual(code, testCase.expectCode, - `create_tx verdict for "${testCase.name}"`) - - const health = await healthVerdict(testCase.sync) - assert.strictEqual(health.tracker_synced, testCase.expectCode === null, - `health() must read ${testCase.expectCode === null ? 'serveable' : 'un-serveable'} ` + - `for "${testCase.name}", the same way create_tx does`) - }) - } - - it('reports the halt and the mempool state alongside the verdict', async function () { - const halted = await healthVerdict({ lag: 0, synced: true, halted: true, halt_reason: 'unrecoverable reorg' }) - assert.strictEqual(halted.tracker_halted, true) - assert.strictEqual(halted.tracker_synced, false) - - const unready = await healthVerdict({ lag: 0, synced: true, mempool_ready: false }) - assert.strictEqual(unready.tracker_mempool_ready, false) - assert.strictEqual(unready.tracker_synced, false) - }) -}) 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 9e88e69..1981f49 100644 --- a/test/unit/xchain_encoder/xchain_encoder_utxo_dedup.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_utxo_dedup.test.js @@ -54,6 +54,23 @@ function outpoints(utxos) { return utxos.map((u) => u.txid + ':' + u.vout) } +function makeCountedUtxos(count, counter) { + const utxos = [] + for (let i = 0; i < count; i++) { + const txid = i.toString(16).padStart(64, '0') + const utxo = makeUtxo(NETWORK, txid, 0, 1000) + Object.defineProperty(utxo, 'txid', { + enumerable: true, + get() { + counter.reads++ + return txid + } + }) + utxos.push(utxo) + } + return utxos +} + describe('_buildTransaction outpoint dedup / mempool filter', function () { it('keeps the FIRST occurrence of a repeated outpoint and drops the rest', async function () { @@ -156,32 +173,21 @@ describe('_buildTransaction outpoint dedup / mempool filter', function () { assert.strictEqual(utxos.length, 1, '5000 copies of one outpoint collapse to one') }) - // The defect itself, in the shape that actually hurts: an address holding many - // DISTINCT outputs. Nothing is spliced, so the cost is pure comparison, and the - // old inner while re-scanned the whole tail for every surviving entry: n^2/2 - // txid string compares. Measured on this machine with the old loop: 5000 -> 119ms, - // 10000 -> 423ms, 20000 -> 1739ms (a clean 4x per doubling). The linear pass: - // 14ms at 20000, 26ms at 40000. The caller path is capped at MAX_UTXO_COUNT, but - // the tracker-fetched path is deliberately uncapped, so this is reachable. - // - // The bound measures the complexity class, not the machine: it sits ~35x above - // the linear timing and ~3.5x below the quadratic one. + // An address holding many DISTINCT outputs: the old inner while re-read every + // later txid for every survivor (n^2/2 reads). Counting reads avoids wall-time flake. it('filters a large set of DISTINCT outpoints in linear time', async function () { - this.timeout(30000) const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxos = [] - for (let i = 0; i < 20000; i++) { - utxos.push(makeUtxo(NETWORK, i.toString(16).padStart(64, '0'), 0, 1000)) - } + const count = 2000 + const counter = { reads: 0 } + const utxos = makeCountedUtxos(count, counter) - const started = Date.now() const result = await build(encoder, address, utxos, true) - const elapsed = Date.now() - started assert.ok(result.psbt) - assert.strictEqual(utxos.length, 20000, 'distinct outpoints are not duplicates') - assert.ok(elapsed < 500, - `filtering 20000 distinct outpoints took ${elapsed}ms; the quadratic scan is back`) + assert.strictEqual(utxos.length, count, 'distinct outpoints are not duplicates') + assert.ok(counter.reads >= count, 'the test must observe every candidate during filtering') + assert.ok(counter.reads < count * 20, + `filtering ${count} distinct outpoints read txid ${counter.reads} times; the quadratic scan is back`) }) })