diff --git a/.ci-siblings b/.ci-siblings index 3850611..a5a41f3 100644 --- a/.ci-siblings +++ b/.ci-siblings @@ -28,3 +28,5 @@ xchain-sdk # The inbound UTXO-record contract, the one seam carrying records INTO the # encoder rather than out of it. xchain-utxo-tracker +# The mainnet prelaunch validator wire-format set against the consensus copy. +xchain-indexer diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 6241305..061bdbb 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,5 +1,9 @@ +## Base branch + +- [ ] This pull request targets `develop`. `master` only takes release merges, and a pull request into it from a fork cannot be merged. + ## What this changes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d626816..d0ca3a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ on: # so the merge commit a release tag points at has its own green run. branches: [master, develop] pull_request: - branches: [master] + branches: [master, develop] jobs: ci: diff --git a/CHANGELOG.md b/CHANGELOG.md index 16dd4b9..a522ee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.24.0] - 2026-10-17 + +### Added +- Added a transaction status lookup method and exposed a node tier mismatch in status readiness. + +### Changed +- Priced transactions at their full size, selected inputs against the final fee and refused sub-dust payments. +- Funded multi-chunk P2WSH reveals with first-leg headroom and a surplus sweep. +- Refused non-validator transaction creation on mainnet before launch. + +### Fixed +- Sized bare-multisig dust from each coin's relay rate and refused a reveal that does not spend its funded scripts. +- Kept fee tiers ordered by confirmation speed and used the relay fallback for implausible test-chain fees. + +### Security +- Bumped axios, fast-uri, ip-address, proxy-addr and brace-expansion to resolve security advisories. + ## [0.23.0] - 2026-10-11 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3162a8b..659acfb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,7 +90,7 @@ Run the no-external-services tiers before every commit (`npm run ci`, `npm run t Match the existing log style: a concise subject line, then a short body explaining what changed and why. -- Branch off `master` and keep history linear (rebase, don't merge). +- Fork the repository, branch off `develop`, and open your pull request against `develop`. Keep history linear (rebase, don't merge). `master` is what production runs and only ever receives release merges from `develop`; a pull request into `master` from a fork or any other branch fails the `base-branch guard` check and cannot be merged. - One logical change per commit; don't batch unrelated work. - **No `Co-Authored-By` trailers.** This is a project policy. - **Never `--no-verify`.** If a hook fails, fix the cause; don't bypass it. diff --git a/README.md b/README.md index 5b6f338..c1b2ccc 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ npm run api | `ENCODER_MAX_CONCURRENT_REQUESTS` | No | `50` | Global cap on requests served at once across all client IPs; excess gets an immediate 429 + `Retry-After` instead of queueing. `/status` and `/openrpc.json` are exempt, over `GET` or `HEAD`, in any letter case and with or without a trailing slash; `0` disables | | `ENCODER_MAX_CONCURRENT_PROBES` | No | `16` | Private concurrency reserve for the two exempt probe routes, so healthchecks stay answerable while the cap above sheds without becoming an uncapped bypass; `0` disables | | `ENCODER_TRUST_PROXY` | No | `loopback, uniquelocal` | Express `trust proxy` setting; controls which hop the per-IP rate limiter keys the client IP on. `false`, a hop count, or an address/CIDR list per the Express docs | +| `ENCODER_MAINNET_PRELAUNCH_OVERRIDE` | No | Unset | On a mainnet encoder, `create_tx` refuses every action outside the consensus validator set before unix time `1798761600` with `-32010` and `data.reason` `MAINNET_PRELAUNCH` (`data.opens_at` carries the time). The set is read by name and format, exactly as the indexer's launch gate reads it: `ANCHOR` formats 0 to 3, `PRICE` format 0, `NODEPROOF` format 0, `ROLLCALL` formats 0 and 1, and the quorum `ATTEST` formats 1, 3, 4, 5 and 6. `PRICE` format 1, `ATTEST` formats 0 and 2, `STAKE`, `UNSTAKE`, `DELEGATE`, `SLASH` and every other action are refused. Set `1`, `true`, `yes` or `on` to lift the refusal for every action; read live on every call, any other value leaves it in force. Set it only on the foundation's own bootstrap encoder that builds the seed sources' pre-launch actions (the encoder cannot see the pinned seed sources), never on a public encoder. Testnet and regtest are never refused | | `ENCODER_MAINTENANCE_FILE` | No | `/tmp/xchain-encoder-maintenance.json` | Where the encoder looks for an operator-declared scheduled-maintenance window. `health` and `GET /status` report it as `maintenance` beside the readiness fields, so a status board can tell a planned outage from a fault; it never changes a readiness field or the 503. See [Scheduled maintenance](#scheduled-maintenance) | | `CORS_ORIGIN` | No | Disabled | Allowed CORS origin(s): `*` for any, one origin, or a comma-separated allowlist matched per-origin (browser wallet shells each send a different origin). A stray `*` inside a list is not a wildcard, so the grant fails closed | diff --git a/bin/pins/at1-identity.json b/bin/pins/at1-identity.json index 0c2565e..1d3d65f 100644 --- a/bin/pins/at1-identity.json +++ b/bin/pins/at1-identity.json @@ -1,9 +1,9 @@ { - "capturedAt": "2026-10-09T19:55:43.652Z", + "capturedAt": "2026-10-17T05:41:32.673Z", "coins": { - "src/coins/BTC.js": "182baf5173e3846b94a5edc1ae15913a54fb266addfc45a3cf9a013bd9ec16c6", + "src/coins/BTC.js": "6868afc2d18911ba112234c980c094fda71cfc3e744a00f493def781c7610f8d", "src/coins/LTC.js": "6155195bfa5fdfeab77dec3802ce67b5cc47a77695e81a4a5545d980e4c421dd", - "src/coins/DOGE.js": "4659b45fb7a5c959ce2777ab9d5e7c193f3515f03cdd8cd23bb6b9c4d012fc11", + "src/coins/DOGE.js": "29ac02299b0c779f4e3eda579eeaa9f673c454b73cfe9978a35f03f0e9aed33c", "src/coins/index.js": "08b678d934fa647d3c7d14dc5ccdaeb86722ee32a3662de69e4a31cd7b09234c", "src/coins/consensus_pin.js": "342bf7af224a19ea0a98ff9cfb2c9a70b3fd5be75de7ecef2db05936f82baa63" }, diff --git a/bin/pins/at1-suite-titles.json b/bin/pins/at1-suite-titles.json index 75ad953..18f0652 100644 --- a/bin/pins/at1-suite-titles.json +++ b/bin/pins/at1-suite-titles.json @@ -684,6 +684,9 @@ "Encoder input validator validateDataParam rejects a rawData code point above U+00FF (latin-1 would truncate it)", "Encoder input validator validateDataParam still accepts every value the wire encodings carry losslessly" ], + "4d2fee8572bf0a2f": [ + "BlockchainConnector.getFeePerKilobyte() test-chain implausible estimate uses the no-estimate fallback instead of clamping an estimate above the sanity ceiling" + ], "4dd0d30bb214ff45": [ "two-phase reveal package prefund @regression @tier1 MAX_CPFP_UPLIFT_SAT=0 turns the prefund off entirely", "two-phase reveal package prefund @regression @tier1 adds nothing when the commit already pays the target rate on its own bytes", @@ -1071,6 +1074,19 @@ "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.27: LIST comma-separated items encoded successfully", "E2E-1: Full ACTION-to-PSBT Pipeline E2E-1.27: LIST full addresses auto-select P2SH" ], + "87462cdada9ac477": [ + "BlockchainConnector getMempoolEntry returns null for an HTTP 200 RPC -5 mempool miss", + "BlockchainConnector getMempoolEntry returns null for an HTTP 500 RPC -5 mempool miss", + "BlockchainConnector getMempoolEntry sends getmempoolentry with the txid and returns the entry", + "BlockchainConnector getMempoolEntry throws a transport error instead of treating it as absent", + "BlockchainConnector getMempoolEntry throws an unexpected RPC error instead of treating it as absent", + "get_tx_status transaction location rejects a malformed txid before either lookup", + "get_tx_status transaction location returns chain after a mempool miss when get_tx_block finds the txid", + "get_tx_status transaction location returns mempool and does not ask the tracker when getmempoolentry finds the txid", + "get_tx_status transaction location returns unknown only when both the mempool and chain lookups miss", + "get_tx_status transaction location throws rather than returning unknown when the node lookup fails", + "get_tx_status transaction location throws rather than returning unknown when the tracker lookup fails" + ], "880c961e264c0519": [ "XChainEncoder TAPROOT envelope sign/finalize/extract round trip (commit -> reveal -> estimator accuracy) signs both halves; the signed commit txid matches the pre-built reveal outpoint" ], @@ -1396,7 +1412,7 @@ "E2E-6: Multi-Chain Validation E2E-6.3: litecoin-regtest OP_RETURN creates valid OP_RETURN PSBT with correct dust threshold", "E2E-6: Multi-Chain Validation E2E-6.7: Bitcoin P2WSH creates P2WSH witness output on bitcoin-regtest", "E2E-6: Multi-Chain Validation E2E-6.8: Bitcoin MULTISIGN (dust=546) multisig output at 546 sats", - "E2E-6: Multi-Chain Validation E2E-6.9: Litecoin MULTISIGN (dust=5460) multisig output at 5460 sats", + "E2E-6: Multi-Chain Validation E2E-6.9: Litecoin MULTISIGN (dust=5460) multisig output at 7860 sats", "E2E-6: Multi-Chain Validation Fee floor uses chain-specific dust Litecoin fee floor is 5460, not 546", "E2E-6: Multi-Chain Validation bitcoin-regtest P2SH creates P2SH with output value >= chain dust threshold", "E2E-6: Multi-Chain Validation dogecoin-regtest P2SH creates P2SH with output value >= chain dust threshold", @@ -2216,7 +2232,7 @@ "REG-04: Validator Functions REG-04.7: validateDust() throws RangeError for negative value", "REG-04: Validator Functions REG-04.7: validateDust() throws TypeError for non-integer string" ], - "fae26ed352b38e34": [ + "e1b10cc67b1fee13": [ "BlockchainConnector.getFeePerKilobyte() does NOT fall back to the relayfee floor on mainnet", "BlockchainConnector.getFeePerKilobyte() falls back to 10x the relayfee floor on testnet when estimatesmartfee has no data", "BlockchainConnector.getFeePerKilobyte() falls back to the regtest default when relayfee is unavailable", @@ -2229,10 +2245,10 @@ "BlockchainConnector.getFeePerKilobyte() rethrows transport errors", "BlockchainConnector.getFeePerKilobyte() returns feerate when present (non-regtest)", "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate clamps a spiked mainnet estimate to the coin-default ceiling and logs loudly", - "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate clamps a spiked testnet estimate using the dogecoin-scale default when NETWORK says dogecoin", "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate honours FEE_ESTIMATE_SANITY_CEILING as an override", "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate ignores a non-positive or unparseable override and keeps the coin default", "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate passes through an estimate under the ceiling unchanged", + "BlockchainConnector.getFeePerKilobyte() sanity ceiling on the raw non-regtest estimate treats a spiked testnet estimate as unavailable and uses the relayfee fallback", "BlockchainConnector.getFeePerKilobyte() sends estimatesmartfee with the blocksNumber param (non-regtest)", "BlockchainConnector.getFeePerKilobyte() still prefers the estimate over the relayfee floor on testnet when one exists", "BlockchainConnector.getFeePerKilobyte() throws on testnet when neither an estimate nor a relayfee is available", @@ -2298,13 +2314,14 @@ }, "scripts": { "test": { - "fileCount": 135, - "titleCount": 1020, + "fileCount": 136, + "titleCount": 1031, "files": { "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", "test/unit/api/cors_preflight.test.js": "78e833e8f04d5380", "test/unit/api/encoder_stress_sweep.test.js": "358184203c4ccfba", + "test/unit/api/get_tx_status.test.js": "87462cdada9ac477", "test/unit/api/health_serve_readiness.test.js": "99f46ad865902c51", "test/unit/api/json_rpc_real_app.test.js": "7174ac9d6bdce059", "test/unit/api/jsonrpc_body_guard.test.js": "58706b2a54fa0008", @@ -2316,11 +2333,12 @@ "test/unit/blockchain_connector.test/03_get_transaction_hex.test.js": "845d953a12be6745", "test/unit/blockchain_connector.test/04_send_raw_transaction.test.js": "e65ba2021038db9f", "test/unit/blockchain_connector.test/05_send_raw_transaction_maxfeerate_retry.test.js": "b70b109181c9f1fc", - "test/unit/blockchain_connector.test/06_get_fee_per_kilobyte.test.js": "fae26ed352b38e34", + "test/unit/blockchain_connector.test/06_get_fee_per_kilobyte.test.js": "e1b10cc67b1fee13", "test/unit/blockchain_connector.test/07_rpc_credential_log_sanitization.test.js": "72c9c5f2d2749796", "test/unit/blockchain_connector.test/08_fee_estimate_rpc_error_detail.test.js": "2842761e884c6f3b", "test/unit/blockchain_connector.test/09_get_block_count.test.js": "8034f6c2b890b23c", "test/unit/blockchain_connector.test/10_sanitize_rpc_error_parity.test.js": "2883f0e9d248552e", + "test/unit/blockchain_connector.test/13_test_chain_implausible_estimate.test.js": "4d2fee8572bf0a2f", "test/unit/build/apply_bufferutils_patch.test.js": "3eb6eaeabe786d34", "test/unit/build/compression.test.js": "68868c37d76d5eb2", "test/unit/build/compression.test/01_action_string_helpers.test.js": "f6cecc24770508b5", @@ -2584,13 +2602,14 @@ } }, "test:unit": { - "fileCount": 135, - "titleCount": 1020, + "fileCount": 136, + "titleCount": 1031, "files": { "test/unit/adapters/evm_adapter.test.js": "2c605d4ec40ce8e5", "test/unit/api/broadcast_tx_logging.test.js": "2f6d87eaf711d8a0", "test/unit/api/cors_preflight.test.js": "78e833e8f04d5380", "test/unit/api/encoder_stress_sweep.test.js": "358184203c4ccfba", + "test/unit/api/get_tx_status.test.js": "87462cdada9ac477", "test/unit/api/health_serve_readiness.test.js": "99f46ad865902c51", "test/unit/api/json_rpc_real_app.test.js": "7174ac9d6bdce059", "test/unit/api/jsonrpc_body_guard.test.js": "58706b2a54fa0008", @@ -2602,11 +2621,12 @@ "test/unit/blockchain_connector.test/03_get_transaction_hex.test.js": "845d953a12be6745", "test/unit/blockchain_connector.test/04_send_raw_transaction.test.js": "e65ba2021038db9f", "test/unit/blockchain_connector.test/05_send_raw_transaction_maxfeerate_retry.test.js": "b70b109181c9f1fc", - "test/unit/blockchain_connector.test/06_get_fee_per_kilobyte.test.js": "fae26ed352b38e34", + "test/unit/blockchain_connector.test/06_get_fee_per_kilobyte.test.js": "e1b10cc67b1fee13", "test/unit/blockchain_connector.test/07_rpc_credential_log_sanitization.test.js": "72c9c5f2d2749796", "test/unit/blockchain_connector.test/08_fee_estimate_rpc_error_detail.test.js": "2842761e884c6f3b", "test/unit/blockchain_connector.test/09_get_block_count.test.js": "8034f6c2b890b23c", "test/unit/blockchain_connector.test/10_sanitize_rpc_error_parity.test.js": "2883f0e9d248552e", + "test/unit/blockchain_connector.test/13_test_chain_implausible_estimate.test.js": "4d2fee8572bf0a2f", "test/unit/build/apply_bufferutils_patch.test.js": "3eb6eaeabe786d34", "test/unit/build/compression.test.js": "68868c37d76d5eb2", "test/unit/build/compression.test/01_action_string_helpers.test.js": "f6cecc24770508b5", diff --git a/docker-compose.yml b/docker-compose.yml index 96ff911..8d72b50 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,10 +3,11 @@ version: '3' services: xchain_encoder: build: . - # tini as PID 1 so it, not node, reaps zombies left by healthcheck - # probes; node itself never reaps orphaned children. Dockerfile's - # exec-form node CMD stays PID 1's direct child and still gets SIGTERM - # straight from tini's forwarding, so that signal path is unchanged. + # Docker's own init as PID 1, on top of the image's tini ENTRYPOINT, the + # same tree xchain-node's `docker run --init` gives production: + # docker-init -> tini -> node. docker-init reaps zombies left by + # healthcheck probes (node never reaps orphaned children), and SIGTERM + # reaches node forwarded through both inits. init: true # The image bakes no .env (Dockerfile); the README's .env is handed to the # container here instead. diff --git a/docs/openrpc.build.js b/docs/openrpc.build.js index 3b24339..78f8fc6 100644 --- a/docs/openrpc.build.js +++ b/docs/openrpc.build.js @@ -51,7 +51,8 @@ const METHODS = [ + 'record ({commitTxid, commitVout, commitValue, commitAddress, internalPubkey, tapleafHash, ' + 'controlBlock, revealFee}) that the wallet must persist durably BEFORE broadcasting the commit; ' + 'sign both, broadcast commit then reveal. TAPROOT requires compressedPubKey (the envelope internal ' - + 'key), segwit-only inputs, and accepts payloads up to the 400,000-byte envelope ceiling.', + + 'key), segwit-only inputs, and accepts payloads up to the 400,000-byte envelope ceiling. ' + + 'An unknown top-level param is refused with -32602 rather than ignored, as unknown options keys are.', params: [ { name: 'pubkey', required: true, schema: str('caller identity: sender address (base58 or bech32) or compressed public key hex (02/03 + 64 hex); uncompressed (04...) keys are rejected') }, { name: 'data', schema: str('ACTION payload string (pipe-delimited, from xchain-sdk createAction)') }, @@ -119,6 +120,23 @@ const METHODS = [ params: [{ name: 'address', required: true, schema: str('address to query') }], result: { name: 'utxos', schema: { type: 'array', items: { type: 'object' } } }, }, + { + name: 'get_tx_status', + summary: 'Classify a transaction as present in the node mempool, confirmed on chain, or unknown.', + description: 'Checks the coin node mempool first, then the UTXO tracker chain index. ' + + 'A lookup failure is an error and is never reported as an unknown transaction.', + params: [{ name: 'txid', required: true, schema: str('transaction id (64-character hexadecimal string)') }], + result: { + name: 'transaction_status', + schema: { + type: 'object', + properties: { + status: str('transaction location', { enum: ['mempool', 'chain', 'unknown'] }), + }, + required: ['status'], + }, + }, + }, { name: 'get_tx_block', summary: 'Locate the confirmed block containing a transaction (proxied from xchain-utxo-tracker).', diff --git a/docs/openrpc.json b/docs/openrpc.json index 0a5552a..ef7f2cc 100644 --- a/docs/openrpc.json +++ b/docs/openrpc.json @@ -142,7 +142,7 @@ { "name": "create_tx", "summary": "Build an unsigned PSBT embedding an XChain ACTION payload. The caller signs and broadcasts.", - "description": "Encoding auto-selects OP_RETURN for payloads ≤76 bytes, else P2SH (two-transaction flow: call once for the funding tx, then again with p2shHash/p2shHex to build the reveal tx). rawData is decoded as Latin-1 so arbitrary bytes round-trip losslessly. Dogecoin has no segwit: P2WSH and TAPROOT encodings are rejected on DOGE networks. encoding:\"TAPROOT\" (BTC/LTC, explicit only) is a single-call two-transaction flow: the result carries the commit PSBT (psbt), the pre-built reveal PSBT (revealPsbt) and an `envelope` recovery record ({commitTxid, commitVout, commitValue, commitAddress, internalPubkey, tapleafHash, controlBlock, revealFee}) that the wallet must persist durably BEFORE broadcasting the commit; sign both, broadcast commit then reveal. TAPROOT requires compressedPubKey (the envelope internal key), segwit-only inputs, and accepts payloads up to the 400,000-byte envelope ceiling.", + "description": "Encoding auto-selects OP_RETURN for payloads ≤76 bytes, else P2SH (two-transaction flow: call once for the funding tx, then again with p2shHash/p2shHex to build the reveal tx). rawData is decoded as Latin-1 so arbitrary bytes round-trip losslessly. Dogecoin has no segwit: P2WSH and TAPROOT encodings are rejected on DOGE networks. encoding:\"TAPROOT\" (BTC/LTC, explicit only) is a single-call two-transaction flow: the result carries the commit PSBT (psbt), the pre-built reveal PSBT (revealPsbt) and an `envelope` recovery record ({commitTxid, commitVout, commitValue, commitAddress, internalPubkey, tapleafHash, controlBlock, revealFee}) that the wallet must persist durably BEFORE broadcasting the commit; sign both, broadcast commit then reveal. TAPROOT requires compressedPubKey (the envelope internal key), segwit-only inputs, and accepts payloads up to the 400,000-byte envelope ceiling. An unknown top-level param is refused with -32602 rather than ignored, as unknown options keys are.", "paramStructure": "by-name", "params": [ { @@ -596,6 +596,42 @@ } } }, + { + "name": "get_tx_status", + "summary": "Classify a transaction as present in the node mempool, confirmed on chain, or unknown.", + "description": "Checks the coin node mempool first, then the UTXO tracker chain index. A lookup failure is an error and is never reported as an unknown transaction.", + "paramStructure": "by-name", + "params": [ + { + "name": "txid", + "required": true, + "schema": { + "type": "string", + "description": "transaction id (64-character hexadecimal string)" + } + } + ], + "result": { + "name": "transaction_status", + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "transaction location", + "enum": [ + "mempool", + "chain", + "unknown" + ] + } + }, + "required": [ + "status" + ] + } + } + }, { "name": "get_tx_block", "summary": "Locate the confirmed block containing a transaction (proxied from xchain-utxo-tracker).", diff --git a/package-lock.json b/package-lock.json index 5547dc1..dd3902f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "xchain-encoder", - "version": "0.23.0", + "version": "0.24.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-encoder", - "version": "0.23.0", + "version": "0.24.0", "license": "AGPL-3.0-or-later", "dependencies": { - "axios": "^1.18.1", + "axios": "^1.20.0", "bip174": "2.1.1", "bip32": "4.0.0", "bip39": "^3.1.0", @@ -2709,13 +2709,13 @@ } }, "node_modules/axios": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", - "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -2876,9 +2876,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3995,9 +3995,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "dev": true, "funding": [ { @@ -4536,9 +4536,9 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ip-address": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", - "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", "license": "MIT", "engines": { "node": ">= 12" @@ -5519,15 +5519,20 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-from-env": { diff --git a/package.json b/package.json index 2c177f2..21eebbd 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { "name": "xchain-encoder", "description": "xchain-encoder encodes XChain Platform ACTION commands into blockchain transactions.", - "version": "0.23.0", + "version": "0.24.0", "license": "AGPL-3.0-or-later", "repository": { "type": "git", "url": "https://github.com/XChain-Platform/xchain-encoder.git" }, "dependencies": { - "axios": "^1.18.1", + "axios": "^1.20.0", "bip174": "2.1.1", "bip32": "4.0.0", "bip39": "^3.1.0", diff --git a/src/XChainEncoder.js b/src/XChainEncoder.js index a316b4a..6e99862 100644 --- a/src/XChainEncoder.js +++ b/src/XChainEncoder.js @@ -49,6 +49,7 @@ const { prepareDataChunks, priceRevealCustomOutputs, initEmissionState, emitData const { initSelection, selectFundingInputs } = require('./XChainEncoder/build_transaction/input_selection.js') const { refuseExcessiveFee, refuseInsufficientRelayFee, upliftForAncestors, floorEstimatedFee, prefundRevealPackage, computeChange } = require('./XChainEncoder/build_transaction/fee_settlement.js') const { emitChangeAndPad, sweepP2shReveal, buildEnvelopeReveal } = require('./XChainEncoder/build_transaction/reveal_outputs.js') +const { refuseUnderStrippedFloor } = require('./XChainEncoder/build_transaction/stripped_floor.js') const { finishBuild } = require('./XChainEncoder/build_transaction/build_result.js') @@ -151,6 +152,11 @@ class XChainEncoder { softDustFloorFor(network), (Number.isFinite(operatorDust) && operatorDust > 0) ? Math.floor(operatorDust) : 0 ) + // The relay dust threshold a caller's payment output must reach: the pinned hard + // threshold (BTC 546, LTC 5460, DOGE 100000). Not the DOGE 0.01 soft limit: wallet + // native-fee outputs are padded only to the hard threshold, and the smallest fees sit + // under the soft limit. Nor the operator DUST_AMOUNT, a floor on authored outputs. + this.paymentOutputFloor = this.dustAmount // Maximum fee rate in BTC/byte (null = no cap). Prevents runaway estimates // (e.g. regtest feedback loop) from producing fees that the node will reject. // MAX_FEE_RATE_KB is in sat/kB, convert to BTC/byte to match feePerBytes units. @@ -261,6 +267,7 @@ function* buildSteps(build){ prefundRevealPackage.call(this, build) computeChange.call(this, build) emitChangeAndPad.call(this, build) + refuseUnderStrippedFloor.call(this, build) const outputsBeforeSweep = build.psbt.txOutputs.length yield* sweepP2shReveal.call(this, build) noteUndersizedRevealLeg.call(this, build, outputsBeforeSweep) diff --git a/src/XChainEncoder/build_transaction/data_outputs.js b/src/XChainEncoder/build_transaction/data_outputs.js index 8149cdd..6b7d410 100644 --- a/src/XChainEncoder/build_transaction/data_outputs.js +++ b/src/XChainEncoder/build_transaction/data_outputs.js @@ -20,11 +20,12 @@ const bitcoin = require('bitcoinjs-lib'); const TxSizeEstimator = require('../../build/tx_size_estimator') -const { parseSatoshiAmount } = require('../../common/validator') +const { parseSatoshiAmount, assertPaymentOverDust } = require('../../common/validator') const { SATOSHI_UNIT, Encoding } = require('../constants.js') const { resolveCallerAddress } = require('../request_resolution.js') const { emitP2shChunk, emitP2wshChunk } = require('./script_hash_legs.js') -const { feeForVsize } = require('../fee_policy.js') +const { feeForVsize, softDustSurchargeFor } = require('../fee_policy.js') +const { softDustFloorFor, dustRelayFeePerByteFor } = require('../script_amount_helpers.js') const { ParamRangeError } = require('../../build/errors') function prepareDataChunks(build){ @@ -85,6 +86,11 @@ function priceRevealCustomOutputs(build){ let revealCustomOutputsFee = (revealCustomOutputsBytes > 0 && feePerBytes > 0) ? feeForVsize(revealCustomOutputsBytes, feePerBytes, SATOSHI_UNIT) : 0 + // A reveal payment under the coin's soft dust limit also costs the reveal that + // limit in relay fee, and only the first leg can carry it. + if (revealCustomOutputsBytes > 0){ + revealCustomOutputsFee = revealCustomOutputsFee + softDustSurchargeFor(customOutputs.map(o => o.value), softDustFloorFor(this.networkKey)) + } Object.assign(build, { isP2shFamily, revealCustomOutputsValue, revealCustomOutputsBytes, revealCustomOutputsFee }) } @@ -123,7 +129,12 @@ function initEmissionState(build){ // revealFee - the fee that output currently prefunds let revealPrefund = null - let estimatedTxSize = 0 + // The transaction header: 4 version + 1 input-count varint + 1 output-count + // varint + 4 locktime. Every output and input below adds only its own bytes, so + // starting at 0 left each single-transaction fee 10 vB short, enough to put a + // many-input build at the relay rate under 1 sat/vB. The segwit marker and flag, + // and wider count varints, are added by input selection. + let estimatedTxSize = 10 Object.assign(build, { outputSatoshis, voutPsbtIndex, obfuscatedData, p2shRevealHeadroomFunded, phaseLegInputSatoshis, p2shTx, envelopeContext, revealPrefund, estimatedTxSize }) } @@ -201,11 +212,13 @@ function* emitMultisignChunk(build, nextDataBuffer){ // below the node's relay dust threshold and the broadcast is // rejected with {"code":-26,"message":"dust"}. Never hard-code a // flat 546 here: DOGE shares this path at 100000. Size the floor - // from the actual output script using Bitcoin Core's dust formula: - // (output_bytes + spend_input_bytes) * 3 sat/byte. The spend cost - // assumes a 148-byte P2PKH-style input. For standard 1-of-3 - // compressed-key scripts (105 bytes) this is ~786 sat. - let bareMultisigDust = Math.ceil((8 + 1 + multisignScript.output.length + 148) * 3) + // from the actual output script using the node's dust formula: + // (output_bytes + spend_input_bytes) * the coin's dust relay rate + // (3 per byte on Bitcoin, 30 on Litecoin). The spend cost assumes a + // 148-byte P2PKH-style input. For standard 1-of-3 compressed-key + // scripts (105 bytes) this is 786 sat on Bitcoin and 7860 litoshi on + // Litecoin, where the flat 5460 would be relay dust. + let bareMultisigDust = Math.ceil((8 + 1 + multisignScript.output.length + 148) * dustRelayFeePerByteFor(this.networkKey)) let multisigOutputValue = Math.max(finalDust, bareMultisigDust) psbt.addOutput({ @@ -282,7 +295,7 @@ function emitEnvelopeCommit(build, nextDataBuffer){ } function emitCustomOutputs(build){ - let { p2shHash, isP2shFamily, customOutputs, psbt, outputSatoshis, estimatedTxSize } = build + let { p2shHash, isP2shFamily, customOutputs, feeQuoteOutput, psbt, outputSatoshis, estimatedTxSize } = build // Process custom outputs (e.g., COINPay native coin payment outputs, or // the native-fee protocol-fee output). On a P2SH/P2WSH FUNDING tx // (p2shHash null) these are NOT emitted here: their value was folded into @@ -300,11 +313,15 @@ function emitCustomOutputs(build){ // the effector too (not just the validator boundary), matching the // native-fee output which is always positive (guarded at injection // above), so this never rejects a legitimate sub-dust FEE_DESTINATION - // value. Interim safe rule: reject only value <= 0, not a full - // per-network dust floor. + // value. if (outputValue <= 0) { throw new ParamRangeError(`customOutputs[${i}].value must be a positive integer (satoshis)`) } + // A caller payment also has to reach the coin's relay dust threshold + // (checkPaymentDust refused it already; this holds the effector to the + // same rule). The feeQuote protocol-fee output keeps only the positive + // rule above: its amount is priced by the hub, not chosen by the caller. + if (output !== feeQuoteOutput) assertPaymentOverDust(outputValue, i, this.paymentOutputFloor) psbt.addOutput({ address: output.address, value: outputValue @@ -313,7 +330,10 @@ function emitCustomOutputs(build){ estimatedTxSize += 43 // Taproot output size estimate (most expensive) } } - Object.assign(build, { outputSatoshis, estimatedTxSize }) + // Every value output is emitted by now except change, which is never under the + // soft limit, so this is the transaction's whole soft-dust relay surcharge. + const softDustSurcharge = softDustSurchargeFor(psbt.txOutputs.map(o => o.value), softDustFloorFor(this.networkKey)) + Object.assign(build, { outputSatoshis, estimatedTxSize, softDustSurcharge }) } module.exports = { prepareDataChunks, priceRevealCustomOutputs, initEmissionState, emitDataOutputs, emitCustomOutputs } diff --git a/src/XChainEncoder/build_transaction/fee_rates.js b/src/XChainEncoder/build_transaction/fee_rates.js index 7a88c07..aa3520d 100644 --- a/src/XChainEncoder/build_transaction/fee_rates.js +++ b/src/XChainEncoder/build_transaction/fee_rates.js @@ -44,7 +44,7 @@ function* resolveRelayFeeRate(build){ const info = yield this.connector.getNetworkInfo() const relayFeePerKb = Number(info && info.relayfee) // Refuse a node that reports no usable relay floor. A plain Error, not a - // RangeError: this is the node's fault, so the API answers retryable -32603. + // RangeError: the node is at fault, not the input, so the API answers -32603. if (!Number.isFinite(relayFeePerKb) || relayFeePerKb <= 0){ throw new Error('Node did not report a positive relayfee; transaction fee safety cannot be verified') } @@ -73,11 +73,11 @@ function* useCallerFeeRate(build){ try { nodeFeePerBytes = (yield this.connector.getFeePerKilobyte(1))/1000 } catch (err) { - // The node cannot produce an estimate (e.g. a quiet testnet, - // exactly the case where callers must pass feePerKb to begin - // with). The relative cap has no anchor; fall back to the - // relayfee anchor below and the absolute MAX_FEE_RATE_KB cap, - // if configured. + // No estimate: mainnet with an empty or failed estimatesmartfee, + // or any chain whose connector relayfee fallback fails (a test + // chain without an estimate gets relayfee x multiplier instead). + // The relative cap has no anchor; fall back to the relayfee + // anchor below and the absolute MAX_FEE_RATE_KB cap, if configured. logger.warn(util.format('Relative fee cap skipped: node fee estimate unavailable:', err.message)) } Object.assign(build, { feePerBytes, nodeFeePerBytes }) @@ -110,20 +110,28 @@ function* useNodeFeeRate(build){ } feePerBytes = suggestedCap } + // Never price a build under the node's own relay floor. An estimate can sit + // under relayfee (a stale or custom estimator, a misconfigured ceiling), and a + // caller who left the rate to the node has nothing to fix, so lift the rate here, + // before selection, rather than refuse the build afterwards. + const relayFeePerBytes = relayFeePerKb / 1000 + if (feePerBytes < relayFeePerBytes) feePerBytes = relayFeePerBytes Object.assign(build, { feePerBytes, nodeFeePerBytes }) } function* anchorRelayFeeRate(build){ let { nodeFeePerBytes, relayFeePerKb } = build - // Relative-cap anchor fallback. On non-regtest chains getFeePerKilobyte - // THROWS when estimatesmartfee has no data (fresh node, warming mempool, - // low activity), so the caller-supplied feePerKb path above leaves - // nodeFeePerBytes null. Without an anchor the relative cap can't bound - // feePerKb, and if no absolute MAX_FEE_RATE_KB is set either, the whole - // fee-drain guard is OFF exactly when a caller supplies its own rate: a - // hostile feePerKb then drains every input into miner fee. Fall back to - // the node's min-relay fee (always available, coin-correct) as the cap - // anchor, matching the regtest path in getFeePerKilobyte. This anchors + // Relative-cap anchor fallback. getFeePerKilobyte THROWS on mainnet when + // estimatesmartfee has no data (fresh node, warming mempool, low + // activity) or errors, and on any chain when the connector's relayfee + // fallback fails, so the caller-supplied feePerKb path above leaves + // nodeFeePerBytes null; test chains normally get relayfee x multiplier + // (bare relayfee on regtest) instead. Without an anchor the relative cap + // can't bound feePerKb, and if no absolute MAX_FEE_RATE_KB is set either, + // the whole fee-drain guard is OFF exactly when a caller supplies its own + // rate: a hostile feePerKb then drains every input into miner fee. Fall + // back to the node's min-relay fee (always available, coin-correct) as the + // cap anchor, matching the regtest path in getFeePerKilobyte. This anchors // ONLY the ceiling, never the fee actually charged; a legitimate rate on // an empty mempool sits well under relayfee x multiplier anyway. // Deliberately NOT gated on maxFeeRateMultiplier: the absolute burn diff --git a/src/XChainEncoder/build_transaction/fee_settlement.js b/src/XChainEncoder/build_transaction/fee_settlement.js index 8c51fc0..b65e432 100644 --- a/src/XChainEncoder/build_transaction/fee_settlement.js +++ b/src/XChainEncoder/build_transaction/fee_settlement.js @@ -26,7 +26,9 @@ const { maxCpfpUpliftSat, packageFeeUpliftSatoshis, feeForVsize } = require('../ // The caller-fee ceilings: the rate cap, then the absolute burn backstop. function refuseExcessiveFee(build){ - let { fee, capFeePerBytes, estimatedTxSize, estimatedFee, nodeFeePerBytes, feePerBytes } = build + let { fee, capFeePerBytes, estimatedTxSize, nodeFeePerBytes, feePerBytes } = build + // The rate ceilings judge the caller's own fee; the soft-dust surcharge is policy. + const estimatedFee = build.estimatedFee - (build.softDustSurcharge || 0) // Reject a caller-supplied absolute fee whose effective rate exceeds the // fee-rate cap for a transaction of this estimated size. Unlike feePerKb // (clamped above), an explicit fee is an exact amount the caller believes @@ -71,17 +73,29 @@ function refuseInsufficientRelayFee(build){ const { fee, feePerKb, feePerBytes, p2shHash, estimatedTxSize, estimatedFee, relayFeePerKb } = build const hasExplicitFee = fee != null && fee !== false const hasCallerRate = feePerKb != null && feePerKb !== false - if (!hasExplicitFee && !hasCallerRate) return + // The node adds the soft-dust surcharge to its relay minimum, and every fee here + // already carries it on top, so the size-based check reads the fee without it. + const surcharge = build.softDustSurcharge || 0 - const suppliedFee = hasExplicitFee - ? estimatedFee + const rateFee = hasExplicitFee + ? estimatedFee - surcharge : p2shHash ? feeForVsize(estimatedTxSize, feePerBytes, SATOSHI_UNIT) - : estimatedFee + : estimatedFee - surcharge + // A caller's fee or rate is judged as supplied. The node path is judged on the fee + // it will charge, which floorEstimatedFee never lets fall under the dust threshold. + const isNodePath = !hasExplicitFee && !hasCallerRate + const suppliedFee = isNodePath ? Math.max(rateFee, this.dustAmount) : rateFee const relayFeeBaseUnitsPerKb = Math.round(relayFeePerKb * SATOSHI_UNIT) const minimumRelayFee = Math.ceil(estimatedTxSize * relayFeeBaseUnitsPerKb / 1000) if (suppliedFee >= minimumRelayFee) return + // The node-estimate path: resolveFeeRates lifted the rate to the relay floor, so a + // fee under it here is an estimator or operator-cap fault, not a caller error. A + // plain Error (API -32603) refuses the unrelayable build without blaming the params. + if (isNodePath){ + throw new Error(`fee ${suppliedFee} from the node fee estimate is below the node relay minimum ${minimumRelayFee} base units for a ~${estimatedTxSize}-byte transaction`) + } if (hasExplicitFee){ throw new ParamRangeError(`fee ${suppliedFee} is below the node relay minimum ${minimumRelayFee} base units for a ~${estimatedTxSize}-byte transaction`) } @@ -286,7 +300,7 @@ if (allowed > 0){ } function computeChange(build){ - let { estimatedFee, inputSatoshis, outputSatoshis, p2shHash, reservedCandidates, change } = build + let { estimatedFee, inputSatoshis, outputSatoshis, p2shHash, reservedCandidates, change, requestedFee } = build // Validate the fee BEFORE the BigInt conversion below: BigInt(estimatedFee) // throws an opaque error on a NaN/Infinity fee, where this check names the cause. // A plain Error (API -32603): the validator already bounds every caller fee @@ -320,10 +334,15 @@ function computeChange(build){ ? `; ${reservedCandidates} candidate input(s) are reserved by a transaction built in the last ` + `${Math.round(RESERVATION_TTL_MS / 60000)} minutes; broadcast that transaction or wait for the reservation to lapse` : '' + // The fee named is the one this build charges. When the floor raised an + // explicit fee, say so: the caller asked for less and should see why more is required. + const raised = (requestedFee != null && requestedFee !== estimatedFee) + const raisedNote = raised ? `, raised from the requested fee ${requestedFee}` : '' throw new OperationalError( 'INSUFFICIENT_FUNDS', - `insufficient funds: selected inputs total ${inputSatoshis} but ${required} is required (outputs ${outputSatoshis} + fee ${estimatedFee})${held}`, - { required: jsonSafeSat(required), available: jsonSafeSat(inputSatoshis), outputs: jsonSafeSat(outputSatoshis), fee: estimatedFee, reservedCandidates } + `insufficient funds: selected inputs total ${inputSatoshis} but ${required} is required (outputs ${outputSatoshis} + fee ${estimatedFee}${raisedNote})${held}`, + { required: jsonSafeSat(required), available: jsonSafeSat(inputSatoshis), outputs: jsonSafeSat(outputSatoshis), fee: estimatedFee, + ...(raised ? { requestedFee } : {}), reservedCandidates } ) } diff --git a/src/XChainEncoder/build_transaction/input_candidates.js b/src/XChainEncoder/build_transaction/input_candidates.js index 84cddd7..731066a 100644 --- a/src/XChainEncoder/build_transaction/input_candidates.js +++ b/src/XChainEncoder/build_transaction/input_candidates.js @@ -24,6 +24,14 @@ const { upstreamErrorMessage } = require('../../common/error_sanitize') const { resolveCallerAddress } = require('../request_resolution.js') const { isRemoteProfile, throwIfRefused, trackerUnreachableError } = require('../trackerless_profile.js') +// Whether a candidate is still in the mempool. A missing confirmations field counts +// as 0, the default validateUtxoEntry writes on the API and tracker paths; a library +// caller skips that validator, and its UTXO objects are read here, never rewritten. +// Loose == 0 keeps the validator's coercion ('0' and 0 alike). +function isUnconfirmedUtxo(utxo){ + return utxo.confirmations == null || utxo.confirmations == 0 +} + function initInputState(build){ let { replacebyfee, p2shHash } = build let psbt = null @@ -102,9 +110,11 @@ function* gatherUtxos(build){ function* fetchTrackerUtxos(build){ let { pubkey } = build + // Resolved outside the try so an unresolvable caller identity stays a -32602, not a tracker fault. + const callerAddress = resolveCallerAddress(pubkey, this.network) let fetched try { - fetched = (yield this.utxoTrackerConnector.getUtxosFromAddress(resolveCallerAddress(pubkey, this.network))) + fetched = (yield this.utxoTrackerConnector.getUtxosFromAddress(callerAddress)) } catch (err) { if (isRemoteProfile(this.trackerProfile)){ throw err.operational ? err : trackerUnreachableError(err) @@ -190,7 +200,7 @@ function refuseShrunkExactInputs(build){ // hand back a child that descends from nothing and still does not mine. if (exactInputs){ if (!unconfirmed){ - const mempoolInput = utxos.find((u) => u.confirmations == 0) + const mempoolInput = utxos.find(isUnconfirmedUtxo) if (mempoolInput){ throw new ParamTypeError( `options.exactInputs names unconfirmed utxo ${mempoolInput.txid}:${mempoolInput.vout}, ` + @@ -234,7 +244,7 @@ function filterAndOrderUtxos(build){ for (let utxoReadIndex = 0; utxoReadIndex < utxos.length; utxoReadIndex++){ const nextUtxo = utxos[utxoReadIndex] // if the tx is in the mempool, drop it if unconfirmed is false - if (!unconfirmed && (nextUtxo.confirmations == 0)) continue + if (!unconfirmed && isUnconfirmedUtxo(nextUtxo)) continue // Loose-equality txid/vout matching became string-key matching: both sides // are concatenated through the same String() coercion, so a numeric 0 and a // string '0' vout still collide exactly as `==` made them collide. @@ -261,8 +271,20 @@ function filterAndOrderUtxos(build){ // lands at ins[0] - and on the OP_RETURN/MULTISIGN path that outpoint is the // obfuscation key, which coin-control callers pick deliberately. if (!exactInputs){ - utxos.sort((a,b)=> a.value < b.value ? 1 : a.value > b.value ? -1 : 0) + utxos.sort(compareCandidates) } } -module.exports = { initInputState, checkExactInputs, gatherUtxos, refuseShrunkExactInputs, filterAndOrderUtxos } +// Value descending, then txid and vout ascending. The tie-break makes the input set +// and its order a function of the UTXO set alone, so the same set listed in another +// order (a tracker page, a wallet cache) builds the same transaction. +function compareCandidates(a, b){ + if (a.value < b.value) return 1 + if (a.value > b.value) return -1 + const txidA = String(a.txid).toLowerCase() + const txidB = String(b.txid).toLowerCase() + if (txidA !== txidB) return txidA < txidB ? -1 : 1 + return Number(a.vout) - Number(b.vout) +} + +module.exports = { initInputState, checkExactInputs, gatherUtxos, refuseShrunkExactInputs, filterAndOrderUtxos, isUnconfirmedUtxo } diff --git a/src/XChainEncoder/build_transaction/input_selection.js b/src/XChainEncoder/build_transaction/input_selection.js index 2dc2418..5ac80ad 100644 --- a/src/XChainEncoder/build_transaction/input_selection.js +++ b/src/XChainEncoder/build_transaction/input_selection.js @@ -25,12 +25,22 @@ const { OperationalError, ParamTypeError, ParamRangeError } = require('../../bui const { SATOSHI_UNIT, RESERVATION_TTL_MS, Encoding } = require('../constants.js') const { jsonSafeSat } = require('../script_amount_helpers.js') const { feeForVsize } = require('../fee_policy.js') +const { isUnconfirmedUtxo } = require('./input_candidates.js') +const { nestedP2wpkhRedeem } = require('./nested_segwit.js') +const { clearsStrippedFloor } = require('./stripped_floor.js') function initSelection(build){ - let { estimatedTxSize, fee } = build + let { estimatedTxSize, fee, psbt, softDustSurcharge } = build estimatedTxSize = estimatedTxSize + 43 // change output (worst case: taproot) + // The output-count varint is 1 byte up to 252 outputs and 3 bytes past that; the + // 10-byte header counted in initEmissionState assumed 1. A MULTISIGN payload plus + // the customOutputs cap can pass 252 outputs. + if (psbt && psbt.txOutputs.length + 1 > 252) estimatedTxSize = estimatedTxSize + 2 let estimatedFee = 0 + // The caller's explicit fee as asked, before the dust floor can raise it; errors + // name both when they differ. + let requestedFee = null if (fee != null && fee !== false) { // Re-parse with the same exact-integer arbiter the validator uses // (toExactInt-based), not parseInt: parseInt silently truncates a @@ -38,21 +48,45 @@ function initSelection(build){ // bypass validateFee. parseSatoshiAmount throws RangeError // ("fee must be a non-negative integer") on any non-exact or negative // value, preserving the previous error contract. - estimatedFee = parseSatoshiAmount(fee, 'fee') + requestedFee = parseSatoshiAmount(fee, 'fee') + // An explicit fee pays for size; the soft-dust relay surcharge rides on top. + estimatedFee = requestedFee + (softDustSurcharge || 0) } let selectedInputCount = 0 // Txids of the SELECTED inputs that are still in the mempool. Their - // ancestor package is what CPFP sizing below has to pay for; an entry - // with no confirmations field is treated as confirmed, so a UTXO source - // that omits it degrades to per-transaction sizing rather than to a - // guess about someone else's fee. + // ancestor package is what CPFP sizing below has to pay for. An entry with no + // confirmations field counts as unconfirmed (isUnconfirmedUtxo), so it is sized as + // a mempool parent; a confirmed one reports an empty ancestor package and asks for nothing. const unconfirmedInputTxids = [] // Candidates the loop skipped because another build holds them. Reported // when nothing could be selected, so the caller learns the inputs exist // and are spoken for rather than that the address is empty. let reservedCandidates = 0 - Object.assign(build, { estimatedTxSize, estimatedFee, selectedInputCount, unconfirmedInputTxids, reservedCandidates }) + // Whether the segwit marker and flag have been counted; see framingBytesForInput. + const segwitMarkerCounted = false + Object.assign(build, { estimatedTxSize, estimatedFee, requestedFee, selectedInputCount, unconfirmedInputTxids, + reservedCandidates, segwitMarkerCounted }) +} + +// The fee selection has to cover: the fee the build will finally charge, which the +// post-selection floor (floorEstimatedFee) never lets fall under the dust threshold. +// Stopping at the unfloored fee left a fundable spend one input short. +function selectionTargetFee(estimatedFee){ + return Math.max(estimatedFee, this.dustAmount) +} + +// Transaction framing an input adds beyond its own estimated size: the segwit marker +// and flag (2 weight units, rounded up to 1 vB) with the first witness input, and two +// more bytes of input-count varint when the 253rd input is added. +function framingBytesForInput(build, utxo, selectedInputCount, nested){ + let bytes = 0 + if (!build.segwitMarkerCounted && (nested || this.isSegwitUTXO(utxo))){ + build.segwitMarkerCounted = true + bytes = bytes + 1 + } + if (selectedInputCount === 253) bytes = bytes + 2 + return bytes } function* selectFundingInputs(build){ @@ -69,7 +103,7 @@ function* selectFundingInputs(build){ function* selectInputs(build){ let { utxos, preparedData, exactInputs, firstReservedOutpoint, callReservations, psbt, utxoSequence, attachPrevTx, fee, feePerBytes, outputSatoshis, estimatedTxSize, estimatedFee, inputSatoshis, - selectedInputCount, unconfirmedInputTxids, reservedCandidates } = build + selectedInputCount, unconfirmedInputTxids, reservedCandidates, softDustSurcharge } = build const now = Date.now() this.evictExpiredReservations(now) let nextUtxoIndex = 0 @@ -87,18 +121,21 @@ function* selectInputs(build){ nextUtxo.value = parseSatoshiAmount(nextUtxo.value, `utxos[${nextUtxoIndex}].value`, { allowBig: true }) - const added = yield* addSelectedInput.call(this, psbt, nextUtxo, utxoSequence, attachPrevTx, estimatedTxSize, inputSatoshis, build.fetchedFromTracker) + const nestedRedeem = nestedP2wpkhRedeem.call(this, build, nextUtxo) + const added = yield* addSelectedInput.call(this, psbt, nextUtxo, utxoSequence, attachPrevTx, estimatedTxSize, inputSatoshis, build.fetchedFromTracker, nestedRedeem) estimatedTxSize = added.estimatedTxSize inputSatoshis = added.inputSatoshis selectedInputCount = selectedInputCount + 1 + estimatedTxSize = estimatedTxSize + framingBytesForInput.call(this, build, nextUtxo, selectedInputCount, !!nestedRedeem) - if (nextUtxo.confirmations == 0){ + if (isUnconfirmedUtxo(nextUtxo)){ unconfirmedInputTxids.push(nextUtxo.txid) } + // A rate-priced fee also carries the soft-dust relay surcharge (0 off Dogecoin). if (fee == null || fee === false) { - estimatedFee = feeForVsize(estimatedTxSize, feePerBytes, SATOSHI_UNIT) + estimatedFee = feeForVsize(estimatedTxSize, feePerBytes, SATOSHI_UNIT) + (softDustSurcharge || 0) } // Exact-input mode never stops early: the caller named this set @@ -107,7 +144,8 @@ function* selectInputs(build){ // at sufficiency is exactly the behaviour that made a stuck batch // unrescuable through this API. Any surplus goes to change, which // the shortfall/change math below already handles. - if (!exactInputs && inputSatoshis > outputSatoshis + BigInt(estimatedFee)){ + const targetFee = selectionTargetFee.call(this, estimatedFee) + if (!exactInputs && inputSatoshis > outputSatoshis + BigInt(targetFee) && clearsStrippedFloor.call(this, build, inputSatoshis, targetFee)){ break } @@ -133,7 +171,7 @@ function refuseCappedSelection(build, nextUtxoIndex, inputSatoshis, estimatedFee function remainingCandidatesCanFund(build, nextUtxoIndex, inputSatoshis, estimatedFee, now){ const { utxos, exactInputs, firstReservedOutpoint, outputSatoshis } = build - const required = outputSatoshis + BigInt(estimatedFee) + const required = outputSatoshis + BigInt(selectionTargetFee.call(this, estimatedFee)) let available = inputSatoshis for (let index = nextUtxoIndex; index < utxos.length; index++){ const candidate = utxos[index] @@ -231,8 +269,9 @@ function assertPrevOutValueMatches(prevTxHex, utxo, fetchedFromTracker){ throw new ParamRangeError(problem) } -function* addSelectedInput(psbt, nextUtxo, utxoSequence, attachPrevTx, estimatedTxSize, inputSatoshis, fetchedFromTracker){ - if (this.isSegwitUTXO(nextUtxo)){ +// nestedRedeem is the P2WPKH redeem script of a P2SH-P2WPKH input, signed as segwit. +function* addSelectedInput(psbt, nextUtxo, utxoSequence, attachPrevTx, estimatedTxSize, inputSatoshis, fetchedFromTracker, nestedRedeem){ + if (nestedRedeem || this.isSegwitUTXO(nextUtxo)){ let nextInput = { hash: nextUtxo.txid, index: nextUtxo.vout, @@ -242,6 +281,7 @@ function* addSelectedInput(psbt, nextUtxo, utxoSequence, attachPrevTx, estimated value: nextUtxo.value, } } + if (nestedRedeem) nextInput.redeemScript = nestedRedeem // A hardware signer needs the FULL previous // transaction even for a segwit input, because Ledger takes // the outpoint it signs from those bytes rather than from @@ -280,7 +320,9 @@ function* addSelectedInput(psbt, nextUtxo, utxoSequence, attachPrevTx, estimated } function refuseEmptySelection(build){ - let { selectedInputCount, reservedCandidates, outputSatoshis, estimatedFee } = build + let { selectedInputCount, reservedCandidates, outputSatoshis } = build + // Report the fee the build would charge, floor included. + const estimatedFee = selectionTargetFee.call(this, build.estimatedFee) // The caller-facing MAX_UTXO_COUNT cap is intentionally NOT // applied to the fetched set before selection (a rich address must // stay spendable). Bound the SELECTED input count instead: a tx that diff --git a/src/XChainEncoder/build_transaction/nested_segwit.js b/src/XChainEncoder/build_transaction/nested_segwit.js new file mode 100644 index 0000000..db876a2 --- /dev/null +++ b/src/XChainEncoder/build_transaction/nested_segwit.js @@ -0,0 +1,55 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * + * XChain Encoder - Encoder Class + * + * This file recognises P2SH-wrapped P2WPKH (nested segwit) funding inputs + * + ********************************************************************/ + +const bitcoin = require('bitcoinjs-lib'); + +// Only a compressed key has a standard P2WPKH script. +const COMPRESSED_PUBKEY_RE = /^0[23][0-9a-fA-F]{64}$/ + +// The P2WPKH redeem script of the caller's key, or null when the key has none. +function p2wpkhRedeemFor(keyHex, network){ + try { + return bitcoin.payments.p2wpkh({ pubkey: Buffer.from(keyHex, 'hex'), network }).output + } catch (e) { + return null + } +} + +// The redeem script of a P2SH-P2WPKH funding UTXO, or null for any other script. +// A P2SH scriptPubKey is only a hash, so the redeem script comes from the public key +// the request carries (pubkey or compressedPubKey) and counts only when it hashes to +// the UTXO's script. A P2SH no supplied key matches, or one built with no key at all +// (a bare P2SH address does not say whether it is nested segwit or, say, multisig), +// stays a legacy input with its whole previous transaction. +function nestedP2wpkhRedeem(build, utxo){ + // Verify the chain has segwit: elsewhere a P2SH output is only ever legacy + if (this.network.supportsSegwit === false) return null + const script = Buffer.from(utxo.scriptPubKey, 'hex') + // Verify the output is P2SH (OP_HASH160 <20> OP_EQUAL) + if (script.length !== 23 || script[0] !== 0xa9 || script[1] !== 0x14 || script[22] !== 0x87) return null + const keys = [build.pubkey, build.compressedPubKey].filter(k => typeof k === 'string' && COMPRESSED_PUBKEY_RE.test(k)) + for (const key of keys){ + const redeem = p2wpkhRedeemFor(key, this.network) + if (redeem && bitcoin.payments.p2sh({ redeem: { output: redeem }, network: this.network }).output.equals(script)) return redeem + } + return null +} + +module.exports = { nestedP2wpkhRedeem } diff --git a/src/XChainEncoder/build_transaction/payload_checks.js b/src/XChainEncoder/build_transaction/payload_checks.js index 0618512..2476d37 100644 --- a/src/XChainEncoder/build_transaction/payload_checks.js +++ b/src/XChainEncoder/build_transaction/payload_checks.js @@ -19,7 +19,7 @@ ********************************************************************/ const bitcoin = require('bitcoinjs-lib'); -const { MAX_COMPILED_ACTION_DATA_LENGTH, ENVELOPE_MAX_PAYLOAD, validateDataParam, validateActionPushDecodability, unknownActionName } = require('../../common/validator') +const { MAX_COMPILED_ACTION_DATA_LENGTH, ENVELOPE_MAX_PAYLOAD, validateDataParam, validateActionPushDecodability, unknownActionName, parseSatoshiAmount, assertPaymentOverDust } = require('../../common/validator') const { compressPayloadForAction } = require('../../build/compression') const { Encoding } = require('../constants.js') const { ensureEccLib } = require('../script_amount_helpers.js') @@ -29,7 +29,7 @@ const { ParamTypeError, ParamRangeError } = require('../../build/errors') // The caller's payload as handed over: wire-safety re-checks, the ACTION-name // read, and the fee-quote output injected ahead of every other output. function checkPayloadInput(build){ - let { data, rawData, feeQuote, customOutputs } = build + let { data, rawData } = build // Re-check what the wire encodings can actually carry, for direct library // callers. api.js runs validateAll before createTransaction, but // createTransaction is a supported library entry point of its own and @@ -75,13 +75,38 @@ function checkPayloadInput(build){ const unknownAction = unknownActionName(data) checkOutputAddresses.call(this, build) + checkPaymentDust.call(this, build) + injectFeeQuoteOutput(build) + Object.assign(build, { unknownAction }) +} - // If feeQuote is provided, inject it as a custom output +// If feeQuote is provided, inject it as a custom output. It is remembered so the +// emission step can tell the protocol-fee output from the caller's own payments. +function injectFeeQuoteOutput(build){ + let { feeQuote, customOutputs } = build + let feeQuoteOutput = null if(feeQuote && feeQuote.address && feeQuote.amount > 0){ - if(!customOutputs) customOutputs = []; - customOutputs.push({ address: feeQuote.address, value: feeQuote.amount }); + // Push onto a copy: the array is the caller's, and an in-process caller that + // reuses it for a second build would otherwise carry the first build's fee output. + customOutputs = Array.isArray(customOutputs) ? customOutputs.slice() : []; + feeQuoteOutput = { address: feeQuote.address, value: feeQuote.amount } + customOutputs.push(feeQuoteOutput); } - Object.assign(build, { unknownAction, customOutputs }) + Object.assign(build, { customOutputs, feeQuoteOutput }) +} + +// Refuse a caller payment under the coin's relay dust threshold in the first build +// step. On a P2SH/P2WSH funding call the payments are only priced into the first leg +// and emitted on the reveal, so a check at emission alone would let the commit confirm +// with a reveal that can never relay. Runs before the feeQuote output is injected. +function checkPaymentDust(build){ + const { customOutputs } = build + if (!Array.isArray(customOutputs)) return + customOutputs.forEach((output, i) => { + const value = parseSatoshiAmount(output && output.value, `customOutputs[${i}].value`, { allowBig: true }) + if (value <= 0) throw new ParamRangeError(`customOutputs[${i}].value must be a positive integer (satoshis)`) + assertPaymentOverDust(value, i, this.paymentOutputFloor) + }) } // Verify every caller output address decodes on this network, inside the first diff --git a/src/XChainEncoder/build_transaction/reveal_outputs.js b/src/XChainEncoder/build_transaction/reveal_outputs.js index 04d0e3c..3b1b6c9 100644 --- a/src/XChainEncoder/build_transaction/reveal_outputs.js +++ b/src/XChainEncoder/build_transaction/reveal_outputs.js @@ -25,6 +25,7 @@ const { logger, TAPROOT_LEAF_VERSION, SATOSHI_UNIT, Encoding } = require('../con const { envelopeTapLeafHash, asSatValue } = require('../script_amount_helpers.js') const { packageFeeUpliftSatoshis, maxCpfpUpliftSat, feeForVsize } = require('../fee_policy.js') const { resolveCallerAddress } = require('../request_resolution.js') +const { revealNeedsFloorPad } = require('./script_hash_legs.js') // The change output, then the P2WSH reveal's stripped-size floor pad. function emitChangeAndPad(build){ @@ -61,8 +62,9 @@ function emitChangeAndPad(build){ // (see the P2WSH funding branch) so the output's value is available. let strippedFloor = this.network.minStandardTxNonWitnessSize if (p2shHash && preparedData["encoding"] === Encoding.P2WSH && strippedFloor){ - let padAddress = change || resolveCallerAddress(pubkey, this.network) - if (padAddress && this.strippedTxSize(psbt) < strippedFloor){ + // Resolved only when the pad is added, so an unused caller identity is never refused here. + let padAddress = this.strippedTxSize(psbt) < strippedFloor ? (change || resolveCallerAddress(pubkey, this.network)) : null + if (padAddress){ psbt.addOutput({ address: padAddress, value: this.dustAmount @@ -73,7 +75,7 @@ function emitChangeAndPad(build){ function* sweepP2shReveal(build){ let { p2shHash, preparedData, phaseLegInputSatoshis, customOutputs, feePerBytes, estimatedFee, outputSatoshis, - change, pubkey, psbt } = build + change, pubkey, psbt, softDustSurcharge } = build // Sweep the P2SH reveal's leg surplus back to the // caller. The funding tx (P2SH funding branch above) tops the first leg // up by the reveal's own fee plus one dust precisely so this output can @@ -87,25 +89,32 @@ function* sweepP2shReveal(build){ // everything above it returns to `change` or the caller's own address. // Legs funded before this fix carry no headroom, leave no surplus at or // above dust, and produce a byte-identical (still outputless) reveal. - if (p2shHash && preparedData["encoding"] === Encoding.P2SH && phaseLegInputSatoshis > 0n){ + // A multi-chunk P2WSH reveal is swept the same way: its first leg carries the same + // headroom. A single-chunk one was funded a floor pad instead and is not swept. + if (p2shHash && sweepsRevealSurplus.call(this, build) && phaseLegInputSatoshis > 0n){ let revealEmittedOutputBytes = 43 // change output (worst case), matching the reserve above if (customOutputs && Array.isArray(customOutputs)){ for (let i = 0; i < customOutputs.length; i++){ revealEmittedOutputBytes = revealEmittedOutputBytes + TxSizeEstimator.estimateOutputSizeForAddress(customOutputs[i].address, this.network) } } - const revealSizeForFee = this.estimateP2shRevealTx(preparedData["dataBufferArray"], revealEmittedOutputBytes) + const revealSizeForFee = preparedData["encoding"] === Encoding.P2SH + ? this.estimateP2shRevealTx(preparedData["dataBufferArray"], revealEmittedOutputBytes) + : this.estimateP2wshRevealTx(preparedData["dataBufferArray"], revealEmittedOutputBytes) let revealFeeKept = feeForVsize(revealSizeForFee, feePerBytes, SATOSHI_UNIT) if (revealFeeKept < estimatedFee){ revealFeeKept = estimatedFee } + // The soft-dust relay surcharge for the reveal's own sub-limit payments is fee too. + revealFeeKept = revealFeeKept + (softDustSurcharge || 0) revealFeeKept = (yield* upliftRevealForCommit.call(this, build, revealSizeForFee, revealFeeKept)) let revealSurplus = phaseLegInputSatoshis - outputSatoshis - BigInt(revealFeeKept) - let sweepAddress = change || resolveCallerAddress(pubkey, this.network) - // The sweep is an authored output: below the relay floor the surplus stays as fee. - if (sweepAddress && revealSurplus >= BigInt(this.outputFloor)){ + // The sweep is an authored output: below the relay floor the surplus stays as fee, + // and the caller identity is resolved only when the sweep is added. + let sweepAddress = revealSurplus >= BigInt(this.outputFloor) ? (change || resolveCallerAddress(pubkey, this.network)) : null + if (sweepAddress){ psbt.addOutput({ address: sweepAddress, value: asSatValue(revealSurplus) @@ -114,6 +123,14 @@ function* sweepP2shReveal(build){ } } +// Which chunk reveals end with a surplus sweep back to the caller: those whose first +// funding leg was topped up with reveal headroom (see topUpFirstLeg). +function sweepsRevealSurplus(build){ + const { preparedData } = build + const encoding = preparedData["encoding"] + return encoding === Encoding.P2SH || (encoding === Encoding.P2WSH && !revealNeedsFloorPad.call(this, preparedData)) +} + function* upliftRevealForCommit(build, revealSizeForFee, revealFeeKept){ let { feePerBytes, p2shHash } = build // Keep the package prefund as FEE instead of sweeping it home. @@ -126,11 +143,12 @@ function* upliftRevealForCommit(build, revealSizeForFee, revealFeeKept){ // leaving the package exactly as under-target as it was. // // The commit's fee is read from the node's mempool rather than - // re-derived: the commit is a broadcast transaction by now, its ACTUAL - // size and fee are what the miner sees, and this also picks up any - // unconfirmed ancestors it inherited. An already-confirmed commit - // reports an empty package and asks for nothing, which is correct - a - // confirmed parent is no longer part of anyone's package. Any failure + // re-derived: a broadcast commit's ACTUAL size and fee are what the + // miner sees, and this also picks up any unconfirmed ancestors it + // inherited. A commit the node does not hold reports an empty package + // and asks for nothing: one already confirmed is in no package, and one + // not yet broadcast (the SDK builds its reveal before broadcasting the + // commit, so it can refuse first) gets no uplift. Any failure // (no method, unreachable node, RPC error) leaves the fee exactly as // it was; the reveal is then priced per-transaction, as before. const revealUpliftBound = maxCpfpUpliftSat() diff --git a/src/XChainEncoder/build_transaction/script_hash_legs.js b/src/XChainEncoder/build_transaction/script_hash_legs.js index d077e5c..c046e63 100644 --- a/src/XChainEncoder/build_transaction/script_hash_legs.js +++ b/src/XChainEncoder/build_transaction/script_hash_legs.js @@ -20,7 +20,7 @@ const bitcoin = require('bitcoinjs-lib'); const TxSizeEstimator = require('../../build/tx_size_estimator') -const { MAGIC_WORD, SATOSHI_UNIT } = require('../constants.js') +const { MAGIC_WORD, SATOSHI_UNIT, Encoding } = require('../constants.js') const { asSatValue } = require('../script_amount_helpers.js') const { assertRevealFundingTxMatches } = require('../request_resolution.js') const { feeForVsize } = require('../fee_policy.js') @@ -69,6 +69,7 @@ function* spendP2shLeg(build, nextDataBuffer){ if (!p2shTx || !p2shTx.outs || voutPsbtIndex >= p2shTx.outs.length) { throw new ParamRangeError(`p2shHex transaction does not have output at index ${voutPsbtIndex}`) } + assertLegFundsScript.call(this, p2shTx, voutPsbtIndex, Encoding.P2SH, nextDataBuffer) let nextInput = { sequence: utxoSequence, hash:p2shHash, @@ -121,7 +122,6 @@ function fundP2shLeg(build, nextDataBuffer){ } function topUpFirstP2shLeg(build, spendingP2shEstimatedFee){ - let { p2shRevealHeadroomFunded, revealCustomOutputsBytes, preparedData, feePerBytes, finalDust, psbt, revealPrefund } = build // The reveal's ONLY inputs are these // funding legs (the reveal path runs no input selection), // so every satoshi the reveal spends - its miner fee AND @@ -155,40 +155,64 @@ function topUpFirstP2shLeg(build, spendingP2shEstimatedFee){ // output emittable is conditional. With customOutputs the // surplus then lands at zero, below the sweep's dust // threshold, and the reveal keeps exactly its two outputs. + return topUpFirstLeg.call(this, build, spendingP2shEstimatedFee, Encoding.P2SH) +} + +// The one-time first-leg top-up both chunk carriers share; the rationale is above. +function topUpFirstLeg(build, spendingEstimatedFee, encoding){ + let { p2shRevealHeadroomFunded, revealCustomOutputsBytes, preparedData, feePerBytes, finalDust, psbt, revealPrefund } = build if (!p2shRevealHeadroomFunded){ p2shRevealHeadroomFunded = true let needsChangeOutput = (revealCustomOutputsBytes === 0) - let { baseLegsTotal, revealFeeNeeded } = sumP2shRevealLegs.call(this, preparedData, feePerBytes, finalDust) + let { baseLegsTotal, revealFeeNeeded, revealSize } = sumRevealLegs.call(this, preparedData, feePerBytes, finalDust, encoding) let revealShortfall = Math.max(0, revealFeeNeeded - baseLegsTotal) // The reveal's change is an authored output, so its headroom is the relay floor. let changeHeadroom = needsChangeOutput ? BigInt(this.outputFloor) : 0n - spendingP2shEstimatedFee = asSatValue(BigInt(spendingP2shEstimatedFee) + BigInt(revealShortfall) + changeHeadroom) + spendingEstimatedFee = asSatValue(BigInt(spendingEstimatedFee) + BigInt(revealShortfall) + changeHeadroom) // This leg is where the reveal's fee money lives, so it is // the one the package-prefund pass tops up. revealPrefund = { outputIndex: psbt.txOutputs.length, - revealSize: this.estimateP2shRevealTx(preparedData["dataBufferArray"], 43), + revealSize, revealFee: revealFeeNeeded } } Object.assign(build, { p2shRevealHeadroomFunded, revealPrefund }) - return spendingP2shEstimatedFee + return spendingEstimatedFee } // What the legs fund at their own per-chunk sizing, and what the whole reveal needs. -function sumP2shRevealLegs(preparedData, feePerBytes, finalDust){ +// A P2WSH leg reaching here has no floor pad, so its per-chunk size carries none. +function sumRevealLegs(preparedData, feePerBytes, finalDust, encoding){ + const isP2sh = encoding === Encoding.P2SH let baseLegsTotal = 0 for (const chunkBuffer of preparedData["dataBufferArray"]){ - let chunkLegFee = feeForVsize(this.estimateSpendingP2shTx(chunkBuffer), feePerBytes, SATOSHI_UNIT) + const chunkSize = isP2sh ? this.estimateSpendingP2shTx(chunkBuffer) : this.estimateSpendingP2wshTx(chunkBuffer, 0) + let chunkLegFee = feeForVsize(chunkSize, feePerBytes, SATOSHI_UNIT) baseLegsTotal = baseLegsTotal + Math.max(chunkLegFee, finalDust) } - let revealFeeNeeded = feeForVsize(this.estimateP2shRevealTx(preparedData["dataBufferArray"], 43), feePerBytes, SATOSHI_UNIT) + const revealSize = isP2sh + ? this.estimateP2shRevealTx(preparedData["dataBufferArray"], 43) + : this.estimateP2wshRevealTx(preparedData["dataBufferArray"], 43) + let revealFeeNeeded = feeForVsize(revealSize, feePerBytes, SATOSHI_UNIT) if (revealFeeNeeded < this.dustAmount){ // Mirrors the reveal path's own dust floor on // estimatedFee, so both phases price the same fee. revealFeeNeeded = this.dustAmount } - return { baseLegsTotal, revealFeeNeeded } + return { baseLegsTotal, revealFeeNeeded, revealSize } +} + +// The reveal rebuilds each chunk script from its own payload, so a payload that differs +// from the funded one yields a script that does not hash to the output it spends and a +// reveal no node will accept. Refuse it here rather than hand back an unspendable PSBT. +function assertLegFundsScript(p2shTx, vout, encoding, script){ + const payment = encoding === Encoding.P2SH ? bitcoin.payments.p2sh : bitcoin.payments.p2wsh + const expected = payment({ redeem: { output: script }, network: this.network }).output + if (!p2shTx.outs[vout].script.equals(expected)){ + throw new ParamRangeError(`${encoding} reveal chunk ${vout} does not match the funded output at index ${vout}: ` + + 'the reveal payload differs from the one the funding transaction committed to') + } } // A P2WSH chunk: a funding output on the commit, or an input spending it on the reveal. @@ -209,7 +233,7 @@ function* emitP2wshChunk(build, nextDataBuffer){ } function* spendP2wshLeg(build, nextDataBuffer){ - let { psbt, txidFirstInput, p2shTx, voutPsbtIndex, utxoSequence, p2shHash, estimatedTxSize } = build + let { psbt, txidFirstInput, p2shTx, voutPsbtIndex, utxoSequence, p2shHash, estimatedTxSize, phaseLegInputSatoshis } = build if (!psbt){ psbt = new bitcoin.Psbt({ network: this.network }) psbt.addOutput({ @@ -231,6 +255,7 @@ function* spendP2wshLeg(build, nextDataBuffer){ if (!p2shTx || !p2shTx.outs || voutPsbtIndex >= p2shTx.outs.length) { throw new ParamRangeError(`p2shHex transaction does not have output at index ${voutPsbtIndex}`) } + assertLegFundsScript.call(this, p2shTx, voutPsbtIndex, Encoding.P2WSH, nextDataBuffer) let nextInput = { sequence: utxoSequence, hash:p2shHash, @@ -244,12 +269,14 @@ function* spendP2wshLeg(build, nextDataBuffer){ psbt.addInput(nextInput) estimatedTxSize = estimatedTxSize + TxSizeEstimator.estimateInputSize(nextInput) + // Leg value this reveal input consumes, for the surplus sweep, as on the P2SH reveal. + phaseLegInputSatoshis = phaseLegInputSatoshis + BigInt(p2shTx.outs[voutPsbtIndex].value) voutPsbtIndex = voutPsbtIndex + 1 - Object.assign(build, { psbt, estimatedTxSize, voutPsbtIndex }) + Object.assign(build, { psbt, estimatedTxSize, phaseLegInputSatoshis, voutPsbtIndex }) } function fundP2wshLeg(build, nextDataBuffer){ - let { feePerBytes, finalDust, preparedData, revealCustomOutputsValue, revealCustomOutputsFee, psbt, outputSatoshis, estimatedTxSize } = build + let { feePerBytes, finalDust, preparedData, revealCustomOutputsValue, revealCustomOutputsFee, psbt, outputSatoshis, estimatedTxSize, change } = build // Size this P2WSH data output to fund its share of the // reveal (spending) transaction's fee, mirroring the // P2SH branch above. A flat per-output dust value leaves @@ -259,7 +286,16 @@ function fundP2wshLeg(build, nextDataBuffer){ // floor), so the broadcast is rejected. The estimate uses // witness-discounted sizing because P2WSH // reveal data lives in the (÷4-weighted) witness. - let spendingP2wshEstimatedSize = this.estimateSpendingP2wshTx(nextDataBuffer) + // A reveal under the stripped-size floor carries a pad output (see + // fundRevealFloorPad), and its bytes are part of the reveal's fee. The pad goes + // to the change address, or to the caller's own address when there is none; + // that is resolved only on the reveal, so price it at the largest standard + // output here rather than resolve the caller now. A reveal over the floor has + // no pad and keeps the plain floored estimate. + const padOutputBytes = revealNeedsFloorPad.call(this, preparedData) + ? TxSizeEstimator.estimateOutputSizeForAddress(change || null, this.network) + : 0 + let spendingP2wshEstimatedSize = this.estimateSpendingP2wshTx(nextDataBuffer, padOutputBytes) let spendingP2wshEstimatedFee = feeForVsize(spendingP2wshEstimatedSize, feePerBytes, SATOSHI_UNIT) if (spendingP2wshEstimatedFee < finalDust){ @@ -278,6 +314,9 @@ function fundP2wshLeg(build, nextDataBuffer){ revealCustomOutputsFee = 0 } + // A reveal with no floor pad leaves no value output of its own: fund it as on P2SH. + if (!padOutputBytes) spendingP2wshEstimatedFee = topUpFirstLeg.call(this, build, spendingP2wshEstimatedFee, Encoding.P2WSH) + psbt.addOutput({ address: bitcoin.payments.p2wsh({ redeem: {output:nextDataBuffer}, network:this.network}).address, value:spendingP2wshEstimatedFee @@ -308,10 +347,7 @@ function fundRevealFloorPad(preparedData, spendingP2wshEstimatedFee){ // and addRevealSizeFloorPadding below), so fund that // output's value here (one dust); otherwise the reveal // has no satoshis left to create it after the fee floor. - let strippedFloor = this.network.minStandardTxNonWitnessSize - let chunkCount = preparedData["dataBufferArray"].length - let predictedRevealStripped = 30 + (41 * chunkCount) - if (strippedFloor && predictedRevealStripped < strippedFloor){ + if (revealNeedsFloorPad.call(this, preparedData)){ // Fund the reveal's stripped-size floor-padding output with // this.dustAmount, the SAME constant the reveal side spends // (see addRevealSizeFloorPadding below). Using finalDust here @@ -322,4 +358,12 @@ function fundRevealFloorPad(preparedData, spendingP2wshEstimatedFee){ return spendingP2wshEstimatedFee } -module.exports = { emitP2shChunk, emitP2wshChunk } +// Whether the P2WSH reveal's stripped size (10 header + 41 per chunk input + ~20 +// marker output) lands under the chain's relay floor, so the reveal adds a pad output. +function revealNeedsFloorPad(preparedData){ + const strippedFloor = this.network.minStandardTxNonWitnessSize + const predictedRevealStripped = 30 + (41 * preparedData["dataBufferArray"].length) + return !!(strippedFloor && predictedRevealStripped < strippedFloor) +} + +module.exports = { emitP2shChunk, emitP2wshChunk, revealNeedsFloorPad } diff --git a/src/XChainEncoder/build_transaction/stripped_floor.js b/src/XChainEncoder/build_transaction/stripped_floor.js new file mode 100644 index 0000000..87897ea --- /dev/null +++ b/src/XChainEncoder/build_transaction/stripped_floor.js @@ -0,0 +1,79 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * + * XChain Encoder - Encoder Class + * + * This file holds single-transaction builds to the chain's stripped-size relay floor + * + ********************************************************************/ + +const TxSizeEstimator = require('../../build/tx_size_estimator') +const { OperationalError } = require('../../build/errors') +const coins = require('../../coins') + +// The relay floor on a transaction's stripped size that the coin's node software +// enforces, MIN_STANDARD_TX_NONWITNESS_SIZE in src/policy/policy.h: Bitcoin Core 65 +// (v25.0 on), Litecoin Core 82. Dogecoin Core has none. These are not the coin +// registry's minStandardTxNonWitnessSize, which pins the reveal pad's target. +const CORE_MIN_STANDARD_TX_NONWITNESS_SIZE = { BTC: 65, LTC: 82 } + +// The node floor for this encoder's coin, or 0 where the node has none. +function coreStrippedFloor(networkKey){ + const key = String(networkKey || '') + const tick = coins.FULL_NAME_TO_TICK[key.slice(0, Math.max(0, key.lastIndexOf('-')))] + return CORE_MIN_STANDARD_TX_NONWITNESS_SIZE[tick] || 0 +} + +// Fewest scriptSig bytes an input carries once signed: none for native segwit, the +// redeem-script push for P2SH-P2WPKH, and a P2PKH signature and key for a legacy input. +function scriptSigLowerBound(input){ + if (input.witnessUtxo) return input.redeemScript ? 1 + input.redeemScript.length : 0 + return 106 +} + +// The stripped size the signed transaction will have, plus changeOutputBytes. +function predictedStrippedSize(build, changeOutputBytes){ + let size = this.strippedTxSize(build.psbt) + changeOutputBytes + for (const input of build.psbt.data.inputs) size = size + scriptSigLowerBound(input) + return size +} + +// Whether a single-transaction build, as selected so far, clears the node's stripped +// floor. Under it selection keeps going, since one more input or a funded change +// output lifts it over; the reveal pads itself instead. +function clearsStrippedFloor(build, inputSatoshis, selectionFee){ + const floor = coreStrippedFloor(this.networkKey) + if (!floor || build.p2shHash || !build.change) return true + const changeSatoshis = inputSatoshis - build.outputSatoshis - BigInt(selectionFee) + const changeBytes = changeSatoshis >= BigInt(this.outputFloor) + ? TxSizeEstimator.estimateOutputSizeForAddress(build.change, this.network) + : 0 + return predictedStrippedSize.call(this, build, changeBytes) >= floor +} + +// Refuse a finished single-transaction build that is still under the floor: a node +// will not relay it, and no input was left to fund the change output that lifts it. +function refuseUnderStrippedFloor(build){ + const floor = coreStrippedFloor(this.networkKey) + if (!floor || build.p2shHash) return + const strippedSize = predictedStrippedSize.call(this, build, 0) + if (strippedSize >= floor) return + throw new OperationalError('TX_BELOW_MIN_STANDARD_SIZE', + `the transaction would be ${strippedSize} non-witness bytes, under this chain's ${floor}-byte relay ` + + `minimum, and its inputs leave too little for a change output to lift it: leave at least ` + + `${this.outputFloor} base units plus its fee for change, or add an input`, + { strippedSize, minimum: floor }) +} + +module.exports = { CORE_MIN_STANDARD_TX_NONWITNESS_SIZE, clearsStrippedFloor, refuseUnderStrippedFloor } diff --git a/src/XChainEncoder/constants.js b/src/XChainEncoder/constants.js index e824061..3e64c5b 100644 --- a/src/XChainEncoder/constants.js +++ b/src/XChainEncoder/constants.js @@ -58,6 +58,16 @@ const SOFT_DUST_FLOOR_BY_COIN = { DOGE: 1000000 } +// Dust relay rate per coin in base units per byte, the node's DUST_RELAY_TX_FEE over 1000: +// an output is relay dust when spending it costs more than it holds at this rate. A coin +// not listed takes Bitcoin Core's 3000 sat/kB (src/policy/policy.h). Litecoin Core sets +// DUST_RELAY_TX_FEE = 30'000 in src/policy/policy.h, which is also where LTC's 5460 +// (182 bytes x 30) comes from. Relay policy only; nothing here is consensus-pinned. +const DEFAULT_DUST_RELAY_FEE_PER_BYTE = 3 +const DUST_RELAY_FEE_PER_BYTE_BY_COIN = { + LTC: 30 +} + // Ceiling on the SUGGESTED fee rate (base units per vByte) for a caller that // supplies none, applied on test chains only. estimatesmartfee needs a populated // fee market to mean anything; a quiet test chain returns a large fallback at @@ -118,4 +128,4 @@ const Encoding = { AUTO: "AUTO" } -module.exports = { logger, OP_RETURN_SIZE, P2SH_SIZE, PW2SH_SIZE, MULTISIGN_SIZE, MAGIC_WORD, TAPROOT_ENVELOPE_CHUNK_SIZE, TAPROOT_LEAF_VERSION, TAPROOT_ENVELOPE_FORMAT_V0, SATOSHI_UNIT, MAX_SAFE_SATOSHI_BIG, SOFT_DUST_FLOOR_BY_COIN, DEFAULT_SUGGESTED_FEE_MAX_PER_VBYTE, TEST_NETWORK_SUFFIXES, SUGGESTED_FEE_CEILING_RELAY_MULTIPLIER, DEFAULT_MAX_CPFP_UPLIFT_SAT, BURN_BACKSTOP_MULTIPLIER, RESERVATION_TTL_MS, Encoding } +module.exports = { logger, OP_RETURN_SIZE, P2SH_SIZE, PW2SH_SIZE, MULTISIGN_SIZE, MAGIC_WORD, TAPROOT_ENVELOPE_CHUNK_SIZE, TAPROOT_LEAF_VERSION, TAPROOT_ENVELOPE_FORMAT_V0, SATOSHI_UNIT, MAX_SAFE_SATOSHI_BIG, SOFT_DUST_FLOOR_BY_COIN, DEFAULT_DUST_RELAY_FEE_PER_BYTE, DUST_RELAY_FEE_PER_BYTE_BY_COIN, DEFAULT_SUGGESTED_FEE_MAX_PER_VBYTE, TEST_NETWORK_SUFFIXES, SUGGESTED_FEE_CEILING_RELAY_MULTIPLIER, DEFAULT_MAX_CPFP_UPLIFT_SAT, BURN_BACKSTOP_MULTIPLIER, RESERVATION_TTL_MS, Encoding } diff --git a/src/XChainEncoder/envelope_cancel.js b/src/XChainEncoder/envelope_cancel.js index 8b70760..6857d1d 100644 --- a/src/XChainEncoder/envelope_cancel.js +++ b/src/XChainEncoder/envelope_cancel.js @@ -133,7 +133,7 @@ function* cancelFeeRate(feeRatePerKb){ const info = yield this.connector.getNetworkInfo() const relayFeePerKb = Number(info && info.relayfee) // Refuse a node that reports no usable relay floor. A plain Error, not a - // RangeError: this is the node's fault, so the API answers retryable -32603. + // RangeError: the node is at fault, not the input, so the API answers -32603. if (!Number.isFinite(relayFeePerKb) || relayFeePerKb <= 0){ throw new Error('Node did not report a positive relayfee; transaction fee safety cannot be verified') } diff --git a/src/XChainEncoder/fee_policy.js b/src/XChainEncoder/fee_policy.js index 308bd04..e0e85c4 100644 --- a/src/XChainEncoder/fee_policy.js +++ b/src/XChainEncoder/fee_policy.js @@ -136,4 +136,17 @@ function feeForVsize(vsize, feePerBytes, satoshiUnit){ return Math.ceil(exact - Math.abs(exact) * 1e-12) } -module.exports = { isTestNetworkKey, suggestedFeeCeilingPerByte, suggestedFeeCeilingFloorPerByte, maxCpfpUpliftSat, packageFeeUpliftSatoshis, capCallerFeeRate, clampLimitLabel, feeForVsize } +// The relay surcharge the soft dust rule (SOFT_DUST_FLOOR_BY_COIN) puts on a transaction: +// the whole soft limit once for every value output under it. A zero-value data carrier +// is not a payment output and adds nothing; a coin with no soft limit adds nothing. +function softDustSurchargeFor(outputValues, softDustFloor){ + if (!(softDustFloor > 0)) return 0 + let surcharge = 0 + for (const value of outputValues){ + const v = BigInt(value) + if (v > 0n && v < BigInt(softDustFloor)) surcharge = surcharge + softDustFloor + } + return surcharge +} + +module.exports = { isTestNetworkKey, suggestedFeeCeilingPerByte, suggestedFeeCeilingFloorPerByte, maxCpfpUpliftSat, packageFeeUpliftSatoshis, capCallerFeeRate, clampLimitLabel, feeForVsize, softDustSurchargeFor } diff --git a/src/XChainEncoder/request_resolution.js b/src/XChainEncoder/request_resolution.js index 3b9ac91..17f3a1b 100644 --- a/src/XChainEncoder/request_resolution.js +++ b/src/XChainEncoder/request_resolution.js @@ -21,7 +21,7 @@ const bitcoin = require('bitcoinjs-lib'); const config = require('../common/config'); const { safeUpstreamReason } = require('../common/error_sanitize'); -const { validateAddress } = require('../common/validator'); +const { validateAddress, assertPubkeyOnCurve } = require('../common/validator'); const { ensureEccLib } = require('./script_amount_helpers.js'); const { ParamTypeError } = require('../build/errors'); @@ -139,7 +139,10 @@ function resolveCallerHash160(pubKey) { // addresses do not start with "0"), so ordering it first is safe and never // changes legacy behavior. if (typeof pubKey === 'string' && /^(0[23][0-9a-fA-F]{64}|04[0-9a-fA-F]{128})$/.test(pubKey)) { - return bitcoin.crypto.hash160(Buffer.from(pubKey, 'hex')) + // An off-curve key has no signer, so its HASH160 would lock every chunk output. + const pubkeyBuf = Buffer.from(pubKey, 'hex') + assertPubkeyOnCurve(pubkeyBuf, 'pubkey') + return bitcoin.crypto.hash160(pubkeyBuf) } // Base58 P2PKH/P2SH address: byte-identical to the original behavior. try { return bitcoin.address.fromBase58Check(pubKey).hash } catch (_) { /* not base58 */ } @@ -147,11 +150,12 @@ function resolveCallerHash160(pubKey) { try { const dec = bitcoin.address.fromBech32(pubKey) if (dec.version === 0 && dec.data.length === 20) return dec.data - throw new Error(`caller "${pubKey}" decodes to a v${dec.version} / ${dec.data.length}-byte witness program; the chunk-lane P2PKH gate needs a 20-byte HASH160 (v0 P2WPKH)`) + throw new ParamTypeError(`caller "${pubKey}" decodes to a v${dec.version} / ${dec.data.length}-byte witness program; the chunk-lane P2PKH gate needs a 20-byte HASH160 (v0 P2WPKH)`) } catch (e) { if (e && typeof e.message === 'string' && e.message.indexOf('witness program') !== -1) throw e } - throw new Error(`prepareData: cannot resolve a 20-byte caller HASH160 from identity "${pubKey}" (expected a base58 P2PKH/P2SH address, a compressed/uncompressed pubkey hex, or a v0 bech32 P2WPKH address)`) + // A caller-input refusal, so it is typed to map to -32602 rather than an internal error. + throw new ParamTypeError(`prepareData: cannot resolve a 20-byte caller HASH160 from identity "${pubKey}" (expected a base58 P2PKH/P2SH address, a compressed/uncompressed pubkey hex, or a v0 bech32 P2WPKH address)`) } // Sibling of resolveCallerHash160, same "any identity in" contract, but for @@ -171,19 +175,23 @@ function resolveCallerHash160(pubKey) { // default is the best a single guess can do. function resolveCallerAddress(pubKey, network) { if (typeof pubKey !== 'string' || pubKey.length === 0) return pubKey - // Already a valid address on this network: pass through unchanged. + // Already a valid address on this network: pass through unchanged. The ECC + // library is loaded first so a Taproot (bech32m) address decodes here too. + ensureEccLib() try { bitcoin.address.toOutputScript(pubKey, network); return pubKey } catch (_) { /* not a valid address here */ } // Raw compressed/uncompressed pubkey hex: derive the network's default address type. if (/^(0[23][0-9a-fA-F]{64}|04[0-9a-fA-F]{128})$/.test(pubKey)) { const pubkeyBuf = Buffer.from(pubKey, 'hex') + // bitcoinjs accepts any 02/03 x below p, so an off-curve key would yield an unspendable address. + assertPubkeyOnCurve(pubkeyBuf, 'pubkey') return (network.supportsSegwit === false ? bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network }) : bitcoin.payments.p2wpkh({ pubkey: pubkeyBuf, network }) ).address } - // Anything else: pass through unchanged and let the downstream call - // (tracker / bitcoinjs-lib) surface its own, more specific error. - return pubKey + // Anything else fails downstream either way (the tracker and bitcoinjs decode + // with this same network), so refuse it here as caller input, naming pubkey. + throw new ParamTypeError('pubkey is neither a valid address for this network nor a public key hex') } // Deployment default for transparent FILE compression. ON unless @@ -216,7 +224,7 @@ function assertRevealFundingTxMatches(p2shHash, fundingTxid){ // Refuse an address that does not decode on this network with a TypeError, so // the API answers -32602 before node, tracker or reservation work. bitcoinjs throws -// a plain Error late (a retryable -32603), or never on a P2SH/P2WSH funding call. +// a plain Error late (a generic -32603), or never on a P2SH/P2WSH funding call. function assertAddressOnNetwork(address, network, label){ validateAddress(address, label) // Decoding a bech32m (Taproot) address needs the ECC library, which this diff --git a/src/XChainEncoder/script_amount_helpers.js b/src/XChainEncoder/script_amount_helpers.js index e5d7fe7..8f0eb21 100644 --- a/src/XChainEncoder/script_amount_helpers.js +++ b/src/XChainEncoder/script_amount_helpers.js @@ -20,15 +20,26 @@ const bitcoin = require('bitcoinjs-lib'); const coins = require('../coins') -const { SOFT_DUST_FLOOR_BY_COIN, TAPROOT_LEAF_VERSION, MAX_SAFE_SATOSHI_BIG } = require('./constants.js') +const { SOFT_DUST_FLOOR_BY_COIN, DEFAULT_DUST_RELAY_FEE_PER_BYTE, DUST_RELAY_FEE_PER_BYTE_BY_COIN, TAPROOT_LEAF_VERSION, MAX_SAFE_SATOSHI_BIG } = require('./constants.js') + +// The coin tick of a "-" key, or undefined. +function tickOf(networkKey){ + const fullName = String(networkKey || '').slice(0, Math.max(0, String(networkKey || '').lastIndexOf('-'))) + return coins.FULL_NAME_TO_TICK[fullName] +} // Keyed on the coin, not the network: the soft limit is node policy on every Dogecoin chain. function softDustFloorFor(networkKey){ - const fullName = String(networkKey || '').slice(0, Math.max(0, String(networkKey || '').lastIndexOf('-'))) - const tick = coins.FULL_NAME_TO_TICK[fullName] + const tick = tickOf(networkKey) return (tick && SOFT_DUST_FLOOR_BY_COIN[tick]) || 0 } +// The coin's dust relay rate in base units per byte, keyed on the coin like the soft limit. +function dustRelayFeePerByteFor(networkKey){ + const tick = tickOf(networkKey) + return (tick && DUST_RELAY_FEE_PER_BYTE_BY_COIN[tick]) || DEFAULT_DUST_RELAY_FEE_PER_BYTE +} + // Byte width of the compactSize varint that prefixes a length on the wire. // Distinct from compiledPushSize: that models bitcoin.script.compile's PUSH // OPCODE framing (direct push / OP_PUSHDATA1 / OP_PUSHDATA2) and is correct @@ -89,4 +100,4 @@ function jsonSafeSat(v) { return v <= MAX_SAFE_SATOSHI_BIG ? Number(v) : v.toString() } -module.exports = { softDustFloorFor, compactSizeLen, envelopeTapLeafHash, ensureEccLib, asSatValue, jsonSafeSat } +module.exports = { softDustFloorFor, dustRelayFeePerByteFor, compactSizeLen, envelopeTapLeafHash, ensureEccLib, asSatValue, jsonSafeSat } diff --git a/src/XChainEncoder/size_estimation.js b/src/XChainEncoder/size_estimation.js index f64aa68..920763f 100644 --- a/src/XChainEncoder/size_estimation.js +++ b/src/XChainEncoder/size_estimation.js @@ -162,7 +162,10 @@ module.exports = { + extraOutputsBytes }, - estimateSpendingP2wshTx(witnessData){ + // padOutputBytes is the serialized size of the pad output the reveal adds when it + // sits under the stripped-size floor; the funding side passes the change address's + // size, and the default is the largest standard output. + estimateSpendingP2wshTx(witnessData, padOutputBytes = 43){ // Per-chunk embedded value sized to cover the P2WSH reveal tx's worst // case at 1 sat/vbyte. A native-segwit input keeps its scriptSig empty // and carries the reveal payload (sig + compressed pubkey + witness @@ -195,15 +198,16 @@ module.exports = { Buffer.from("p2wsh",'utf8') ])) - // A single-chunk reveal's stripped size (71 B) is below Litecoin's - // tx-size-small floor, so the reveal builder pads it up to the chain's - // minStandardTxNonWitnessSize with one extra output. Reflect that padded - // stripped size here so the fee estimate covers the larger reveal. (In - // normal fee regimes the dust floor dominates the embedded value, but - // this keeps the estimate honest on high-fee chains.) + // A single-chunk reveal's stripped size (71 B) is below the chain's + // tx-size-small floor, so the reveal builder adds one pad output to clear + // minStandardTxNonWitnessSize. That output is a whole output, not just the + // bytes up to the floor, so charge its full size, then keep the floor as a + // lower bound, as the P2SH and envelope reveal estimates price their + // outputs. Flooring to the threshold alone left a 34-byte pad unpriced and + // the reveal ~10% under the requested rate on high-fee chains. let strippedFloor = this.network.minStandardTxNonWitnessSize if (strippedFloor && nonWitnessBytes < strippedFloor){ - nonWitnessBytes = strippedFloor + nonWitnessBytes = Math.max(nonWitnessBytes + padOutputBytes, strippedFloor) } // vsize = ceil(total weight / 4) = nonWitnessBytes + ceil(witnessBytes/4) @@ -213,4 +217,28 @@ module.exports = { return sizeEstimated }, + + // Whole-tx vsize of the P2WSH reveal: every chunk input with its witness (sig, + // pubkey, witness script), the OP_RETURN marker, plus extraOutputsBytes for the + // value outputs it emits. The P2WSH twin of estimateP2shRevealTx, and both phases + // price the reveal with it for the same reason given there. + estimateP2wshRevealTx(dataBufferArray, extraOutputsBytes = 0){ + let witnessBytes = 2 // marker + flag + for (const witnessData of dataBufferArray){ + witnessBytes = witnessBytes + 1 + (1 + 72) + (1 + 33) + + compactSizeLen(witnessData.length) + witnessData.length + } + let nonWitnessBytes = 10 // 4 version + 1 inputs count + 1 outputs count + 4 locktime + + (36 + 1 + 4) * dataBufferArray.length + + TxSizeEstimator.estimateOpReturnOutput(Buffer.concat([ + Buffer.from(MAGIC_WORD,'utf8'), + Buffer.from("p2wsh",'utf8') + ])) + + extraOutputsBytes + // A reveal under the chain's stripped-size floor is padded up to it. + const strippedFloor = this.network.minStandardTxNonWitnessSize + if (strippedFloor && nonWitnessBytes < strippedFloor) nonWitnessBytes = strippedFloor + return nonWitnessBytes + Math.ceil(witnessBytes / 4) + + 8 // safety margin for DER-sig length jitter, as in the per-chunk estimate + }, } diff --git a/src/api.js b/src/api.js index 4f89b0a..69ed4bf 100644 --- a/src/api.js +++ b/src/api.js @@ -26,13 +26,19 @@ const config = require('./common/config') // immediately below are exactly the ones an operator needs levelled and // timestamped, and installObservability does not run until ~190 lines further // down. -const { patchConsole } = require('./observability'); +const { patchConsole, getLogger } = require('./observability'); patchConsole({ service: 'xchain-encoder', version: require('../package.json').version, network: config.NETWORK }); +// Install the crash handlers before the singleton encoder below is built, so its +// boot throws (unknown NETWORK, consensus-pin mismatch) leave a CRASH record. +// Entry point only: a test that requires this file must not get them. +const { installCrashHandlers } = require('./server/crash_handlers') +if (require.main === module) installCrashHandlers() + const bitcoin = require('bitcoinjs-lib'); const express = require('express'); const bodyParser = require('body-parser'); @@ -64,7 +70,6 @@ const { assertSingleInstance, acquireInstanceLock, releaseLockOnSignals } = requ const { parseCorsOrigin } = require('./server/cors_origin') const { version: ENCODER_VERSION } = require('../package.json') const { installObservability } = require('./observability'); // default-off /metrics + structured log shim -const { installCrashHandlers } = require('./server/crash_handlers') const NETWORK = config.NETWORK_RAW @@ -271,6 +276,9 @@ installObservability(app, { // close over the encoder instance and network built above, so the part hands // back the controller from a factory rather than a module-level literal. const jsonRpcController = require('./api/json_rpc_methods.js').createJsonRpcController({ encoder, NETWORK }) +if (encoder.consensusNetwork === 'mainnet' && config.ENCODER_MAINNET_PRELAUNCH_OVERRIDE) { + getLogger().warn('ENCODER_MAINNET_PRELAUNCH_OVERRIDE=' + config.ENCODER_MAINNET_PRELAUNCH_OVERRIDE + ' on a mainnet encoder: the prelaunch refusal is lifted only when it is 1, true, yes or on') +} // GET /status: returns 200 when the encoder's hard dependencies are reachable // and the UTXO tracker is synced, or 503 when not. Distinct from the JSON-RPC @@ -288,7 +296,8 @@ app.get('/status', probeGate.hold(async (req, res) => { // tracker_halted and tracker_mempool_ready travel alongside so the board names WHY // an unhealthy encoder is unhealthy; both already fold into tracker_synced above. const { tracker_reachable, tracker_synced, tracker_lag, tracker_halted, tracker_mempool_ready, maintenance } = await jsonRpcController.health() - const healthy = tracker_reachable && tracker_synced + const nodeTierMismatch = encoder.connector.nodeTierMismatch || null + const healthy = tracker_reachable && tracker_synced && !nodeTierMismatch // A declared window does NOT move the code. The encoder still cannot serve, // and every load balancer and uptime monitor keyed on this 503 must keep // seeing it; the window is context for whoever reads the body, not a way to @@ -305,7 +314,7 @@ app.get('/status', probeGate.hold(async (req, res) => { // request_gate exposes the global concurrency cap and how many requests it // has shed; a climbing shed count is the only outward sign that a // distinct-IP stampede is being refused. - res.status(code).json({ status: healthy ? 'healthy' : 'unhealthy', tracker_reachable, tracker_synced, tracker_lag, tracker_halted, tracker_mempool_ready, tracker_max_lag_blocks, maintenance, request_gate: requestGate.getStats(), probe_gate: probeGate.getStats() }) + res.status(code).json({ status: healthy ? 'healthy' : 'unhealthy', tracker_reachable, tracker_synced, tracker_lag, tracker_halted, tracker_mempool_ready, tracker_max_lag_blocks, node_tier_mismatch: nodeTierMismatch, maintenance, request_gate: requestGate.getStats(), probe_gate: probeGate.getStats() }) })) // Machine-readable API spec (OpenRPC 1.3.2). Regenerated by docs/openrpc.build.js; @@ -352,9 +361,8 @@ if (require.main === module) { // boot if the deploy declares replicas > 1 (ENCODER_REPLICAS) or another // encoder process on this host already holds the instance lock. See // src/server/single_instance_guard.js. - // Before the instance guard, so a throw inside it is still a CRASH record - // rather than node's bare stderr dump. - installCrashHandlers() + // The crash handlers are already installed at the top of this file, so a + // throw from the instance guard is still a CRASH record. assertSingleInstance() const releaseInstanceLock = acquireInstanceLock() process.on('exit', releaseInstanceLock) diff --git a/src/api/json_rpc_methods.js b/src/api/json_rpc_methods.js index 3160e65..1460242 100644 --- a/src/api/json_rpc_methods.js +++ b/src/api/json_rpc_methods.js @@ -28,6 +28,7 @@ const { ParamTypeError, isInvalidParams } = require('../build/errors') const { getLogger } = require('../observability'); const { version: ENCODER_VERSION } = require('../../package.json') const { readMaintenanceWindow } = require('../server/maintenance_window') // operator-declared scheduled outage, reported beside readiness +const config = require('../common/config') const logger = getLogger(); // Each builder closes one group of methods over the encoder (and, for fees, @@ -129,7 +130,7 @@ return { const feerate = await encoder.connector.getFeePerKilobyte(targets[tier]); // coin/kB let perVbyte = Math.max(1, Math.round(Number(feerate) * 100000)); // -> base-unit/byte if (capPerVbyte != null && perVbyte > capPerVbyte) perVbyte = capPerVbyte; - out[tier] = perVbyte; + out[tier] = Math.max(perVbyte, ...Object.values(out)); } } catch (err) { logger.error(util.format('Fee estimation error:', err)) @@ -179,13 +180,14 @@ return { async create_tx(rawParams) { let params try { - params = validator.validateAll(rawParams) + params = validator.validateAll(rawParams, { paymentOutputFloor: encoder.paymentOutputFloor }) // sub-dust payment: -32602 } catch (err) { const e = new Error(err.message) e.code = -32602 throw e } + config.assertNotMainnetPrelaunch(encoder, params) await assertNodeTierOrRpcError(encoder) let psbt try { @@ -196,7 +198,8 @@ return { params.compressedPubKey, params.unconfirmed, params.feePerKb, params.dust, params.feeQuote, params.attachPrevTx, params.compress, params.options) } catch (err) { - // Node and tracker faults inside the build throw plain Error, so they stay retryable + // Map the fault: a tracker refusal is operational (-32010), a plain node Error a + // generic -32603; the SDK retries neither body error, so any retry is the caller's throw toRpcError(err) } @@ -211,7 +214,6 @@ return { } } - function buildCancelMethods({ encoder }) { return { // Key-path cancel of an unrevealed TAPROOT envelope commit: rebuilds the @@ -239,7 +241,6 @@ return { } } - function buildReleaseMethods({ encoder }) { return { // Hand back the input reservations ONE earlier build took, named by the @@ -328,26 +329,26 @@ return { }, } } - -// Tracker-facing lookups; upstream error text is sanitized before it leaves. +function validateLookupTxid(rawParams) { const txid = rawParams && rawParams.txid + if (typeof txid === 'string' && /^[0-9a-fA-F]{64}$/.test(txid)) return txid + throw Object.assign(new Error('txid must be a 64-hex-character string'), { code: -32602 }) +} +function lookupFailure(err, name) { + logger.error(util.format(`${name} lookup error:`, err)); return Object.assign(new Error(upstreamErrorMessage(err, `${name} lookup failed`)), { code: -32603 }) +} +// Transaction and UTXO lookups; upstream error text is sanitized before it leaves. function buildUtxoMethods({ encoder }) { return { - async get_tx_block(rawParams) { - const txid = rawParams && rawParams.txid - if (typeof txid !== 'string' || !/^[0-9a-fA-F]{64}$/.test(txid)) { - const e = new Error('txid must be a 64-hex-character string') - e.code = -32602 - throw e - } - + async get_tx_status(rawParams) { + const txid = validateLookupTxid(rawParams) try { - return await encoder.utxoTrackerConnector.getTxBlock(txid) - } catch (err) { - logger.error(util.format('Transaction block lookup error:', err)) - const e = new Error(upstreamErrorMessage(err, 'Transaction block lookup failed')) - e.code = -32603 - throw e - } + if (await encoder.connector.getMempoolEntry(txid) !== null) return { status: 'mempool' } + return { status: await encoder.utxoTrackerConnector.getTxBlock(txid) === null ? 'unknown' : 'chain' } + } catch (err) { throw lookupFailure(err, 'Transaction status') } + }, + async get_tx_block(rawParams) { + const txid = validateLookupTxid(rawParams) + try { return await encoder.utxoTrackerConnector.getTxBlock(txid) } catch (err) { throw lookupFailure(err, 'Transaction block') } }, async get_utxos(rawParams) { let address = rawParams && rawParams.address @@ -383,7 +384,9 @@ return { // encoder and network the entry builds from its environment, so requiring this // file constructs nothing. Group order is the dispatch table's key order. function createJsonRpcController({ encoder, NETWORK }) { + // No prototype, so the router's `in` check cannot dispatch an inherited name (constructor, toString). return Object.assign( + Object.create(null), buildReadinessMethods({ encoder }), buildFeeMethods({ encoder, NETWORK }), buildTransactionMethods({ encoder }), diff --git a/src/build/blockchain_connector/chain_identity.js b/src/build/blockchain_connector/chain_identity.js index 89b1eea..e53e930 100644 --- a/src/build/blockchain_connector/chain_identity.js +++ b/src/build/blockchain_connector/chain_identity.js @@ -14,10 +14,9 @@ * * XChain Encoder - coin-node chain-tier identity * - * The tier half of xchain-decoder's src/protocol/chain_identity.js, kept in - * step with it. getblockchaininfo's `chain` names the network TIER, never the - * coin, and `test`/`testnet3`/`testnet4` collapse to one tier, so this can - * prove "wrong tier" and nothing more. + * The pure identity decisions from xchain-decoder's + * src/protocol/chain_identity.js, kept in step with it. The reported chain + * proves tier identity, while the block-0 hash proves coin and chain identity. * ********************************************************************/ @@ -44,4 +43,23 @@ function chainTierMismatch(consensusNetwork, reportedChain){ ') but this encoder is configured for ' + consensusNetwork; } -module.exports = { CHAIN_TO_NETWORK, chainTierMismatch }; +// Return a reason string when two usable block-0 hashes differ, else null. +function chainGenesisMismatch(pinnedHash, reportedHash){ + if (typeof pinnedHash !== 'string' || pinnedHash === '') return null; + if (typeof reportedHash !== 'string' || reportedHash === '') return null; + if (pinnedHash.toLowerCase() === reportedHash.toLowerCase()) return null; + return 'node block-0 hash is ' + reportedHash + ', but this encoder is pinned to ' + + pinnedHash + ', so the endpoint is serving a different chain'; +} + +// Return true when no usable block-0 hash is pinned. +function chainGenesisUnpinned(pinnedHash){ + return typeof pinnedHash !== 'string' || pinnedHash === ''; +} + +module.exports = { + CHAIN_TO_NETWORK, + chainTierMismatch, + chainGenesisMismatch, + chainGenesisUnpinned, +}; diff --git a/src/build/blockchain_connector/fee_estimation.js b/src/build/blockchain_connector/fee_estimation.js index 966b8f2..f6a4b1e 100644 --- a/src/build/blockchain_connector/fee_estimation.js +++ b/src/build/blockchain_connector/fee_estimation.js @@ -135,16 +135,16 @@ async function requestSmartFee(connector, blocksNumber) { return response.data; } -// Non-regtest estimates must be positive and below the configured ceiling. -function usableSmartFee(responseData) { +async function usableSmartFee(connector, responseData) { if (!responseData.result || !(Number(responseData.result.feerate) > 0)) return null const feerate = Number(responseData.result.feerate); const ceiling = feeEstimateSanityCeiling(); if (feerate > ceiling) { - logger.error('estimatesmartfee returned ' + feerate + '/kB on a non-regtest ' + - 'chain, above the ' + ceiling + '/kB sanity ceiling; clamping to the ceiling. ' + - 'Set FEE_ESTIMATE_SANITY_CEILING to override.'); - return ceiling; + const mainnet = (await connector.chainName()) === 'main'; + logger.error('estimatesmartfee returned ' + feerate + '/kB, above the ' + ceiling + + '/kB sanity ceiling; ' + (mainnet ? 'clamping to the ceiling' : 'treating it as no estimate') + + '. Set FEE_ESTIMATE_SANITY_CEILING to override.'); + return mainnet ? ceiling : null; } return feerate; } @@ -242,7 +242,7 @@ module.exports = { try { if (await this.isRegtest()) return await regtestFee(this) const responseData = await requestSmartFee(this, blocksNumber) - const feerate = usableSmartFee(responseData) + const feerate = await usableSmartFee(this, responseData) if (feerate !== null) return feerate return await requireNoEstimateFallback(this, responseData) } catch (error) { diff --git a/src/build/blockchain_connector/node_queries.js b/src/build/blockchain_connector/node_queries.js index 6d07228..5dd35e8 100644 --- a/src/build/blockchain_connector/node_queries.js +++ b/src/build/blockchain_connector/node_queries.js @@ -19,6 +19,11 @@ const { sanitizeRpcError, rpcErrorDetail } = require('./rpc_helpers'); const { chainTierMismatch } = require('./chain_identity'); const { OperationalError } = require('../errors'); +function mempoolEntryAbsent(body) { + const error = body && body.error; + return Boolean(error && error.code === -5); +} + 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.) @@ -33,7 +38,10 @@ module.exports = { return; } const reason = chainTierMismatch(this.consensusNetwork, chain); - if (reason) throw new OperationalError('NODE_CHAIN_MISMATCH', 'Coin node is on the wrong network: ' + reason); + if (reason) { + this.nodeTierMismatch = reason; + throw new OperationalError('NODE_CHAIN_MISMATCH', 'Coin node is on the wrong network: ' + reason); + } this.nodeTierOk = true; }, @@ -187,4 +195,39 @@ module.exports = { throw new Error(`Error getting transaction hex: ${message}${detail}`); } }, + + // Return null only for the node's normal "not in mempool" answer. A node + // or transport failure must remain distinguishable from an absent txid so + // callers never classify an outage as a dropped transaction. + async getMempoolEntry(txid) { + try { + const response = await axios.post(this.url, { + jsonrpc: '2.0', + method: 'getmempoolentry', + params: [txid], + id: 1, + }, { + auth: { + username: this.rpcUser, + password: this.rpcPassword, + }, + timeout: RPC_TIMEOUT + }); + const body = response && response.data; + + if (mempoolEntryAbsent(body)) return null; + if (body && body.result && typeof body.result === 'object' && !Array.isArray(body.result)) { + return body.result; + } + throw new Error('Error getting mempool entry' + rpcErrorDetail(body)); + } catch (error) { + const body = error && error.response && error.response.data; + if (mempoolEntryAbsent(body)) return null; + const detail = rpcErrorDetail(body); + const message = sanitizeRpcError(error); + logger.error(util.format('Error:', message + detail)); + if (!detail) throw error; + throw new Error(`Error getting mempool entry: ${message}${detail}`); + } + }, } diff --git a/src/build/tx_size_estimator.js b/src/build/tx_size_estimator.js index f40be6c..3ef5abf 100644 --- a/src/build/tx_size_estimator.js +++ b/src/build/tx_size_estimator.js @@ -53,6 +53,11 @@ function inputSizeForScript(scriptPubKey, isSegwit) { // P2WSH: 0020{32-byte hash} - Assuming 2-3 Multisig return 105 } + if (scriptHex.length === 46 && scriptHex.startsWith('a914') && scriptHex.endsWith('87')) { + // P2SH-P2WPKH: 64 non-witness bytes (outpoint, sequence, 23-byte scriptSig + // pushing the redeem script) plus a 108-weight sig and key witness = 91 vB. + return 91 + } if (scriptHex.startsWith('5120')) { // P2TR: 5120{32-byte x-only key}. Key-path spend = 1 Schnorr // witness (~57.5 vbytes incl. prevout + sequence). Without this it diff --git a/src/coins/BTC.js b/src/coins/BTC.js index b50f3d4..8dfbdfb 100644 --- a/src/coins/BTC.js +++ b/src/coins/BTC.js @@ -162,10 +162,19 @@ module.exports = { // plus airdropSetHash - the sha256 over the canonical `name:hash:amount` lines, // which genesis.js verifies before it credits anything - followed by a // bin/sync-coins.sh re-vendoring wave. + // Both hashes re-pinned 2026-10-13: the name registrations BASE, + // DASH, HBAR, HOOD, HYPE and NEAR left the CSV manifest so the bridge owns + // those chain roots, six rows out of 121,716. They were supply-0, balance-free + // name rows with no subassets and no post-genesis action on either chain, so + // nothing else in the manifest or the dump moved: the dump loses only those six + // names' rows and its ledger and contracts block hashes are unchanged. + // The six stay reserved names (RESERVED_FUTURE_ROOTS in xchain-indexer, operator + // ruling 2026-10-17): with no row left, a new ISSUE of any of them is refused + // wherever TICK_NAMESPACE_ACTIVATION binds (unarmed on mainnet today). genesis: { block: 950000, - ledgerHash: 'f347a0499654b128dd0461441ed6341ac27a24b909d6ff6e3995db4e69dc23e5', - dumpHash: 'e416c382dd6951ce5c4da7e053f6dbb407ca3d966a513209eb59886dbe95e738', + ledgerHash: '13aeeebb9f2e27d507ca96fb7c54a1bfa98b9cc80c3feebd5d1ad022ce7e2b73', + dumpHash: '85431c2c41d6442e38aa78af804148dd6ead207aca036f6e44fbf2bee8c2846e', airdropPaths: [], airdropHashes: [], airdropAmounts: [], diff --git a/src/coins/DOGE.js b/src/coins/DOGE.js index 787327f..41e51c0 100644 --- a/src/coins/DOGE.js +++ b/src/coins/DOGE.js @@ -93,10 +93,19 @@ module.exports = { // plus airdropSetHash - the sha256 over the canonical `name:hash:amount` lines, // which genesis.js verifies before it credits anything - followed by a // bin/sync-coins.sh re-vendoring wave. + // Both hashes re-pinned 2026-10-13: the name registrations BASE, + // DASH, HBAR, HOOD, HYPE and NEAR left the CSV manifest so the bridge owns + // those chain roots, six rows out of 42,704. They were supply-0, balance-free + // name rows with no subassets and no post-genesis action on either chain, so + // nothing else in the manifest or the dump moved: the dump loses only those six + // names' rows and its ledger and contracts block hashes are unchanged. + // The six stay reserved names (RESERVED_FUTURE_ROOTS in xchain-indexer, operator + // ruling 2026-10-17): with no row left, a new ISSUE of any of them is refused + // wherever TICK_NAMESPACE_ACTIVATION binds (unarmed on mainnet today). genesis: { block: 6240000, - ledgerHash: '87abac0b03cbd24694f7c5e666425bdae6f9a9c51b826826bd4848bd4ade991b', - dumpHash: '81d3d2cb4714bc40ec12c2d1c8573040858ddf66e4ba89a05d44d86d3bffa0f1', + ledgerHash: '888fc0be89d4ac58dff24ef5b02c8ccfb947cef9c3d4bc3b28db2202e68da5df', + dumpHash: '9aae4cdf2bc14de119e089c267d442aec4671e01f6139aff486460798bb96a92', airdropPaths: [], airdropHashes: [], airdropAmounts: [], diff --git a/src/common/config.js b/src/common/config.js index 0fc7e01..ce2472f 100644 --- a/src/common/config.js +++ b/src/common/config.js @@ -29,6 +29,8 @@ */ 'use strict'; +const { VALIDATOR_WIRE_FORMATS, isValidatorWireAction } = require('../protocol/validator_wire_actions') + const config = { // codemod:env-entries get ENCODER_MAINTENANCE_FILE() { return process.env.ENCODER_MAINTENANCE_FILE; }, @@ -67,6 +69,31 @@ const config = { get ENCODER_MAX_CONCURRENT_PROBES() { return process.env.ENCODER_MAX_CONCURRENT_PROBES; }, get ENCODER_MAX_CONCURRENT_REQUESTS() { return process.env.ENCODER_MAX_CONCURRENT_REQUESTS; }, get ENCODER_MAX_RPC_BATCH() { return process.env.ENCODER_MAX_RPC_BATCH; }, + // Operator override of the mainnet prelaunch refusal; raw string, parsed by assertNotMainnetPrelaunch below. + get ENCODER_MAINNET_PRELAUNCH_OVERRIDE() { return process.env.ENCODER_MAINNET_PRELAUNCH_OVERRIDE; }, }; +// Unix time (2027-01-01T00:00:00Z) before which a mainnet encoder builds only validator actions. +const MAINNET_LAUNCH_TS = 1798761600 +const OVERRIDE_TRUE = new Set(['1', 'true', 'yes', 'on']) + +// create_tx guard: a mainnet encoder refuses a build before launch (-32010 MAINNET_PRELAUNCH) unless +// its data is a validator wire action by name and format, the set consensus admits pre-launch, or +// ENCODER_MAINNET_PRELAUNCH_OVERRIDE is 1/true/yes/on, read live. +function assertNotMainnetPrelaunch(encoder, params, now = Date.now) { + if (encoder.consensusNetwork !== 'mainnet' || Math.floor(now() / 1000) >= MAINNET_LAUNCH_TS) return + if (OVERRIDE_TRUE.has(String(config.ENCODER_MAINNET_PRELAUNCH_OVERRIDE || '').trim().toLowerCase())) return + const data = Buffer.isBuffer(params.data) ? params.data.toString('utf8') : params.data + if (isValidatorWireAction(data)) return + const e = new Error('Mainnet is not open yet: only validator actions can be built before launch') + e.code = -32010 + e.data = { reason: 'MAINNET_PRELAUNCH', opens_at: MAINNET_LAUNCH_TS } + throw e +} + module.exports = config; +Object.defineProperties(config, { + MAINNET_LAUNCH_TS: { value: MAINNET_LAUNCH_TS }, + VALIDATOR_WIRE_FORMATS: { value: VALIDATOR_WIRE_FORMATS }, + assertNotMainnetPrelaunch: { value: assertNotMainnetPrelaunch }, +}) diff --git a/src/common/validator.js b/src/common/validator.js index 2f9a5d8..8870a86 100644 --- a/src/common/validator.js +++ b/src/common/validator.js @@ -58,7 +58,8 @@ const { validateOutpoint, validateUtxoArray, validateUtxoEntry, - validateCustomOutputs + validateCustomOutputs, + assertPaymentOverDust } = require('./validator/fee_and_utxo_checks') const { validateP2shParams, @@ -101,7 +102,7 @@ const COMPRESSION_MAX_RATIO = 150 // service (it holds an exclusive lockfile), so compression is async and bounded. const COMPRESSION_MAX_INPUT_BYTES = 16 * 1024 * 1024 -function validateAll(params) { +function validateAll(params, context) { // API contract coverage tracks these validator inputs: // params.utxos, params.pubkey, params.customOutputs, params.data, // params.rawData, params.fee, params.rbf, params.encoding, params.change, @@ -109,7 +110,7 @@ function validateAll(params) { // params.unconfirmed, params.feePerKb, params.dust, params.feeQuote, // params.attachPrevTx, params.compress, params.options return { - ...validateAllFields(params) + ...validateAllFields(params, context) } } @@ -142,6 +143,7 @@ module.exports = { validateUtxoArray, validateUtxoEntry, validateCustomOutputs, + assertPaymentOverDust, validateP2shParams, validateRawTxHex, validateCompressedPubKey, diff --git a/src/common/validator/action_data_checks.js b/src/common/validator/action_data_checks.js index 9121e07..c14dd4f 100644 --- a/src/common/validator/action_data_checks.js +++ b/src/common/validator/action_data_checks.js @@ -21,7 +21,7 @@ const { ENVELOPE_MAX_PAYLOAD, VALID_ENCODINGS } = require('./constants') -const { firstNonLatin1 } = require('./value_checks') +const { firstNonLatin1, truncateForMessage } = require('./value_checks') const { ParamTypeError, ParamRangeError } = require('../../build/errors') // Gate `data` and `rawData` on what their wire encodings can actually carry, not @@ -140,7 +140,7 @@ function validateActionName(data) { const rawActionName = unknownActionName(data) if (rawActionName == null) return throw new ParamRangeError( - `data has unknown ACTION name '${rawActionName.slice(0, 32)}'; ` + + `data has unknown ACTION name '${truncateForMessage(rawActionName)}'; ` + 'the decoder rejects any leading token that is not a canonical action ' + 'name or alias, silently dropping the ACTION on a fee-paid transaction') } @@ -249,7 +249,9 @@ function validateCombinedDataLength(data, rawData, encoding) { function validateEncoding(encoding) { if (encoding == null) return null if (typeof encoding !== 'string' || !VALID_ENCODINGS.has(encoding)) { - throw new ParamTypeError(`Invalid encoding: "${encoding}". Valid values: ${[...VALID_ENCODINGS].join(', ')}`) + // Quote a string's prefix only, and name any other type rather than print [object Object] + const shown = typeof encoding === 'string' ? `"${truncateForMessage(encoding)}"` : `a value of type ${typeof encoding}` + throw new ParamTypeError(`Invalid encoding: ${shown}. Valid values: ${[...VALID_ENCODINGS].join(', ')}`) } return encoding } diff --git a/src/common/validator/constants.js b/src/common/validator/constants.js index d58e968..1c16218 100644 --- a/src/common/validator/constants.js +++ b/src/common/validator/constants.js @@ -60,7 +60,8 @@ const MAX_OP_RETURN_COMPILED_LENGTH = OP_RETURN_OUTPUT_SIZE - OP_RETURN_MAGIC_WO const ENVELOPE_MAX_PAYLOAD = 390_000 const MAX_UTXO_COUNT = 500 const MAX_CUSTOM_OUTPUTS = 100 -// Sanity ceiling on the fee/dust/feeQuote money fields: 21,000 BTC in satoshis. +// Sanity ceiling on the fee/dust/feeQuote money fields and the feePerKb rate +// (base units per kB): 21,000 BTC in satoshis. // This is deliberately far tighter than the 21M-BTC supply, and the value is the // contract, not a rounding of it. Do NOT "repair" it to 2_100_000_000_000_000 to // chase a supply figure: that loosens every fee-path ceiling 1000x. Large output @@ -144,6 +145,14 @@ const MAX_SATOSHI_U64 = 0xffffffffffffffffn // signer down the P2WSH path at 2x the cost with no signal, and a misspelled one // that silently meant "yes" would be worse. const VALID_CREATE_TX_OPTIONS = new Set(['signerSupportsTapscript', 'exactInputs']) +// The create_tx top-level params, refused when unknown for the options-bag reason +// above: a misspelled unconfirmed, feePerKb or rbf would silently build under the +// default policy. Must equal the create_tx params in docs/openrpc.build.js. +const VALID_CREATE_TX_PARAMS = new Set([ + 'utxos', 'pubkey', 'customOutputs', 'data', 'rawData', 'fee', 'rbf', + 'encoding', 'change', 'p2shHash', 'p2shHex', 'compressedPubKey', + 'unconfirmed', 'feePerKb', 'dust', 'feeQuote', 'attachPrevTx', 'compress', 'options' +]) module.exports = { MAX_COMPILED_ACTION_DATA_LENGTH, @@ -163,5 +172,6 @@ module.exports = { VALID_ACTION_NAMES, ACTION_ALIASES, MAX_SATOSHI_U64, - VALID_CREATE_TX_OPTIONS + VALID_CREATE_TX_OPTIONS, + VALID_CREATE_TX_PARAMS } diff --git a/src/common/validator/fee_and_utxo_checks.js b/src/common/validator/fee_and_utxo_checks.js index 3eb2f23..5967d63 100644 --- a/src/common/validator/fee_and_utxo_checks.js +++ b/src/common/validator/fee_and_utxo_checks.js @@ -24,7 +24,8 @@ const { const { validateOptionalBoolean, toExactInt, - parseSatoshiAmount + parseSatoshiAmount, + truncateForMessage } = require('./value_checks') const validateAddress = (...args) => require('./transaction_checks').validateAddress(...args) const { ParamTypeError, ParamRangeError } = require('../../build/errors') @@ -36,7 +37,7 @@ function validateCreateTxOptions(options) { } for (const key of Object.keys(options)) { if (!VALID_CREATE_TX_OPTIONS.has(key)) { - throw new ParamTypeError(`Unknown options key: "${key}". Valid keys: ${[...VALID_CREATE_TX_OPTIONS].join(', ')}`) + throw new ParamTypeError(`Unknown options key: "${truncateForMessage(key)}". Valid keys: ${[...VALID_CREATE_TX_OPTIONS].join(', ')}`) } } if (options.signerSupportsTapscript !== undefined) { @@ -55,7 +56,7 @@ function validateFee(fee) { if (fee == null || fee === false) return null const num = toExactInt(fee) if (isNaN(num)) { - throw new ParamTypeError(`fee must be a valid integer, got: ${typeof fee === 'string' ? fee : typeof fee}`) + throw new ParamTypeError(`fee must be a valid integer, got: ${typeof fee === 'string' ? truncateForMessage(fee) : typeof fee}`) } if (num < 0) { throw new ParamRangeError('fee must be non-negative') @@ -94,6 +95,11 @@ function validateFeePerKb(feePerKb) { if (num <= 0) { throw new ParamRangeError('feePerKb must be positive') } + // Reject an absurd rate like the other money fields instead of leaving it to + // the build's clamp: above this, one kB alone costs more than the fee ceiling. + if (num > MAX_FEE_SATOSHIS) { + throw new ParamRangeError(`feePerKb (${num}) exceeds maximum (${MAX_FEE_SATOSHIS})`) + } return num } @@ -211,7 +217,17 @@ function validateUtxoArray(utxos) { return utxos } -function validateCustomOutput(output, index) { +// Refuse a payment output under the coin's hard relay dust threshold: a node will not +// relay it, so the caller would sign a transaction that never reaches a miner. `floor` +// is the encoder's paymentOutputFloor; a falsy floor (a network-agnostic caller) leaves +// only the positive-amount rule. +function assertPaymentOverDust(value, index, floor) { + if (floor && value < floor) { + throw new ParamRangeError(`customOutputs[${index}].value ${value} is below the ${floor} base-unit relay dust threshold for this coin`) + } +} + +function validateCustomOutput(output, index, paymentOutputFloor) { if (typeof output !== 'object' || output === null || Array.isArray(output)) { throw new ParamTypeError(`customOutputs[${index}] must be an object`) } @@ -222,18 +238,20 @@ function validateCustomOutput(output, index) { // A caller-supplied output of 0 sats is consensus-valid but relay-rejected // as an unspendable/dust output, so the caller signs a PSBT that can never // broadcast. Reject it at the trust boundary, matching validateFeeQuote's - // positive-amount contract. Interim safe rule: reject only value <= 0 (not a - // full per-network dust floor). The native-fee FEE_DESTINATION output is + // positive-amount contract. The per-coin dust floor below applies when the + // caller passes one (create_tx passes the encoder's). A feeQuote output is // injected into customOutputs AFTER validateCustomOutputs runs (see - // XChainEncoder.createTransaction), so it never passes through here and its - // sub-dust-but-positive DOGE fee values keep working. + // checkPayloadInput), so it keeps only its own positive-amount rule; a native-fee + // output a client appends to customOutputs itself (the SDK does) is a caller + // output here and is held to the floor like any payment. if (output.value <= 0) { throw new ParamRangeError(`customOutputs[${index}].value must be a positive integer (satoshis)`) } + assertPaymentOverDust(output.value, index, paymentOutputFloor) return output } -function validateCustomOutputs(customOutputs) { +function validateCustomOutputs(customOutputs, paymentOutputFloor) { if (customOutputs == null) return null if (!Array.isArray(customOutputs)) { throw new ParamTypeError('customOutputs must be an array') @@ -242,7 +260,7 @@ function validateCustomOutputs(customOutputs) { throw new ParamRangeError(`customOutputs length (${customOutputs.length}) exceeds maximum (${MAX_CUSTOM_OUTPUTS})`) } for (let i = 0; i < customOutputs.length; i++) { - validateCustomOutput(customOutputs[i], i) + validateCustomOutput(customOutputs[i], i, paymentOutputFloor) } return customOutputs } @@ -278,6 +296,7 @@ module.exports = { validateOutpoint, validateUtxoEntry, validateUtxoArray, + assertPaymentOverDust, validateCustomOutput, validateCustomOutputs, validateFeeQuote diff --git a/src/common/validator/transaction_checks.js b/src/common/validator/transaction_checks.js index aaa75d0..34569da 100644 --- a/src/common/validator/transaction_checks.js +++ b/src/common/validator/transaction_checks.js @@ -17,11 +17,13 @@ const { RAW_TX_HEX_RE, HEX_64_RE, MAX_BROADCAST_TX_HEX_LENGTH, - COMPRESSED_PUBKEY_RE + COMPRESSED_PUBKEY_RE, + VALID_CREATE_TX_PARAMS } = require('./constants') const { validateOptionalBoolean, - validatePubkey + validatePubkey, + truncateForMessage } = require('./value_checks') const { validateDataParam, @@ -151,13 +153,13 @@ function validateActionFields(params) { return { data, rawData, encoding } } -function validatePaymentFields(params) { +function validatePaymentFields(params, context) { const pubkey = validatePubkey(params.pubkey) const fee = validateFee(params.fee) const feePerKb = validateFeePerKb(params.feePerKb) const dust = validateDust(params.dust) const utxos = validateUtxoArray(params.utxos) - const customOutputs = validateCustomOutputs(params.customOutputs) + const customOutputs = validateCustomOutputs(params.customOutputs, context && context.paymentOutputFloor) const feeQuote = validateFeeQuote(params.feeQuote) const { p2shHash, p2shHex } = validateP2shParams(params.p2shHash, params.p2shHex) const compressedPubKey = validateCompressedPubKey(params.compressedPubKey) @@ -241,12 +243,14 @@ function validatePolicyFields(params) { /** * Validate and coerce every createTransaction parameter in one pass. * @param {object} params - the raw JSON-RPC params object for create_tx. + * @param {object} [context] - what the serving encoder knows about its coin: + * { paymentOutputFloor }, the relay dust threshold every customOutputs value must reach. * @returns {object} the same fields, each coerced to its checked form * (amounts to exact integers, hex strings to lowercase, etc). * @throws {TypeError} a field is missing, the wrong type, or the wrong shape. * @throws {RangeError} a field is the right type but outside its allowed bound. */ -function validateAll(params) { +function validateAll(params, context) { // Array.isArray, because typeof [] is 'object': a JSON-RPC call with POSITIONAL // params cleared this gate and died 50 lines later on 'pubkey is required', // reporting a missing field for what is a shape error. Same guard the @@ -255,12 +259,19 @@ function validateAll(params) { if (typeof params !== 'object' || params === null || Array.isArray(params)) { throw new ParamTypeError('Request params must be an object') } + // Refuse an unknown top-level param before any field check, so a misspelled + // money-path setting is named instead of silently falling back to its default. + for (const key of Object.keys(params)) { + if (!VALID_CREATE_TX_PARAMS.has(key)) { + throw new ParamTypeError(`Unknown create_tx param: "${truncateForMessage(key)}". Valid params: ${[...VALID_CREATE_TX_PARAMS].join(', ')}`) + } + } const { data, rawData, encoding } = validateActionFields(params) const { pubkey, fee, feePerKb, dust, utxos, customOutputs, feeQuote, p2shHash, p2shHex, compressedPubKey, change - } = validatePaymentFields(params) + } = validatePaymentFields(params, context) validateEncodingRequirements(encoding, compressedPubKey, p2shHash, pubkey) const { rbf, unconfirmed, attachPrevTx, compress, options diff --git a/src/common/validator/value_checks.js b/src/common/validator/value_checks.js index 7998460..169e634 100644 --- a/src/common/validator/value_checks.js +++ b/src/common/validator/value_checks.js @@ -12,9 +12,17 @@ * **********************************************************************/ -const { MAX_SATOSHI_U64 } = require('./constants') +const { MAX_SATOSHI_U64, COMPRESSED_PUBKEY_RE } = require('./constants') const { ParamTypeError, ParamRangeError } = require('../../build/errors') const validateAddress = (...args) => require('./transaction_checks').validateAddress(...args) +const assertPubkeyOnCurve = (...args) => require('./transaction_checks').assertPubkeyOnCurve(...args) + +// Quote at most 32 characters of a caller string in an error message, so a -32602 +// never sends multi-megabyte junk back whole; a cut value carries its full length. +function truncateForMessage(value, max = 32) { + const s = String(value) + return s.length > max ? `${s.slice(0, max)}...(${s.length} chars)` : s +} // Exact-integer parse for satoshi/fee money fields. Unlike parseInt, this // rejects decimals, scientific notation, hex, and trailing garbage ("1e8" -> @@ -39,7 +47,7 @@ function toExactInt(raw) { function validateOptionalBoolean(raw, label) { if (raw === undefined || raw === null) return undefined if (typeof raw !== 'boolean') { - throw new ParamTypeError(`${label} must be a boolean (got ${typeof raw}${typeof raw === 'string' ? ` "${raw}"` : ''}); string "false" would coerce to true`) + throw new ParamTypeError(`${label} must be a boolean (got ${typeof raw}${typeof raw === 'string' ? ` "${truncateForMessage(raw)}"` : ''}); string "false" would coerce to true`) } return raw } @@ -86,11 +94,11 @@ function parseSatoshiAmount(raw, label, opts) { // guaranteed non-negative, so this BigInt parse cannot throw. const big = BigInt(raw.trim()) if (big > MAX_SATOSHI_U64) { - throw new ParamRangeError(`${label} (${raw.trim()}) exceeds the maximum 64-bit satoshi amount (${MAX_SATOSHI_U64})`) + throw new ParamRangeError(`${label} (${truncateForMessage(raw.trim())}) exceeds the maximum 64-bit satoshi amount (${MAX_SATOSHI_U64})`) } return big } - throw new ParamRangeError(`${label} (${typeof raw === 'string' ? raw : num}) exceeds the maximum safe satoshi amount (${Number.MAX_SAFE_INTEGER}) and cannot be represented without precision loss${allowBig ? '; pass amounts above it as an exact decimal string' : ''}`) + throw new ParamRangeError(`${label} (${typeof raw === 'string' ? truncateForMessage(raw) : num}) exceeds the maximum safe satoshi amount (${Number.MAX_SAFE_INTEGER}) and cannot be represented without precision loss${allowBig ? '; pass amounts above it as an exact decimal string' : ''}`) } // Uncompressed public key hex: 04 then the 64-byte X and Y coordinates. @@ -107,7 +115,11 @@ function validatePubkey(pubkey) { throw new ParamTypeError('pubkey: uncompressed public keys (04 + 128 hex) are not supported; pass the compressed 02/03 + 64 hex form or a sender address') } // Hold everything else to the same shared bound as every address-shaped field. - return validateAddress(pubkey, 'pubkey') + validateAddress(pubkey, 'pubkey') + // A compressed key must also be a curve point: its HASH160 gates the chunk + // outputs and its address takes the reveal change, so an off-curve key locks both. + if (COMPRESSED_PUBKEY_RE.test(pubkey)) assertPubkeyOnCurve(Buffer.from(pubkey, 'hex'), 'pubkey') + return pubkey } // Index of the first code unit a latin-1 byte cannot hold, or -1. Written as a scan @@ -118,6 +130,7 @@ function firstNonLatin1(str) { } module.exports = { + truncateForMessage, toExactInt, validateOptionalBoolean, parseSatoshiAmount, diff --git a/src/protocol/validator_wire_actions.js b/src/protocol/validator_wire_actions.js new file mode 100644 index 0000000..fa5a53a --- /dev/null +++ b/src/protocol/validator_wire_actions.js @@ -0,0 +1,86 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * + * Validator wire actions: the name and format set the mainnet launch gate + * admits before launch. + * + * Consensus skips every pre-launch mainnet transaction that is not a + * validator wire action or from a pinned seed source, so the encoder's + * prelaunch refusal must admit exactly this set: a wider set lets a caller + * pay a fee for an action the indexer drops, and a narrower one refuses the + * hub's own oracle and anchor publishes. The set, the name and format reading + * and the verdict are pinned to the indexer's copy (src/consensus/gates/ + * validator_wire_actions.js) by test/unit/protocol/ + * validator_wire_actions_conformance.test.js. + * + ********************************************************************/ + +'use strict'; + +// ATTEST batch head and continuation formats, value-pinned to the indexer's attest batch wire constants. +const ATTEST_BATCH_HEAD_VERSION = 5; +const ATTEST_BATCH_CONTINUATION_VERSION = 6; + +// Largest format number the dispatcher reads; anything larger names no format. +const MAX_FORMAT = 255; + +// Hub-broadcast wire formats by action name. PRICE format 1 is the user oracle sharing the name, so it is absent. +const VALIDATOR_WIRE_FORMATS = Object.freeze({ + ANCHOR: Object.freeze([0, 1, 2, 3]), + PRICE: Object.freeze([0]), + NODEPROOF: Object.freeze([0]), + ROLLCALL: Object.freeze([0, 1]), + ATTEST: Object.freeze([1, 3, 4, ATTEST_BATCH_HEAD_VERSION, ATTEST_BATCH_CONTINUATION_VERSION]) +}); + +// Format of the second field, or null when it names no format; a missing or empty field means 0. +function parseFormat(field) { + if (field === undefined) + return 0; + const text = field.trim().replace(/"|'/g, ''); + if (text === '') + return 0; + // Refuse anything but a whole decimal number, so a signed, fractional or hex format names none. + if (!/^[0-9]+$/.test(text)) + return null; + const format = parseInt(text, 10); + return format <= MAX_FORMAT ? format : null; +} + +/** + * True when the raw action string is a validator wire action: its trimmed, + * upper-cased first field names a validator action and its second field a + * format that action broadcasts. Aliases are not expanded, as consensus does not. + * @param {*} data + * @returns {boolean} + */ +function isValidatorWireAction(data) { + // Refuse anything but a non-empty string, the only shape a wire action arrives in. + if (typeof data !== 'string' || data === '') + return false; + const parts = data.split('|'); + const name = parts[0].trim().toUpperCase(); + // Refuse a name outside the validator set, own keys only so an inherited name never matches. + if (!Object.prototype.hasOwnProperty.call(VALIDATOR_WIRE_FORMATS, name)) + return false; + const format = parseFormat(parts[1]); + return format !== null && VALIDATOR_WIRE_FORMATS[name].includes(format); +} + +module.exports = { + VALIDATOR_WIRE_FORMATS, + ATTEST_BATCH_HEAD_VERSION, + ATTEST_BATCH_CONTINUATION_VERSION, + isValidatorWireAction +}; diff --git a/src/server/crash_handlers.js b/src/server/crash_handlers.js index f5a3af5..93b7e45 100644 --- a/src/server/crash_handlers.js +++ b/src/server/crash_handlers.js @@ -26,10 +26,11 @@ * a supervised restart. An unhandled rejection logs and CONTINUES: attaching * the listener turns off node's default exit on an unhandled rejection. * - * They are installed from the entry-point guard in api.js, never at module - * scope: several suites require api.js in-process under mocha, which installs - * its own handlers, and a module-scope handler that exits would abort the whole - * run instead of failing one test. + * They are installed behind an entry-point guard near the top of api.js, before + * the singleton encoder is built, never at module scope: several suites require + * api.js in-process under mocha, which installs its own handlers, and a + * module-scope handler that exits would abort the whole run instead of failing + * one test. * ********************************************************************/ diff --git a/src/server/single_instance_guard.js b/src/server/single_instance_guard.js index a28f842..7a0944f 100644 --- a/src/server/single_instance_guard.js +++ b/src/server/single_instance_guard.js @@ -342,7 +342,8 @@ function acquireInstanceLock(lockPath, env = config, deps = {}) { const SIGNAL_EXIT_STATUS = { SIGTERM: 143, SIGINT: 130 } // How long to wait for the re-raise below to actually kill us before forcing -// the exit ourselves. Only the PID 1 case ever waits this long. +// the exit ourselves. Only a node running as PID 1 ever waits this long, which +// the supported container deploys never do (tini sits in front of node). const RERAISE_GRACE_MS = 50 // Release the instance lock when the process is SIGNALLED, not only when it @@ -362,12 +363,14 @@ function releaseLockOnSignals(release, proc = process) { // Re-raise so the process still dies OF the signal, leaving exit // status and supervision semantics exactly as they were. proc.kill(proc.pid, signal) - // ...except as PID 1, which is what the container CMD makes this - // process: the kernel drops signals PID 1 has installed no handler - // for, so the re-raise above is silently a no-op there and the - // process would sit until Docker's 10s SIGKILL. If we are still - // alive a moment later, exit with the status that signal would have - // produced, which is what the supervisor was going to record anyway. + // ...except as PID 1: the kernel drops signals PID 1 has installed + // no handler for, so the re-raise above is silently a no-op there + // and the process would sit until Docker's 10s SIGKILL. The + // supported deploys run node under the image's tini, so the + // re-raise normally kills it and this timer is a safety net for a + // run with no init in front of node. If we are still alive a moment + // later, exit with the status that signal would have produced, + // which is what the supervisor was going to record anyway. const timer = setTimeout(() => proc.exit(SIGNAL_EXIT_STATUS[signal]), RERAISE_GRACE_MS) if (typeof timer.unref === 'function') timer.unref() } diff --git a/test/boundary/change_address_boundaries.test.js b/test/boundary/change_address_boundaries.test.js index 9471fef..65cb81e 100644 --- a/test/boundary/change_address_boundaries.test.js +++ b/test/boundary/change_address_boundaries.test.js @@ -36,6 +36,9 @@ const { // sub-100000 fees, so this suite runs on bitcoin-regtest; LTC dust (5460) has // its own tests below using a local litecoin-regtest encoder. const NETWORK = 'bitcoin-regtest' +// One input, an OP_RETURN and no change output is 79 stripped bytes with 'SEND|0|X|1|a', +// under Litecoin Core's 82-byte relay floor, so the Litecoin case carries a longer action. +const LTC_FOLDING_ACTION = 'SEND|0|XCHAIN|1|abcdef' const BTC_DUST = 546 const LTC_DUST = 5460 @@ -183,7 +186,7 @@ describe('Change Address Boundaries', () => { const result = await ltcEncoder.createTransaction( [utxo], ltcAddress, null, - 'SEND|0|X|1|a', null, fee, false, null, null, + LTC_FOLDING_ACTION, null, fee, false, null, null, null, null, null, true, 0.00001 ) diff --git a/test/e2e/multi_chain_e2e.test.js b/test/e2e/multi_chain_e2e.test.js index 58db61a..f6ac0b7 100644 --- a/test/e2e/multi_chain_e2e.test.js +++ b/test/e2e/multi_chain_e2e.test.js @@ -160,7 +160,7 @@ describe('E2E-6: Multi-Chain Validation', () => { describe('E2E-6: Multi-Chain Validation', () => { describe('E2E-6.9: Litecoin MULTISIGN (dust=5460)', () => { - it('multisig output at 5460 sats', async () => { + it('multisig output at 7860 sats', async () => { const encoder = makeEncoder('litecoin-regtest') const address = getTestAddress('litecoin-regtest') const utxo = makeUtxo('litecoin-regtest', TXID_MULTISIGN, 0, 100000000) @@ -172,8 +172,8 @@ describe('E2E-6: Multi-Chain Validation', () => { ) assert.strictEqual(result.encoding, 'MULTISIGN') - const msOutput = result.psbt.txOutputs.find(o => o.value === 5460) - assert.ok(msOutput, 'Litecoin multisig output should be 5460 sats') + const msOutput = result.psbt.txOutputs.find(o => o.value === 7860) + assert.ok(msOutput, 'Litecoin multisig output should be 7860 sats, the bare-multisig relay floor at 30 sat/byte') }) }) }) diff --git a/test/helpers/psbt_build_fixtures.js b/test/helpers/psbt_build_fixtures.js new file mode 100644 index 0000000..23c82bd --- /dev/null +++ b/test/helpers/psbt_build_fixtures.js @@ -0,0 +1,88 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Fixtures for PSBT conformance suites: one test key (private key 1, so its public +// key is the curve generator), funding UTXOs of each script type with their whole +// previous transactions, an encoder over a stubbed node, and named-argument builds. + +const crypto = require('crypto') +const bitcoin = require('bitcoinjs-lib') +const ecc = require('tiny-secp256k1') +const { ECPairFactory } = require('ecpair') +const XChainEncoder = require('../../src/XChainEncoder') +const CryptoNetworks = require('../../src/build/crypto_networks') + +const ECPair = ECPairFactory(ecc) +const PRIVATE_KEY = Buffer.concat([Buffer.alloc(31), Buffer.from([1])]) +const PUBKEY = Buffer.from(ECPair.fromPrivateKey(PRIVATE_KEY).publicKey) +const PUBKEY_HEX = PUBKEY.toString('hex') + +function networkOf (key) { + return CryptoNetworks.getBitcoinJsNetwork(key) +} + +// The output script of `kind` for `pubkey` (default: the test key) on `network`. +function paymentFor (kind, network, pubkey = PUBKEY) { + const net = networkOf(network) + if (kind === 'p2wpkh') return bitcoin.payments.p2wpkh({ pubkey, network: net }) + if (kind === 'p2pkh') return bitcoin.payments.p2pkh({ pubkey, network: net }) + if (kind === 'p2sh-p2wpkh') return bitcoin.payments.p2sh({ redeem: bitcoin.payments.p2wpkh({ pubkey, network: net }), network: net }) + throw new Error('unknown kind ' + kind) +} + +// A UTXO paying `script`, plus its previous transaction registered in `prevTxs`. +function utxoFor (prevTxs, script, value, seed) { + const tx = new bitcoin.Transaction() + tx.addInput(crypto.createHash('sha256').update('fixture:' + seed).digest(), 0) + tx.addOutput(script, value) + prevTxs.set(tx.getId(), tx.toHex()) + return { txid: tx.getId(), vout: 0, value, confirmations: 6, scriptPubKey: script.toString('hex') } +} + +// An encoder whose node answers fee, relay and previous-transaction calls from memory. +function makeEncoder (network, { estimate, relayfee, prevTxs } = {}) { + const encoder = new XChainEncoder(network, '127.0.0.1', '8333', 'rpc', 'rpc', '', '') + encoder.connector = { + getFeePerKilobyte: async () => estimate, + getNetworkInfo: async () => ({ relayfee }), + getTransactionHex: async (txid) => { + if (!prevTxs || !prevTxs.has(txid)) throw new Error('no previous transaction ' + txid) + return prevTxs.get(txid) + } + } + return encoder +} + +// createTransaction with named arguments, in payload_preparation.js's positional order. +function build (encoder, a) { + return encoder.createTransaction( + a.utxos, a.pubkey, a.customOutputs || null, a.data == null ? null : a.data, null, + a.fee == null ? null : a.fee, false, a.encoding || null, a.change || null, + a.p2shHash || null, a.p2shHex || null, a.compressedPubKey || null, true, + a.feePerKb == null ? null : a.feePerKb, null, a.feeQuote || null) +} + +function unsignedTx (result) { + return bitcoin.Transaction.fromBuffer(result.psbt.data.globalMap.unsignedTx.toBuffer()) +} + +// Input total minus output total of a built PSBT, in base units. +function feeOf (psbt) { + let total = 0n + psbt.data.inputs.forEach((input, i) => { + total += input.witnessUtxo + ? BigInt(input.witnessUtxo.value) + : BigInt(bitcoin.Transaction.fromBuffer(input.nonWitnessUtxo).outs[psbt.txInputs[i].index].value) + }) + for (const out of psbt.txOutputs) total -= BigInt(out.value) + return total +} + +module.exports = { ECPair, PRIVATE_KEY, PUBKEY, PUBKEY_HEX, networkOf, paymentFor, utxoFor, makeEncoder, build, unsignedTx, feeOf } diff --git a/test/integration/custom_outputs.test.js b/test/integration/custom_outputs.test.js index aa14f82..4f3ece0 100644 --- a/test/integration/custom_outputs.test.js +++ b/test/integration/custom_outputs.test.js @@ -123,8 +123,12 @@ describe('Category E: Custom Outputs (COINPAY Integration)', () => { const changeWithCustom = resultWithCustom.psbt.txOutputs .reduce((max, o) => o.value > max ? o.value : max, 0) - assert.strictEqual(changeNoCustom - changeWithCustom, 500000, - 'change should decrease by exactly the custom output value') + // On Dogecoin a 500000-koinu payment sits under the 0.01 DOGE soft dust limit, so + // its fee carries the whole 1000000 limit on top of the explicit 10000 (1010000), + // where the build without it pays the 100000 dust floor. Change drops by the + // payment plus that 910000 fee difference. + assert.strictEqual(changeNoCustom - changeWithCustom, 500000 + (10000 + 1000000 - 100000), + 'change should decrease by the custom output value plus its soft-dust surcharge') }) }) }) diff --git a/test/integration/multi_chain.test.js b/test/integration/multi_chain.test.js index 3634677..92cd8df 100644 --- a/test/integration/multi_chain.test.js +++ b/test/integration/multi_chain.test.js @@ -38,14 +38,16 @@ const CHAINS = [ const MS_DATA = 'A'.repeat(59) // A bare 1-of-3 multisig output is far larger than a P2PKH, so its relay dust -// floor is Bitcoin Core's size formula (output_bytes + 148) * 3 sat/byte, not -// the chain's flat P2PKH dustThreshold. The encoder takes the MAX of the two. -// On Bitcoin the size formula wins (786 > 546); on Litecoin and Dogecoin the +// floor is the node's size formula (output_bytes + 148) * the coin's dust relay +// rate (DUST_RELAY_TX_FEE / 1000: 3 on Bitcoin, 30 on Litecoin), not the chain's +// flat P2PKH dustThreshold. The encoder takes the MAX of the two. On Bitcoin +// (786 > 546) and Litecoin (7860 > 5460) the size formula wins; on Dogecoin the // chain threshold wins. Looking the multisig output up by -// `value === encoder.dustAmount` silently fails to match on Bitcoin under -// that max(), so such a lookup asserts nothing on either chain. -function bareMultisigDust (scriptLength) { - return Math.ceil((8 + 1 + scriptLength + 148) * 3) +// `value === encoder.dustAmount` silently fails to match under that max(), so +// such a lookup asserts nothing. +const DUST_RELAY_PER_BYTE = { 'bitcoin-regtest': 3, 'litecoin-regtest': 30 } +function bareMultisigDust (scriptLength, networkName = 'bitcoin-regtest') { + return Math.ceil((8 + 1 + scriptLength + 148) * (DUST_RELAY_PER_BYTE[networkName] || 3)) } async function buildMultisign (networkName) { @@ -125,7 +127,7 @@ describe('Category F: Multi-Chain Network Configs', () => { it('MULTISIGN output clears both the chain dust floor and the bare-multisig relay floor', async () => { for (const chain of CHAINS) { const { encoder, output } = await buildMultisign(chain.name) - const relayFloor = bareMultisigDust(output.script.length) + const relayFloor = bareMultisigDust(output.script.length, chain.name) assert.strictEqual(encoder.dustAmount, chain.dustThreshold) assert.strictEqual(output.value, Math.max(chain.dustThreshold, relayFloor), `${chain.name} MULTISIGN output should sit at max(dustThreshold, bare-multisig relay floor)`) @@ -140,8 +142,9 @@ describe('Category F: Multi-Chain Network Configs', () => { // Bitcoin's 546 floor is below the relay formula, so the formula binds. assert.strictEqual(btc.output.value, bareMultisigDust(btc.output.script.length)) - // Litecoin's 5460 floor is above it, so the chain threshold binds. - assert.strictEqual(ltc.output.value, 5460) + // Litecoin relays dust at 30 per byte, so the same formula binds above its 5460 threshold. + assert.strictEqual(ltc.output.value, bareMultisigDust(ltc.output.script.length, 'litecoin-regtest')) + assert.strictEqual(ltc.output.value, 7860) assert.ok(ltc.output.value > btc.output.value, 'Litecoin dust should be higher than Bitcoin') }) diff --git a/test/security/input_validation.test.js b/test/security/input_validation.test.js index 76fd6ff..6ab7dea 100644 --- a/test/security/input_validation.test.js +++ b/test/security/input_validation.test.js @@ -185,6 +185,11 @@ describe('Security: validateFee / validateFeePerKb / validateDust', () => { assert.throws(() => V.validateDust('xyz'), TypeError) }) + it('rejects an over-max feePerKb (parity with the fee ceiling)', () => { + assert.throws(() => V.validateFeePerKb(V.MAX_FEE_SATOSHIS + 1), RangeError) + assert.strictEqual(V.validateFeePerKb(2000), 2000) + }) + it('rejects over-max dust (parity with the fee ceiling)', () => { assert.throws(() => V.validateDust(V.MAX_FEE_SATOSHIS + 1), RangeError) assert.strictEqual(V.validateDust(546), 546) diff --git a/test/unit/XChainEncoder/caller_pubkey_resolution.test.js b/test/unit/XChainEncoder/caller_pubkey_resolution.test.js new file mode 100644 index 0000000..28e022b --- /dev/null +++ b/test/unit/XChainEncoder/caller_pubkey_resolution.test.js @@ -0,0 +1,122 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// The caller pubkey is refused as caller input (-32602) when it is off the curve +// or resolves to no address, on the validator, both resolvers and the build. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const secp = require('tiny-secp256k1') +const v = require('../../../src/common/validator.js') +const { resolveCallerHash160, resolveCallerAddress } = require('../../../src/XChainEncoder/request_resolution.js') +const { TXID_A, PUBKEY_BUF, makeUtxo, makeEncoder, getTestAddress } = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') +const { createJsonRpcController } = require('../../../src/api/json_rpc_methods') + +// x = 5 has no secp256k1 point; the shape regexes and bitcoinjs both accept it. +const OFF_CURVE = '02' + '0'.repeat(63) + '5' +const ON_CURVE = PUBKEY_BUF.toString('hex') +const DOGE = 'dogecoin-regtest' + +function isCallerInput (pattern) { + return (err) => err instanceof TypeError && err.invalidParams === true && pattern.test(err.message) +} + +describe('caller pubkey: curve check and caller-input refusals', () => { + it('uses a fixture key that really is off the curve and passes the shape checks', () => { + assert.strictEqual(secp.isPoint(Buffer.from(OFF_CURVE, 'hex')), false) + assert.strictEqual(secp.isPoint(PUBKEY_BUF), true) + assert.ok(/^(02|03)[0-9a-fA-F]{64}$/.test(OFF_CURVE)) + }) + + it('validatePubkey refuses an off-curve compressed key and keeps a real one', () => { + assert.throws(() => v.validatePubkey(OFF_CURVE), isCallerInput(/pubkey is not a valid secp256k1 point/)) + assert.strictEqual(v.validatePubkey(ON_CURVE), ON_CURVE) + }) + + it('resolveCallerHash160 refuses an off-curve key instead of hashing it into the chunk gate', () => { + assert.throws(() => resolveCallerHash160(OFF_CURVE), isCallerInput(/not a valid secp256k1 point/)) + assert.deepStrictEqual(resolveCallerHash160(ON_CURVE), bitcoin.crypto.hash160(PUBKEY_BUF)) + }) + + it('resolveCallerHash160 types an unresolvable identity as caller input', () => { + assert.throws(() => resolveCallerHash160('deadbeefdeadbeef'), isCallerInput(/cannot resolve a 20-byte caller HASH160/)) + }) + + it('resolveCallerAddress refuses an off-curve key and an unresolvable identity', () => { + const network = bitcoin.networks.regtest + assert.throws(() => resolveCallerAddress(OFF_CURVE, network), isCallerInput(/not a valid secp256k1 point/)) + assert.throws(() => resolveCallerAddress('deadbeefdeadbeef', network), isCallerInput(/pubkey is neither a valid address/)) + }) + + it('resolveCallerAddress still passes an on-network address through, Taproot included', () => { + const network = bitcoin.networks.regtest + const p2wpkh = bitcoin.payments.p2wpkh({ pubkey: PUBKEY_BUF, network }).address + const p2tr = bitcoin.address.toBech32(Buffer.alloc(32, 1), 1, network.bech32) + assert.strictEqual(resolveCallerAddress(p2wpkh, network), p2wpkh) + assert.strictEqual(resolveCallerAddress(p2tr, network), p2tr) + assert.strictEqual(resolveCallerAddress(ON_CURVE, network), p2wpkh) + }) + + it('a P2SH build with an off-curve pubkey and caller utxos is refused before any output is built', async () => { + const encoder = makeEncoder(DOGE) + await assert.rejects( + () => encoder.createTransaction( + [makeUtxo(DOGE, TXID_A, 0, 100000000)], OFF_CURVE, null, + actions.makeIssueFull().data, null, 10000, false, null, getTestAddress(DOGE), + null, null, null, true, 0.00001 + ), + isCallerInput(/not a valid secp256k1 point/) + ) + }) +}) + +describe('caller pubkey: the tracker lane answers an unresolvable identity with -32602', () => { + const NETWORK = 'bitcoin-regtest' + const address = getTestAddress(NETWORK) + + // Run create_tx with no caller utxos, so the build fetches from the tracker. + async function createTx (pubkey) { + const encoder = makeEncoder(NETWORK) + const trackerCalls = [] + const fetch = encoder.utxoTrackerConnector.getUtxosFromAddress + encoder.utxoTrackerConnector.getUtxosFromAddress = (...a) => { trackerCalls.push(a[0]); return fetch(...a) } + const controller = createJsonRpcController({ encoder, NETWORK }) + const params = { pubkey, data: actions.makeSend().data, change: address, unconfirmed: true } + try { + return { result: await controller.create_tx(params), trackerCalls } + } catch (caught) { + return { caught, trackerCalls } + } + } + + function assertPubkeyFault (outcome) { + assert.ok(outcome.caught, 'the request should have been refused') + assert.strictEqual(outcome.caught.code, -32602, outcome.caught.message) + assert.ok(outcome.caught.message.includes('pubkey'), outcome.caught.message) + assert.deepStrictEqual(outcome.trackerCalls, [], 'the tracker was asked for an unresolvable identity') + } + + it('a hex string that is no pubkey and no address', async () => { + assertPubkeyFault(await createTx('deadbeefdeadbeef')) + }) + + it('a mainnet address on regtest', async () => { + assertPubkeyFault(await createTx('1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa')) + }) + + it('a regtest address and a compressed pubkey hex still reach the tracker and build', async () => { + for (const pubkey of [address, ON_CURVE]) { + const outcome = await createTx(pubkey) + assert.ok(!outcome.caught, outcome.caught && outcome.caught.message) + assert.strictEqual(outcome.trackerCalls.length, 1) + } + }) +}) diff --git a/test/unit/api/estimate_fee_tier_order.test.js b/test/unit/api/estimate_fee_tier_order.test.js new file mode 100644 index 0000000..f9b7fd3 --- /dev/null +++ b/test/unit/api/estimate_fee_tier_order.test.js @@ -0,0 +1,38 @@ +'use strict' + +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +const assert = require('assert') +const { createJsonRpcController } = require('../../../src/api/json_rpc_methods') + +function controllerFor (rates, calls = []) { + const encoder = { + connector: { + assertNodeTier: async () => {}, + getFeePerKilobyte: async target => { + calls.push(target) + return rates[target] + } + } + } + return createJsonRpcController({ encoder, NETWORK: 'bitcoin-mainnet' }) +} + +describe('estimate_fee tier ordering', function () { + it('lifts faster tiers to the slower tier when node estimates decrease', async function () { + const calls = [] + const controller = controllerFor({ 6: 0.00005, 3: 0.00001, 1: 0.00001 }, calls) + + assert.deepStrictEqual(await controller.estimate_fee(), { low: 5, medium: 5, high: 5 }) + assert.deepStrictEqual(calls, [6, 3, 1]) + }) + + it('preserves increases while lifting a later dip', async function () { + const controller = controllerFor({ 6: 0.00001, 3: 0.00003, 1: 0.00002 }) + + assert.deepStrictEqual(await controller.estimate_fee(), { low: 1, medium: 3, high: 3 }) + }) +}) diff --git a/test/unit/api/get_tx_status.test.js b/test/unit/api/get_tx_status.test.js new file mode 100644 index 0000000..d90f14c --- /dev/null +++ b/test/unit/api/get_tx_status.test.js @@ -0,0 +1,190 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +const assert = require('assert') +const axios = require('axios') +const BlockchainConnector = require('../../../src/build/blockchain_connector') +const { createJsonRpcController } = require('../../../src/api/json_rpc_methods') + +const TXID = 'a'.repeat(64) + +function controllerWith ({ mempoolEntry = null, block = null, nodeError, trackerError } = {}) { + const calls = [] + const encoder = { + connector: { + async getMempoolEntry (txid) { + calls.push(['mempool', txid]) + if (nodeError) throw nodeError + return mempoolEntry + } + }, + utxoTrackerConnector: { + async getTxBlock (txid) { + calls.push(['chain', txid]) + if (trackerError) throw trackerError + return block + } + } + } + return { + calls, + controller: createJsonRpcController({ encoder, NETWORK: 'dogecoin-testnet' }) + } +} + +function connector () { + return new BlockchainConnector('127.0.0.1', 22555, 'rpcuser', 'rpcpass') +} + +describe('get_tx_status transaction location', function () { + it('returns mempool and does not ask the tracker when getmempoolentry finds the txid', async function () { + const { controller, calls } = controllerWith({ mempoolEntry: { vsize: 250 } }) + + assert.deepStrictEqual(await controller.get_tx_status({ txid: TXID }), { status: 'mempool' }) + assert.deepStrictEqual(calls, [['mempool', TXID]]) + }) + + it('returns chain after a mempool miss when get_tx_block finds the txid', async function () { + const { controller, calls } = controllerWith({ block: { block_height: 123 } }) + + assert.deepStrictEqual(await controller.get_tx_status({ txid: TXID }), { status: 'chain' }) + assert.deepStrictEqual(calls, [['mempool', TXID], ['chain', TXID]]) + }) + + it('returns unknown only when both the mempool and chain lookups miss', async function () { + const { controller, calls } = controllerWith() + + assert.deepStrictEqual(await controller.get_tx_status({ txid: TXID }), { status: 'unknown' }) + assert.deepStrictEqual(calls, [['mempool', TXID], ['chain', TXID]]) + }) +}) + +describe('get_tx_status transaction location', function () { + it('throws rather than returning unknown when the node lookup fails', async function () { + const { controller, calls } = controllerWith({ nodeError: new Error('node warming up') }) + const originalError = console.error + console.error = () => {} + + try { + await assert.rejects( + () => controller.get_tx_status({ txid: TXID }), + (err) => err.code === -32603 && /node warming up/.test(err.message) + ) + assert.deepStrictEqual(calls, [['mempool', TXID]]) + } finally { + console.error = originalError + } + }) + + it('throws rather than returning unknown when the tracker lookup fails', async function () { + const { controller } = controllerWith({ trackerError: new Error('tracker unavailable') }) + const originalError = console.error + console.error = () => {} + + try { + await assert.rejects( + () => controller.get_tx_status({ txid: TXID }), + (err) => err.code === -32603 && /tracker unavailable/.test(err.message) + ) + } finally { + console.error = originalError + } + }) + + it('rejects a malformed txid before either lookup', async function () { + const { controller, calls } = controllerWith() + + await assert.rejects( + () => controller.get_tx_status({ txid: 'not-a-txid' }), + (err) => err.code === -32602 && /64-hex-character/.test(err.message) + ) + assert.deepStrictEqual(calls, []) + }) +}) + +describe('BlockchainConnector getMempoolEntry', function () { + let originalPost + + beforeEach(function () { + originalPost = axios.post + }) + + afterEach(function () { + axios.post = originalPost + }) + + it('sends getmempoolentry with the txid and returns the entry', async function () { + const expected = { vsize: 250 } + let request + axios.post = async (url, data, options) => { + request = { url, data, options } + return { data: { result: expected } } + } + + assert.strictEqual(await connector().getMempoolEntry(TXID), expected) + assert.strictEqual(request.url, 'http://127.0.0.1:22555') + assert.deepStrictEqual(request.data, { + jsonrpc: '2.0', method: 'getmempoolentry', params: [TXID], id: 1 + }) + assert.deepStrictEqual(request.options.auth, { username: 'rpcuser', password: 'rpcpass' }) + assert.ok(request.options.timeout > 0) + }) + + it('returns null for an HTTP 200 RPC -5 mempool miss', async function () { + axios.post = async () => ({ data: { error: { code: -5, message: 'Transaction not in mempool' } } }) + + assert.strictEqual(await connector().getMempoolEntry(TXID), null) + }) + + it('returns null for an HTTP 500 RPC -5 mempool miss', async function () { + axios.post = async () => { + const err = new Error('Request failed with status code 500') + err.response = { data: { error: { code: -5, message: 'Transaction not in mempool' } } } + throw err + } + + assert.strictEqual(await connector().getMempoolEntry(TXID), null) + }) +}) + +describe('BlockchainConnector getMempoolEntry', function () { + let originalPost + + beforeEach(function () { + originalPost = axios.post + }) + + afterEach(function () { + axios.post = originalPost + }) + + it('throws an unexpected RPC error instead of treating it as absent', async function () { + const originalError = console.error + console.error = () => {} + axios.post = async () => ({ data: { error: { code: -28, message: 'Transaction not in mempool' } } }) + + try { + await assert.rejects(() => connector().getMempoolEntry(TXID), /Transaction not in mempool/) + } finally { + console.error = originalError + } + }) + + it('throws a transport error instead of treating it as absent', async function () { + const originalError = console.error + console.error = () => {} + const transport = new Error('connect ECONNREFUSED 127.0.0.1:22555') + transport.code = 'ECONNREFUSED' + axios.post = async () => { throw transport } + + try { + await assert.rejects(() => connector().getMempoolEntry(TXID), (err) => err === transport) + } finally { + console.error = originalError + } + }) +}) diff --git a/test/unit/api/inherited_method_names.test.js b/test/unit/api/inherited_method_names.test.js new file mode 100644 index 0000000..5e7f6ce --- /dev/null +++ b/test/unit/api/inherited_method_names.test.js @@ -0,0 +1,159 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * + * The JSON-RPC router decides a method exists with `method in controller`, + * which also finds names inherited from Object.prototype. These tests pin that + * the encoder's dispatch table holds only its own methods, so `constructor`, + * `toString` and the rest answer -32601 Method not found like any unknown name. + * + ********************************************************************/ + +'use strict' + +const assert = require('assert') +const http = require('http') + +const API_PATH = require.resolve('../../../src/api.js') +const JSON_RPC_METHODS_PATH = require.resolve('../../../src/api/json_rpc_methods.js') +const API_KEY_ENV = ['API', 'KEY'].join('_') +const { createJsonRpcController } = require('../../../src/api/json_rpc_methods.js') + +// Every name a plain object inherits; none of them is an encoder method. +const INHERITED = [ + 'constructor', 'valueOf', 'toString', 'toLocaleString', 'hasOwnProperty', + 'isPrototypeOf', 'propertyIsEnumerable', '__proto__', '__defineGetter__', + '__defineSetter__', '__lookupGetter__', '__lookupSetter__' +] + +// POST one JSON body to the bound app and parse the JSON reply. +function postRpc (server, payload) { + const body = JSON.stringify(payload) + return new Promise((resolve, reject) => { + const req = http.request({ + host: '127.0.0.1', + port: server.address().port, + path: '/', + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } + }, (res) => { + const chunks = [] + res.on('data', chunk => chunks.push(chunk)) + res.on('end', () => { + try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))) } catch (err) { reject(err) } + }) + }) + req.on('error', reject) + req.end(body) + }) +} + +describe('JSON-RPC controller holds only its own methods', function () { + it('finds no inherited name in the dispatch table', function () { + const controller = createJsonRpcController({ encoder: {}, NETWORK: 'bitcoin-regtest' }) + for (const name of INHERITED) { + assert.ok(!(name in controller), `${name} is reachable through the router's \`in\` check`) + } + }) + + it('still carries the real methods as own functions', function () { + const controller = createJsonRpcController({ encoder: {}, NETWORK: 'bitcoin-regtest' }) + for (const name of ['ping', 'health', 'create_tx', 'broadcast_tx']) { + assert.ok(Object.hasOwn(controller, name), `${name} must be an own key`) + assert.strictEqual(typeof controller[name], 'function') + } + }) +}) + +// Load the real app in open mode (no key gate) with dotenv and the maintenance file stubbed out. +async function startOpenApp() { + const dotenv = require('dotenv') + const maintenance = require('../../../src/server/maintenance_window') + const state = { cachedApi: require.cache[API_PATH], cachedMethods: require.cache[JSON_RPC_METHODS_PATH] } + const originalDotenvConfig = dotenv.config + const originalReadMaintenanceWindow = maintenance.readMaintenanceWindow + const priorNetwork = process.env.NETWORK + const priorApiKey = process.env[API_KEY_ENV] + dotenv.config = () => ({ parsed: {} }) + maintenance.readMaintenanceWindow = async () => null + process.env.NETWORK = 'bitcoin-regtest' + delete process.env[API_KEY_ENV] + delete require.cache[API_PATH] + delete require.cache[JSON_RPC_METHODS_PATH] + let api + try { + api = require(API_PATH) + } finally { + dotenv.config = originalDotenvConfig + maintenance.readMaintenanceWindow = originalReadMaintenanceWindow + if (priorNetwork === undefined) delete process.env.NETWORK + else process.env.NETWORK = priorNetwork + if (priorApiKey === undefined) delete process.env[API_KEY_ENV] + else process.env[API_KEY_ENV] = priorApiKey + } + state.server = await new Promise(resolve => { + const listener = api.app.listen(0, '127.0.0.1', () => resolve(listener)) + }) + return state +} + +// Close the listener and put the module cache back as it was. +async function stopOpenApp(state) { + if (!state) return + if (state.server) { + if (typeof state.server.closeAllConnections === 'function') state.server.closeAllConnections() + await new Promise(resolve => state.server.close(resolve)) + } + delete require.cache[API_PATH] + delete require.cache[JSON_RPC_METHODS_PATH] + if (state.cachedApi) require.cache[API_PATH] = state.cachedApi + if (state.cachedMethods) require.cache[JSON_RPC_METHODS_PATH] = state.cachedMethods +} + +describe('JSON-RPC controller holds only its own methods over HTTP', function () { + this.timeout(10000) + let app + let server + + before(async function () { + app = await startOpenApp() + server = app.server + }) + + after(async function () { + await stopOpenApp(app) + }) + + it('answers -32601 for constructor instead of echoing the params', async function () { + const body = await postRpc(server, { jsonrpc: '2.0', id: 1, method: 'constructor', params: { probe: 1 } }) + assert.strictEqual(body.result, undefined, `constructor dispatched: ${JSON.stringify(body)}`) + assert.strictEqual(body.error.code, -32601) + assert.strictEqual(body.id, 1) + }) + + it('answers -32601 for every inherited name in one batch', async function () { + const batch = INHERITED.map((method, i) => ({ jsonrpc: '2.0', id: i + 1, method, params: {} })) + const replies = await postRpc(server, batch) + assert.strictEqual(replies.length, INHERITED.length) + for (const reply of replies) { + const name = INHERITED[reply.id - 1] + assert.strictEqual(reply.result, undefined, `${name} dispatched: ${JSON.stringify(reply)}`) + assert.strictEqual(reply.error.code, -32601, `${name}: ${JSON.stringify(reply.error)}`) + } + }) + + it('still dispatches a real method', async function () { + const body = await postRpc(server, { jsonrpc: '2.0', id: 99, method: 'ping' }) + assert.strictEqual(body.result.status, 'success') + }) +}) diff --git a/test/unit/api/json_rpc_real_app.test.js b/test/unit/api/json_rpc_real_app.test.js index b6d9c90..806a738 100644 --- a/test/unit/api/json_rpc_real_app.test.js +++ b/test/unit/api/json_rpc_real_app.test.js @@ -126,6 +126,7 @@ async function routeEachPublicHandler () { } assert.deepStrictEqual(broadcast.body.result, { txid }) assert.deepStrictEqual(calls.broadcast, [txHex]) + assert.deepStrictEqual(fees.body.result, { low: 6, medium: 6, high: 6 }) assert.deepStrictEqual(calls.fees, [6, 3, 1]) assert.deepStrictEqual(cancel.body.result, { psbt: 'cancel-psbt', encoding: 'TAPROOT', cancel: true }) assert.deepStrictEqual(calls.cancel, [cancelParams]) diff --git a/test/unit/api/mainnet_prelaunch_refusal.test.js b/test/unit/api/mainnet_prelaunch_refusal.test.js new file mode 100644 index 0000000..83acbe1 --- /dev/null +++ b/test/unit/api/mainnet_prelaunch_refusal.test.js @@ -0,0 +1,154 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// A mainnet encoder refuses create_tx for non-validator actions before 1798761600, +// with MAINNET_PRELAUNCH, unless the operator sets ENCODER_MAINNET_PRELAUNCH_OVERRIDE. + +const assert = require('assert') +const { createJsonRpcController } = require('../../../src/api/json_rpc_methods') +const { MAINNET_LAUNCH_TS, assertNotMainnetPrelaunch } = require('../../../src/common/config') +const { makeEncoder, getTestAddress } = require('../../integration/helpers/utxoFactory') +const actions = require('../../integration/helpers/actionFactory') + +const ENV = 'ENCODER_MAINNET_PRELAUNCH_OVERRIDE' +const BEFORE = (MAINNET_LAUNCH_TS - 60) * 1000 +const AT = MAINNET_LAUNCH_TS * 1000 + +let built, address, controller, savedEnv, realNow + +function setup (network, clock = BEFORE) { + const encoder = makeEncoder(network) + built = 0 + encoder.createTransaction = async () => { + built++ + return { psbt: { toHex: () => 'aa' } } + } + address = getTestAddress(network) + controller = createJsonRpcController({ encoder, NETWORK: network }) + Date.now = () => clock +} +const params = (data) => ({ pubkey: address, data, change: address, unconfirmed: true, feePerKb: 5000 }) +const send = () => controller.create_tx(params(actions.makeSend().data)) +const isPrelaunch = (err) => err.data.reason === 'MAINNET_PRELAUNCH' + +function isolateClockAndEnv () { + beforeEach(() => { savedEnv = process.env[ENV]; realNow = Date.now; delete process.env[ENV] }) + afterEach(() => { + Date.now = realNow + if (savedEnv === undefined) delete process.env[ENV]; else process.env[ENV] = savedEnv + }) +} + +describe('mainnet prelaunch refusal', () => { + isolateClockAndEnv() + + it('refuses a non-validator action before launch as -32010 MAINNET_PRELAUNCH', async () => { + setup('bitcoin-mainnet') + await assert.rejects(send(), (err) => { + assert.strictEqual(err.code, -32010) + assert.strictEqual(err.data.reason, 'MAINNET_PRELAUNCH') + assert.strictEqual(err.data.opens_at, MAINNET_LAUNCH_TS) + return true + }) + assert.strictEqual(built, 0) + }) + + it('refuses an alias of a non-validator action (TRANSFER)', async () => { + setup('bitcoin-mainnet') + await assert.rejects(controller.create_tx(params('TRANSFER|0|JDOG|1|' + address)), isPrelaunch) + }) + + it('builds a non-validator action at and after launch', async () => { + setup('bitcoin-mainnet', AT) + await send() + assert.strictEqual(built, 1) + }) + + it('never refuses a testnet encoder', async () => { + setup('bitcoin-testnet') + await send() + assert.strictEqual(built, 1) + }) +}) + +// The validator wire set by name and format, the set consensus admits on mainnet before launch. +const ADMITTED = ['ANCHOR|0', 'ANCHOR|1', 'ANCHOR|2', 'ANCHOR|3', 'PRICE|0', 'NODEPROOF|0', + 'ROLLCALL|0', 'ROLLCALL|1', 'ATTEST|1', 'ATTEST|3', 'ATTEST|4', 'ATTEST|5', 'ATTEST|6'] +// User actions consensus skips before launch, including the user formats that share a validator name. +const REFUSED = ['PRICE|1', 'ATTEST|0', 'ATTEST|2', 'ANCHOR|4', 'STAKE|0', 'UNSTAKE|0', 'DELEGATE|0', 'SLASH|0'] + +describe('mainnet prelaunch validator actions', () => { + isolateClockAndEnv() + + for (const head of ADMITTED) { + it(`lets ${head} through before launch`, async () => { + setup('bitcoin-mainnet') + await controller.create_tx(params(head + '|x')) + assert.strictEqual(built, 1) + }) + } + + it('reads the name the way consensus does: trimmed and upper-cased, format optional, Buffer decoded', () => { + const guard = (data) => assertNotMainnetPrelaunch({ consensusNetwork: 'mainnet' }, { data }, () => BEFORE) + for (const data of [' anchor |0|x', 'NODEPROOF', 'PRICE|"0"|x', Buffer.from('ATTEST|5|x')]) { + assert.doesNotThrow(() => guard(data), `refused ${data}`) + } + for (const data of ['PRICE|01x', 'ATTEST|5.0', 'ANCHOR|256', null, '']) { + assert.throws(() => guard(data), isPrelaunch, `admitted ${data}`) + } + }) +}) + +describe('mainnet prelaunch user actions sharing or missing the validator set', () => { + isolateClockAndEnv() + + for (const head of REFUSED) { + it(`refuses ${head} before launch`, async () => { + setup('bitcoin-mainnet') + await assert.rejects(controller.create_tx(params(head + '|x')), isPrelaunch) + assert.strictEqual(built, 0) + }) + } + + it('builds a user format of a validator name at launch', async () => { + setup('bitcoin-mainnet', AT) + await controller.create_tx(params('PRICE|1|x')) + assert.strictEqual(built, 1) + }) +}) + +describe('mainnet prelaunch operator override', () => { + isolateClockAndEnv() + + for (const value of ['1', 'true', 'YES', 'on']) { + it(`${ENV}=${value} lifts the refusal`, async () => { + setup('bitcoin-mainnet') + process.env[ENV] = value + await send() + assert.strictEqual(built, 1) + }) + } + + it('is read live: unsetting it restores the refusal on the same controller', async () => { + setup('bitcoin-mainnet') + process.env[ENV] = '1' + await send() + delete process.env[ENV] + await assert.rejects(send(), isPrelaunch) + }) + + for (const value of ['0', 'false', '', 'maybe']) { + it(`${ENV}=${JSON.stringify(value)} leaves the refusal in force`, async () => { + setup('bitcoin-mainnet') + process.env[ENV] = value + await assert.rejects(send(), isPrelaunch) + }) + } +}) diff --git a/test/unit/api/node_tier_gate.test.js b/test/unit/api/node_tier_gate.test.js index d8bd80b..41ab4b0 100644 --- a/test/unit/api/node_tier_gate.test.js +++ b/test/unit/api/node_tier_gate.test.js @@ -79,4 +79,19 @@ describe('JSON-RPC node tier gate', () => { assert.strictEqual(typeof result.psbt, 'string') assert.ok(entered.includes('build')) }) + + it('the mainnet prelaunch refusal fires before the node tier check', async () => { + const realNow = Date.now + Date.now = () => 0 + const { encoder, entered } = testnetEncoderOn('main') + encoder.consensusNetwork = 'mainnet' + const controller = createJsonRpcController({ encoder, NETWORK }) + try { + await assert.rejects(controller.create_tx(sendParams()), (err) => { + assert.strictEqual(err.data.reason, 'MAINNET_PRELAUNCH') + return true + }) + } finally { Date.now = realNow } + assert.deepStrictEqual(entered, []) + }) }) diff --git a/test/unit/api/status_node_tier.test.js b/test/unit/api/status_node_tier.test.js new file mode 100644 index 0000000..a1c7d05 --- /dev/null +++ b/test/unit/api/status_node_tier.test.js @@ -0,0 +1,82 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +'use strict' + +const assert = require('assert') +const { app, encoder } = require('../../../src/api') +const { assertNodeTier } = require('../../../src/build/blockchain_connector/node_queries') + +async function statusResponse () { + const server = await new Promise((resolve) => { + const listening = app.listen(0, '127.0.0.1', () => resolve(listening)) + }) + try { + const response = await fetch(`http://127.0.0.1:${server.address().port}/status`) + return { code: response.status, body: await response.json() } + } finally { + await new Promise((resolve) => server.close(resolve)) + } +} + +describe('assertNodeTier mismatch state', function () { + it('records the mismatch reason on the connector before refusing the node', async function () { + const connector = { + consensusNetwork: 'testnet', + chainName: async () => 'main' + } + + await assert.rejects(assertNodeTier.call(connector), (error) => { + assert.strictEqual(error.xchainCode, 'NODE_CHAIN_MISMATCH') + return true + }) + assert.strictEqual( + connector.nodeTierMismatch, + 'node reports chain="main" (mainnet) but this encoder is configured for testnet' + ) + }) +}) + +describe('GET /status node-tier readiness', function () { + let originalSyncStatus + let originalMismatch + + beforeEach(function () { + originalSyncStatus = encoder.utxoTrackerConnector.getSyncStatus + originalMismatch = encoder.connector.nodeTierMismatch + encoder.utxoTrackerConnector.getSyncStatus = async () => ({ synced: true, lag: 0 }) + delete encoder.connector.nodeTierMismatch + }) + + afterEach(function () { + encoder.utxoTrackerConnector.getSyncStatus = originalSyncStatus + if (originalMismatch === undefined) delete encoder.connector.nodeTierMismatch + else encoder.connector.nodeTierMismatch = originalMismatch + }) + + it('returns 503 and publishes a recorded node-tier mismatch reason', async function () { + const reason = 'node reports chain="main" (mainnet) but this encoder is configured for testnet' + encoder.connector.nodeTierMismatch = reason + + const response = await statusResponse() + + assert.strictEqual(response.code, 503) + assert.strictEqual(response.body.status, 'unhealthy') + assert.strictEqual(response.body.node_tier_mismatch, reason) + }) + + it('returns 200 with a null mismatch when no mismatch has been recorded', async function () { + const response = await statusResponse() + + assert.strictEqual(response.code, 200) + assert.strictEqual(response.body.status, 'healthy') + assert.strictEqual(response.body.node_tier_mismatch, null) + }) +}) diff --git a/test/unit/blockchain_connector.test/06_get_fee_per_kilobyte.test.js b/test/unit/blockchain_connector.test/06_get_fee_per_kilobyte.test.js index a545b8a..c31a516 100644 --- a/test/unit/blockchain_connector.test/06_get_fee_per_kilobyte.test.js +++ b/test/unit/blockchain_connector.test/06_get_fee_per_kilobyte.test.js @@ -329,16 +329,18 @@ describe('BlockchainConnector.getFeePerKilobyte()', () => { assert.ok(errorSpy.some(m => /sanity ceiling/.test(m)), 'expected a loud diagnostic') }) - it('clamps a spiked testnet estimate using the dogecoin-scale default when NETWORK says dogecoin', async () => { + it('treats a spiked testnet estimate as unavailable and uses the relayfee fallback', async () => { process.env.NETWORK = 'dogecoin-testnet' axios.post = async (url, data) => { if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'test' } } } if (data.method === 'estimatesmartfee') return { data: { result: { feerate: 5000 } } } // 5000 DOGE/kB: absurd + if (data.method === 'getnetworkinfo') return { data: { result: { relayfee: 0.001 } } } return { data: { result: {} } } } const c = makeConnector() const feerate = await c.getFeePerKilobyte(6) - assert.strictEqual(feerate, 10, 'expected clamp to the dogecoin default ceiling 10 DOGE/kB') + assert.ok(Math.abs(feerate - 0.01) < 1e-12, 'expected 10x the 0.001 relay floor, got ' + feerate) + assert.ok(errorSpy.some(m => /treating it as no estimate/.test(m)), 'expected a loud diagnostic') }) }) }) diff --git a/test/unit/blockchain_connector.test/13_test_chain_implausible_estimate.test.js b/test/unit/blockchain_connector.test/13_test_chain_implausible_estimate.test.js new file mode 100644 index 0000000..e12b455 --- /dev/null +++ b/test/unit/blockchain_connector.test/13_test_chain_implausible_estimate.test.js @@ -0,0 +1,45 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const assert = require('assert') +const axios = require('axios') +const BlockchainConnector = require('../../../src/build/blockchain_connector') + +describe('BlockchainConnector.getFeePerKilobyte() test-chain implausible estimate', () => { + let originalPost + let originalNetwork + + beforeEach(() => { + originalPost = axios.post + originalNetwork = process.env.NETWORK + process.env.NETWORK = 'bitcoin-testnet' + }) + + afterEach(() => { + axios.post = originalPost + if (originalNetwork === undefined) delete process.env.NETWORK + else process.env.NETWORK = originalNetwork + }) + + it('uses the no-estimate fallback instead of clamping an estimate above the sanity ceiling', async () => { + axios.post = async (url, data) => { + if (data.method === 'getblockchaininfo') return { data: { result: { chain: 'testnet4' } } } + if (data.method === 'estimatesmartfee') return { data: { result: { feerate: 7.48876052, blocks: 6 } } } + if (data.method === 'getnetworkinfo') return { data: { result: { relayfee: 0.000001 } } } + return { data: { result: {} } } + } + + const connector = new BlockchainConnector('127.0.0.1', 18332, 'rpcuser', 'rpcpass') + const feerate = await connector.getFeePerKilobyte(6) + + assert.ok(Math.abs(feerate - 0.00001) < 1e-12, + 'expected 10x the 0.000001 relay floor, got ' + feerate) + }) +}) diff --git a/test/unit/chain_identity_genesis.test.js b/test/unit/chain_identity_genesis.test.js new file mode 100644 index 0000000..95c271a --- /dev/null +++ b/test/unit/chain_identity_genesis.test.js @@ -0,0 +1,70 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************/ + +'use strict'; + +const assert = require('assert'); +const { + chainGenesisMismatch, + chainGenesisUnpinned, +} = require('../../src/build/blockchain_connector/chain_identity.js'); + +const BTC_MAINNET = '000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f'; +const DOGE_MAINNET = '1a91e3dace36e2be3bf030a65679fe821aa1d6ef92e7c9902eb318182c355691'; +const TESTNET3 = '3'.repeat(64); +const TESTNET4 = '4'.repeat(64); + +describe('chain identity genesis helpers', function () { + it('catches a same-tier cross-coin pairing', function () { + const reason = chainGenesisMismatch(BTC_MAINNET, DOGE_MAINNET); + assert.ok(reason); + assert.ok(reason.includes(BTC_MAINNET)); + assert.ok(reason.includes(DOGE_MAINNET)); + }); + + it('catches a testnet3 versus testnet4 pairing', function () { + const reason = chainGenesisMismatch(TESTNET3, TESTNET4); + assert.ok(reason); + assert.ok(reason.includes(TESTNET3)); + assert.ok(reason.includes(TESTNET4)); + }); + + it('accepts an agreeing hash regardless of case', function () { + assert.strictEqual(chainGenesisMismatch(BTC_MAINNET, BTC_MAINNET), null); + assert.strictEqual(chainGenesisMismatch(BTC_MAINNET.toUpperCase(), BTC_MAINNET), null); + assert.strictEqual(chainGenesisMismatch(BTC_MAINNET, BTC_MAINNET.toUpperCase()), null); + }); + + it('fails open when no usable hash is pinned', function () { + assert.strictEqual(chainGenesisMismatch(null, DOGE_MAINNET), null); + assert.strictEqual(chainGenesisMismatch(undefined, DOGE_MAINNET), null); + assert.strictEqual(chainGenesisMismatch('', DOGE_MAINNET), null); + assert.strictEqual(chainGenesisMismatch(42, DOGE_MAINNET), null); + }); + + it('fails open when the node reports no usable hash', function () { + assert.strictEqual(chainGenesisMismatch(BTC_MAINNET, null), null); + assert.strictEqual(chainGenesisMismatch(BTC_MAINNET, undefined), null); + assert.strictEqual(chainGenesisMismatch(BTC_MAINNET, ''), null); + assert.strictEqual(chainGenesisMismatch(BTC_MAINNET, 42), null); + }); + + it('reports whether the genesis pin is absent', function () { + assert.strictEqual(chainGenesisUnpinned(null), true); + assert.strictEqual(chainGenesisUnpinned(undefined), true); + assert.strictEqual(chainGenesisUnpinned(''), true); + assert.strictEqual(chainGenesisUnpinned(42), true); + assert.strictEqual(chainGenesisUnpinned(BTC_MAINNET), false); + }); +}); diff --git a/test/unit/chain_identity_parity.test.js b/test/unit/chain_identity_parity.test.js new file mode 100644 index 0000000..05be281 --- /dev/null +++ b/test/unit/chain_identity_parity.test.js @@ -0,0 +1,39 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************/ + +'use strict'; + +const assert = require('assert'); +const path = require('path'); + +const { CHAIN_TO_NETWORK } = require('../../src/build/blockchain_connector/chain_identity'); +const { siblingCheckout, skipOrFail } = require('../helpers/sibling_checkout'); + +const DECODER_DIR = process.env.XCHAIN_DECODER_DIR || path.join(__dirname, '../../../xchain-decoder'); +const DECODER_IDENTITY = path.join(DECODER_DIR, 'src', 'protocol', 'chain_identity.js'); + +describe('chain identity map parity with xchain-decoder', function(){ + let decoderChainToNetwork; + + before(function(){ + const decoder = siblingCheckout(__dirname, DECODER_IDENTITY); + if(!decoder.usable) + return skipOrFail(this, decoder, 'the encoder/decoder chain identity parity guard'); + ({ CHAIN_TO_NETWORK: decoderChainToNetwork } = require(DECODER_IDENTITY)); + }); + + it('matches every decoder chain key and network value', function(){ + assert.deepStrictEqual(CHAIN_TO_NETWORK, decoderChainToNetwork); + }); +}); diff --git a/test/unit/common/validator/message_truncation.test.js b/test/unit/common/validator/message_truncation.test.js new file mode 100644 index 0000000..46fed79 --- /dev/null +++ b/test/unit/common/validator/message_truncation.test.js @@ -0,0 +1,72 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Validator -32602 messages quote at most a short prefix of a caller string, + * the rule validateActionName set first, so a multi-megabyte junk value is + * never sent back whole in the error. + ********************************************************************/ + +'use strict'; + +const assert = require('assert'); +const v = require('../../../../src/common/validator.js'); +const { validateCreateTxOptions, validateFee } = require('../../../../src/common/validator/fee_and_utxo_checks.js'); +const { validateOptionalBoolean, parseSatoshiAmount, truncateForMessage } = require('../../../../src/common/validator/value_checks.js'); +const { ParamTypeError, ParamRangeError } = require('../../../../src/build/errors'); + +const LONG = 'Z'.repeat(100000); +const LONG_DIGITS = '9'.repeat(100000); +const MAX_MESSAGE = 400; + +// Assert fn throws errClass with a short message that does not carry the input. +function assertShortRefusal(fn, errClass, input) { + assert.throws(fn, (err) => { + assert.ok(err instanceof errClass, `expected ${errClass.name}, got ${err && err.constructor.name}`); + assert.ok(err.message.length < MAX_MESSAGE, `message is ${err.message.length} chars`); + assert.ok(!err.message.includes(input), 'message echoes the whole input'); + return true; + }); +} + +describe('Validator messages truncate caller strings', function () { + it('truncates an oversized encoding, fee, options key and boolean string', function () { + assertShortRefusal(() => v.validateEncoding(LONG), ParamTypeError, LONG); + assertShortRefusal(() => validateFee(LONG), ParamTypeError, LONG); + assertShortRefusal(() => validateCreateTxOptions({ [LONG]: true }), ParamTypeError, LONG); + assertShortRefusal(() => validateOptionalBoolean(LONG, 'rbf'), ParamTypeError, LONG); + }); + + it('truncates an oversized all-digit satoshi amount, with and without allowBig', function () { + assertShortRefusal(() => parseSatoshiAmount(LONG_DIGITS, 'value'), ParamRangeError, LONG_DIGITS); + assertShortRefusal(() => parseSatoshiAmount(LONG_DIGITS, 'value', { allowBig: true }), ParamRangeError, LONG_DIGITS); + }); + + it('truncates an oversized unknown create_tx param name', function () { + assertShortRefusal(() => v.validateAll({ data: 'SEND', pubkey: '02ab', [LONG]: 1 }), ParamTypeError, LONG); + }); + + it('names the type of a non-string encoding instead of [object Object]', function () { + assert.throws(() => v.validateEncoding({}), (err) => { + assert.ok(err instanceof ParamTypeError); + assert.match(err.message, /object/); + assert.ok(!err.message.includes('[object Object]'), err.message); + return true; + }); + }); + + it('leaves a short value whole and marks a cut one with its length', function () { + assert.strictEqual(truncateForMessage('exactInput'), 'exactInput'); + assert.strictEqual(truncateForMessage('x'.repeat(32)), 'x'.repeat(32)); + assert.strictEqual(truncateForMessage('x'.repeat(33)), `${'x'.repeat(32)}...(33 chars)`); + }); +}); diff --git a/test/unit/common/validator/validator.test/08_validate_fee_per_kb.test.js b/test/unit/common/validator/validator.test/08_validate_fee_per_kb.test.js index 1788033..c981248 100644 --- a/test/unit/common/validator/validator.test/08_validate_fee_per_kb.test.js +++ b/test/unit/common/validator/validator.test/08_validate_fee_per_kb.test.js @@ -39,5 +39,13 @@ describe('Encoder input validator', function () { assert.throws(() => v.validateFeePerKb([50]), TypeError); // array -> 50 assert.throws(() => v.validateFeePerKb({}), TypeError); // object -> NaN }); + it('rejects a rate above the money-field ceiling on both the number and string paths', function () { + const max = v.MAX_FEE_SATOSHIS; + assert.strictEqual(v.validateFeePerKb(max), max); + assert.strictEqual(v.validateFeePerKb(String(max)), max); + assert.throws(() => v.validateFeePerKb(max + 1), RangeError); + assert.throws(() => v.validateFeePerKb(String(max + 1)), RangeError); + assert.throws(() => v.validateFeePerKb(1e300), RangeError); + }); }); }); diff --git a/test/unit/common/validator/validator.test/18_validate_all_unknown_params.test.js b/test/unit/common/validator/validator.test/18_validate_all_unknown_params.test.js new file mode 100644 index 0000000..ea1b017 --- /dev/null +++ b/test/unit/common/validator/validator.test/18_validate_all_unknown_params.test.js @@ -0,0 +1,59 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * create_tx refuses an unknown top-level param, the same rule the options + * bag follows: a misspelled money-path setting (unconfirmed, feePerKb, rbf, + * compressedPubKey) must fail loudly rather than fall back to a default. + ********************************************************************/ + +const assert = require('assert'); +const v = require('../../../../../src/common/validator.js'); +const { VALID_CREATE_TX_PARAMS } = require('../../../../../src/common/validator/constants.js'); +const { ParamTypeError } = require('../../../../../src/build/errors'); +const { spec } = require('../../../../../docs/openrpc.build.js'); + +const BASE = { data: 'SEND', pubkey: '02ab' }; + +describe('Encoder input validator', function () { + describe('validateAll unknown top-level params', function () { + it('refuses each misspelled money-path setting and names the key', function () { + for (const key of ['unconfirmd', 'feePerKB', 'RBF', 'compressedPubkey', 'source']) { + assert.throws(() => v.validateAll({ ...BASE, [key]: false }), (err) => { + assert.ok(err instanceof ParamTypeError, `${key}: ${err && err.constructor.name}`); + assert.match(err.message, new RegExp(`Unknown create_tx param: "${key}"`)); + return true; + }); + } + }); + + it('reports the unknown key ahead of any other field error', function () { + assert.throws(() => v.validateAll({ ...BASE, fee: 'not-a-number', unconfirmd: false }), + /Unknown create_tx param: "unconfirmd"/); + }); + + it('still accepts every documented param, null values included', function () { + const nulled = {}; + for (const key of VALID_CREATE_TX_PARAMS) nulled[key] = null; + const result = v.validateAll({ ...nulled, data: 'SEND', pubkey: '02ab' }); + assert.strictEqual(result.pubkey, '02ab'); + assert.strictEqual(result.unconfirmed, undefined); + }); + + it('keeps the allow-list equal to the published create_tx params', function () { + const createTx = spec.methods.find((m) => m.name === 'create_tx'); + const declared = createTx.params.map((p) => p.name).sort(); + assert.ok(declared.length > 10, `extraction too small: ${declared.length}`); + assert.deepStrictEqual([...VALID_CREATE_TX_PARAMS].sort(), declared); + }); + }); +}); diff --git a/test/unit/crash_handlers.test.js b/test/unit/crash_handlers.test.js index f5d47d8..94fc5d5 100644 --- a/test/unit/crash_handlers.test.js +++ b/test/unit/crash_handlers.test.js @@ -16,8 +16,10 @@ // execute without mocha's own handlers or a live process.exit taking part. const assert = require('assert') +const childProcess = require('child_process') const { EventEmitter } = require('events') const fs = require('fs') +const os = require('os') const path = require('path') const crash = require('../../src/server/crash_handlers') @@ -25,6 +27,7 @@ const observability = require('../../src/observability') const registerSignalTests = require('./crash_handlers.test/01_signal_registration.test.js') const registerExitPathTests = require('./crash_handlers.test/02_exit_paths.test.js') +const registerBootOrderTests = require('./crash_handlers.test/03_boot_order.test.js') describe('encoder crash handlers', function () { let sink @@ -62,3 +65,8 @@ describe('encoder crash handlers', function () { registerSignalTests({ assert, crash, fakeProc, lines, crashCount }) registerExitPathTests({ assert, crash, observability, fakeProc, fs, path, __dirname }) }) + +// Kept out of the block above: its hooks reset the shim this process does not use here. +describe('encoder crash handlers', function () { + registerBootOrderTests({ assert, childProcess, fs, os, path, dirname: __dirname }) +}) diff --git a/test/unit/crash_handlers.test/03_boot_order.test.js b/test/unit/crash_handlers.test/03_boot_order.test.js new file mode 100644 index 0000000..dfb28a3 --- /dev/null +++ b/test/unit/crash_handlers.test/03_boot_order.test.js @@ -0,0 +1,73 @@ +'use strict' + +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +const API = '../../src/api.js' +const COINS = '../../src/coins' + +// Boot api.js as its own process from an empty directory, so no local .env is read. +function bootApi({ childProcess, fs, os, path, dirname }, network, preload) { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'encoder-boot-')) + const env = Object.assign({}, process.env, { + NETWORK: network, + ENCODER_API_PORT: '0', + ENCODER_INSTANCE_LOCK_FILE: path.join(cwd, 'instance.lock') + }) + // The mocha bootstrap turns the console patch off; the child needs the real boot. + delete env.XCHAIN_LOG_PATCH + const args = [] + if (preload) { + const file = path.join(cwd, 'preload.js') + fs.writeFileSync(file, preload) + args.push('-r', file) + } + args.push(path.join(dirname, API)) + const run = childProcess.spawnSync(process.execPath, args, { cwd, env, encoding: 'utf8', timeout: 15000 }) + return { run, out: String(run.stdout || '') + String(run.stderr || '') } +} + +// A boot throw counts only if it left the structured record and the exit code. +function assertCrashRecord(assert, { run, out }, text) { + assert.strictEqual(run.signal, null, 'boot did not fail fast; it was still running at the timeout') + assert.strictEqual(run.status, 1) + const crash = out.split('\n').filter(l => l.includes('CRASH')) + assert.strictEqual(crash.length, 1, 'expected one CRASH record, got:\n' + out) + assert.ok(crash[0].includes('uncaughtException'), crash[0]) + assert.ok(crash[0].includes(text), crash[0]) +} + +function registerBootOrderTests(deps) { + const { assert, fs, path } = deps + + it('an unknown NETWORK at module load leaves a CRASH record, not a bare stack dump', function () { + this.timeout(20000) + assertCrashRecord(assert, bootApi(deps, 'nope-regtest'), 'Unknown network') + }) + + it('a consensus-pin mismatch at module load leaves a CRASH record, not a bare stack dump', function () { + this.timeout(20000) + const coins = JSON.stringify(path.join(deps.dirname, COINS)) + const preload = 'require(' + coins + ').verifyConsensusPin = () => {' + + " throw new Error('CONSENSUS CONFIG PIN MISMATCH for boot-order test') }\n" + assertCrashRecord(assert, bootApi(deps, 'bitcoin-regtest', preload), 'CONSENSUS CONFIG PIN MISMATCH') + }) + + // Read off the file as well: the singleton is built at module load. + it('api.js installs the handlers before it builds the singleton encoder', function () { + const src = fs.readFileSync(path.join(deps.dirname, API), 'utf8') + const install = src.indexOf('installCrashHandlers()') + assert.ok(install > 0, 'installCrashHandlers() is called') + assert.strictEqual(src.indexOf('installCrashHandlers()', install + 1), -1, 'installed exactly once') + assert.ok(install < src.indexOf('new XChainEncoder('), 'installed before new XChainEncoder(') + }) +} + +module.exports = registerBootOrderTests; diff --git a/test/unit/repo/sibling_coverage.test.js b/test/unit/repo/sibling_coverage.test.js index b6fb1cc..edbe519 100644 --- a/test/unit/repo/sibling_coverage.test.js +++ b/test/unit/repo/sibling_coverage.test.js @@ -72,6 +72,9 @@ const SIBLINGS = [ { repo: 'xchain-utxo-tracker', envs: ['XCHAIN_UTXO_TRACKER_DIR'], marker: 'src', guards: 'the inbound UTXO-record contract, the one seam carrying records INTO the encoder' }, + { repo: 'xchain-indexer', envs: ['XCHAIN_INDEXER_DIR'], + marker: path.join('src', 'consensus', 'gates'), + guards: 'the mainnet prelaunch validator wire-format set against the consensus copy' }, ]; function resolve(entry) { diff --git a/test/unit/server/single_instance_guard.test/03_release_lock_on_signals.test.js b/test/unit/server/single_instance_guard.test/03_release_lock_on_signals.test.js index c9fd67e..6ae998b 100644 --- a/test/unit/server/single_instance_guard.test/03_release_lock_on_signals.test.js +++ b/test/unit/server/single_instance_guard.test/03_release_lock_on_signals.test.js @@ -110,11 +110,11 @@ describe('singleInstanceGuard', function () { describe('singleInstanceGuard', function () { describe('releaseLockOnSignals', function () { - // PID 1 is what the container CMD makes this process, and the kernel - // drops signals PID 1 has no handler for, so the re-raise above is a - // no-op and nothing would ever exit: docker stop would hang out its - // full 10s timeout and SIGKILL, which is the one death that cannot - // release anything. + // The safety-net case: node running as PID 1 with no init in front of + // it. The kernel drops signals PID 1 has no handler for, so the + // re-raise above is a no-op and nothing would ever exit: docker stop + // would hang out its full 10s timeout and SIGKILL, which is the one + // death that cannot release anything. it('forces exit 143 when a re-raised SIGTERM is ignored (PID 1)', async function () { const proc = fakeProcess({ diesOnReraise: false }) releaseLockOnSignals(() => {}, proc) diff --git a/test/unit/xchain_encoder/build_correctness.test/01_relay_floor_size_estimate.test.js b/test/unit/xchain_encoder/build_correctness.test/01_relay_floor_size_estimate.test.js new file mode 100644 index 0000000..d1b99ed --- /dev/null +++ b/test/unit/xchain_encoder/build_correctness.test/01_relay_floor_size_estimate.test.js @@ -0,0 +1,46 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// The single-transaction size estimate has to count the 10-byte header and, with a +// segwit input, the marker and flag. Without them a many-input build at the relay +// floor paid a few satoshis under 1 sat/vB and the node refused it. + +'use strict' + +const assert = require('assert') +const F = require('./helpers/build_fixture') + +async function buildSegwitSpend (coin, inputCount, feePerKb, encoderOver) { + const utxos = [] + for (let i = 0; i < inputCount; i++) utxos.push(F.makeUtxo(coin, 'p2wpkh', 100000)) + const res = await F.create(F.makeEncoder(coin, encoderOver), { + utxos, pubkey: F.p2pkhAddr(coin), data: 'BROADCAST|0|0|x', encoding: 'OP_RETURN', change: F.p2wshAddr(coin), + customOutputs: [{ address: F.p2wshAddr(coin), value: 100000 * inputCount - 60000 }], feePerKb + }) + return F.account(res.psbt) +} + +describe('single-transaction size estimate covers the header and segwit marker', () => { + // LTC's 5460-litoshi fee floor masks the undercount at 1 lit/vB, so it is measured at 5. + const cases = [['btc', 10, 1000], ['btc', 20, 1000], ['ltc', 20, 5000]] + for (const [coin, inputCount, feePerKb] of cases) { + it(`${coin}: ${inputCount} P2WPKH inputs at ${feePerKb / 1000} sat/vB pay the rate on the real vsize`, async () => { + const a = await buildSegwitSpend(coin, inputCount, feePerKb) + assert.strictEqual(a.ins, a.outs + a.fee, 'value is conserved') + assert.ok(Number(a.fee) >= a.vsize * feePerKb / 1000, `fee ${a.fee} is under ${a.vsize} vB at ${feePerKb / 1000} sat/vB`) + }) + } + + it('a node estimate under the relay floor is lifted to the floor rather than built under it', async () => { + // 0.5 sat/vB quoted by the node against a 1 sat/vB relayfee. + const a = await buildSegwitSpend('btc', 20, null, { est: 0.000005 }) + assert.ok(Number(a.fee) >= a.vsize, `fee ${a.fee} is under ${a.vsize} vB at the relay floor`) + }) +}) diff --git a/test/unit/xchain_encoder/build_correctness.test/02_fee_floor_selection.test.js b/test/unit/xchain_encoder/build_correctness.test/02_fee_floor_selection.test.js new file mode 100644 index 0000000..133d2a9 --- /dev/null +++ b/test/unit/xchain_encoder/build_correctness.test/02_fee_floor_selection.test.js @@ -0,0 +1,61 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// The fee is floored at the coin's dust threshold, and input selection has to aim at +// that floored fee. Selecting against the unfloored fee stopped one input short and a +// fundable spend failed with insufficient funds. + +'use strict' + +const assert = require('assert') +const F = require('./helpers/build_fixture') + +function twoInputSpend (coin, kind, a, b, pay, extra) { + return F.create(F.makeEncoder(coin, extra.encoderOver), { + utxos: [F.makeUtxo(coin, kind, a), F.makeUtxo(coin, kind, b)], pubkey: F.p2pkhAddr(coin), + data: 'BROADCAST|0|0|x', encoding: 'OP_RETURN', change: F.p2pkhAddr(coin), + customOutputs: [{ address: F.otherAddr(coin), value: pay }], fee: extra.fee, feePerKb: extra.feePerKb + }) +} + +describe('input selection targets the floored fee', () => { + const cases = [ + { label: 'btc caller rate 1 sat/vB', coin: 'btc', kind: 'p2wpkh', a: 10000, b: 5000, pay: 9600, feePerKb: 1000 }, + { label: 'btc explicit fee under the dust floor', coin: 'btc', kind: 'p2wpkh', a: 10000, b: 5000, pay: 9500, fee: 300 }, + { label: 'ltc caller rate 1 lit/vB', coin: 'ltc', kind: 'p2wpkh', a: 100000, b: 50000, pay: 99000, feePerKb: 1000 }, + { label: 'ltc node estimate of 2 lit/vB', coin: 'ltc', kind: 'p2wpkh', a: 100000, b: 50000, pay: 99000, encoderOver: { est: 0.00002 } }, + { label: 'doge caller rate at the relay floor', coin: 'doge', kind: 'p2pkh', a: 5000000, b: 3000000, pay: 4950000, feePerKb: 100000 } + ] + for (const c of cases) { + it(`${c.label}: a spend the two inputs fund builds with both`, async () => { + const res = await twoInputSpend(c.coin, c.kind, c.a, c.b, c.pay, c) + const a = F.account(res.psbt) + assert.strictEqual(res.psbt.inputCount, 2) + assert.strictEqual(a.ins, a.outs + a.fee, 'value is conserved') + assert.ok(Number(a.fee) >= F.makeEncoder(c.coin).dustAmount, 'the fee is at least the dust floor') + }) + } + + it('an unfundable spend names the fee the build needs and the fee that was requested', async () => { + let caught = null + try { + await F.create(F.makeEncoder('btc'), { + utxos: [F.makeUtxo('btc', 'p2wpkh', 10000)], pubkey: F.p2pkhAddr('btc'), data: 'BROADCAST|0|0|x', + encoding: 'OP_RETURN', change: F.p2pkhAddr('btc'), customOutputs: [{ address: F.otherAddr('btc'), value: 9500 }], fee: 300 + }) + } catch (err) { caught = err } + assert.ok(caught, 'the build is refused') + assert.strictEqual(caught.xchainCode, 'INSUFFICIENT_FUNDS') + assert.strictEqual(caught.details.fee, 546) + assert.strictEqual(caught.details.requestedFee, 300) + assert.match(caught.message, /fee 546/) + assert.match(caught.message, /requested fee 300/) + }) +}) diff --git a/test/unit/xchain_encoder/build_correctness.test/03_deterministic_order.test.js b/test/unit/xchain_encoder/build_correctness.test/03_deterministic_order.test.js new file mode 100644 index 0000000..f2c8901 --- /dev/null +++ b/test/unit/xchain_encoder/build_correctness.test/03_deterministic_order.test.js @@ -0,0 +1,60 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Candidates are ordered by value, then txid, then vout, so the same UTXO set gives the +// same transaction whatever order the caller or the tracker listed it in. + +'use strict' + +const assert = require('assert') +const F = require('./helpers/build_fixture') +const { filterAndOrderUtxos } = require('../../../../src/XChainEncoder/build_transaction/input_candidates.js') + +function spend (utxos) { + return F.create(F.makeEncoder('btc'), { + utxos, pubkey: F.p2pkhAddr('btc'), data: 'BROADCAST|0|0|x', encoding: 'OP_RETURN', change: F.p2pkhAddr('btc'), + customOutputs: [{ address: F.otherAddr('btc'), value: 1000000 }] + }) +} + +function unsignedHex (res) { return res.psbt.data.globalMap.unsignedTx.toBuffer().toString('hex') } + +describe('candidate order is deterministic on equal values', () => { + it('two equal-value UTXOs in either order build the same transaction', async () => { + const a = F.makeUtxo('btc', 'p2wpkh', 5000000) + const b = F.makeUtxo('btc', 'p2wpkh', 5000000) + const first = await spend([{ ...a }, { ...b }]) + const second = await spend([{ ...b }, { ...a }]) + assert.strictEqual(unsignedHex(first), unsignedHex(second)) + assert.strictEqual(F.inputTxids(first.psbt)[0], [a.txid, b.txid].sort()[0], 'the lower txid leads a tie') + }) + + it('the order is value descending, then txid, then vout', () => { + const t1 = '1'.repeat(64) + const t2 = '2'.repeat(64) + const build = { + utxos: [ + { txid: t2, vout: 0, value: 700 }, { txid: t1, vout: 3, value: 700 }, { txid: t1, vout: 1, value: 700 }, + { txid: t2, vout: 5, value: 900 }, { txid: t1, vout: 0, value: 100 } + ], + unconfirmed: true, isReveal: false, exactInputs: false + } + filterAndOrderUtxos(build) + assert.deepStrictEqual(build.utxos.map((u) => u.txid[0] + ':' + u.vout), ['2:5', '1:1', '1:3', '2:0', '1:0']) + }) + + it('every permutation of a set with ties builds one transaction', async () => { + const base = [5, 5, 3, 5].map((v) => F.makeUtxo('btc', 'p2wpkh', v * 1000000)) + const orders = [[0, 1, 2, 3], [3, 2, 1, 0], [1, 3, 0, 2], [2, 0, 3, 1], [3, 1, 2, 0]] + const seen = new Set() + for (const order of orders) seen.add(unsignedHex(await spend(order.map((i) => ({ ...base[i] }))))) + assert.strictEqual(seen.size, 1) + }) +}) diff --git a/test/unit/xchain_encoder/build_correctness.test/04_p2wsh_reveal_pad_pricing.test.js b/test/unit/xchain_encoder/build_correctness.test/04_p2wsh_reveal_pad_pricing.test.js new file mode 100644 index 0000000..56ebb04 --- /dev/null +++ b/test/unit/xchain_encoder/build_correctness.test/04_p2wsh_reveal_pad_pricing.test.js @@ -0,0 +1,53 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// A single-chunk P2WSH reveal sits under the stripped-size floor and carries one pad +// output to clear it. The funding leg has to price that output's bytes; flooring the +// estimate at the floor alone left the reveal several sat/vB under the asked rate. + +'use strict' + +const assert = require('assert') +const F = require('./helpers/build_fixture') + +async function revealAt (coin, feePerKb, payloadLength, change) { + const data = 'BROADCAST|0|0|' + 'w'.repeat(payloadLength - 14) + const funding = await F.create(F.makeEncoder(coin), { + utxos: [F.makeUtxo(coin, 'p2wpkh', 5000000)], pubkey: F.p2pkhAddr(coin), data, encoding: 'P2WSH', change, feePerKb + }) + const fundingTx = F.account(funding.psbt).tx + const reveal = await F.create(F.makeEncoder(coin), { + pubkey: F.p2pkhAddr(coin), data, encoding: 'P2WSH', change, feePerKb, p2shHash: fundingTx.getId(), p2shHex: fundingTx.toHex() + }) + return { a: F.account(reveal.psbt), outputs: reveal.psbt.txOutputs.length } +} + +describe('P2WSH reveal funding prices the stripped-size pad output', () => { + for (const coin of ['btc', 'ltc']) { + for (const feePerKb of [10000, 50000, 100000]) { + for (const payloadLength of [30, 470]) { + it(`${coin}: a ${payloadLength}-byte single-chunk reveal at ${feePerKb / 1000} sat/vB pays the rate`, async () => { + const { a, outputs } = await revealAt(coin, feePerKb, payloadLength, F.p2pkhAddr(coin)) + assert.strictEqual(outputs, 2, 'the marker and the pad output') + assert.ok(a.stripped >= F.net(coin).minStandardTxNonWitnessSize, 'the pad clears the stripped floor') + const rate = Number(a.fee) / a.vsize + assert.ok(rate >= feePerKb / 1000, `reveal pays ${rate.toFixed(3)} sat/vB of ${feePerKb / 1000}`) + }) + } + } + } + + it('with no change address the pad is priced at the largest standard output', async () => { + const encoder = F.makeEncoder('btc') + const bare = encoder.estimateSpendingP2wshTx(Buffer.alloc(40), 34) + const worst = encoder.estimateSpendingP2wshTx(Buffer.alloc(40)) + assert.strictEqual(worst - bare, 43 - 34) + }) +}) diff --git a/test/unit/xchain_encoder/build_correctness.test/05_payment_dust_floor.test.js b/test/unit/xchain_encoder/build_correctness.test/05_payment_dust_floor.test.js new file mode 100644 index 0000000..725b155 --- /dev/null +++ b/test/unit/xchain_encoder/build_correctness.test/05_payment_dust_floor.test.js @@ -0,0 +1,88 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// A caller payment output under the coin's hard relay dust threshold makes the +// transaction unrelayable, so it is refused before anything is built. Dogecoin's 0.01 +// DOGE soft limit is not a refusal floor: wallet native-fee outputs are padded only to +// the hard threshold, so a payment between the two still builds. + +'use strict' + +const assert = require('assert') +const F = require('./helpers/build_fixture') +const validator = require('../../../../src/common/validator') +const { createJsonRpcController } = require('../../../../src/api/json_rpc_methods') + +function pay (coin, value, extra = {}) { + const kind = coin === 'doge' ? 'p2pkh' : 'p2wpkh' + return F.create(F.makeEncoder(coin), { + utxos: [F.makeUtxo(coin, kind, coin === 'doge' ? 500000000 : 5000000)], pubkey: F.p2pkhAddr(coin), + data: 'BROADCAST|0|0|x', encoding: extra.encoding || 'OP_RETURN', change: F.p2pkhAddr(coin), + customOutputs: [{ address: F.otherAddr(coin), value }], feeQuote: extra.feeQuote + }) +} + +describe('caller payment outputs respect the coin relay dust threshold', () => { + const floors = { btc: 546, ltc: 5460, doge: 100000 } + + for (const coin of Object.keys(floors)) { + it(`${coin}: the encoder exposes ${floors[coin]} as its payment floor`, () => { + assert.strictEqual(F.makeEncoder(coin).paymentOutputFloor, floors[coin]) + }) + it(`${coin}: one unit under the floor is refused`, async () => { + await assert.rejects(pay(coin, floors[coin] - 1), (err) => err instanceof RangeError && /dust/.test(err.message)) + }) + it(`${coin}: exactly the floor builds`, async () => { + const res = await pay(coin, floors[coin]) + assert.ok(res.psbt.txOutputs.some((o) => Number(o.value) === floors[coin])) + }) + } + + it('doge: a 0.005 DOGE payment between the hard and soft limits builds', async () => { + const res = await pay('doge', 500000) + assert.ok(res.psbt.txOutputs.some((o) => Number(o.value) === 500000)) + }) + + it('a P2WSH funding call refuses a sub-dust payment before the commit is built', async () => { + await assert.rejects(pay('btc', 545, { encoding: 'P2WSH' }), /dust/) + }) + + it('the protocol-fee output injected from feeQuote keeps its own positive-amount rule', async () => { + const res = await pay('btc', 546, { feeQuote: { address: F.otherAddr('btc'), amount: 300 } }) + assert.ok(res.psbt.txOutputs.some((o) => Number(o.value) === 300)) + }) + + it('create_tx answers a sub-dust payment with -32602 before any node call', async () => { + const encoder = F.makeEncoder('btc') + const calls = [] + const info = encoder.connector.getNetworkInfo + encoder.connector.getNetworkInfo = (...args) => { calls.push('networkinfo'); return info(...args) } + const controller = createJsonRpcController({ encoder, NETWORK: 'bitcoin-regtest' }) + const params = { pubkey: F.p2pkhAddr('btc'), data: 'BROADCAST|0|0|x', change: F.p2pkhAddr('btc'), + customOutputs: [{ address: F.otherAddr('btc'), value: 545 }] } + await assert.rejects(controller.create_tx(params), (err) => err.code === -32602 && /dust/.test(err.message)) + assert.deepStrictEqual(calls, []) + }) + + describe('validateAll at the API boundary', () => { + const base = { pubkey: 'addr', data: 'BROADCAST|0|0|x' } + it('refuses an output under the floor the caller passes', () => { + assert.throws(() => validator.validateAll({ ...base, customOutputs: [{ address: 'a', value: 545 }] }, { paymentOutputFloor: 546 }), + (err) => err instanceof RangeError && /customOutputs\[0\]\.value 545 is below the 546/.test(err.message)) + }) + it('accepts an output at the floor', () => { + const r = validator.validateAll({ ...base, customOutputs: [{ address: 'a', value: 546 }] }, { paymentOutputFloor: 546 }) + assert.strictEqual(r.customOutputs[0].value, 546) + }) + it('keeps the positive-amount rule when no floor is passed', () => { + assert.strictEqual(validator.validateAll({ ...base, customOutputs: [{ address: 'a', value: 1 }] }).customOutputs[0].value, 1) + }) + }) +}) diff --git a/test/unit/xchain_encoder/build_correctness.test/06_missing_confirmations.test.js b/test/unit/xchain_encoder/build_correctness.test/06_missing_confirmations.test.js new file mode 100644 index 0000000..03d73f7 --- /dev/null +++ b/test/unit/xchain_encoder/build_correctness.test/06_missing_confirmations.test.js @@ -0,0 +1,56 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// A library caller that hands over UTXOs without a confirmations field gets the same +// treatment the API validator gives them: the field defaults to 0, so unconfirmed=false +// never spends an output whose confirmation state nobody stated. + +'use strict' + +const assert = require('assert') +const F = require('./helpers/build_fixture') + +function spend (utxos, unconfirmed) { + return F.create(F.makeEncoder('btc'), { + utxos, unconfirmed, pubkey: F.p2pkhAddr('btc'), data: 'BROADCAST|0|0|x', encoding: 'OP_RETURN', + change: F.p2pkhAddr('btc'), customOutputs: [{ address: F.otherAddr('btc'), value: 500000 }] + }) +} + +describe('a UTXO with no confirmations field counts as unconfirmed', () => { + it('unconfirmed=false skips it and spends a confirmed output instead', async () => { + const unstated = F.makeUtxo('btc', 'p2wpkh', 9000000) + delete unstated.confirmations + const confirmed = F.makeUtxo('btc', 'p2wpkh', 1000000) + const res = await spend([unstated, confirmed], false) + assert.deepStrictEqual(F.inputTxids(res.psbt), [confirmed.txid]) + }) + + it('unconfirmed=false with only such outputs reports no spendable UTXOs', async () => { + const unstated = F.makeUtxo('btc', 'p2wpkh', 9000000) + delete unstated.confirmations + await assert.rejects(spend([unstated], false), (err) => err.xchainCode === 'NO_UTXOS') + }) + + it('leaves the caller UTXO objects as they were handed over', async () => { + const unstated = F.makeUtxo('btc', 'p2wpkh', 9000000) + delete unstated.confirmations + const confirmed = F.makeUtxo('btc', 'p2wpkh', 1000000) + await spend([unstated, confirmed], false) + assert.ok(!('confirmations' in unstated), 'no confirmations field was written onto the caller object') + }) + + it('unconfirmed=true still spends it', async () => { + const unstated = F.makeUtxo('btc', 'p2wpkh', 9000000) + delete unstated.confirmations + const res = await spend([unstated], true) + assert.deepStrictEqual(F.inputTxids(res.psbt), [unstated.txid]) + }) +}) diff --git a/test/unit/xchain_encoder/build_correctness.test/helpers/build_fixture.js b/test/unit/xchain_encoder/build_correctness.test/helpers/build_fixture.js new file mode 100644 index 0000000..be10d20 --- /dev/null +++ b/test/unit/xchain_encoder/build_correctness.test/helpers/build_fixture.js @@ -0,0 +1,118 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Shared fixture for the build-correctness suites: an encoder with a stubbed node, +// UTXOs backed by real previous transactions, and value accounting read straight off +// the PSBT after every input is filled with a worst-case signature, so a test measures +// the size and fee rate the network will actually see. + +'use strict' + +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../../../../src/XChainEncoder') +const CryptoNetworks = require('../../../../../src/build/crypto_networks') + +// Generator point G (private key 1); only its public half is needed. +const PUB = Buffer.from('0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', 'hex') +const PUB_HEX = PUB.toString('hex') +// A 72-byte DER signature plus sighash byte, the longest a low-S signature gets. +const SIG72 = Buffer.concat([Buffer.from([0x30, 0x45]), Buffer.alloc(69, 0x11), Buffer.from([0x01])]) + +// Node rates per coin in coin/kB: relayfee, and the estimate the node quotes. +const NETS = { + btc: { key: 'bitcoin-regtest', relay: 0.00001, est: 0.0001 }, + ltc: { key: 'litecoin-regtest', relay: 0.00001, est: 0.0001 }, + doge: { key: 'dogecoin-regtest', relay: 0.001, est: 0.01 } +} + +const PREV = new Map() +let prevSeq = 0 + +function net (coin) { return CryptoNetworks.getBitcoinJsNetwork(NETS[coin].key) } +function p2pkhAddr (coin) { return bitcoin.payments.p2pkh({ pubkey: PUB, network: net(coin) }).address } +function p2wpkhAddr (coin) { return bitcoin.payments.p2wpkh({ pubkey: PUB, network: net(coin) }).address } +function otherAddr (coin) { return bitcoin.payments.p2pkh({ hash: Buffer.alloc(20, 0x22), network: net(coin) }).address } +function p2wshAddr (coin) { return bitcoin.payments.p2wsh({ hash: Buffer.alloc(32, 0x44), network: net(coin) }).address } + +// A UTXO whose txid is a real previous transaction paying `value` at vout 0. +function makeUtxo (coin, kind, value, extra = {}) { + const network = net(coin) + const script = kind === 'p2wpkh' + ? bitcoin.payments.p2wpkh({ pubkey: PUB, network }).output + : bitcoin.payments.p2pkh({ pubkey: PUB, network }).output + const tx = new bitcoin.Transaction() + const seed = Buffer.alloc(32, 0) + seed.writeUInt32BE(++prevSeq, 0) + tx.addInput(seed, 0) + tx.addOutput(script, value) + PREV.set(tx.getId(), tx.toHex()) + return { txid: tx.getId(), vout: 0, value, confirmations: 6, scriptPubKey: script.toString('hex'), ...extra } +} + +function makeEncoder (coin, over = {}) { + const rates = NETS[coin] + const encoder = new XChainEncoder(rates.key, '127.0.0.1', '18443', 'rpc', 'rpc', '', '') + encoder.connector = { + getFeePerKilobyte: async () => (over.est != null ? over.est : rates.est), + getNetworkInfo: async () => ({ relayfee: over.relay != null ? over.relay : rates.relay }), + getTransactionHex: async (txid) => { + if (!PREV.has(txid)) throw new Error('no previous transaction ' + txid) + return PREV.get(txid) + }, + getBlockCount: async () => 1000000 + } + encoder.utxoTrackerConnector = { getUtxosFromAddress: async () => { throw new Error('tracker not stubbed') } } + return encoder +} + +// createTransaction with named arguments. +function create (encoder, a) { + return encoder.createTransaction( + a.utxos === undefined ? null : a.utxos, a.pubkey, a.customOutputs || null, a.data, null, + a.fee == null ? null : a.fee, false, a.encoding == null ? null : a.encoding, a.change == null ? null : a.change, + a.p2shHash || null, a.p2shHex || null, a.compressedPubKey || null, + a.unconfirmed === undefined ? true : a.unconfirmed, a.feePerKb == null ? null : a.feePerKb, + null, a.feeQuote || null, false, false, null) +} + +// The unsigned transaction with every input carrying a worst-case signature. +function finalizedTx (psbt) { + const tx = bitcoin.Transaction.fromBuffer(psbt.data.globalMap.unsignedTx.toBuffer()) + psbt.data.inputs.forEach((input, i) => { + if (input.witnessScript) tx.ins[i].witness = [SIG72, PUB, input.witnessScript] + else if (input.redeemScript) tx.ins[i].script = bitcoin.script.compile([SIG72, PUB, input.redeemScript]) + else if (input.witnessUtxo) tx.ins[i].witness = [SIG72, PUB] + else tx.ins[i].script = bitcoin.script.compile([SIG72, PUB]) + }) + return tx +} + +function inputValue (psbt, i) { + const input = psbt.data.inputs[i] + if (input.witnessUtxo) return BigInt(input.witnessUtxo.value) + return BigInt(bitcoin.Transaction.fromBuffer(input.nonWitnessUtxo).outs[psbt.txInputs[i].index].value) +} + +// Inputs, outputs, fee and the broadcast vsize of a built PSBT. +function account (psbt) { + let ins = 0n + for (let i = 0; i < psbt.data.inputs.length; i++) ins += inputValue(psbt, i) + let outs = 0n + for (const o of psbt.txOutputs) outs += BigInt(o.value) + const tx = finalizedTx(psbt) + const fee = ins - outs + return { ins, outs, fee, vsize: tx.virtualSize(), stripped: tx.byteLength(false), tx } +} + +function inputTxids (psbt) { + return psbt.txInputs.map((i) => Buffer.from(i.hash).reverse().toString('hex')) +} + +module.exports = { PUB_HEX, net, p2pkhAddr, p2wpkhAddr, otherAddr, p2wshAddr, makeUtxo, makeEncoder, create, account, inputTxids } diff --git a/test/unit/xchain_encoder/psbt_conformance/bare_multisig_dust_relay_rate.test.js b/test/unit/xchain_encoder/psbt_conformance/bare_multisig_dust_relay_rate.test.js new file mode 100644 index 0000000..fde2044 --- /dev/null +++ b/test/unit/xchain_encoder/psbt_conformance/bare_multisig_dust_relay_rate.test.js @@ -0,0 +1,52 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// A bare 1-of-3 multisig carrier output is sized at the coin's own dust relay rate. +// Litecoin Core relays dust at 30000 litoshi/kB (src/policy/policy.h DUST_RELAY_TX_FEE), +// ten times Bitcoin Core's 3000 sat/kB, so the 262-byte output-plus-spend is 7860 +// litoshi there; the chain's flat 5460 P2PKH threshold sits under it and is relay dust. +// Bitcoin and Dogecoin values are pinned byte for byte. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const { PUBKEY_HEX, paymentFor, utxoFor, makeEncoder, build } = require('../../../helpers/psbt_build_fixtures.js') + +const CASES = [ + { network: 'bitcoin-regtest', kind: 'p2wpkh', estimate: 0.00001, relayfee: 0.00001, value: 100000000, expected: 786 }, + { network: 'litecoin-regtest', kind: 'p2wpkh', estimate: 0.0001, relayfee: 0.0001, value: 100000000, expected: 7860 }, + { network: 'dogecoin-regtest', kind: 'p2pkh', estimate: 0.01, relayfee: 0.001, value: 100000000000, expected: 1000000 } +] + +async function multisigValues (c) { + const prevTxs = new Map() + const encoder = makeEncoder(c.network, { estimate: c.estimate, relayfee: c.relayfee, prevTxs }) + const pay = paymentFor(c.kind, c.network) + const result = await build(encoder, { + utxos: [utxoFor(prevTxs, pay.output, c.value, 'ms-dust:' + c.network)], pubkey: pay.address, + data: 'FILE|0|b.bin|application/octet-stream|' + 'A'.repeat(300), encoding: 'MULTISIGN', + change: pay.address, compressedPubKey: PUBKEY_HEX + }) + assert.strictEqual(result.encoding, 'MULTISIGN') + const outs = result.psbt.txOutputs.filter(o => o.script[o.script.length - 1] === bitcoin.opcodes.OP_CHECKMULTISIG) + assert.ok(outs.length >= 2, 'the payload spans several multisig outputs') + return outs +} + +describe('XChainEncoder bare-multisig carrier dust per coin relay rate', function () { + for (const c of CASES) { + it(`${c.network}: every multisig carrier output is ${c.expected}`, async function () { + const outs = await multisigValues(c) + for (const o of outs) { + assert.strictEqual(o.script.length, 105, 'a 1-of-3 compressed-key script') + assert.strictEqual(o.value, c.expected) + } + }) + } +}) diff --git a/test/unit/xchain_encoder/psbt_conformance/custom_outputs_not_mutated.test.js b/test/unit/xchain_encoder/psbt_conformance/custom_outputs_not_mutated.test.js new file mode 100644 index 0000000..7a91af4 --- /dev/null +++ b/test/unit/xchain_encoder/psbt_conformance/custom_outputs_not_mutated.test.js @@ -0,0 +1,36 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// The hub feeQuote output is added to the build's own output list. An in-process +// caller that reuses its customOutputs array for a second build must get the same +// transaction shape, not one extra fee output per earlier build. + +const assert = require('assert') +const { paymentFor, utxoFor, makeEncoder, build } = require('../../../helpers/psbt_build_fixtures.js') + +describe('XChainEncoder leaves the caller\'s customOutputs array alone', function () { + it('builds twice from one array with one fee output each time', async function () { + const prevTxs = new Map() + const network = 'bitcoin-regtest' + const pay = paymentFor('p2wpkh', network) + const hub = paymentFor('p2pkh', network) + const encoder = makeEncoder(network, { estimate: 0.00002, relayfee: 0.00001, prevTxs }) + const customOutputs = [{ address: pay.address, value: 10000 }] + const request = (seed) => ({ + utxos: [utxoFor(prevTxs, pay.output, 1000000, seed)], pubkey: pay.address, data: 'BROADCAST|0|hi', + encoding: 'OP_RETURN', change: pay.address, customOutputs, feeQuote: { address: hub.address, amount: 5000 } + }) + const first = await build(encoder, request('first')) + const second = await build(encoder, request('second')) + assert.strictEqual(customOutputs.length, 1, 'the caller\'s array keeps its one payment') + assert.strictEqual(second.psbt.txOutputs.length, first.psbt.txOutputs.length) + assert.strictEqual(second.psbt.txOutputs.filter(o => o.script.equals(hub.output)).length, 1) + }) +}) diff --git a/test/unit/xchain_encoder/psbt_conformance/doge_soft_dust_surcharge.test.js b/test/unit/xchain_encoder/psbt_conformance/doge_soft_dust_surcharge.test.js new file mode 100644 index 0000000..f118e2f --- /dev/null +++ b/test/unit/xchain_encoder/psbt_conformance/doge_soft_dust_surcharge.test.js @@ -0,0 +1,87 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Dogecoin relays an output under its 0.01 DOGE soft dust limit only when the whole +// limit is added to the fee, once per such output. A payment between the hard limit +// (100000 koinu) and the soft limit (1000000) is allowed, so the fee carries that +// surcharge; a payment at the soft limit carries none. + +const assert = require('assert') +const { paymentFor, utxoFor, makeEncoder, build, unsignedTx, feeOf } = require('../../../helpers/psbt_build_fixtures.js') + +const DOGE = 'dogecoin-regtest' +const SOFT_DUST = 1000000n +const RELAY_PER_KB = 100000 // 0.001 DOGE/kB, the node estimate as well + +function dogeEncoder (prevTxs) { + return makeEncoder(DOGE, { estimate: 0.001, relayfee: 0.001, prevTxs }) +} + +// Relay minimum on the signed size: each P2PKH input gains a ~107-byte scriptSig. +function relayMinimum (res) { + const size = unsignedTx(res).byteLength() + 107 * res.psbt.txInputs.length + return BigInt(Math.ceil(size * RELAY_PER_KB / 1000)) +} + +async function payment (value, extra = {}) { + const prevTxs = new Map() + const pay = paymentFor('p2pkh', DOGE) + return build(dogeEncoder(prevTxs), Object.assign({ + utxos: [utxoFor(prevTxs, pay.output, 500000000, 'doge-pay' + value + JSON.stringify(extra))], pubkey: pay.address, + customOutputs: [{ address: pay.address, value }], change: pay.address + }, extra)) +} + +describe('XChainEncoder Dogecoin soft-dust relay surcharge', function () { + for (const value of [100000, 500000, 999999]) { + it(`adds the whole soft limit to the fee for a ${value}-koinu payment`, async function () { + const res = await payment(value) + assert.ok(feeOf(res.psbt) >= relayMinimum(res) + SOFT_DUST, + `fee ${feeOf(res.psbt)} lacks the ${SOFT_DUST} surcharge over ${relayMinimum(res)}`) + }) + } + + it('adds nothing for a payment at the soft limit', async function () { + const res = await payment(1000000) + assert.ok(feeOf(res.psbt) < SOFT_DUST, `fee ${feeOf(res.psbt)} carries a surcharge it does not owe`) + }) + + it('surcharges a hub feeQuote output under the soft limit', async function () { + const prevTxs = new Map() + const pay = paymentFor('p2pkh', DOGE) + const res = await build(dogeEncoder(prevTxs), { + utxos: [utxoFor(prevTxs, pay.output, 500000000, 'doge-quote')], pubkey: pay.address, data: 'BROADCAST|0|hi', + encoding: 'OP_RETURN', change: pay.address, feeQuote: { address: pay.address, amount: 300000 } + }) + assert.ok(feeOf(res.psbt) >= relayMinimum(res) + SOFT_DUST) + }) + + it('adds the surcharge on top of an explicit fee', async function () { + const res = await payment(500000, { fee: 200000 }) + assert.strictEqual(feeOf(res.psbt), 200000n + SOFT_DUST) + }) + + it('prefunds the surcharge for a sub-limit payment on a P2SH reveal', async function () { + const prevTxs = new Map() + const pay = paymentFor('p2pkh', DOGE) + const encoder = dogeEncoder(prevTxs) + const common = { pubkey: pay.address, data: 'x'.repeat(600), encoding: 'P2SH', change: pay.address, + customOutputs: [{ address: pay.address, value: 500000 }] } + const funding = await build(encoder, Object.assign({ utxos: [utxoFor(prevTxs, pay.output, 500000000, 'doge-p2sh')] }, common)) + const fundingTx = unsignedTx(funding) + const reveal = await build(encoder, Object.assign({ utxos: [], p2shHash: fundingTx.getId(), p2shHex: fundingTx.toHex() }, common)) + // A P2SH reveal input carries its whole redeem script plus a signature and key. + const signedSize = unsignedTx(reveal).byteLength() + + reveal.psbt.data.inputs.reduce((n, input) => n + input.redeemScript.length + 110, 0) + const fee = feeOf(reveal.psbt) + assert.ok(fee >= BigInt(Math.ceil(signedSize * RELAY_PER_KB / 1000)) + SOFT_DUST, + `reveal fee ${fee} lacks the surcharge for its 500000-koinu payment`) + }) +}) diff --git a/test/unit/xchain_encoder/psbt_conformance/nested_segwit_input.test.js b/test/unit/xchain_encoder/psbt_conformance/nested_segwit_input.test.js new file mode 100644 index 0000000..e1e5547 --- /dev/null +++ b/test/unit/xchain_encoder/psbt_conformance/nested_segwit_input.test.js @@ -0,0 +1,79 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// A P2SH-P2WPKH (nested segwit) funding UTXO is a segwit spend: it needs witnessUtxo +// and its P2WPKH redeemScript to be signable, and it costs about 91 vbytes, not the +// 289 a legacy multisig P2SH input is priced at. The redeem script comes from the +// public key the request carries; without one the input stays legacy. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const { ECPair, PRIVATE_KEY, PUBKEY, PUBKEY_HEX, networkOf, paymentFor, utxoFor, makeEncoder, build, feeOf } = + require('../../../helpers/psbt_build_fixtures.js') + +const BTC = 'bitcoin-regtest' +const FEE_PER_KB = 2000 + +async function nestedBuild (request) { + const prevTxs = new Map() + const nested = paymentFor('p2sh-p2wpkh', BTC) + const encoder = makeEncoder(BTC, { estimate: 0.00002, relayfee: 0.00001, prevTxs }) + const res = await build(encoder, Object.assign({ + utxos: [utxoFor(prevTxs, nested.output, 1000000, 'nested'), utxoFor(prevTxs, nested.output, 300000, 'nested2')], + data: 'BROADCAST|0|hi', encoding: 'OP_RETURN', change: nested.address, feePerKb: FEE_PER_KB, + customOutputs: [{ address: paymentFor('p2wpkh', BTC).address, value: 1100000 }] + }, request)) + return { res, nested } +} + +describe('XChainEncoder P2SH-P2WPKH funding inputs', function () { + it('builds each input with witnessUtxo and the key\'s P2WPKH redeemScript', async function () { + const { res, nested } = await nestedBuild({ pubkey: PUBKEY_HEX }) + for (const input of res.psbt.data.inputs) { + assert.ok(input.witnessUtxo, 'witnessUtxo carries the value a segwit sighash commits to') + assert.ok(input.redeemScript && input.redeemScript.equals(nested.redeem.output)) + } + }) + + it('prices the inputs at their signed size, so the fee lands at the requested rate', async function () { + const { res } = await nestedBuild({ pubkey: PUBKEY_HEX }) + const psbt = bitcoin.Psbt.fromHex(res.psbt.toHex(), { network: networkOf(BTC) }) + psbt.signAllInputs(ECPair.fromPrivateKey(PRIVATE_KEY)) + psbt.finalizeAllInputs() + const vsize = psbt.extractTransaction(true).virtualSize() + const rate = Number(feeOf(res.psbt)) / vsize + assert.ok(rate >= FEE_PER_KB / 1000 && rate < 1.25 * FEE_PER_KB / 1000, `fee rate ${rate} per vbyte`) + }) + + it('takes the key from compressedPubKey when pubkey is the P2SH address', async function () { + const nested = paymentFor('p2sh-p2wpkh', BTC) + const { res } = await nestedBuild({ pubkey: nested.address, compressedPubKey: PUBKEY_HEX }) + assert.ok(res.psbt.data.inputs.every(input => input.redeemScript && input.witnessUtxo)) + }) + + it('builds a P2SH address given with no public key as a legacy input', async function () { + const nested = paymentFor('p2sh-p2wpkh', BTC) + const { res } = await nestedBuild({ pubkey: nested.address }) + assert.ok(res.psbt.data.inputs.every(input => input.nonWitnessUtxo && !input.witnessUtxo && !input.redeemScript)) + }) + + it('leaves a P2SH input the key does not match as a legacy input', async function () { + const prevTxs = new Map() + const net = networkOf(BTC) + const other = bitcoin.payments.p2sh({ redeem: bitcoin.payments.p2ms({ m: 1, pubkeys: [PUBKEY], network: net }), network: net }) + const pay = paymentFor('p2wpkh', BTC) + const res = await build(makeEncoder(BTC, { estimate: 0.00002, relayfee: 0.00001, prevTxs }), { + utxos: [utxoFor(prevTxs, other.output, 1000000, 'legacy-p2sh')], pubkey: PUBKEY_HEX, + data: 'BROADCAST|0|hi', encoding: 'OP_RETURN', change: pay.address, feePerKb: FEE_PER_KB + }) + const input = res.psbt.data.inputs[0] + assert.ok(input.nonWitnessUtxo && !input.witnessUtxo && !input.redeemScript) + }) +}) diff --git a/test/unit/xchain_encoder/psbt_conformance/p2wsh_reveal_headroom.test.js b/test/unit/xchain_encoder/psbt_conformance/p2wsh_reveal_headroom.test.js new file mode 100644 index 0000000..2a2e318 --- /dev/null +++ b/test/unit/xchain_encoder/psbt_conformance/p2wsh_reveal_headroom.test.js @@ -0,0 +1,74 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// A P2WSH action of two or more chunks has no floor pad on its reveal, so the reveal +// leaves a value output only if its legs carry more than its fee. The first leg is +// topped up to the whole reveal's fee plus one output floor, and the reveal sweeps +// the surplus back to the caller: never a lone zero-value OP_RETURN, which the +// signer refuses as a full burn after the commit is already on chain. + +const assert = require('assert') +const { PUBKEY, paymentFor, utxoFor, makeEncoder, build, unsignedTx } = require('../../../helpers/psbt_build_fixtures.js') + +const FEE_PER_KB = 2000 // base units per kB: 2 per vbyte + +async function fundAndReveal (network, payloadLength) { + const prevTxs = new Map() + const encoder = makeEncoder(network, { estimate: 0.00002, relayfee: 0.00001, prevTxs }) + const pay = paymentFor('p2wpkh', network) + const data = 'x'.repeat(payloadLength) + const funding = await build(encoder, { + utxos: [utxoFor(prevTxs, pay.output, 5000000, network + payloadLength)], pubkey: pay.address, + data, encoding: 'P2WSH', change: pay.address, feePerKb: FEE_PER_KB + }) + const fundingTx = unsignedTx(funding) + const reveal = await build(encoder, { + utxos: [], pubkey: pay.address, data, encoding: 'P2WSH', change: pay.address, + p2shHash: fundingTx.getId(), p2shHex: fundingTx.toHex(), feePerKb: FEE_PER_KB + }) + return { encoder, pay, fundingTx, reveal } +} + +// The reveal's vsize once signed: a 72-byte signature, the key and the witness script per input. +function signedRevealVsize (reveal) { + const tx = unsignedTx(reveal) + reveal.psbt.data.inputs.forEach((input, i) => { + tx.ins[i].witness = [Buffer.alloc(72, 1), PUBKEY, input.witnessScript] + }) + return tx.virtualSize() +} + +function legTotal (reveal) { + return reveal.psbt.data.inputs.reduce((sum, input) => sum + BigInt(input.witnessUtxo.value), 0n) +} + +describe('XChainEncoder multi-chunk P2WSH reveal headroom', function () { + for (const network of ['bitcoin-regtest', 'litecoin-regtest']) { + for (const payloadLength of [600, 1500]) { + it(`${network} ${payloadLength}-byte payload: the reveal returns change at or above the output floor`, async function () { + const { encoder, pay, reveal } = await fundAndReveal(network, payloadLength) + assert.ok(reveal.psbt.data.inputs.length >= 2, 'the payload spans at least two chunks') + const change = reveal.psbt.txOutputs.filter(o => o.script.equals(pay.output)) + assert.strictEqual(change.length, 1, 'one change output back to the caller') + assert.ok(BigInt(change[0].value) >= BigInt(encoder.outputFloor), + `change ${change[0].value} is under the output floor ${encoder.outputFloor}`) + }) + + it(`${network} ${payloadLength}-byte payload: the reveal still pays its target rate on its signed size`, async function () { + const { reveal } = await fundAndReveal(network, payloadLength) + let outputs = 0n + for (const out of reveal.psbt.txOutputs) outputs += BigInt(out.value) + const fee = legTotal(reveal) - outputs + const needed = BigInt(Math.ceil(signedRevealVsize(reveal) * FEE_PER_KB / 1000)) + assert.ok(fee >= needed, `reveal fee ${fee} is under ${needed} for its signed size`) + }) + } + } +}) diff --git a/test/unit/xchain_encoder/psbt_conformance/reveal_funding_pairing.test.js b/test/unit/xchain_encoder/psbt_conformance/reveal_funding_pairing.test.js new file mode 100644 index 0000000..f9988a7 --- /dev/null +++ b/test/unit/xchain_encoder/psbt_conformance/reveal_funding_pairing.test.js @@ -0,0 +1,68 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// A chunk reveal spends the funding outputs with the scripts rebuilt from the reveal +// call's own payload. When that payload differs from the funded one at the same chunk +// count, the scripts do not hash to the outputs they claim to spend and the reveal can +// never be valid, so the encoder refuses it instead of returning an unspendable PSBT. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const { paymentFor, utxoFor, makeEncoder, build, unsignedTx } = require('../../../helpers/psbt_build_fixtures.js') + +const FEE_PER_KB = 2000 + +async function fund (network, encoding, data) { + const prevTxs = new Map() + const encoder = makeEncoder(network, { estimate: 0.00002, relayfee: 0.00001, prevTxs }) + const pay = paymentFor('p2wpkh', network) + const funding = await build(encoder, { + utxos: [utxoFor(prevTxs, pay.output, 5000000, network + encoding + data.length)], pubkey: pay.address, + data, encoding, change: pay.address, feePerKb: FEE_PER_KB + }) + return { encoder, pay, fundingTx: unsignedTx(funding) } +} + +function reveal (encoder, pay, fundingTx, encoding, data) { + return build(encoder, { + utxos: [], pubkey: pay.address, data, encoding, change: pay.address, + p2shHash: fundingTx.getId(), p2shHex: fundingTx.toHex(), feePerKb: FEE_PER_KB + }) +} + +describe('XChainEncoder chunk reveal refuses scripts that do not match the funded outputs', function () { + for (const encoding of ['P2SH', 'P2WSH']) { + for (const length of [100, 600, 1500]) { + it(`${encoding} ${length}-byte payload: a reveal of the funded payload is built`, async function () { + const data = 'x'.repeat(length) + const { encoder, pay, fundingTx } = await fund('bitcoin-regtest', encoding, data) + const r = await reveal(encoder, pay, fundingTx, encoding, data) + const tx = unsignedTx(r) + for (const input of tx.ins) assert.strictEqual(Buffer.from(input.hash).reverse().toString('hex'), fundingTx.getId()) + }) + + it(`${encoding} ${length}-byte payload: a reveal of a different same-length payload is refused`, async function () { + const { encoder, pay, fundingTx } = await fund('bitcoin-regtest', encoding, 'x'.repeat(length)) + await assert.rejects(reveal(encoder, pay, fundingTx, encoding, 'y'.repeat(length)), + (err) => err instanceof RangeError && /does not match the funded output/.test(err.message)) + }) + } + } + + it('P2WSH: a reveal whose payload needs more chunks than were funded is refused', async function () { + const { encoder, pay, fundingTx } = await fund('bitcoin-regtest', 'P2WSH', 'x'.repeat(600)) + const fundedLegs = fundingTx.outs.filter(o => { + const d = bitcoin.script.decompile(o.script) + return d && d.length === 2 && d[0] === bitcoin.opcodes.OP_0 && d[1].length === 32 + }).length + assert.ok(fundedLegs >= 2) + await assert.rejects(reveal(encoder, pay, fundingTx, 'P2WSH', 'x'.repeat(1500)), RangeError) + }) +}) diff --git a/test/unit/xchain_encoder/psbt_conformance/stripped_size_floor.test.js b/test/unit/xchain_encoder/psbt_conformance/stripped_size_floor.test.js new file mode 100644 index 0000000..78985e6 --- /dev/null +++ b/test/unit/xchain_encoder/psbt_conformance/stripped_size_floor.test.js @@ -0,0 +1,97 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// A single-transaction build whose change folds into the fee can serialize under the +// node's MIN_STANDARD_TX_NONWITNESS_SIZE (Litecoin Core 82, Bitcoin Core 65), and the +// node will not relay it. Selection then takes another input to lift it over the +// floor, and a build with no input left to do that is refused instead of returned. + +const assert = require('assert') +const { paymentFor, utxoFor, makeEncoder, build, unsignedTx, feeOf } = require('../../../helpers/psbt_build_fixtures.js') + +const FEE_PER_KB = 2000 +const LTC = 'litecoin-regtest' + +function encoderFor (network, prevTxs) { + return makeEncoder(network, { estimate: 0.00002, relayfee: 0.00001, prevTxs }) +} + +// The fee an OP_RETURN action costs from one large p2wpkh input on `network`. +async function opReturnFee (network, data) { + const prevTxs = new Map() + const pay = paymentFor('p2wpkh', network) + const res = await build(encoderFor(network, prevTxs), { + utxos: [utxoFor(prevTxs, pay.output, 1000000, 'probe' + network + data)], pubkey: pay.address, + data, encoding: 'OP_RETURN', change: pay.address, feePerKb: FEE_PER_KB + }) + return Number(feeOf(res.psbt)) +} + +async function opReturnBuild (network, data, values) { + const prevTxs = new Map() + const pay = paymentFor('p2wpkh', network) + const utxos = values.map((v, i) => utxoFor(prevTxs, pay.output, v, network + data + i + ':' + v)) + return build(encoderFor(network, prevTxs), { + utxos, pubkey: pay.address, data, encoding: 'OP_RETURN', change: pay.address, feePerKb: FEE_PER_KB + }) +} + +// A one-input p2wpkh payment to one p2wpkh output whose change folds: 82 stripped bytes. +async function sweepBuild (network) { + const prevTxs = new Map() + const pay = paymentFor('p2wpkh', network) + const value = 1000000 + const probe = await build(encoderFor(network, prevTxs), { + utxos: [utxoFor(prevTxs, pay.output, value, 'sweep-probe' + network)], pubkey: pay.address, + customOutputs: [{ address: pay.address, value: 500000 }], change: pay.address, feePerKb: FEE_PER_KB + }) + const sweepValue = value - Number(feeOf(probe.psbt)) - 10 + return build(encoderFor(network, prevTxs), { + utxos: [utxoFor(prevTxs, pay.output, value, 'sweep' + network)], pubkey: pay.address, + customOutputs: [{ address: pay.address, value: sweepValue }], change: pay.address, feePerKb: FEE_PER_KB + }) +} + +describe('XChainEncoder stripped-size floor on single-transaction builds', function () { + for (const data of ['BROADCAST|0|hi', 'ADDRESS|0|1|0']) { + it(`litecoin-regtest ${data}: refuses an OP_RETURN-only build its one input cannot lift over 82 bytes`, async function () { + const fee = await opReturnFee(LTC, data) + await assert.rejects(opReturnBuild(LTC, data, [fee + 100]), + (e) => e.xchainCode === 'TX_BELOW_MIN_STANDARD_SIZE' && e.details.minimum === 82 && e.details.strippedSize < 82) + }) + + it(`litecoin-regtest ${data}: takes a second input to lift an OP_RETURN-only build over 82 bytes`, async function () { + const fee = await opReturnFee(LTC, data) + const res = await opReturnBuild(LTC, data, [fee + 100, fee + 50]) + const stripped = unsignedTx(res).byteLength(false) + assert.strictEqual(res.psbt.txInputs.length, 2) + assert.ok(stripped >= 82, `stripped size ${stripped} is under 82`) + }) + } + + it('bitcoin-regtest: builds a one-input OP_RETURN-only transaction, which clears 65 bytes', async function () { + const fee = await opReturnFee('bitcoin-regtest', 'BROADCAST|0|hi') + const res = await opReturnBuild('bitcoin-regtest', 'BROADCAST|0|hi', [fee + 100]) + assert.strictEqual(res.psbt.txInputs.length, 1) + assert.ok(unsignedTx(res).byteLength(false) >= 65) + }) + + it('litecoin-regtest: builds a full sweep to one p2wpkh payment, 82 bytes at the 82-byte floor', async function () { + const res = await sweepBuild(LTC) + assert.strictEqual(res.psbt.txOutputs.length, 1) + assert.strictEqual(unsignedTx(res).byteLength(false), 82) + }) + + it('leaves a build that returns change untouched by the floor', async function () { + const res = await opReturnBuild(LTC, 'BROADCAST|0|hi', [1000000]) + assert.strictEqual(res.psbt.txInputs.length, 1) + assert.strictEqual(res.psbt.txOutputs.length, 2) + }) +}) diff --git a/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/06_custom_dust_parameter.test.js b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/06_custom_dust_parameter.test.js index afe420a..1b53ab3 100644 --- a/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/06_custom_dust_parameter.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_create_transaction.test/06_custom_dust_parameter.test.js @@ -8,6 +8,7 @@ // license (without AGPL source-disclosure terms) is available - // contact legal@dankest.llc. +const bitcoin = require('bitcoinjs-lib') const { assert, pubkeyBuf, @@ -24,8 +25,9 @@ describe('XChainEncoder.createTransaction()', () => { const encoder = makeEncoder() const utxo = makeSegwitUtxo(TXID_MS, 0, 100000000) const compressedPubKey = pubkeyBuf.toString('hex') - // Above the LTC output floor (5460), so the caller's value is honoured. - const customDust = 6000 + // Above the LTC output floor (5460) and the 1-of-3 bare-multisig relay floor + // (262 bytes at Litecoin's 30 per byte = 7860), so the caller's value is honoured. + const customDust = 9000 assert.ok(customDust > encoder.outputFloor) const MS_DATA = 'A'.repeat(59) @@ -38,7 +40,7 @@ describe('XChainEncoder.createTransaction()', () => { ) const msOutput = result.psbt.txOutputs.find(o => o.value === customDust) - assert.ok(msOutput, 'MULTISIGN output should use custom dust value of 6000') + assert.ok(msOutput, 'MULTISIGN output should use custom dust value of 9000') }) }) }) @@ -63,7 +65,11 @@ describe('XChainEncoder.createTransaction()', () => { ) assert.ok(!result.psbt.txOutputs.find(o => o.value === customDust), 'no output at the sub-floor value') - assert.ok(result.psbt.txOutputs.find(o => o.value === encoder.outputFloor), 'the data output sits at the floor') + // The data output sits at the larger of the output floor and the bare-multisig + // relay floor, which on Litecoin is 7860 (262 bytes at 30 per byte). + const msOutputs = result.psbt.txOutputs.filter(o => o.script[o.script.length - 1] === bitcoin.opcodes.OP_CHECKMULTISIG) + assert.ok(msOutputs.length > 0, 'a multisig data output') + for (const o of msOutputs) assert.strictEqual(o.value, Math.max(encoder.outputFloor, 7860), 'the data output sits at the floor') }) it('does NOT override the fee floor (fee floor uses this.dustAmount)', async () => { diff --git a/test/unit/xchain_encoder/xchain_encoder_extra.test/02_p2wsh_tx2_spending.test.js b/test/unit/xchain_encoder/xchain_encoder_extra.test/02_p2wsh_tx2_spending.test.js index 8851f41..3f52d33 100644 --- a/test/unit/xchain_encoder/xchain_encoder_extra.test/02_p2wsh_tx2_spending.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_extra.test/02_p2wsh_tx2_spending.test.js @@ -65,6 +65,20 @@ const BTC_REGTEST_ADDR = bitcoin.payments.p2pkh({ const RAW_TX_HEX = buildRawP2pkhTxHex(100000000) +// The real funding transaction for `data`, cut down to its first output: the first +// chunk leg matches the reveal's first script, and every later chunk has no output. +async function fundingWithFirstLegOnly (encoder, utxo, address, data, encoding) { + const funding = await encoder.createTransaction( + [utxo], address, null, data, null, 10000, false, encoding, address, + null, null, null, true, 0.00001) + const full = funding.psbt.__CACHE.__TX + const cut = new bitcoin.Transaction() + cut.version = full.version + for (const input of full.ins) cut.addInput(input.hash, input.index, input.sequence) + cut.addOutput(full.outs[0].script, full.outs[0].value) + return cut +} + function makeEncoder (network) { const net = network || 'dogecoin-regtest' const encoder = new XChainEncoder( @@ -121,21 +135,13 @@ describe('XChainEncoder.createTransaction(): P2WSH tx2 (spending)', () => { const encoder = makeEncoder('bitcoin-regtest') const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) - // Build a minimal tx with just ONE output but request it for two chunks - // (The data will be split into 2 chunks but the funding tx only has 1 output) - const smallTx = new bitcoin.Transaction() - smallTx.addInput(Buffer.alloc(32, 0x11), 0) - const script = bitcoin.payments.p2pkh({ - pubkey: pubkeyBuf, network: bitcoin.networks.regtest - }).output - smallTx.addOutput(script, 10000) // only 1 P2WSH-sized output at index 0 + // Data that will produce 2 chunks: each P2WSH chunk holds 476 bytes, so 480 + // forces 2. The funding tx keeps only its first leg, so chunk 1 has no output. + const bigData = 'x'.repeat(480) + const smallTx = await fundingWithFirstLegOnly(encoder, utxo, BTC_REGTEST_ADDR, bigData, 'P2WSH') const smallTxHex = smallTx.toHex() const smallTxId = smallTx.getId() - // Data that will produce 2 chunks (> 76 bytes for OP_RETURN, so P2WSH means 2 chunks) - // Each P2WSH chunk uses 476 bytes of data; let's use data > 476 to force 2 chunks - const bigData = 'x'.repeat(480) - await assert.rejects( () => encoder.createTransaction( [utxo], BTC_REGTEST_ADDR, null, @@ -154,19 +160,13 @@ describe('XChainEncoder.createTransaction(): P2WSH tx2 (spending)', () => { const encoder = makeEncoder() // dogecoin-regtest (P2SH, no segwit) const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) - // Funding tx with a SINGLE output, but a payload that splits into >=2 chunks. - const smallTx = new bitcoin.Transaction() - smallTx.addInput(Buffer.alloc(32, 0x11), 0) - const script = bitcoin.payments.p2pkh({ - pubkey: pubkeyBuf, network: bitcoin.networks.regtest - }).output - smallTx.addOutput(script, 10000) // only 1 P2SH-sized output at index 0 + // >476 compiled bytes forces 2 P2SH chunks, but the funding tx keeps only its first leg. + // The second chunk's index is past the funding outputs, so the bounds check refuses it. + const bigData = 'x'.repeat(480) + const smallTx = await fundingWithFirstLegOnly(encoder, utxo, TEST_ADDRESS, bigData, 'P2SH') const smallTxHex = smallTx.toHex() const smallTxId = smallTx.getId() - // >476 compiled bytes forces 2 P2SH chunks, but the funding tx has 1 output. - const bigData = 'x'.repeat(480) - await assert.rejects( () => encoder.createTransaction( [utxo], TEST_ADDRESS, null, 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 7aabd0a..bd31f23 100644 --- a/test/unit/xchain_encoder/xchain_encoder_fee_rate_cap.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_fee_rate_cap.test.js @@ -19,7 +19,8 @@ // own estimatesmartfee(1) estimate (default 100×). feePerKb is clamped; an // explicit absolute fee is rejected with a RangeError (silently lowering an // exact amount would change what the caller signs). The cap degrades -// gracefully when the node cannot produce an estimate (quiet testnets). +// gracefully when the node cannot produce an estimate (mainnet with no +// estimatesmartfee data, or a failed relayfee fallback query). const assert = require('assert') const bitcoin = require('bitcoinjs-lib') diff --git a/test/unit/xchain_encoder/xchain_encoder_p2sh_reveal_headroom.test.js b/test/unit/xchain_encoder/xchain_encoder_p2sh_reveal_headroom.test.js index 813eced..f8d82a2 100644 --- a/test/unit/xchain_encoder/xchain_encoder_p2sh_reveal_headroom.test.js +++ b/test/unit/xchain_encoder/xchain_encoder_p2sh_reveal_headroom.test.js @@ -248,12 +248,17 @@ describe('XChainEncoder P2SH reveal headroom', () => { // XChainEncoder, so any drift on these lanes fails loudly. The BTC funding // change is one base unit lower (feeForVsize rounds a fee up), so the BTC // reveal spends a new txid and re-keys its obfuscated marker; values hold. + // Re-pinned when the single-transaction size estimate gained the 10-byte header + // and the segwit marker, and the P2WSH funding leg began pricing the reveal's pad + // output: BTC change fell 340 (11 vB + the 23-byte pad delta at 10 sat/vB, which + // also lifts the leg 230), LTC change 3100 (leg +2000) and DOGE change 10000 (the + // header at 1000 koinu/B). The reveals keep their values and re-key the marker. const PRE_FIX = { - btcP2wshFunding: '0200000001aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000000000ffffffff02020900000000000022002050efb27b1cb5812e1bb95788a3ffd8550464371c1d57a36e4649e9b5ae8ed1fefad1f505000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000', - ltcP2wshFunding: '0200000001aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000000000ffffffff02405b00000000000022002050efb27b1cb5812e1bb95788a3ffd8550464371c1d57a36e4649e9b5ae8ed1fe9849f505000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000', - btcP2wshReveal: '02000000018e1823ac37c2755d15f8a2b8a7865a7d6143d07eae6152191b64fae949bc0f850000000000ffffffff0200000000000000000b6a0975819111a938ed57e522020000000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000', - ltcP2wshReveal: '0200000001e681017eda2e84c8f80a5d543af89041236fc9e7549630025e3d1588ef60e5c60000000000ffffffff0200000000000000000b6a09e56b6a299a4ddadb6654150000000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000', - dogeOpReturn: '0200000001aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000000000ffffffff0200000000000000002f6a2d09cb8e09635af4c5aa3a916a26c6652b18482f3a1ec3636bebdfa9f4d1599a8385426040f9baaff90aa6b137f0289ff105000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000' + btcP2wshFunding: '0200000001aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000000000ffffffff02e80900000000000022002050efb27b1cb5812e1bb95788a3ffd8550464371c1d57a36e4649e9b5ae8ed1fea6d0f505000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000', + ltcP2wshFunding: '0200000001aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000000000ffffffff02106300000000000022002050efb27b1cb5812e1bb95788a3ffd8550464371c1d57a36e4649e9b5ae8ed1fe7c3df505000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000', + btcP2wshReveal: '0200000001d89df09a778e91773ab1a19027134f65f51e42bc7f0ec80ae8f15ed8e29308e70000000000ffffffff0200000000000000000b6a094bf148797506b449e022020000000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000', + ltcP2wshReveal: '02000000010964edb1b808477b18d6126f863f88a86ebbaae1ab18ec09f9d607cb999773590000000000ffffffff0200000000000000000b6a094341679d879db7f9e654150000000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000', + dogeOpReturn: '0200000001aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000000000ffffffff0200000000000000002f6a2d09cb8e09635af4c5aa3a916a26c6652b18482f3a1ec3636bebdfa9f4d1599a8385426040f9baaff90aa6b137f01878f105000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000' } it('keeps the BTC default two-phase lane (P2WSH) byte-identical, funding and reveal', async () => {