diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ba8da8..93b7435 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.15.0] - 2026-09-07 + +### Added +- An `exactInputs` createtransaction option spends the caller's UTXO list verbatim. +- The operator maintenance window is surfaced through `/status` without silencing the 503. + +### Fixed +- Every named input is reserved at build time so chained sends cannot respend a UTXO. + +### Changed +- The vendored coin registry is resynced from the hub. + ## [0.12.0] - 2026-08-30 ### Added diff --git a/README.md b/README.md index ffeb011..d7e68b9 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ # XChain Platform Encoder

- Version - Tests + Version + Tests Node License

@@ -31,7 +31,7 @@ PSBT encoding service for the XChain Platform. Takes an ACTION string, a set of - **Token-gated content support**: encodes [FILE v1](https://github.com/XChain-Platform/xchain-documentation/blob/master/protocol/actions/file.md) gated files and `BATCH(FILE, MESSAGE)` issuer-publish flows; ciphertext travels as `rawData` via P2WSH alongside the action string - **JSON-RPC API**: Express server with Helmet security headers, optional API key auth, configurable rate limiting, CORS - **Browser bundle**: Browserify build for client-side PSBT generation without a server -- **Single-instance guard**: refuses to boot when `ENCODER_REPLICAS` declares more than one replica, and takes an exclusive PID lockfile against a second local process; the UTXO reservation guard and rate limiter are in-process only until a shared store exists +- **Single-instance guard**: refuses to boot when `ENCODER_REPLICAS` declares more than one replica, and takes an exclusive PID lockfile against a second local process; the UTXO reservation guard, the recent-build duplicate refusal and the rate limiter are in-process only until a shared store exists - **1330+ tests**: unit, integration, e2e, boundary, security, fuzz, chaos, mutation, regression, performance, smoke ## Documentation @@ -81,21 +81,52 @@ npm run api | `NODE_RPC_TIMEOUT` | No | `30000` | Coin-node RPC call timeout in milliseconds | | `UTXO_TRACKER_URL` | No | (none) | xchain-utxo-tracker service host | | `UTXO_TRACKER_API_PORT` | No | (none) | xchain-utxo-tracker service port | -| `UTXO_TRACKER_MAX_LAG_BLOCKS` | No | `2` | Max blocks the utxo-tracker's reported sync lag may be before `create_tx` refuses to select UTXOs from it | +| `UTXO_TRACKER_MAX_LAG_BLOCKS` | No | `2` | Max blocks the utxo-tracker's reported sync lag may be before `create_tx` refuses to select UTXOs from it. `GET /status` publishes the effective value as `tracker_max_lag_blocks`, so a status board can rank lag against the other unready causes without mirroring a constant it cannot see (the tracker's own `SYNCED_THRESHOLD` is looser and is not this gate) | | `MAX_FEE_RATE_KB` | No | Uncapped | Absolute maximum fee rate in sat/kB | | `MAX_FEE_RATE_MULTIPLIER` | No | `100` | Caps caller-supplied fee/feePerKb at this multiple of the node's fee estimate (`0` disables) | | `MAX_CPFP_UPLIFT_SAT` | No | `10000000` | Most a transaction spending unconfirmed inputs may add to its fee so the whole mempool package reaches the target rate (`0` disables package-aware sizing) | | `FEE_NO_ESTIMATE_RELAY_MULTIPLIER` | No | `10` | Multiple of the node's relay floor charged on a non-mainnet chain when `estimatesmartfee` has no data. Raise it where miners ignore the documented rate (`100` gives 0.1 DOGE/kB). Mainnet is unaffected | | `XCHAIN_COMPRESSION_DEFAULT` | No | Enabled | Deployment default for transparent FILE compression; set `0`, `false`, or `off` to disable | -| `ENCODER_REPLICAS` | No | `1` | Deploy-manifest declared replica count; boot refuses above `1` until a shared UTXO-reservation store exists | +| `ENCODER_REPLICAS` | No | `1` | Deploy-manifest declared replica count; boot refuses above `1` until the in-process reservation, recent-build and rate-limit stores are shared | | `API_KEY` | No | Disabled | API key for `x-api-key` header authentication | | `ENCODER_RATE_LIMIT_RPM` | No | `60` | Maximum requests per minute per IP | | `ENCODER_MAX_RPC_BATCH` | No | `20` | Maximum JSON-RPC batch array length per request | | `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. `GET /status` and `GET /openrpc.json` are exempt; `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_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 | +## Scheduled maintenance + +Planned work takes an encoder's dependencies down. The monthly UTXO-tracker +bootstrap publish stops the tracker, so `GET /status` answers 503 with +`tracker_reachable: false` and a status board has no way to tell that outage +apart from a broken encoder. + +Declaring a window fixes the label, not the probe. Write a small JSON file at +`ENCODER_MAINTENANCE_FILE`: + +```json +{ "reason": "utxo-tracker bootstrap publish", + "since": "2026-09-02T02:00:00.000Z", + "until": "2026-09-02T08:00:00.000Z" } +``` + +`health` and `GET /status` then carry it as `maintenance` alongside the +readiness fields. What it does **not** do is as important: the readiness +booleans and the 503 are unchanged, so every load balancer and uptime monitor +keyed on them keeps seeing exactly what it saw before. + +`until` is required, and a window longer than 24 hours, already expired, +malformed, or oversized is ignored: a publish that dies without cleaning up +stops excusing the outage at its own declared end time rather than hiding it +indefinitely. A `since` in the future holds the window closed until it opens. +`reason` is optional, bounded, and stripped to printable ASCII. + +`xchain-node` writes and removes this file automatically around a bootstrap +publish (`src/services/EncoderMaintenanceWindow.js`). + ## Metrics and log shipping (optional, off by default) A Prometheus `/metrics` endpoint and a structured log shim ship with this @@ -134,20 +165,20 @@ defaults hold on an unconfigured box: | `npm run build` | Production browser bundle (minified) -> `dist/xchain_encoder.min.js` | | `npm run build:dev` | Development browser bundle (unminified) | | `npm run smoke-test` | Smoke tests (~52 tests, <1s) | -| `npm run test:unit` | Unit tests (724 tests) | +| `npm run test:unit` | Unit tests (759 tests) | | `npm run test:integration` | Integration tests (112 tests) | | `npm run test:boundary` | Boundary condition tests (~101 tests) | | `npm run test:security` | Security tests (57 tests) | | `npm run test:fuzz` | Property-based fuzz tests (6 suites, 6 tests) | | `npm run test:chaos` | Chaos engineering tests (63 tests) | | `npm run test:e2e` | End-to-end tests (~158 tests) | -| `npm run test:regression` | Regression tests (282 tests) | +| `npm run test:regression` | Regression tests (290 tests) | | `npm run mutate` | Full mutation testing via StrykerJS | | `npm run mutate:quick` | Quick mutation check (XChainEncoder.js only) | | `npm run bench` | Performance benchmarks | | `npm run bench:full` | Extended benchmarks with JSON output | | `npm run bench:soak` | Soak test (sustained load) | -| `npm test` | Unit tests (hermetic, no external services, 724 tests) | +| `npm test` | Unit tests (hermetic, no external services, 759 tests) | | `npm run test:regtest` | Regtest integration tests (requires local bitcoind) | ## Test Suite diff --git a/docker-compose.yml b/docker-compose.yml index e44e08b..2013c0e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,10 +4,12 @@ services: xchain_encoder: build: . # HARD CONSTRAINT: exactly one encoder replica per endpoint. The UTXO - # outpoint-reservation double-spend guard and the rate limiter are - # in-process (src/singleInstanceGuard.js); scaling out lets two replicas - # build PSBTs spending the same UTXO. If a deploy manifest ever sets a - # replica count, mirror it in ENCODER_REPLICAS so boot fails loudly. + # outpoint-reservation double-spend guard, the recent-build duplicate + # refusal and the rate limiter are all in-process + # (src/singleInstanceGuard.js); scaling out lets two replicas build PSBTs + # spending the same UTXO and lets one byte-identical transaction be built + # once per replica. If a deploy manifest ever sets a replica count, mirror + # it in ENCODER_REPLICAS so boot fails loudly. deploy: replicas: 1 networks: diff --git a/docs/openrpc.build.js b/docs/openrpc.build.js index bdbbe22..4d7a1c3 100644 --- a/docs/openrpc.build.js +++ b/docs/openrpc.build.js @@ -31,7 +31,7 @@ const METHODS = [ description: 'Unlike ping, a health failure means the encoder cannot serve requests correctly. ' + 'When the tracker is unreachable the fields stay at their defaults (false / null).', params: [], - result: { name: 'health', schema: { type: 'object', properties: { tracker_reachable: bool('UTXO tracker reachable'), tracker_synced: bool('tracker is serveable: synced, inside our lag bounds on both sides, not halted, and mempool-reconverged'), tracker_lag: { type: ['integer', 'null'], description: 'tracker block lag, or null when unknown/unreachable; negative means the tracker is ahead of the node' }, tracker_halted: bool('tracker stopped polling on an unrecoverable reorg'), tracker_mempool_ready: bool('tracker has reconverged its mempool, so an already-spent confirmed output can be filtered out') } } }, + result: { name: 'health', schema: { type: 'object', properties: { tracker_reachable: bool('UTXO tracker reachable'), tracker_synced: bool('tracker is serveable: synced, inside our lag bounds on both sides, not halted, and mempool-reconverged'), tracker_lag: { type: ['integer', 'null'], description: 'tracker block lag, or null when unknown/unreachable; negative means the tracker is ahead of the node' }, tracker_halted: bool('tracker stopped polling on an unrecoverable reorg'), tracker_mempool_ready: bool('tracker has reconverged its mempool, so an already-spent confirmed output can be filtered out'), maintenance: { type: ['object', 'null'], description: 'operator-declared scheduled-maintenance window, or null; context only, it never changes the readiness fields above', properties: { active: { type: 'boolean' }, reason: { type: 'string' }, since: { type: ['string', 'null'] }, until: { type: 'string' } } } } } }, }, { name: 'estimate_fee', @@ -71,7 +71,7 @@ const METHODS = [ { name: 'compressedPubKey', schema: str('compressed public key when pubkey is an address') }, { name: 'compress', schema: bool('transparent FILE payload compression (deflate-raw, kept only when it is smaller and within the 150:1 guard), which appends the COMPRESSION field to a FILE v0 ACTION string. ON BY DEFAULT: omit this to take the deployment default, pass false to opt out. An EXPLICIT true that cannot be honoured is an error (a non-FILE action has nowhere to record the marker; a token-gated FILE\'s COMPRESSION means inflate-after-decrypt and belongs to the client that compressed before encrypting; an action that already declares a codec is never re-compressed). The default pass simply rides raw in those cases and says why in the result.') }, { name: 'attachPrevTx', schema: bool('attach each segwit input\'s FULL previous transaction alongside its witnessUtxo; default off. Only a hardware signer needs it: a Ledger derives the outpoint it signs from the prev tx it is handed, so a witnessUtxo-only input cannot be signed on the device. Off by default because it costs one node round trip per input plus the prev tx bytes in every copy of the PSBT.') }, - { name: 'options', schema: { type: 'object', description: 'per-call capabilities. signerSupportsTapscript (boolean, default false) tells AUTO whether this caller can sign a tapscript script-path spend; without it AUTO never selects TAPROOT, because the reveal must be signable before the commit is broadcast. Unknown keys are refused.' } }, + { name: 'options', schema: { type: 'object', description: 'per-call capabilities. signerSupportsTapscript (boolean, default false) tells AUTO whether this caller can sign a tapscript script-path spend; without it AUTO never selects TAPROOT, because the reveal must be signable before the commit is broadcast. exactInputs (boolean, default false) turns UTXO selection off: the supplied utxos array becomes the exact input set, in the given order, all of it, with no value sort and no stop at sufficiency. Requires a non-empty caller-supplied utxos array, is refused with p2shHash, and errors rather than silently dropping a named outpoint (a duplicate, or an unconfirmed one under unconfirmed=false). Use it to build a CPFP child that must descend from every unconfirmed output of a stuck chain, or to chain a transaction onto a specific change output. Unknown keys are refused.' } }, ], result: { name: 'tx', schema: { type: 'object', properties: { psbt: str('unsigned PSBT, hex (the commit PSBT for TAPROOT)'), encoding: str('encoding actually used (the resolved carrier when AUTO was requested)'), compression: { type: 'object', description: 'present whenever compression ran: {compressed, rawLength, storedLength, reason}. reason names why a payload rode raw (not-a-file-action, gated-file, codec-already-declared, not-smaller, ratio-guard, over-input-cap, deflate-failed).' }, revealPsbt: str('TAPROOT only: pre-built reveal PSBT, hex'), envelope: { type: 'object', description: 'TAPROOT only: recovery record to persist before broadcasting the commit' }, carrierScripts: { type: 'array', description: 'P2SH/P2WSH/TAPROOT: carrier scripts (hex) for verify-before-sign', items: { type: 'string' } }, warnings: { type: 'array', description: 'present only when the built transaction carries a caveat the caller should see before signing; each entry is {code, message}. RAWDATA_ONLY_NOT_DECODED: rawData sent without data compiles to an OP_0-led payload that current decoders read as empty, so the transaction confirms and the fee is paid but the payload is not indexed as an ACTION.', items: { type: 'object', properties: { code: { type: 'string' }, message: { type: 'string' } } } } } } }, }, diff --git a/docs/openrpc.json b/docs/openrpc.json index 0dd32a6..22f846d 100644 --- a/docs/openrpc.json +++ b/docs/openrpc.json @@ -84,6 +84,30 @@ "tracker_mempool_ready": { "type": "boolean", "description": "tracker has reconverged its mempool, so an already-spent confirmed output can be filtered out" + }, + "maintenance": { + "type": [ + "object", + "null" + ], + "description": "operator-declared scheduled-maintenance window, or null; context only, it never changes the readiness fields above", + "properties": { + "active": { + "type": "boolean" + }, + "reason": { + "type": "string" + }, + "since": { + "type": [ + "string", + "null" + ] + }, + "until": { + "type": "string" + } + } } } } @@ -284,7 +308,7 @@ "required": false, "schema": { "type": "object", - "description": "per-call capabilities. signerSupportsTapscript (boolean, default false) tells AUTO whether this caller can sign a tapscript script-path spend; without it AUTO never selects TAPROOT, because the reveal must be signable before the commit is broadcast. Unknown keys are refused." + "description": "per-call capabilities. signerSupportsTapscript (boolean, default false) tells AUTO whether this caller can sign a tapscript script-path spend; without it AUTO never selects TAPROOT, because the reveal must be signable before the commit is broadcast. exactInputs (boolean, default false) turns UTXO selection off: the supplied utxos array becomes the exact input set, in the given order, all of it, with no value sort and no stop at sufficiency. Requires a non-empty caller-supplied utxos array, is refused with p2shHash, and errors rather than silently dropping a named outpoint (a duplicate, or an unconfirmed one under unconfirmed=false). Use it to build a CPFP child that must descend from every unconfirmed output of a stuck chain, or to chain a transaction onto a specific change output. Unknown keys are refused." } } ], diff --git a/package-lock.json b/package-lock.json index ffe4046..7f350a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-encoder", - "version": "0.11.0", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-encoder", - "version": "0.11.0", + "version": "0.15.0", "license": "AGPL-3.0-or-later", "dependencies": { "axios": "^1.18.1", diff --git a/package.json b/package.json index 894be33..f4c4f41 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xchain-encoder", "description": "xchain-encoder encodes XChain Platform ACTION commands into blockchain transactions.", - "version": "0.12.0", + "version": "0.15.0", "license": "AGPL-3.0-or-later", "repository": { "type": "git", diff --git a/src/XChainEncoder.js b/src/XChainEncoder.js index db42d4e..7861070 100644 --- a/src/XChainEncoder.js +++ b/src/XChainEncoder.js @@ -28,7 +28,7 @@ const BlockchainConnector = require('./BlockchainConnector') const CryptoNetworks = require('./CryptoNetworks') const UtxoTracker = require('./UtxoTracker') const TxSizeEstimator = require("./TxSizeEstimator") -const { MAX_COMPILED_ACTION_DATA_LENGTH, ENVELOPE_MAX_PAYLOAD, MAX_UTXO_COUNT, validateUtxoEntry, parseSatoshiAmount, validateFeePerKb, validateOptionalBoolean, validateAddress } = require('./validator') +const { MAX_COMPILED_ACTION_DATA_LENGTH, ENVELOPE_MAX_PAYLOAD, MAX_UTXO_COUNT, validateUtxoEntry, parseSatoshiAmount, validateFeePerKb, validateOptionalBoolean, validateAddress, validateDataParam, validateActionPushDecodability } = require('./validator') const { compressPayloadForAction } = require('./compression') const { OperationalError } = require('./errors') const { upstreamErrorMessage } = require('./errorSanitize') @@ -353,6 +353,20 @@ class XChainEncoder { // bitcoinjs params, which carry no chain identity, and the envelope // recognition gate needs to know WHICH chain+network it is building for. this.networkKey = network + // Net portion ('mainnet'|'testnet'|'regtest') of the "-" + // key. getBitcoinJsNetwork above already rejected an unknown key, so the + // suffix here is a valid network name. + this.consensusNetwork = String(network).slice(String(network).lastIndexOf('-') + 1) + // Verify the bundled canonical coin files against CONSENSUS_CONFIG_PIN before + // any consensus-relevant field is read, matching decoder, indexer, hub and + // utxo-tracker. A null pin (pre-arm) skips; a mismatch on an armed network + // throws, so a drifted or partially re-vendored bundle halts instead of + // authoring transactions under divergent params (dustThreshold below, and the + // supportsSegwit / address-prefix rules every output script is built from). + // Deliberately not wrapped in try/catch, and deliberately in the constructor: + // api.js builds the singleton encoder at module load, so a later check would + // let the HTTP surface bind and serve builds first. + require('./coins').verifyConsensusPin(this.consensusNetwork) this.connector = new BlockchainConnector(nodeUrl, nodePort, nodeUser, nodePassword) this.utxoTrackerConnector = new UtxoTracker(utxoTrackerUrl, utxoTrackerPort) this.dustAmount = this.network["dustThreshold"] @@ -369,11 +383,57 @@ class XChainEncoder { // parameter, so this normalizes `null` the same way). this.maxUtxoTrackerLagBlocks = (maxUtxoTrackerLagBlocks == null) ? DEFAULT_MAX_UTXO_TRACKER_LAG_BLOCKS : maxUtxoTrackerLagBlocks // outpoint ("txid:vout") -> reservation-expiry epoch ms. Guards against - // two concurrent create_tx calls for the same address both selecting the - // same tracker-fetched UTXOs and emitting conflicting double-spends. Only - // engaged for tracker-fetched selections (caller-supplied UTXOs are the - // caller's own coin-control). See RESERVATION_TTL_MS. + // two create_tx calls for the same address, concurrent or a few hundred + // milliseconds apart, both selecting the same UTXO and emitting conflicting + // double-spends. Engaged for EVERY selection, caller-supplied sets + // included: the SDK fetches the funding set itself and hands it over as + // `utxos`, so "caller-supplied" is the mainstream wallet path, not a + // coin-control opt-in. Treating it as unreserved is how three chained + // MINTs on BTC testnet4 built the same transaction twice. + // See RESERVATION_TTL_MS. this.outpointReservations = new Map() + // unsigned txid -> expiry epoch ms of every transaction this process built + // within RESERVATION_TTL_MS. Second line of defense behind the outpoint + // map: a byte-identical rebuild hashes to the same txid, and returning it + // as a fresh build let a caller journal one broadcast as two successes. + this.recentBuilds = new Map() + // outpoint -> the reservation expiry the envelope-cancel path itself wrote, + // its ownership stamp in outpointReservations. A cancel is deterministic + // from the recovery record, so it must be able to re-take its OWN live + // claim; a claim it did not write belongs to another build and blocks it. + this.envelopeCancelClaims = new Map() + } + + // Sweep the recent-build map the same way _evictExpiredReservations sweeps + // the outpoint map, so neither grows unbounded in a long-lived process. + _evictExpiredRecentBuilds(now) { + for (const [txid, expiry] of this.recentBuilds) { + if (expiry <= now) this.recentBuilds.delete(txid) + } + } + + // Refuse to hand back a transaction identical to one built within the + // reservation window, then record this one. Identity is the UNSIGNED txid + // (inputs, outputs, version, locktime): a rebuild that changes any of those + // (an RBF bump, a different amount, a different input) is a different + // transaction and passes. The outpoint reservations normally stop an + // identical rebuild one layer earlier (every input is still reserved), so + // this only fires when those were bypassed or cleared; it exists so the + // "same txid twice" failure can never present as two successes again. + _refuseDuplicateBuild(psbt, now) { + this._evictExpiredRecentBuilds(now) + const unsignedTx = bitcoin.Transaction.fromBuffer(psbt.data.globalMap.unsignedTx.toBuffer()) + const txid = unsignedTx.getId() + if (this.recentBuilds.has(txid)) { + throw new OperationalError( + 'DUPLICATE_TRANSACTION', + 'refusing to rebuild a transaction identical to one built in the last ' + + Math.round(RESERVATION_TTL_MS / 60000) + ' minutes (same inputs and outputs, same txid ' + + txid + '); broadcast the one you already have, or change the inputs or outputs', + { txid } + ) + } + this.recentBuilds.set(txid, now + RESERVATION_TTL_MS) } // A reserved outpoint is one an in-flight selection has claimed and not yet @@ -418,6 +478,26 @@ class XChainEncoder { } } + // Release an envelope-cancel claim and its ownership stamp together, so a + // failed cancel build hands the commit outpoint back instead of squatting it + // for the whole TTL. Ownership-checked exactly like _releaseCallReservations. + _releaseEnvelopeCancelClaims(callReservations) { + for (const claim of callReservations) { + if (this.envelopeCancelClaims.get(claim.key) === claim.expiry){ + this.envelopeCancelClaims.delete(claim.key) + } + } + this._releaseCallReservations(callReservations) + } + + // Sweep the cancel ownership stamps alongside the reservation map, so neither + // grows unbounded in a long-lived process. + _evictExpiredEnvelopeCancelClaims(now) { + for (const [key, expiry] of this.envelopeCancelClaims) { + if (expiry <= now) this.envelopeCancelClaims.delete(key) + } + } + // Sweep expired reservations so the map cannot grow unbounded across a // long-lived process. Called opportunistically at the start of selection. _evictExpiredReservations(now) { @@ -426,10 +506,13 @@ class XChainEncoder { } } - // Explicit release of all reservations. The encoder holds no durable state, - // so this is primarily a test seam; production relies on the TTL. + // Explicit release of all reservations and recent-build records. The encoder + // holds no durable state, so this is primarily a test seam; production + // relies on the TTL. clearReservations() { this.outpointReservations.clear() + this.recentBuilds.clear() + this.envelopeCancelClaims.clear() } isSegwitUTXO(utxo) { @@ -769,6 +852,28 @@ class XChainEncoder { unconfirmed=true, feePerKb=null, dust=null, feeQuote=null, attachPrevTx=false, compress=null, options=null){ + // Re-check what the wire encodings can actually carry, for direct library + // callers. api.js runs validateAll before createTransaction, but + // createTransaction is a supported library entry point of its own and + // reaches this builder with no validator in front of it, so the + // round-trip guard existed on one surface only: a `rawData` code unit + // above U+00FF was truncated to its low byte by the Latin-1 conversion + // below, and the compiled-size ceiling cannot see it because the length + // is unchanged. Same idiom as the per-entry UTXO, txid, fee and address + // re-validation further down. + // + // Runs BEFORE compression on purpose: the compression pass itself does + // Buffer.from(rawData,'binary'), so a check placed after it would measure + // bytes the truncation already produced. + // + // STRING inputs only, deliberately. The corruption is a property of the + // string-to-wire conversion; a caller handing the builder a Buffer is + // copied byte-for-byte and loses nothing, so refusing it here would + // impose RPC-surface argument-shape policy on library callers rather + // than close a data-loss path. + if (typeof data === 'string') validateDataParam(data, 'data') + if (typeof rawData === 'string') validateDataParam(rawData, 'rawData') + // If feeQuote is provided, inject it as a custom output if(feeQuote && feeQuote.address && feeQuote.amount > 0){ if(!customOutputs) customOutputs = []; @@ -918,6 +1023,30 @@ class XChainEncoder { } } + // Refuse the single-byte shapes bitcoin.script.compile canonicalizes into + // a bare opcode, for direct library callers. The same reason as the + // round-trip guard at the head of this method: validateAll runs this + // check on the JSON-RPC path only, so a library call with rawData '\x05' + // compiled to OP_5 and the decoder dropped the byte off a fee-paid + // transaction (its element test is Buffer.isBuffer). + // + // Runs AFTER compression on purpose, unlike the guard above: this one + // measures the bytes that are about to be compiled, so a lone minimal-op + // byte that compression rewrote into a multi-byte payload is correctly + // allowed through and a payload compression rewrote is covered too. + // + // The empty-data shapes stay buildable: isMinimalOpSingleByte excludes + // the empty buffer, so the payment-only and rawData-only contracts are + // untouched and the flag-day decision they belong to is not pre-empted + // here (see the rawDataOnlyPayload advisory below). + // + // Runs on Buffer inputs too, unlike the latin-1 guard above. That guard + // is string-only because a Buffer is copied byte for byte and loses + // nothing in the conversion; canonicalization is a property of the + // COMPILED push, so a byte-for-byte-copied one-byte Buffer loses + // everything. validateActionPushDecodability normalizes either shape. + validateActionPushDecodability(data, rawData) + // `data` is optional (openrpc.json create_tx data.required=false) and // validateAll passes null through when omitted, but Buffer.from(null,'utf8') // throws a Node TypeError, so a valid data-omitted request (e.g. a @@ -1044,6 +1173,34 @@ class XChainEncoder { const isReveal = !!p2shHash let fetchedFromTracker = false + // Exact-input mode. Normal selection sorts the candidate set + // value-descending and stops the moment the running total covers outputs + // plus fee, so a caller who names N outpoints usually gets one input. That + // makes an operator rescue of a stuck batch impossible to build here: a CPFP + // child has to descend from EVERY unconfirmed output of the stuck chain + // (including the tiny ones greedy selection will never reach), and a + // deliberate chain has to spend a named change output rather than whichever + // output happens to be largest. Exact-input mode turns selection off: the + // caller's list IS the input set, in the caller's order, all of it. + // + // It is coin-control, so it is only meaningful over a caller-supplied set. + // Refuse it rather than silently degrade when there is nothing to be exact + // about, and on the reveal path, whose inputs come from p2shHex and never + // pass through selection at all. + const exactInputs = !!(options && options.exactInputs) + if (exactInputs){ + if (isReveal){ + throw new TypeError( + 'options.exactInputs cannot be combined with p2shHash: the reveal spends the ' + + 'funding transaction\'s own outputs, which are derived from p2shHex, not selected') + } + if ((utxos == null) || (utxos.length == 0)){ + throw new TypeError( + 'options.exactInputs requires a non-empty utxos array: it names the exact input ' + + 'set to spend, so there is nothing to be exact about when the set is fetched') + } + } + if ((utxos == null) || (utxos.length == 0)){ if (isReveal && p2shHex){ utxos = [] @@ -1154,6 +1311,31 @@ class XChainEncoder { } } + // Exact-input mode promises the caller's list is the input set, so the two + // filters below (which silently SHRINK that list) have to be errors instead. + // Dropping a named mempool outpoint is the CPFP-fatal one: the whole point of + // the rescue is descending from unconfirmed outputs, and a silent drop would + // hand back a child that descends from nothing and still does not mine. + if (exactInputs){ + if (!unconfirmed){ + const mempoolInput = utxos.find((u) => u.confirmations == 0) + if (mempoolInput){ + throw new TypeError( + `options.exactInputs names unconfirmed utxo ${mempoolInput.txid}:${mempoolInput.vout}, ` + + 'but unconfirmed=false would drop it; pass unconfirmed: true to spend it') + } + } + const seenOutpoints = new Set() + for (const u of utxos){ + const k = u.txid + ':' + u.vout + if (seenOutpoints.has(k)){ + throw new TypeError( + `options.exactInputs names outpoint ${k} more than once; a transaction cannot spend the same output twice`) + } + seenOutpoints.add(k) + } + } + //Remove duplicated utxos (the utxo tracker returns duplicated utxos sometimes, this should be fixed) //Also if unconfirmed is false, then all mempool txs will be eliminated let utxoIndex = 0 @@ -1191,14 +1373,20 @@ class XChainEncoder { // Comparator, not subtraction: a >2^53-1 value is a BigInt here, and // BigInt - Number throws. Relational operators mix the two types fine. - utxos.sort((a,b)=> a.value < b.value ? 1 : a.value > b.value ? -1 : 0) + // Exact-input mode keeps the caller's order untouched: every named outpoint + // is spent either way, so the only thing sorting would change is WHICH one + // lands at ins[0] - and on the OP_RETURN/MULTISIGN path that outpoint is the + // obfuscation key, which coin-control callers pick deliberately. + if (!exactInputs){ + utxos.sort((a,b)=> a.value < b.value ? 1 : a.value > b.value ? -1 : 0) + } //On the reveal path utxos is empty; txidFirstInput is (re)assigned from //p2shHex inside the data loop below before it is ever read. // // The OP_RETURN/MULTISIGN obfuscation key MUST bind to the txid of the input actually // placed at ins[0]: the decoder derives its deobfuscation key from transaction.ins[0]. // The selection loop below skips outpoints a concurrent/recent create_tx reserved - // (tracker-fetched sets only), so sorted utxos[0] is NOT necessarily the first input. + // (caller-supplied sets included), so sorted utxos[0] is NOT necessarily the first input. // Synchronously pre-reserve the first AVAILABLE outpoint now - before the async data // loop, so no concurrent call can claim it in between - and bind the key to it; the // selection loop carves this outpoint out of its skip check so it is taken as ins[0]. @@ -1213,10 +1401,47 @@ class XChainEncoder { // the async data loop un-skips that outpoint and it takes ins[0] while the key stays // bound here. Move the key-bound outpoint to the head of the selection order so ins[0] // is correct by construction, independent of any clock. + // + // Reservation is NOT gated on fetchedFromTracker. The SDK fetches the funding + // set through get_utxos and passes it as `utxos`, so a caller-supplied set is + // the wallet's normal path; leaving it unreserved is exactly the hole that let + // two chained sends 800ms apart build the same transaction. + // + // Exact-input mode is the one shape that cannot SKIP a reserved outpoint: it + // promises every named outpoint is spent (a CPFP rescue descends from all of + // them), so dropping one would hand back a child that descends from nothing, + // the same silent shrink the unconfirmed/duplicate filters above were turned + // into errors to prevent. It still reserves - a named set is exactly the + // chained-send shape this guards against - but it refuses the build outright + // when another build holds one of the named outpoints, and it never reorders + // (the caller picked which outpoint lands at ins[0] and owns that key). let firstReservedOutpoint = null let txidFirstInput = null if (utxos.length){ - if (fetchedFromTracker){ + if (exactInputs){ + const nowFirst = Date.now() + this._evictExpiredReservations(nowFirst) + const heldByOthers = utxos + .map((u) => u.txid + ':' + u.vout) + .filter((k) => this._isOutpointReserved(k, nowFirst)) + if (heldByOthers.length){ + throw new OperationalError( + 'INPUT_RESERVED', + `options.exactInputs names ${heldByOthers.length} outpoint(s) reserved by a transaction ` + + `built in the last ${Math.round(RESERVATION_TTL_MS / 60000)} minutes ` + + `(${heldByOthers.join(', ')}); exact-input mode cannot drop them, so broadcast that ` + + 'transaction and rebuild from the resulting view, or wait for the reservation to lapse', + { reserved: heldByOthers } + ) + } + for (const u of utxos){ + this._claimOutpoint(callReservations, u.txid + ':' + u.vout, nowFirst) + } + // No head-of-order splice: the caller's order IS the input order here, + // and firstReservedOutpoint stays null so the selection loop below + // reserves nothing twice (every outpoint is already claimed). + txidFirstInput = utxos[0]["txid"] + } else { const nowFirst = Date.now() this._evictExpiredReservations(nowFirst) for (let i = 0; i < utxos.length; i++){ @@ -1242,9 +1467,6 @@ class XChainEncoder { // free a DIFFERENT outpoint first; the post-selection guard below catches that // and fails closed rather than emitting a silently-undecodable action. if (txidFirstInput === null) txidFirstInput = utxos[0]["txid"] - } else { - // Caller-supplied UTXOs are the caller's own coin-control; ins[0] is utxos[0]. - txidFirstInput = utxos[0]["txid"] } // Lowercase where the key BINDS, not only at validation: this string is the // obfuscation key itself, the decoder's half of it always renders lowercase, @@ -1747,6 +1969,10 @@ class XChainEncoder { // that omits it degrades to per-transaction sizing rather than to a // guess about someone else's fee. const unconfirmedInputTxids = [] + // Candidates the loop skipped because another build holds them. Reported + // when nothing could be selected, so the caller learns the inputs exist + // and are spoken for rather than that the address is empty. + let reservedCandidates = 0 if (!p2shHash){//The p2sh input is already created before const now = Date.now() this._evictExpiredReservations(now) @@ -1771,19 +1997,37 @@ class XChainEncoder { `commit txid at signing time and strand the pre-built reveal.`) } - // Best-effort double-spend guard: when this set was fetched from - // the tracker for the sender address, skip any outpoint another - // in-flight create_tx just claimed, and reserve the ones we take. - // Two concurrent calls for one address would otherwise both pick - // the largest UTXOs and build conflicting double-spends. Reserve - // synchronously here (before the getTransactionHex await below) - // so a concurrent call observes the claim. Caller-supplied UTXOs - // are the caller's own coin-control and are left unreserved. - const outpointKey = nextUtxo.txid + ':' + nextUtxo.vout - if (fetchedFromTracker){ - // Skip outpoints reserved by OTHER in-flight calls, but NOT the one this + // Refuse a witness-program input on a chain whose consensus rules have + // no segwit: there the output is anyone-can-spend and a witnessUtxo + // input signs nothing the network enforces. Fail closed, never skip. + if (this.network.supportsSegwit === false && this.isSegwitUTXO(nextUtxo)){ + throw new TypeError( + `Input ${nextUtxo.txid}:${nextUtxo.vout} carries a witness-program scriptPubKey, ` + + `which this network does not support (no segwit). Spend legacy inputs on this chain.`) + } + + // Double-spend guard: skip any outpoint another create_tx claimed + // within RESERVATION_TTL_MS, and reserve the ones we take. Two + // calls for one address would otherwise both pick the largest + // UTXOs and build conflicting double-spends (or, with identical + // outputs, the identical transaction). Reserve synchronously here + // (before the getTransactionHex await below) so a concurrent call + // observes the claim. Applies to caller-supplied sets too: the SDK + // and wallet hand over a tracker-fetched set as `utxos`, and the + // tracker keeps publishing a spent input until it sees the spend, + // so a chained send re-supplies the input the previous build took. + // A caller that truly wants to respend a reserved input (an RBF + // bump) waits out the TTL or restarts the encoder; that is the + // price of never building the same spend twice. + // Exact-input mode settled reservations up front: it claimed every + // named outpoint, or refused the build. Re-checking here would see + // this call's OWN claims and skip the whole set. + if (!exactInputs){ + const outpointKey = nextUtxo.txid + ':' + nextUtxo.vout + // Skip outpoints reserved by OTHER calls, but NOT the one this // call pre-reserved for ins[0] above (the obfuscation key binds to it). if (outpointKey !== firstReservedOutpoint && this._isOutpointReserved(outpointKey, now)){ + reservedCandidates = reservedCandidates + 1 nextUtxoIndex = nextUtxoIndex + 1 continue } @@ -1845,7 +2089,13 @@ class XChainEncoder { estimatedFee = Math.trunc(estimatedTxSize * feePerBytes * SATOSHI_UNIT) } - if (inputSatoshis > outputSatoshis + BigInt(estimatedFee)){ + // Exact-input mode never stops early: the caller named this set + // because the transaction has to descend from all of it (a CPFP + // rescue) or spend a specific output (a deliberate chain). Stopping + // at sufficiency is exactly the behaviour that made a stuck batch + // unrescuable through this API. Any surplus goes to change, which + // the shortfall/change math below already handles. + if (!exactInputs && inputSatoshis > outputSatoshis + BigInt(estimatedFee)){ break } @@ -1868,10 +2118,19 @@ class XChainEncoder { // fee against a zero-input transaction's tiny size and mask the real // cause. (The reveal path has p2shHash set and never reaches here.) if (selectedInputCount === 0){ + // Name the real cause when the inputs exist but are spoken for: a + // chained send that re-supplied an input the previous build took + // is not an empty address, and the caller's fix is to wait for + // that spend to reach the tracker, not to fund the address. + const allReserved = reservedCandidates > 0 throw new OperationalError( 'INSUFFICIENT_FUNDS', - 'insufficient funds: no spendable inputs available (all candidates reserved or empty)', - { required: jsonSafeSat(outputSatoshis + BigInt(estimatedFee)), available: 0, outputs: jsonSafeSat(outputSatoshis), fee: estimatedFee } + allReserved + ? `insufficient funds: all ${reservedCandidates} candidate input(s) are reserved by a transaction ` + + `built in the last ${Math.round(RESERVATION_TTL_MS / 60000)} minutes; broadcast that transaction ` + + 'and wait for its change to appear, or wait for the reservation to lapse' + : 'insufficient funds: no spendable inputs available (all candidates reserved or empty)', + { required: jsonSafeSat(outputSatoshis + BigInt(estimatedFee)), available: 0, outputs: jsonSafeSat(outputSatoshis), fee: estimatedFee, reservedCandidates } ) } @@ -2241,6 +2500,17 @@ class XChainEncoder { // output and require a match in the PSBT, and require the leading data // pushes to concatenate to the action it intended. Passing a forged // script means failing one or the other. + + // Last gate before the transaction leaves: an identical unsigned tx built + // inside the reservation window is refused, not returned as a new success. + // Every path through _buildTransaction reaches it, the P2SH/P2WSH reveal + // included (an identical reveal is a duplicate too), and the TAPROOT reveal + // is derived from this commit, so guarding the commit guards the pair. + // createEnvelopeCancelTransaction is the one build path outside + // _buildTransaction; it takes its own outpoint reservation instead, and + // states there why duplicate refusal must not apply to it. + this._refuseDuplicateBuild(psbt, Date.now()) + let result = {"psbt":psbt,"encoding":preparedData["encoding"]} // Non-fatal advisory for the fee-payer; see rawDataOnlyPayload above. Additive @@ -2453,7 +2723,21 @@ class XChainEncoder { // PSBT carries tapInternalKey + tapMerkleRoot so the signer can compute // the BIP341 tweak; it conflicts with the reveal by construction (same // outpoint) and the wallet treats it as a replacement of the reveal. - async createEnvelopeCancelTransaction({ commitTxid, commitVout, commitValue, internalPubkey, tapleafHash, destination, feePerKb = null, replacebyfee = false } = {}){ + // + // Public entry point. Like createTransaction it owns the per-call reservation + // ledger: a build that throws for any reason hands the commit outpoint back at + // once instead of squatting it until RESERVATION_TTL_MS. + async createEnvelopeCancelTransaction(params = {}){ + const callReservations = [] + try { + return await this._buildEnvelopeCancelTransaction(callReservations, params) + } catch (err) { + this._releaseEnvelopeCancelClaims(callReservations) + throw err + } + } + + async _buildEnvelopeCancelTransaction(callReservations, { commitTxid, commitVout, commitValue, internalPubkey, tapleafHash, destination, feePerKb = null, replacebyfee = false } = {}){ if (typeof commitTxid !== 'string' || !/^[0-9a-fA-F]{64}$/.test(commitTxid)) { throw new TypeError('commitTxid must be a 64-character hex string') } @@ -2492,6 +2776,37 @@ class XChainEncoder { ensureEccLib() const tapleafHashBuf = Buffer.from(tapleafHash, 'hex') + // Cross-path double-spend guard. This is the one build path outside + // _buildTransaction, so without a claim here a concurrent create_tx whose + // fetched set still carries the commit output selects and reserves it while + // an unsigned cancel of that same output is outstanding. Lowercased because + // commitTxid is accepted in either case above while create_tx keys are + // canonicalized in validator.validateUtxoEntry, and an uppercase key can + // never collide with the reservation it is meant to see. Claimed + // synchronously, before the first await below, so a concurrent call + // observes it. + const outpointKey = commitTxid.toLowerCase() + ':' + commitVout + const nowClaim = Date.now() + this._evictExpiredReservations(nowClaim) + this._evictExpiredEnvelopeCancelClaims(nowClaim) + if (this._isOutpointReserved(outpointKey, nowClaim) && + this.envelopeCancelClaims.get(outpointKey) !== this.outpointReservations.get(outpointKey)){ + throw new OperationalError( + 'ENVELOPE_CANCEL_OUTPOINT_RESERVED', + `commit outpoint ${outpointKey} is reserved by a transaction built in the last ` + + `${Math.round(RESERVATION_TTL_MS / 60000)} minutes; broadcast that transaction and rebuild ` + + 'from the resulting view, or wait for the reservation to lapse', + { outpoint: outpointKey } + ) + } + // Re-taking this path's OWN live claim is allowed, and _refuseDuplicateBuild + // is deliberately not wired in here, for the same reason: the header + // contract above is that a cancel rebuilds from the persisted recovery + // record alone, so a lost-response retry must not be refused for five + // minutes for producing the byte-identical transaction it is supposed to. + this._claimOutpoint(callReservations, outpointKey, nowClaim) + this.envelopeCancelClaims.set(outpointKey, callReservations[callReservations.length - 1].expiry) + // Fee-rate resolution with the same drain guards as createTransaction, // in miniature: the caller rate is clamped to the tighter of the // absolute MAX_FEE_RATE_KB cap and the relative multiplier x the node's diff --git a/src/api.js b/src/api.js index f272661..5846c2f 100644 --- a/src/api.js +++ b/src/api.js @@ -64,6 +64,7 @@ const { parseCorsOrigin } = require('./corsOrigin') const { version: ENCODER_VERSION } = require('../package.json') const { installObservability } = require('./observability'); // default-off /metrics + structured log shim const { installCrashHandlers } = require('./crashHandlers') +const { readMaintenanceWindow } = require('./maintenanceWindow') // operator-declared scheduled outage, reported beside readiness const NETWORK = process.env.NETWORK @@ -135,6 +136,21 @@ app.set('trust proxy', trustProxy); app.use(helmet()); +// CORS configuration (default: disabled; `*` allows all; a comma-separated list +// is an ALLOWLIST matched per-origin). parseCorsOrigin is what makes the list +// case work: handing `cors` the raw string would echo it verbatim to everyone +// and be accepted by no browser. See src/corsOrigin.js. +// +// Mounted above the API-key gate and the shedding layers, and the position is +// load-bearing: a preflight is an OPTIONS carrying no x-api-key (that header is +// not CORS-safelisted, which is what forces the preflight), so a gate mounted +// first answers it 401 bare and the browser never sends the real request. The +// same order is what lets a browser read the gate 401 and the limiter/gate 429s +// rather than an opaque network error. Trade: a preflight skips the limiter and +// both gates for a 204 that does no upstream work, as in every sibling service. +// Ordering pinned by test/unit/corsPreflight.test.js. +app.use(cors({ origin: parseCorsOrigin(CORS_ORIGIN) })); + // 3mb (was 1mb): the TAPROOT envelope raises the largest legitimate request // well past 1mb. A create_tx may carry ~400 KB of rawData // that arrives base64/hex-encoded (~0.5-0.8 MB) or, worst case, as @@ -205,12 +221,6 @@ const requestGate = concurrencyGate.createConcurrencyGate({ }) app.use(requestGate) -// CORS configuration (default: disabled; `*` allows all; a comma-separated list -// is an ALLOWLIST matched per-origin). parseCorsOrigin is what makes the list -// case work: handing `cors` the raw string would echo it verbatim to everyone -// and be accepted by no browser. See src/corsOrigin.js. -app.use(cors({ origin: parseCorsOrigin(CORS_ORIGIN) })); - // Prometheus /metrics plus a structured log shim, both DEFAULT OFF. // Nothing is registered and no timer starts unless METRICS_ENABLED (and, for log // shipping, LOG_SHIP_ENABLED + LOG_SHIP_URL) are set, so the encoder gains no new @@ -229,7 +239,14 @@ installObservability(app, { // /status, so the two endpoints can never drift apart: probes the // UTXO tracker and returns its reachability / sync state. Fields: // tracker_reachable (bool), tracker_synced (bool), tracker_lag (number|null), -// tracker_halted (bool), tracker_mempool_ready (bool). +// tracker_halted (bool), tracker_mempool_ready (bool), maintenance (object|null). +// +// maintenance is the operator's DECLARED scheduled-outage window (see +// src/maintenanceWindow.js), carried alongside the readiness fields and never +// folded into them: it cannot make an unready encoder read ready, and it does +// not move the 503. A planned tracker stop and a broken tracker are the same +// readiness verdict - the difference is only that somebody meant one of them, +// which is exactly the distinction the public board had no way to draw. // // tracker_synced is SERVE-readiness, not the tracker's raw verdict: it // applies the same maxUtxoTrackerLagBlocks ceiling create_tx enforces @@ -247,6 +264,10 @@ installObservability(app, { // refuses it UTXO_TRACKER_NOT_READY, so leaving it out of this probe recreated // the same board-versus-encoder divergence the probe exists to prevent. async function getServeReadiness() { + // Read first and independently of the tracker probe: the window is what + // explains a tracker that is deliberately down, so it must survive the + // catch below rather than depend on the probe it annotates. + const maintenance = await readMaintenanceWindow() let tracker_reachable = false let tracker_synced = false let tracker_lag = null @@ -267,7 +288,7 @@ async function getServeReadiness() { } catch (_err) { // tracker unreachable; fields stay at defaults } - return { tracker_reachable, tracker_synced, tracker_lag, tracker_halted, tracker_mempool_ready } + return { tracker_reachable, tracker_synced, tracker_lag, tracker_halted, tracker_mempool_ready, maintenance } } const jsonRpcController = { @@ -365,7 +386,9 @@ const jsonRpcController = { // record alone. Validation lives in the encoder method (typed // TypeError/RangeError -> -32602, OperationalError -> -32010). async create_envelope_cancel_tx(rawParams) { - if (typeof rawParams !== 'object' || rawParams === null) { + // Array.isArray for the same reason validator.validateAll carries it: + // positional params otherwise clear the gate and destructure to undefined. + if (typeof rawParams !== 'object' || rawParams === null || Array.isArray(rawParams)) { const e = new Error('Request params must be an object') e.code = -32602 throw e @@ -457,13 +480,25 @@ const jsonRpcController = { app.get('/status', 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 } = await getServeReadiness() + const { tracker_reachable, tracker_synced, tracker_lag, tracker_halted, tracker_mempool_ready, maintenance } = await getServeReadiness() const healthy = tracker_reachable && tracker_synced + // A declared window does NOT move the code. The encoder still cannot serve, + // and every load balancer and uptime monitor keyed on this 503 must keep + // seeing it; the window is context for whoever reads the body, not a way to + // paint an un-serveable endpoint green. const code = healthy ? 200 : 503 + // Publishes the lag ceiling tracker_synced was actually gated on, so a + // status board can rank lag against mempool without mirroring a constant + // it cannot see. Mirroring guessed wrong in both directions: the tracker's + // own SYNCED_THRESHOLD is 3 while this gate defaults to 2, and + // UTXO_TRACKER_MAX_LAG_BLOCKS moves it per deployment. Read-only config, + // /status only: the JSON-RPC health() shape stays as docs/openrpc.json + // documents it. + const tracker_max_lag_blocks = encoder.maxUtxoTrackerLagBlocks // 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, 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, maintenance, request_gate: requestGate.getStats(), probe_gate: probeGate.getStats() }) }) // Machine-readable API spec (OpenRPC 1.3.2). Regenerated by docs/openrpc.build.js; @@ -497,11 +532,11 @@ app.use(jsonRouter({methods: jsonRpcController})) // Start the server only when run directly (node src/api.js). When required by a // test the controller and app are exported without binding a port. if (require.main === module) { - // HARD deploy constraint: the outpoint-reservation double-spend guard and the - // rate limiter are in-process, so exactly ONE encoder instance may serve an - // endpoint. Fail at boot if the deploy declares replicas > 1 (ENCODER_REPLICAS) - // or another encoder process on this host already holds the instance lock. - // See src/singleInstanceGuard.js. + // HARD deploy constraint: the outpoint-reservation double-spend guard, the + // recent-build duplicate refusal and the rate limiter are in-process, so + // exactly ONE encoder instance may serve an endpoint. Fail at boot if the + // deploy declares replicas > 1 (ENCODER_REPLICAS) or another encoder process + // on this host already holds the instance lock. See src/singleInstanceGuard.js. // Before the instance guard, so a throw inside it is still a CRASH record // rather than node's bare stderr dump. installCrashHandlers() diff --git a/src/coins/BTC.js b/src/coins/BTC.js index f36c86f..c846405 100644 --- a/src/coins/BTC.js +++ b/src/coins/BTC.js @@ -284,6 +284,26 @@ module.exports = { OWNERSHIP_ESCROW: 50000, AIRDROP_PER_RECIPIENT: 100, DIVIDEND_PER_RECIPIENT: 100, + // SWEEP / CALLBACK, priced on the unified schedule from the + // UNIFIED_FEES_SWEEP_CALLBACK flag day. The legacy flat per-DB-hit fee prices a + // small action BELOW the dust threshold of a native-fee chain (LTC/DOGE, where a + // missing fee output is rejected outright rather than falling back to an XCHAIN + // balance debit), so the fee output cannot be created and the action cannot be + // submitted at all: a Litecoin SWEEP needs ~273 DB hits before it clears LTC's + // 5460-satoshi floor at LTC $100 / XCHAIN $2. + // + // The BASE keys are what close that: gas is what buys the output, so the SMALLEST + // possible SWEEP or CALLBACK has to buy an above-dust one on its own. The floor a + // chain demands is dust_sats * COIN_USD / (1000 * XCHAIN_USD) gas units, so 5000 gas + // (0.05 XCHAIN) clears Litecoin while COIN/XCHAIN stays under ~915 and Dogecoin + // while it stays under ~50, both far outside any plausible band. The PER_ITEM keys + // hold the marginal cost at AIRDROP/DIVIDEND per-recipient parity; a SWEEP item is + // one swept balance, one closed order/swap/dispenser escrow, or one transferred + // ownership. + SWEEP_BASE: 5000, + SWEEP_PER_ITEM: 100, + CALLBACK_BASE: 5000, + CALLBACK_PER_RECIPIENT: 100, // BET (parimutuel betting, spec decision F): feed creation is duration- // metered like ORDER/SWAP/DISPENSER expiration (same free window via // UNIFIED_EXPIRATION_FEE_FREE_DAYS) but under its OWN per-day key so the diff --git a/src/coins/DOGE.js b/src/coins/DOGE.js index 71bd5d5..0ec1a85 100644 --- a/src/coins/DOGE.js +++ b/src/coins/DOGE.js @@ -218,6 +218,26 @@ module.exports = { OWNERSHIP_ESCROW: 50000, AIRDROP_PER_RECIPIENT: 100, DIVIDEND_PER_RECIPIENT: 100, + // SWEEP / CALLBACK, priced on the unified schedule from the + // UNIFIED_FEES_SWEEP_CALLBACK flag day. The legacy flat per-DB-hit fee prices a + // small action BELOW the dust threshold of a native-fee chain (LTC/DOGE, where a + // missing fee output is rejected outright rather than falling back to an XCHAIN + // balance debit), so the fee output cannot be created and the action cannot be + // submitted at all: a Litecoin SWEEP needs ~273 DB hits before it clears LTC's + // 5460-satoshi floor at LTC $100 / XCHAIN $2. + // + // The BASE keys are what close that: gas is what buys the output, so the SMALLEST + // possible SWEEP or CALLBACK has to buy an above-dust one on its own. The floor a + // chain demands is dust_sats * COIN_USD / (1000 * XCHAIN_USD) gas units, so 5000 gas + // (0.05 XCHAIN) clears Litecoin while COIN/XCHAIN stays under ~915 and Dogecoin + // while it stays under ~50, both far outside any plausible band. The PER_ITEM keys + // hold the marginal cost at AIRDROP/DIVIDEND per-recipient parity; a SWEEP item is + // one swept balance, one closed order/swap/dispenser escrow, or one transferred + // ownership. + SWEEP_BASE: 5000, + SWEEP_PER_ITEM: 100, + CALLBACK_BASE: 5000, + CALLBACK_PER_RECIPIENT: 100, // BET (parimutuel betting, spec decision F): feed creation is duration- // metered like ORDER/SWAP/DISPENSER expiration (same free window via // UNIFIED_EXPIRATION_FEE_FREE_DAYS) but under its OWN per-day key so the diff --git a/src/coins/LTC.js b/src/coins/LTC.js index f02d1e3..3f26fe9 100644 --- a/src/coins/LTC.js +++ b/src/coins/LTC.js @@ -213,6 +213,26 @@ module.exports = { OWNERSHIP_ESCROW: 50000, AIRDROP_PER_RECIPIENT: 100, DIVIDEND_PER_RECIPIENT: 100, + // SWEEP / CALLBACK, priced on the unified schedule from the + // UNIFIED_FEES_SWEEP_CALLBACK flag day. The legacy flat per-DB-hit fee prices a + // small action BELOW the dust threshold of a native-fee chain (LTC/DOGE, where a + // missing fee output is rejected outright rather than falling back to an XCHAIN + // balance debit), so the fee output cannot be created and the action cannot be + // submitted at all: a Litecoin SWEEP needs ~273 DB hits before it clears LTC's + // 5460-satoshi floor at LTC $100 / XCHAIN $2. + // + // The BASE keys are what close that: gas is what buys the output, so the SMALLEST + // possible SWEEP or CALLBACK has to buy an above-dust one on its own. The floor a + // chain demands is dust_sats * COIN_USD / (1000 * XCHAIN_USD) gas units, so 5000 gas + // (0.05 XCHAIN) clears Litecoin while COIN/XCHAIN stays under ~915 and Dogecoin + // while it stays under ~50, both far outside any plausible band. The PER_ITEM keys + // hold the marginal cost at AIRDROP/DIVIDEND per-recipient parity; a SWEEP item is + // one swept balance, one closed order/swap/dispenser escrow, or one transferred + // ownership. + SWEEP_BASE: 5000, + SWEEP_PER_ITEM: 100, + CALLBACK_BASE: 5000, + CALLBACK_PER_RECIPIENT: 100, // BET (parimutuel betting, spec decision F): feed creation is duration- // metered like ORDER/SWAP/DISPENSER expiration (same free window via // UNIFIED_EXPIRATION_FEE_FREE_DAYS) but under its OWN per-day key so the diff --git a/src/coins/consensus_pin.js b/src/coins/consensus_pin.js index 50ce2d0..53a2113 100644 --- a/src/coins/consensus_pin.js +++ b/src/coins/consensus_pin.js @@ -66,16 +66,25 @@ module.exports = { // so the public testnet announces with zero pre-announcement test actions. // Same one-wave rule as every regeneration above. Regtest and mainnet are // untouched and were re-verified as unchanged by this edit. + // REGENERATED 2026-09-01: GAS_SCHEDULE gains SWEEP_BASE, + // SWEEP_PER_ITEM, CALLBACK_BASE and CALLBACK_PER_RECIPIENT, the unified prices + // SWEEP and CALLBACK move onto at the UNIFIED_FEES_SWEEP_CALLBACK flag day (both + // networks UNARMED; regtest genesis-active). GAS_SCHEDULE is hashed whole by + // consensusSubset(), so ADDING a key moves every hash even while the flag that + // reads it is unarmed, and the same one-wave rollout rule as every regeneration + // above applies in full: every service bundling these must ship the new values + // together, and a straggler fail-closes on verifyConsensusPin() at boot rather + // than forking. Mainnet stays null (Phase 6 arms it). testnet: { - BTC: 'f6589c6b88dc930db05998070ef0b73743f58623a0d23fbc30fdb158c49d1427', - LTC: '9faf066a1470be2486d8a2cd121548ca02de1397d0678a2ab8dc0e712ebfa8fd', - DOGE: '2991d7e7caf2b212de959dd5831ac1477e0b13da95ac1ed8c2b43e2704732439', + BTC: 'd3c66a4fb288b2666a2a4fad85200bbeac162bb36fed8a3eddcfc7b2d4d48070', + LTC: 'ae94a951a838e64f9c36e503b978d9b9ad5ea74f7b443465baaabca8f675ea0d', + DOGE: 'b90aec4381b0ad32caba078706c8fb244cbe267390e41668fa063d9e64fb60e6', }, regtest: { - BTC: '24e6a363e5a36285574dea357328a997fdee5762ef812d8947eacf69c51afc24', - LTC: '5ad03b383d873d309640e75dfefa2787a5806cb8a84ee46f4cc7fb25ca7f808b', - DOGE: '019220a461e34c99fcf5cbf107673f13d3f2a57d2a20e16a0323ed44c81edd11', + BTC: '29976bd33cad1842320c57acdc849250646adea765f70a0ae5dad3f201f7d5d7', + LTC: 'bca62db9f59a6f7566620b086380c10fffac08dabe99f00a4fcc7cd038e46146', + DOGE: '816632e9f6647e726042282c37789ae8d924e8d4a1b2995ddde8d6a54a0bba54', }, }, }; diff --git a/src/errorSanitize.js b/src/errorSanitize.js index 95c9832..79862d6 100644 --- a/src/errorSanitize.js +++ b/src/errorSanitize.js @@ -49,4 +49,4 @@ function upstreamErrorMessage(err, fallback) { return (err && err.message) || fallback } -module.exports = { TRANSPORT_ERROR_CODES, isTransportError, upstreamErrorMessage } +module.exports = { isTransportError, upstreamErrorMessage } diff --git a/src/maintenanceWindow.js b/src/maintenanceWindow.js new file mode 100644 index 0000000..4f7e30f --- /dev/null +++ b/src/maintenanceWindow.js @@ -0,0 +1,133 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * 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 - scheduled-maintenance window + * + * A PLANNED outage reads exactly like a fault on the public board: the + * monthly tracker-bootstrap publish takes the UTXO tracker down, /status + * answers 503 with tracker_reachable:false, and encoder.xchain.io paints + * the row Degraded for the length of the run (2026-08-01: 3h36m on BTC). + * The probe is telling the truth and must not be silenced - what was + * missing is the operator's side of the story, so the board can say + * "this outage is on purpose" instead of "this encoder is broken". + * + * The operator declares a window by dropping a small JSON sentinel where + * ENCODER_MAINTENANCE_FILE points (xchain-node's BootstrapService writes + * and removes it around the tracker stop). This module reads it, and the + * readiness probe carries the result alongside the UNCHANGED readiness + * booleans: nothing here can make an unready encoder read ready, flip the + * 503, or alter tracker_reachable / tracker_synced. It only adds context. + * + * The sentinel is deliberately fail-safe in the honest direction. Every + * way it can be wrong - missing, unreadable, oversized, unparseable, no + * expiry, expired, or claiming a window longer than MAX_WINDOW_MS - + * resolves to "no maintenance", which puts the row back on Degraded. A + * publish that crashes without cleaning up therefore stops excusing the + * outage at its own declared end time rather than hiding it forever. + * + ********************************************************************/ + +const fs = require('fs') + +// Inside the encoder container, so xchain-node can write it with a plain +// `docker exec tee` against an already-running encoder: no bind mount, and +// therefore no container recreate, to start reporting maintenance. Living on +// the container's own filesystem also means a restart clears it, which is the +// safe direction (the row goes back to reporting the raw fault). +const DEFAULT_SENTINEL = '/tmp/xchain-encoder-maintenance.json' + +// A sentinel is a handful of fields. Anything larger is not one, and refusing +// it by size keeps a runaway file from being parsed at all. +const MAX_SENTINEL_BYTES = 4096 + +// Operator-authored, and /status is public, so it is bounded and stripped to +// printable ASCII before it leaves the process. +const MAX_REASON_CHARS = 120 + +// The longest window that still reads as "scheduled". The slowest publish on +// record is under 4h; past a day an outage is an incident whatever the sentinel +// says, and an unbounded `until` would let one stale file paint a dead encoder +// green-adjacent forever. +const MAX_WINDOW_MS = 24 * 60 * 60 * 1000 + +function sentinelPath() { + return process.env.ENCODER_MAINTENANCE_FILE || DEFAULT_SENTINEL +} + +// Accepts an ISO-8601 string or epoch milliseconds; returns null for anything +// else, including the NaN a malformed date parses to. +function toEpochMs(value) { + if (typeof value === 'number') return Number.isFinite(value) ? value : null + if (typeof value !== 'string') return null + const ms = Date.parse(value) + return Number.isFinite(ms) ? ms : null +} + +function cleanReason(value) { + if (typeof value !== 'string') return '' + // Printable ASCII only: the field is echoed in a public JSON body, and a + // control character or a stray newline there is nobody's maintenance note. + return value.replace(/[^\x20-\x7e]/g, ' ').trim().slice(0, MAX_REASON_CHARS) +} + +// Turn raw sentinel text into the window /status publishes, or null when the +// file does not describe an ACTIVE, bounded, currently-open window. +function parseMaintenanceWindow(text, now = Date.now()) { + if (typeof text !== 'string' || text.length === 0 || text.length > MAX_SENTINEL_BYTES) return null + + let doc + try { doc = JSON.parse(text) } catch { return null } + if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return null + + // An expiry is REQUIRED. Without one a publish that dies mid-run leaves the + // board excusing a genuine outage with no end date, which is worse than the + // Degraded label this exists to replace. + const until = toEpochMs(doc.until) + if (until === null || until <= now) return null + if (until - now > MAX_WINDOW_MS) return null + + // A window may be declared ahead of time; it is not active until it opens. + const since = toEpochMs(doc.since) + if (since !== null && since > now) return null + + return { + active: true, + reason: cleanReason(doc.reason), + since: since === null ? null : new Date(since).toISOString(), + until: new Date(until).toISOString() + } +} + +// Reads the sentinel. Never throws and never rejects: a readiness probe that +// fails because a maintenance note is unreadable would be a strictly worse +// probe than the one that predates this file. +async function readMaintenanceWindow(now = Date.now(), filePath = sentinelPath()) { + let text + try { + text = await fs.promises.readFile(filePath, 'utf8') + } catch { + return null // absent (the normal case) or unreadable + } + return parseMaintenanceWindow(text, now) +} + +module.exports = { + readMaintenanceWindow, + parseMaintenanceWindow, + sentinelPath, + DEFAULT_SENTINEL, + MAX_SENTINEL_BYTES, + MAX_REASON_CHARS, + MAX_WINDOW_MS +} diff --git a/src/observability/logShipper.js b/src/observability/logShipper.js index 656141a..dae9542 100644 --- a/src/observability/logShipper.js +++ b/src/observability/logShipper.js @@ -187,6 +187,15 @@ function readLogEnv(env = process.env) { }; } +// fetch() settles on the response HEADERS, so the body is still an open stream +// holding its socket while the abort timer is cleared. Release it inside that +// window by cancel, not read; a cancel on an errored body is not a ship failure. +function releaseBody(res) { + const stream = res && res.body; + if (!stream || typeof stream.cancel !== 'function') return Promise.resolve(); + return Promise.resolve(stream.cancel()).catch(() => {}); +} + class LogShipper { /** * @param {object} opts @@ -331,9 +340,9 @@ class LogShipper { const headers = { 'Content-Type': 'application/x-ndjson' }; if (token) headers.Authorization = `Bearer ${token}`; return fetch(url, { method: 'POST', headers, body, signal: controller.signal }) - .then((res) => { + .then((res) => releaseBody(res).then(() => { if (!res.ok) throw new Error(`collector responded ${res.status}`); - }) + })) .finally(() => clearTimeout(timer)); } diff --git a/src/singleInstanceGuard.js b/src/singleInstanceGuard.js index 910729e..3f754db 100644 --- a/src/singleInstanceGuard.js +++ b/src/singleInstanceGuard.js @@ -14,16 +14,19 @@ * * XChain Encoder - Single-instance deploy guard * - * The encoder's UTXO outpoint-reservation store (XChainEncoder.js - * `outpointReservations`) and the express-rate-limit MemoryStore are both - * in-process. Running more than one encoder replica behind one endpoint - * silently defeats both: two replicas can each build a PSBT spending the - * same tracker-fetched UTXO (one tx is rejected at broadcast and the - * signer's fee work is wasted), and per-IP rate limits multiply by the - * replica count. Until a shared (e.g. Redis-backed) reservation store - * exists, single-instance is a HARD deploy constraint; this module makes - * the constraint fail loudly at boot instead of failing silently at - * broadcast time. + * Three of the encoder's guards hold their whole state in-process: the UTXO + * outpoint-reservation store (XChainEncoder.js `outpointReservations`), the + * recent-build duplicate refusal behind it (`recentBuilds`, enforced in + * `_refuseDuplicateBuild`), and the express-rate-limit MemoryStore. Running + * more than one encoder replica behind one endpoint silently defeats all + * three: two replicas can each build a PSBT spending the same tracker-fetched + * UTXO (one tx is rejected at broadcast and the signer's fee work is wasted), + * each replica keeps its own recent-build map so one byte-identical + * transaction is built once per replica and journaled as two broadcast + * successes, and per-IP rate limits multiply by the replica count. Until a + * shared (e.g. Redis-backed) store exists for all three, single-instance is a + * HARD deploy constraint; this module makes the constraint fail loudly at boot + * instead of failing silently at broadcast time. * ********************************************************************/ @@ -33,9 +36,9 @@ const path = require('path') // Refuses boot when the operator declares a horizontally scaled deploy. // ENCODER_REPLICAS is a deploy-manifest declaration (set it next to the -// orchestrator's replica count); any value above 1 is rejected because no -// shared reservation store exists yet. Unset/empty means the default -// single-replica deploy and passes. +// orchestrator's replica count); any value above 1 is rejected because the +// reservation, recent-build and rate-limit stores are all still per-process. +// Unset/empty means the default single-replica deploy and passes. function assertSingleInstance(env = process.env) { const raw = env.ENCODER_REPLICAS if (raw === undefined || raw === '') return true @@ -46,10 +49,11 @@ function assertSingleInstance(env = process.env) { if (replicas > 1) { throw new Error( 'ENCODER_REPLICAS=' + replicas + ' is unsupported: the UTXO outpoint-reservation ' + - 'double-spend guard and the rate limiter are in-process (single-instance only). ' + - 'Horizontally scaling the encoder lets two replicas build PSBTs spending the same ' + - 'UTXO. Run exactly one replica per endpoint until a shared reservation store ' + - '(e.g. Redis-backed) is implemented.' + 'double-spend guard, the recent-build duplicate refusal and the rate limiter are ' + + 'all in-process (single-instance only). Horizontally scaling the encoder lets two ' + + 'replicas build PSBTs spending the same UTXO, and lets one byte-identical ' + + 'transaction be built once per replica and journaled as two successes. Run exactly ' + + 'one replica per endpoint until a shared store (e.g. Redis-backed) is implemented.' ) } return true @@ -101,24 +105,25 @@ function selfDescription() { // Same-host duplicate-process guard: takes an exclusive PID lockfile so two // encoder processes accidentally started on one host (each with its own -// reservation Map) fail fast instead of racing UTXO selections. Stale locks +// reservation and recent-build Maps) fail fast instead of racing UTXO +// selections and rebuilding one transaction twice. Stale locks // (dead PID, unreadable contents, or a REUSED pid, see below) are broken and // re-taken. This cannot see replicas on OTHER hosts or in sibling containers; // ENCODER_REPLICAS above is the cross-host declaration. Returns a release // function. // -// The lock used to hold a bare pid, and a bare pid is not an identity. -// The file lives in the container's writable layer, so it SURVIVES a restart, -// and the next boot's process tree hands that same pid number to a different -// live process: measured on the regtest encoder, the lock held node's pid while -// that number had become npm's `sh` wrapper. The guard then reported "another -// instance holds the lock" against npm's own shell and the container crash -// -looped indefinitely, with `docker exec` refused the whole time because the -// container never finished starting. So the holder's identity is recorded and -// re-checked: a live pid whose command line no longer matches what wrote the -// lock is a reused number, not an encoder, and the lock is stale. When identity -// cannot be established (non-Linux, or /proc unreadable) the old conservative -// rule stands, since refusing to boot is the safe side of a genuine conflict. +// A bare pid is not an identity, so the lock records the holder's command line +// and re-checks it. The file lives in the container's writable layer and +// survives a restart, and the next boot hands that pid number to a different +// live process: measured on the regtest encoder, the lock held node's number +// after it had become npm's `sh` wrapper, so the guard reported a conflict +// against npm's own shell and the container crash-looped, with `docker exec` +// refused throughout because the container never finished starting. +// +// A live pid whose command line no longer matches what wrote the lock is a +// reused number rather than an encoder, so the lock is stale. Where identity +// cannot be established (non-Linux, or /proc unreadable) the conservative rule +// stands, because refusing to boot is the safe side of a genuine conflict. // `deps` is injectable so the tests can drive both branches without spawning // real processes. function acquireInstanceLock(lockPath, env = process.env, deps = {}) { @@ -176,10 +181,11 @@ function acquireInstanceLock(lockPath, env = process.env, deps = {}) { if (!reused) { throw new Error( 'Another xchain-encoder instance (pid ' + holderPid + ') holds the instance lock ' + - file + '. The outpoint-reservation store is in-process; running two encoder ' + - 'instances against one UTXO set risks conflicting double-spend PSBTs. Stop the ' + - 'other instance, or set ENCODER_INSTANCE_LOCK_FILE to isolate intentionally ' + - 'separate deployments.' + file + '. The outpoint-reservation and recent-build stores are in-process; ' + + 'running two encoder instances against one UTXO set risks conflicting ' + + 'double-spend PSBTs, and lets one transaction be built twice and journaled ' + + 'as two successes. Stop the other instance, or set ENCODER_INSTANCE_LOCK_FILE ' + + 'to isolate intentionally separate deployments.' ) } console.warn('singleInstanceGuard: breaking a stale lock on ' + file + ': pid ' + diff --git a/src/validator.js b/src/validator.js index 3c8246d..e86d503 100644 --- a/src/validator.js +++ b/src/validator.js @@ -478,7 +478,7 @@ function validateEncoding(encoding) { // misspelled capability that silently means "no" would send an envelope-capable // signer down the P2WSH lane at 2x the cost with no signal, and a misspelled one // that silently meant "yes" would be worse. -const VALID_CREATE_TX_OPTIONS = new Set(['signerSupportsTapscript']) +const VALID_CREATE_TX_OPTIONS = new Set(['signerSupportsTapscript', 'exactInputs']) function validateCreateTxOptions(options) { if (options == null) return null if (typeof options !== 'object' || Array.isArray(options)) { @@ -492,6 +492,12 @@ function validateCreateTxOptions(options) { if (options.signerSupportsTapscript !== undefined) { validateOptionalBoolean(options.signerSupportsTapscript, 'options.signerSupportsTapscript') } + // Strict boolean for the same reason rbf is: exactInputs turns UTXO selection + // off, and the string "false" is truthy, so a loose check would spend every + // outpoint in the caller's set when they asked for the opposite. + if (options.exactInputs !== undefined) { + validateOptionalBoolean(options.exactInputs, 'options.exactInputs') + } return options } @@ -763,7 +769,11 @@ function validateChange(change) { } function validateAll(params) { - if (typeof params !== 'object' || params === null) { + // Array.isArray, because typeof [] is 'object': a JSON-RPC call with POSITIONAL + // params cleared this gate and died 50 lines later on 'pubkey is required', + // reporting a missing field for what is a shape error. Same guard the + // object-shaped validators below carry (lines 484/566/644/686). + if (typeof params !== 'object' || params === null || Array.isArray(params)) { throw new TypeError('Request params must be an object') } diff --git a/test/boundary/change-address-boundaries.test.js b/test/boundary/change-address-boundaries.test.js index 452553d..0b247f6 100644 --- a/test/boundary/change-address-boundaries.test.js +++ b/test/boundary/change-address-boundaries.test.js @@ -26,7 +26,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const { TXID_A, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') @@ -46,7 +46,7 @@ describe('Change Address Boundaries', () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) // fee = utxoValue → changeSatoshis = 0 - const utxo = makeSegwitUtxo(TXID_A, 0, 10000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 10000) const result = await encoder.createTransaction( [utxo], address, null, @@ -62,7 +62,7 @@ describe('Change Address Boundaries', () => { it('does not throw even without change address when changeSatoshis = 0', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 10000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 10000) // change = null, but changeSatoshis = 0 → 0 > dustAmount is false → no throw const result = await encoder.createTransaction( @@ -84,7 +84,7 @@ describe('Change Address Boundaries', () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) // fee = 9999, utxo = 10000 → changeSatoshis = 1 (< 546 dust) - const utxo = makeSegwitUtxo(TXID_A, 0, 10000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 10000) const result = await encoder.createTransaction( [utxo], address, null, @@ -100,7 +100,7 @@ describe('Change Address Boundaries', () => { it('does not throw with null change when changeSatoshis = 1 (1 <= dustAmount)', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 10000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 10000) // change=null, changeSatoshis=1 → 1 > 546 is false → no throw, but also no output const result = await encoder.createTransaction( @@ -122,7 +122,7 @@ describe('Change Address Boundaries', () => { const address = getTestAddress(NETWORK) // changeSatoshis = 546 → 546 > 546 is false → no throw const fee = 10000 - const utxo = makeSegwitUtxo(TXID_A, 0, fee + BTC_DUST) + const utxo = makeUtxo(NETWORK, TXID_A, 0, fee + BTC_DUST) const result = await encoder.createTransaction( [utxo], address, null, @@ -138,7 +138,7 @@ describe('Change Address Boundaries', () => { const address = getTestAddress(NETWORK) // changeSatoshis = 547 → 547 > 546 is true → throws const fee = 10000 - const utxo = makeSegwitUtxo(TXID_A, 0, fee + BTC_DUST + 1) + const utxo = makeUtxo(NETWORK, TXID_A, 0, fee + BTC_DUST + 1) await assert.rejects( () => encoder.createTransaction( @@ -154,7 +154,7 @@ describe('Change Address Boundaries', () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) const fee = 10000 - const utxo = makeSegwitUtxo(TXID_A, 0, fee + BTC_DUST + 1) + const utxo = makeUtxo(NETWORK, TXID_A, 0, fee + BTC_DUST + 1) const result = await encoder.createTransaction( [utxo], address, null, @@ -173,7 +173,7 @@ describe('Change Address Boundaries', () => { const ltcEncoder = makeEncoder('litecoin-regtest') const ltcAddress = getTestAddress('litecoin-regtest') const fee = 10000 - const utxo = makeSegwitUtxo(TXID_A, 0, fee + LTC_DUST) + const utxo = makeUtxo('litecoin-regtest', TXID_A, 0, fee + LTC_DUST) const result = await ltcEncoder.createTransaction( [utxo], ltcAddress, null, @@ -188,7 +188,7 @@ describe('Change Address Boundaries', () => { const ltcEncoder = makeEncoder('litecoin-regtest') const ltcAddress = getTestAddress('litecoin-regtest') const fee = 10000 - const utxo = makeSegwitUtxo(TXID_A, 0, fee + LTC_DUST + 1) + const utxo = makeUtxo('litecoin-regtest', TXID_A, 0, fee + LTC_DUST + 1) await assert.rejects( () => ltcEncoder.createTransaction( @@ -210,7 +210,7 @@ describe('Change Address Boundaries', () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) // utxo = 1000, fee = 5000 → changeSatoshis = -4000 - const utxo = makeSegwitUtxo(TXID_A, 0, 1000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 1000) await assert.rejects( () => encoder.createTransaction( @@ -228,7 +228,7 @@ describe('Change Address Boundaries', () => { it('throws INSUFFICIENT_FUNDS with a null change address too', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 1000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 1000) await assert.rejects( () => encoder.createTransaction( diff --git a/test/boundary/custom-output-boundaries.test.js b/test/boundary/custom-output-boundaries.test.js index 167abe0..be6164c 100644 --- a/test/boundary/custom-output-boundaries.test.js +++ b/test/boundary/custom-output-boundaries.test.js @@ -23,7 +23,7 @@ const bitcoin = require('bitcoinjs-lib') const { TXID_A, TXID_B, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') @@ -44,7 +44,7 @@ describe('Custom Output Boundaries', () => { it('"1.5" value is rejected, not truncated to 1', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) await assert.rejects(() => encoder.createTransaction( [utxo], address, [ @@ -58,7 +58,7 @@ describe('Custom Output Boundaries', () => { it('"999999.9" is rejected, not truncated to 999999', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) await assert.rejects(() => encoder.createTransaction( [utxo], address, [ @@ -72,7 +72,7 @@ describe('Custom Output Boundaries', () => { it('a clean integer custom output value still builds correctly', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const result = await encoder.createTransaction( [utxo], address, [ @@ -94,7 +94,7 @@ describe('Custom Output Boundaries', () => { it('10 custom outputs each worth 100000 → correct total in outputSatoshis', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const customOutputs = Array.from({ length: 10 }, () => ({ address, @@ -118,7 +118,7 @@ describe('Custom Output Boundaries', () => { it('each custom output adds 43 bytes to estimated tx size', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) // With 10 custom outputs (each adding 43 bytes), the total estimated // size is 430 bytes larger. With auto fee, the fee should be measurably higher. @@ -129,6 +129,8 @@ describe('Custom Output Boundaries', () => { null, null, null, true, 100000 // sat/kB: 100 sat/byte, moderate fee rate ) + // Same input on purpose: release the first build's reservation. + encoder.clearReservations() const resultWithout = await encoder.createTransaction( [utxo], address, null, 'SEND|0|X|1|a', null, null, false, null, address, @@ -155,7 +157,7 @@ describe('Custom Output Boundaries', () => { it('value=0 output is rejected as a non-positive custom output', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) await assert.rejects( () => encoder.createTransaction( @@ -178,7 +180,7 @@ describe('Custom Output Boundaries', () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) // UTXO = 10000, custom output = 50000 - const utxo = makeSegwitUtxo(TXID_A, 0, 10000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 10000) await assert.rejects( () => encoder.createTransaction( @@ -200,7 +202,7 @@ describe('Custom Output Boundaries', () => { it('empty array has no effect on outputs or fees', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const resultEmpty = await encoder.createTransaction( [utxo], address, [], @@ -208,6 +210,8 @@ describe('Custom Output Boundaries', () => { null, null, null, true, 0.00001 ) + // Same input on purpose: release the first build's reservation. + encoder.clearReservations() const resultNull = await encoder.createTransaction( [utxo], address, null, 'SEND|0|X|1|a', null, 10000, false, null, address, diff --git a/test/boundary/data-payload-boundaries.test.js b/test/boundary/data-payload-boundaries.test.js index 913c6ca..5afae71 100644 --- a/test/boundary/data-payload-boundaries.test.js +++ b/test/boundary/data-payload-boundaries.test.js @@ -22,7 +22,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const { TXID_A, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') @@ -32,12 +32,12 @@ const { MAGIC_WORD } = require('../integration/helpers/deobfuscate') -// BTC semantics: the forced-OP_RETURN over-capacity test expects a rejection, -// which only applies on chains with singleOpReturnPolicy=true (bitcoin). +// Fixes the fixtures on BTC. The forced-OP_RETURN over-capacity rejection itself +// is unconditional: singleOpReturnPolicy is declared but read nowhere (uuid:0ca8479c). const NETWORK = 'bitcoin-regtest' function standardUtxo () { - return makeSegwitUtxo(TXID_A, 0, 100000000) + return makeUtxo(NETWORK, TXID_A, 0, 100000000) } describe('Data/Payload Boundaries', () => { diff --git a/test/boundary/encoding-chunk-boundaries.test.js b/test/boundary/encoding-chunk-boundaries.test.js index 797d5b4..2bdf017 100644 --- a/test/boundary/encoding-chunk-boundaries.test.js +++ b/test/boundary/encoding-chunk-boundaries.test.js @@ -35,7 +35,7 @@ const { TXID_A, TXID_MULTISIGN, PUBKEY_BUF, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') @@ -48,7 +48,7 @@ const { const NETWORK = 'dogecoin-regtest' function standardUtxo (txid = TXID_A) { - return makeSegwitUtxo(txid, 0, 100000000) + return makeUtxo(NETWORK, txid, 0, 100000000) } describe('Encoding Chunk Boundaries: Full Pipeline', () => { @@ -118,9 +118,8 @@ describe('Encoding Chunk Boundaries: Full Pipeline', () => { // chunk must therefore be rejected at construction, not split into outputs // that always fail to relay. it('76-char data (compiled=78) → rejected (would exceed single output)', async () => { - // Every shipped coin (BTC, LTC, DOGE) sets singleOpReturnPolicy=true (see - // src/coins/*.js), so an oversized forced OP_RETURN is rejected on any of - // them; this uses bitcoin-regtest. + // Rejects an oversized forced OP_RETURN on every coin: prepareData enforces the + // ceiling unconditionally, so bitcoin-regtest here is a fixture, not a condition. const orNet = 'bitcoin-regtest' const encoder = makeEncoder(orNet) const address = getTestAddress(orNet) diff --git a/test/boundary/fee-calculation-boundaries.test.js b/test/boundary/fee-calculation-boundaries.test.js index 9537a57..9237908 100644 --- a/test/boundary/fee-calculation-boundaries.test.js +++ b/test/boundary/fee-calculation-boundaries.test.js @@ -24,7 +24,7 @@ const XChainEncoder = require('../../src/XChainEncoder') const { TXID_A, TXID_B, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress, buildRawTxHex @@ -47,7 +47,7 @@ describe('Fee Calculation Boundaries', () => { it('BTC: fee floors to 546 sats', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const result = await encoder.createTransaction( [utxo], address, null, @@ -64,7 +64,7 @@ describe('Fee Calculation Boundaries', () => { it('LTC: fee floors to 5460 sats', async () => { const encoder = makeEncoder('litecoin-regtest') const address = getTestAddress('litecoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('litecoin-regtest', TXID_A, 0, 100000000) const result = await encoder.createTransaction( [utxo], address, null, @@ -81,7 +81,7 @@ describe('Fee Calculation Boundaries', () => { it('DOGE: fee floors to 100000 sats (Dogecoin high dust threshold)', async () => { const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('dogecoin-regtest', TXID_A, 0, 100000000) const result = await encoder.createTransaction( [utxo], address, null, @@ -100,7 +100,7 @@ describe('Fee Calculation Boundaries', () => { it('negative feePerKb does not produce negative fee', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const result = await encoder.createTransaction( [utxo], address, null, @@ -131,7 +131,7 @@ describe('Fee Calculation Boundaries', () => { const uncapped = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const highFee = 100000000 // sat/kB: 100000 sat/byte = extremely high const cappedResult = await capped.createTransaction( @@ -164,7 +164,7 @@ describe('Fee Calculation Boundaries', () => { const uncapped = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const lowFee = 1000 // sat/kB: 1 sat/byte, below the cap const cappedResult = await capped.createTransaction( @@ -190,7 +190,7 @@ describe('Fee Calculation Boundaries', () => { it('fractional fee is truncated toward zero, not rounded', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) // Use a fee rate that produces a fractional satoshi amount // feePerBytes * txSize * SATOSHI_UNIT needs to be fractional @@ -216,7 +216,7 @@ describe('Fee Calculation Boundaries', () => { it('1-sat UTXO with dust-floored fee throws INSUFFICIENT_FUNDS', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 1) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 1) await assert.rejects( () => encoder.createTransaction( @@ -241,9 +241,9 @@ describe('Fee Calculation Boundaries', () => { await assert.rejects( () => encoder.createTransaction( [ - makeSegwitUtxo(TXID_A, 0, 100), - makeSegwitUtxo(TXID_B, 0, 100), - makeSegwitUtxo(TXID_A, 1, 100) + makeUtxo(NETWORK, TXID_A, 0, 100), + makeUtxo(NETWORK, TXID_B, 0, 100), + makeUtxo(NETWORK, TXID_A, 1, 100) ], address, null, 'SEND|0|X|1|a', null, 10000, false, null, address, @@ -260,7 +260,7 @@ describe('Fee Calculation Boundaries', () => { it('fee=10000 sets estimatedFee=10000 regardless of tx size', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const result = await encoder.createTransaction( [utxo], address, null, @@ -275,7 +275,7 @@ describe('Fee Calculation Boundaries', () => { it('explicit fee=0 still gets floored to dustAmount', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const result = await encoder.createTransaction( [utxo], address, null, @@ -292,7 +292,7 @@ describe('Fee Calculation Boundaries', () => { it('explicit fee=1 still gets floored to dustAmount', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const result = await encoder.createTransaction( [utxo], address, null, diff --git a/test/boundary/obfuscation-boundaries.test.js b/test/boundary/obfuscation-boundaries.test.js index c5a6f8f..71d6584 100644 --- a/test/boundary/obfuscation-boundaries.test.js +++ b/test/boundary/obfuscation-boundaries.test.js @@ -23,7 +23,7 @@ const bitcoin = require('bitcoinjs-lib') const XChainEncoder = require('../../src/XChainEncoder') const { TXID_A, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') @@ -125,7 +125,7 @@ describe('Obfuscation Boundaries', () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(zeroTxid, 0, 100000000) + const utxo = makeUtxo(NETWORK, zeroTxid, 0, 100000000) const result = await encoder.createTransaction( [utxo], address, null, @@ -148,7 +148,7 @@ describe('Obfuscation Boundaries', () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(shortTxid, 0, 100000000) + const utxo = makeUtxo(NETWORK, shortTxid, 0, 100000000) await assert.rejects( () => encoder.createTransaction( diff --git a/test/boundary/txsize-estimator-boundaries.test.js b/test/boundary/txsize-estimator-boundaries.test.js index ecbe17a..4e7799b 100644 --- a/test/boundary/txsize-estimator-boundaries.test.js +++ b/test/boundary/txsize-estimator-boundaries.test.js @@ -24,7 +24,7 @@ const TxSizeEstimator = require('../../src/TxSizeEstimator') const { TXID_A, PUBKEY_BUF, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') @@ -144,7 +144,7 @@ describe('TxSizeEstimator Boundaries', () => { it('missing UTXO data uses 350-byte fallback → fee includes input estimate', async () => { const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('dogecoin-regtest', TXID_A, 0, 100000000) const result = await encoder.createTransaction( [utxo], address, null, diff --git a/test/boundary/utxo-value-boundaries.test.js b/test/boundary/utxo-value-boundaries.test.js index 448bc22..9df1df2 100644 --- a/test/boundary/utxo-value-boundaries.test.js +++ b/test/boundary/utxo-value-boundaries.test.js @@ -24,7 +24,7 @@ const { TXID_A, TXID_B, TXID_C, - makeSegwitUtxo, + makeUtxo, makeMempoolUtxo, makeEncoder, getTestAddress @@ -44,7 +44,7 @@ describe('UTXO Value Boundaries', () => { it('1-sat UTXO: throws INSUFFICIENT_FUNDS (cannot cover fee)', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 1) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 1) await assert.rejects( () => encoder.createTransaction( @@ -62,8 +62,8 @@ describe('UTXO Value Boundaries', () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) // Two UTXOs: one with 0, one with enough to cover - const zeroUtxo = makeSegwitUtxo(TXID_A, 0, 0) - const realUtxo = makeSegwitUtxo(TXID_B, 0, 100000000) + const zeroUtxo = makeUtxo(NETWORK, TXID_A, 0, 0) + const realUtxo = makeUtxo(NETWORK, TXID_B, 0, 100000000) const result = await encoder.createTransaction( [zeroUtxo, realUtxo], address, null, @@ -81,7 +81,7 @@ describe('UTXO Value Boundaries', () => { it('"1000000" string value → treated as 1000000 sats correctly', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, '1000000') + const utxo = makeUtxo(NETWORK, TXID_A, 0, '1000000') const result = await encoder.createTransaction( [utxo], address, null, @@ -97,7 +97,7 @@ describe('UTXO Value Boundaries', () => { it('"1.9" string value is rejected, not truncated to 1', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, '1.9') + const utxo = makeUtxo(NETWORK, TXID_A, 0, '1.9') // parseInt would have truncated "1.9" to 1 sat and built a nonsensical // (negative-change) tx; the exact-integer parse rejects it up front. @@ -114,9 +114,9 @@ describe('UTXO Value Boundaries', () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const dup1 = makeSegwitUtxo(TXID_A, 0, 100000000) - const dup2 = makeSegwitUtxo(TXID_A, 0, 100000000) - const dup3 = makeSegwitUtxo(TXID_A, 0, 100000000) + const dup1 = makeUtxo(NETWORK, TXID_A, 0, 100000000) + const dup2 = makeUtxo(NETWORK, TXID_A, 0, 100000000) + const dup3 = makeUtxo(NETWORK, TXID_A, 0, 100000000) const result = await encoder.createTransaction( [dup1, dup2, dup3], address, null, @@ -135,9 +135,9 @@ describe('UTXO Value Boundaries', () => { const address = getTestAddress(NETWORK) // 3 copies of 100 sats → dedup → 1 UTXO of 100 sats. Fee = 10000. Insufficient. - const dup1 = makeSegwitUtxo(TXID_A, 0, 100) - const dup2 = makeSegwitUtxo(TXID_A, 0, 100) - const dup3 = makeSegwitUtxo(TXID_A, 0, 100) + const dup1 = makeUtxo(NETWORK, TXID_A, 0, 100) + const dup2 = makeUtxo(NETWORK, TXID_A, 0, 100) + const dup3 = makeUtxo(NETWORK, TXID_A, 0, 100) await assert.rejects( () => encoder.createTransaction( @@ -157,7 +157,7 @@ describe('UTXO Value Boundaries', () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) const maxBtcSats = 2100000000000000 // 21M BTC in satoshis - const utxo = makeSegwitUtxo(TXID_A, 0, maxBtcSats) + const utxo = makeUtxo(NETWORK, TXID_A, 0, maxBtcSats) const result = await encoder.createTransaction( [utxo], address, null, @@ -176,7 +176,7 @@ describe('UTXO Value Boundaries', () => { // is still Number-exact. const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, Number.MAX_SAFE_INTEGER) + const utxo = makeUtxo(NETWORK, TXID_A, 0, Number.MAX_SAFE_INTEGER) const result = await encoder.createTransaction( [utxo], address, null, @@ -195,7 +195,7 @@ describe('UTXO Value Boundaries', () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) const bigSats = '10000000100000000' // 100,000,001 DOGE in sats, > MAX_SAFE_INTEGER - const utxo = makeSegwitUtxo(TXID_A, 0, bigSats) + const utxo = makeUtxo(NETWORK, TXID_A, 0, bigSats) const result = await encoder.createTransaction( [utxo], address, null, @@ -222,7 +222,7 @@ describe('UTXO Value Boundaries', () => { let trackerCalled = false encoder.utxoTrackerConnector.getUtxosFromAddress = async () => { trackerCalled = true - return { utxos: [makeSegwitUtxo(TXID_C, 0, 100000000)] } + return { utxos: [makeUtxo(NETWORK, TXID_C, 0, 100000000)] } } const result = await encoder.createTransaction( @@ -245,9 +245,9 @@ describe('UTXO Value Boundaries', () => { const address = getTestAddress(NETWORK) // Three UTXOs in random order; 50M is the largest - const small = makeSegwitUtxo(TXID_A, 0, 1000) - const large = makeSegwitUtxo(TXID_B, 0, 50000000) - const medium = makeSegwitUtxo(TXID_C, 0, 500000) + const small = makeUtxo(NETWORK, TXID_A, 0, 1000) + const large = makeUtxo(NETWORK, TXID_B, 0, 50000000) + const medium = makeUtxo(NETWORK, TXID_C, 0, 500000) const result = await encoder.createTransaction( [small, medium, large], address, null, diff --git a/test/chaos/api-layer.test.js b/test/chaos/api-layer.test.js index 0463f04..584ed7f 100644 --- a/test/chaos/api-layer.test.js +++ b/test/chaos/api-layer.test.js @@ -22,7 +22,7 @@ const bitcoin = require('bitcoinjs-lib') const validator = require('../../src/validator') const { TXID_A, TXID_MULTISIGN, PUBKEY_BUF, - makeSegwitUtxo, makeEncoder, getTestAddress + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') const actions = require('../integration/helpers/actionFactory') @@ -48,6 +48,28 @@ describe('Chaos Category F: API Layer Failures', () => { ) }) + it('positional array params → TypeError naming the SHAPE, not a missing field', () => { + // typeof [] is 'object', so before the Array.isArray clause this reached + // 'pubkey is required' 50 lines later and blamed the wrong thing. + assert.throws( + () => validator.validateAll([]), + { name: 'TypeError', message: 'Request params must be an object' } + ) + }) + + it('create_envelope_cancel_tx rejects positional array params at the gate', async () => { + // Required lazily: src/api.js is the process entrypoint module, and the + // rest of this suite has no reason to load it. + const { jsonRpcController } = require('../../src/api') + let err = null + try { await jsonRpcController.create_envelope_cancel_tx([]) } catch (e) { err = e } + assert.ok(err, 'expected the array to be refused') + assert.strictEqual(err.code, -32602) + // Pre-fix this destructured to undefined and surfaced + // 'commitTxid must be a 64-character hex string'. + assert.strictEqual(err.message, 'Request params must be an object') + }) + it('data exceeding 65536 bytes → RangeError', () => { assert.throws( () => validator.validateAll({ data: 'X'.repeat(65537), pubkey: 'test' }), @@ -94,7 +116,7 @@ describe('Chaos Category F: API Layer Failures', () => { describe('F-3: psbt.toHex() callability (api.js line 124 exposure)', () => { it('OP_RETURN result: psbt.toHex() succeeds', async () => { const encoder = makeEncoder(DOGE) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(DOGE, TXID_A, 0, 100000000) const result = await encoder.createTransaction( [utxo], DOGE_ADDR, null, @@ -107,7 +129,7 @@ describe('Chaos Category F: API Layer Failures', () => { it('P2SH result: psbt.toHex() succeeds', async () => { const encoder = makeEncoder(DOGE) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(DOGE, TXID_A, 0, 100000000) const result = await encoder.createTransaction( [utxo], DOGE_ADDR, null, @@ -119,7 +141,7 @@ describe('Chaos Category F: API Layer Failures', () => { it('MULTISIGN result: psbt.toHex() succeeds', async () => { const encoder = makeEncoder(DOGE) - const utxo = makeSegwitUtxo(TXID_MULTISIGN, 0, 100000000) + const utxo = makeUtxo(DOGE, TXID_MULTISIGN, 0, 100000000) const result = await encoder.createTransaction( [utxo], DOGE_ADDR, null, @@ -147,7 +169,7 @@ describe('Chaos Category F: API Layer Failures', () => { it('toHex failure after successful createTransaction → unhandled in api.js', async () => { const encoder = makeEncoder(DOGE) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(DOGE, TXID_A, 0, 100000000) // createTransaction itself succeeds (toHex is not called inside it) const result = await encoder.createTransaction( diff --git a/test/chaos/arithmetic-state.test.js b/test/chaos/arithmetic-state.test.js index 25e2c22..e47c040 100644 --- a/test/chaos/arithmetic-state.test.js +++ b/test/chaos/arithmetic-state.test.js @@ -21,7 +21,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const { TXID_A, TXID_B, TXID_C, - makeSegwitUtxo, makeEncoder, getTestAddress + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') const actions = require('../integration/helpers/actionFactory') @@ -51,7 +51,7 @@ describe('Chaos Category D: Arithmetic & State Corruption', () => { describe('D-1: Negative change is refused, not signed', () => { it('1-sat UTXO + 10000-sat fee → INSUFFICIENT_FUNDS, no PSBT', async () => { const encoder = makeEncoder(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 1) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 1) await assert.rejects( () => encoder.createTransaction( @@ -73,9 +73,9 @@ describe('Chaos Category D: Arithmetic & State Corruption', () => { await assert.rejects( () => encoder.createTransaction( [ - makeSegwitUtxo(TXID_A, 0, 100), - makeSegwitUtxo(TXID_B, 0, 100), - makeSegwitUtxo(TXID_C, 0, 100) + makeUtxo(NETWORK, TXID_A, 0, 100), + makeUtxo(NETWORK, TXID_B, 0, 100), + makeUtxo(NETWORK, TXID_C, 0, 100) ], ADDRESS, null, actions.makeSend().data, null, 10000, false, null, ADDRESS, @@ -100,9 +100,9 @@ describe('Chaos Category D: Arithmetic & State Corruption', () => { await assert.rejects( () => encoder.createTransaction( [ - makeSegwitUtxo(TXID_A, 0, 100), - makeSegwitUtxo(TXID_B, 0, 100), - makeSegwitUtxo(TXID_A, 1, 100) + makeUtxo(NETWORK, TXID_A, 0, 100), + makeUtxo(NETWORK, TXID_B, 0, 100), + makeUtxo(NETWORK, TXID_A, 1, 100) ], ADDRESS, null, actions.makeSend().data, null, 10000, false, null, ADDRESS, @@ -114,7 +114,7 @@ describe('Chaos Category D: Arithmetic & State Corruption', () => { it('a single dust UTXO is refused rather than serialized', async () => { const encoder = makeEncoder(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100) await assert.rejects( () => encoder.createTransaction( @@ -131,7 +131,7 @@ describe('Chaos Category D: Arithmetic & State Corruption', () => { // not simply refuse every transaction these cases feed it. const encoder = makeEncoder(NETWORK) const result = await encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], ADDRESS, null, + [makeUtxo(NETWORK, TXID_A, 0, 100000000)], ADDRESS, null, actions.makeSend().data, null, 10000, false, null, ADDRESS, null, null, null, true, 0.00001 ) @@ -145,13 +145,13 @@ describe('Chaos Category D: Arithmetic & State Corruption', () => { const [r1, r2] = await Promise.all([ encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], + [makeUtxo(NETWORK, TXID_A, 0, 100000000)], ADDRESS, null, actions.makeSend().data, null, 10000, false, null, ADDRESS, null, null, null, true, 0.00001 ), encoder.createTransaction( - [makeSegwitUtxo(TXID_B, 0, 100000000)], + [makeUtxo(NETWORK, TXID_B, 0, 100000000)], ADDRESS, null, actions.makeSend().data, null, 10000, false, null, ADDRESS, null, null, null, true, 0.00001 @@ -165,8 +165,8 @@ describe('Chaos Category D: Arithmetic & State Corruption', () => { it('two concurrent calls sharing same array both complete', async () => { const encoder = makeEncoder(NETWORK) const sharedUtxos = [ - makeSegwitUtxo(TXID_A, 0, 100000000), - makeSegwitUtxo(TXID_B, 0, 50000000) + makeUtxo(NETWORK, TXID_A, 0, 100000000), + makeUtxo(NETWORK, TXID_B, 0, 50000000) ] // Both calls share the same array reference. JavaScript is @@ -193,9 +193,9 @@ describe('Chaos Category D: Arithmetic & State Corruption', () => { it('createTransaction mutates the caller\'s utxos array in-place', async () => { const encoder = makeEncoder(NETWORK) const utxos = [ - makeSegwitUtxo(TXID_C, 0, 10000000), // small - makeSegwitUtxo(TXID_A, 0, 100000000), // large - makeSegwitUtxo(TXID_B, 0, 50000000) // medium + makeUtxo(NETWORK, TXID_C, 0, 10000000), // small + makeUtxo(NETWORK, TXID_A, 0, 100000000), // large + makeUtxo(NETWORK, TXID_B, 0, 50000000) // medium ] const orderBefore = utxos.map(u => u.txid) @@ -214,8 +214,8 @@ describe('Chaos Category D: Arithmetic & State Corruption', () => { it('second call with same pre-sorted array still succeeds', async () => { const encoder = makeEncoder(NETWORK) const utxos = [ - makeSegwitUtxo(TXID_A, 0, 100000000), - makeSegwitUtxo(TXID_B, 0, 50000000) + makeUtxo(NETWORK, TXID_A, 0, 100000000), + makeUtxo(NETWORK, TXID_B, 0, 50000000) ] await encoder.createTransaction( diff --git a/test/chaos/input-corruption.test.js b/test/chaos/input-corruption.test.js index 6b000b0..826831d 100644 --- a/test/chaos/input-corruption.test.js +++ b/test/chaos/input-corruption.test.js @@ -22,7 +22,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const { TXID_A, TXID_B, PUBKEY_BUF, - makeSegwitUtxo, makeMempoolUtxo, makeEncoder, getTestAddress, buildRawTxHex + makeUtxo, makeMempoolUtxo, makeEncoder, getTestAddress, buildRawTxHex } = require('../integration/helpers/utxoFactory') const actions = require('../integration/helpers/actionFactory') @@ -71,7 +71,7 @@ describe('Chaos Category B: Input & Data Corruption', () => { const encoder = makeEncoder(DOGE) const result = await encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], + [makeUtxo(DOGE, TXID_A, 0, 100000000)], DOGE_ADDR, null, actions.makeSend().data, null, 10000, false, null, DOGE_ADDR, null, null, null, false, 0.00001 @@ -86,7 +86,7 @@ describe('Chaos Category B: Input & Data Corruption', () => { // selection instead, so both now assert the typed refusal. it('value=0 → INSUFFICIENT_FUNDS, available reported as 0', async () => { const encoder = makeEncoder(DOGE) - const utxo = makeSegwitUtxo(TXID_A, 0, 0) + const utxo = makeUtxo(DOGE, TXID_A, 0, 0) await assert.rejects( () => encoder.createTransaction( @@ -104,7 +104,7 @@ describe('Chaos Category B: Input & Data Corruption', () => { it('value=1 → INSUFFICIENT_FUNDS rather than negative change', async () => { const encoder = makeEncoder(DOGE) - const utxo = makeSegwitUtxo(TXID_A, 0, 1) + const utxo = makeUtxo(DOGE, TXID_A, 0, 1) await assert.rejects( () => encoder.createTransaction( @@ -122,7 +122,7 @@ describe('Chaos Category B: Input & Data Corruption', () => { it('hex string value "0xff" is rejected (parseInt would read it as 0)', async () => { const encoder = makeEncoder(DOGE) - const utxo = { ...makeSegwitUtxo(TXID_A, 0, 1), value: '0xff' } + const utxo = { ...makeUtxo(DOGE, TXID_A, 0, 1), value: '0xff' } // parseInt('0xff', 10) === 0 silently zeroed the UTXO; reject instead. await assert.rejects(() => encoder.createTransaction( @@ -134,7 +134,7 @@ describe('Chaos Category B: Input & Data Corruption', () => { it('float string "100.7" is rejected (parseInt would truncate to 100)', async () => { const encoder = makeEncoder(DOGE) - const utxo = { ...makeSegwitUtxo(TXID_A, 0, 1), value: '100.7' } + const utxo = { ...makeUtxo(DOGE, TXID_A, 0, 1), value: '100.7' } await assert.rejects(() => encoder.createTransaction( [utxo], DOGE_ADDR, null, @@ -147,7 +147,7 @@ describe('Chaos Category B: Input & Data Corruption', () => { describe('B-3: Obfuscation key edge cases (degenerate AES keys)', () => { it('all-zero txid (000...0) works as AES key', async () => { const encoder = makeEncoder(DOGE) - const utxo = makeSegwitUtxo('0'.repeat(64), 0, 100000000) + const utxo = makeUtxo(DOGE, '0'.repeat(64), 0, 100000000) const result = await encoder.createTransaction( [utxo], DOGE_ADDR, null, @@ -162,7 +162,7 @@ describe('Chaos Category B: Input & Data Corruption', () => { const utxo = { txid: 'a'.repeat(32), vout: 0, value: 100000000, confirmations: 6, - scriptPubKey: makeSegwitUtxo(TXID_A, 0, 1).scriptPubKey + scriptPubKey: makeUtxo(DOGE, TXID_A, 0, 1).scriptPubKey } // Short txid causes failure either at bitcoinjs-lib buffer @@ -178,7 +178,7 @@ describe('Chaos Category B: Input & Data Corruption', () => { it('all-F txid (fff...f) works as AES key', async () => { const encoder = makeEncoder(DOGE) - const utxo = makeSegwitUtxo('f'.repeat(64), 0, 100000000) + const utxo = makeUtxo(DOGE, 'f'.repeat(64), 0, 100000000) const result = await encoder.createTransaction( [utxo], DOGE_ADDR, null, @@ -198,7 +198,7 @@ describe('Chaos Category B: Input & Data Corruption', () => { describe('B-4: Maximum payload stress (8192 byte compiled boundary)', () => { it('8189-byte data → compiled=8192 → at limit, P2WSH succeeds', async () => { const encoder = makeEncoder(BTC) - const utxo = makeSegwitUtxo(TXID_A, 0, 1000000000) + const utxo = makeUtxo(BTC, TXID_A, 0, 1000000000) const result = await encoder.createTransaction( [utxo], BTC_ADDR, null, @@ -211,7 +211,7 @@ describe('Chaos Category B: Input & Data Corruption', () => { it('8190-byte data → compiled=8193 → RangeError: Payload too large', async () => { const encoder = makeEncoder(BTC) - const utxo = makeSegwitUtxo(TXID_A, 0, 1000000000) + const utxo = makeUtxo(BTC, TXID_A, 0, 1000000000) await assert.rejects( () => encoder.createTransaction( @@ -269,7 +269,7 @@ describe('Chaos Category B: Input & Data Corruption', () => { describe('B-6: Binary/NUL content in ACTION data', () => { it('NUL bytes in data produce valid PSBT', async () => { const encoder = makeEncoder(DOGE) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(DOGE, TXID_A, 0, 100000000) const result = await encoder.createTransaction( [utxo], DOGE_ADDR, null, @@ -281,7 +281,7 @@ describe('Chaos Category B: Input & Data Corruption', () => { it('emoji/high-Unicode in data produce valid PSBT', async () => { const encoder = makeEncoder(DOGE) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(DOGE, TXID_A, 0, 100000000) const result = await encoder.createTransaction( [utxo], DOGE_ADDR, null, diff --git a/test/chaos/library-failures.test.js b/test/chaos/library-failures.test.js index b41145e..650b43f 100644 --- a/test/chaos/library-failures.test.js +++ b/test/chaos/library-failures.test.js @@ -21,7 +21,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const crypto = require('crypto') const { - TXID_A, PUBKEY_BUF, makeSegwitUtxo, makeEncoder, getTestAddress + TXID_A, PUBKEY_BUF, makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') const actions = require('../integration/helpers/actionFactory') @@ -46,7 +46,7 @@ describe('Chaos Category C: Library & Crypto Failures', () => { it('addInput failure propagates from createTransaction', async () => { const encoder = makeEncoder(DOGE) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(DOGE, TXID_A, 0, 100000000) await assert.rejects( () => encoder.createTransaction( @@ -75,7 +75,7 @@ describe('Chaos Category C: Library & Crypto Failures', () => { it('first addOutput (OP_RETURN data) failure propagates', async () => { const encoder = makeEncoder(DOGE) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(DOGE, TXID_A, 0, 100000000) await assert.rejects( () => encoder.createTransaction( @@ -108,7 +108,7 @@ describe('Chaos Category C: Library & Crypto Failures', () => { it('second addOutput (change output) failure propagates', async () => { const encoder = makeEncoder(DOGE) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(DOGE, TXID_A, 0, 100000000) await assert.rejects( () => encoder.createTransaction( @@ -137,7 +137,7 @@ describe('Chaos Category C: Library & Crypto Failures', () => { it('cipher failure in obfuscate() propagates', async () => { const encoder = makeEncoder(DOGE) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(DOGE, TXID_A, 0, 100000000) await assert.rejects( () => encoder.createTransaction( @@ -159,7 +159,7 @@ describe('Chaos Category C: Library & Crypto Failures', () => { // Create encoder BEFORE patching, since makeEncoder uses // script.compile internally via buildRawTxHex → p2pkh encoder = makeEncoder(DOGE) - utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + utxo = makeUtxo(DOGE, TXID_A, 0, 100000000) _origCompile = bitcoin.script.compile bitcoin.script.compile = function () { throw new TypeError('chaos: script compile failed') @@ -196,7 +196,7 @@ describe('Chaos Category C: Library & Crypto Failures', () => { describe('C-5: Non-base58 caller identity for P2SH encoding', () => { it('bech32 P2WPKH caller resolves to the same HASH160 as the raw pubkey', async () => { const encoder = makeEncoder(DOGE) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(DOGE, TXID_A, 0, 100000000) const bech32Addr = bitcoin.payments.p2wpkh({ pubkey: PUBKEY_BUF, @@ -208,8 +208,10 @@ describe('Chaos Category C: Library & Crypto Failures', () => { actions.makeIssueFull().data, null, 10000, false, null, DOGE_ADDR, null, null, null, true, 0.00001 ) + // Same input on purpose: release the first build's reservation. + encoder.clearReservations() const viaPubkey = await encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], PUBKEY_BUF.toString('hex'), null, + [makeUtxo(DOGE, TXID_A, 0, 100000000)], PUBKEY_BUF.toString('hex'), null, actions.makeIssueFull().data, null, 10000, false, null, DOGE_ADDR, null, null, null, true, 0.00001 ) @@ -224,7 +226,7 @@ describe('Chaos Category C: Library & Crypto Failures', () => { it('an identity that is no address and no pubkey is refused by name', async () => { const encoder = makeEncoder(DOGE) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(DOGE, TXID_A, 0, 100000000) await assert.rejects( () => encoder.createTransaction( diff --git a/test/chaos/network-failures.test.js b/test/chaos/network-failures.test.js index 78aee4d..4fcccef 100644 --- a/test/chaos/network-failures.test.js +++ b/test/chaos/network-failures.test.js @@ -20,7 +20,7 @@ const assert = require('assert') const { - TXID_A, makeSegwitUtxo, makeLegacyUtxo, makeEncoder, getTestAddress + TXID_A, makeUtxo, makeLegacyUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') const actions = require('../integration/helpers/actionFactory') const { delay } = require('../helpers/timing') @@ -33,7 +33,7 @@ describe('Chaos Category A: Network & Dependency Failures', () => { describe('A-1: Coin daemon unreachable (ECONNREFUSED)', () => { it('getFeePerKilobyte failure propagates when feePerKb not provided', async () => { const encoder = makeEncoder(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) encoder.connector.getFeePerKilobyte = async () => { const err = new Error('connect ECONNREFUSED 127.0.0.1:8332') @@ -73,7 +73,7 @@ describe('Chaos Category A: Network & Dependency Failures', () => { it('feePerKb parameter bypasses broken getFeePerKilobyte', async () => { const encoder = makeEncoder(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) encoder.connector.getFeePerKilobyte = async () => { throw new Error('ECONNREFUSED') @@ -91,7 +91,7 @@ describe('Chaos Category A: Network & Dependency Failures', () => { describe('A-2: Coin daemon returns invalid JSON (502 HTML)', () => { it('SyntaxError from JSON parse propagates', async () => { const encoder = makeEncoder(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) encoder.connector.getFeePerKilobyte = async () => { throw new SyntaxError('Unexpected token < in JSON at position 0') @@ -111,7 +111,7 @@ describe('Chaos Category A: Network & Dependency Failures', () => { describe('A-3: Coin daemon RPC error (node still loading)', () => { it('RPC error from getFeePerKilobyte propagates', async () => { const encoder = makeEncoder(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) encoder.connector.getFeePerKilobyte = async () => { throw new Error('RPC error -28: Verifying blocks...') @@ -246,7 +246,7 @@ describe('Chaos Category A: Network & Dependency Failures', () => { describe('A-6: Intermittent RPC failures', () => { it('first call succeeds, second call fails (alternating pattern)', async () => { const encoder = makeEncoder(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) let callCount = 0 encoder.connector.getFeePerKilobyte = async () => { @@ -257,16 +257,18 @@ describe('Chaos Category A: Network & Dependency Failures', () => { // Call 1 (odd) → success const result1 = await encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], ADDRESS, null, + [makeUtxo(NETWORK, TXID_A, 0, 100000000)], ADDRESS, null, actions.makeSend().data, null, null, false, null, ADDRESS, null, null, null, true, null ) assert.ok(result1.psbt) - // Call 2 (even) → failure + // Call 2 (even) → failure. Every call here respends the one fixture + // input, so release call 1's reservation or the RPC never gets asked. + encoder.clearReservations() await assert.rejects( () => encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], ADDRESS, null, + [makeUtxo(NETWORK, TXID_A, 0, 100000000)], ADDRESS, null, actions.makeSend().data, null, null, false, null, ADDRESS, null, null, null, true, null ), @@ -275,7 +277,7 @@ describe('Chaos Category A: Network & Dependency Failures', () => { // Call 3 (odd) → success again const result3 = await encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], ADDRESS, null, + [makeUtxo(NETWORK, TXID_A, 0, 100000000)], ADDRESS, null, actions.makeSend().data, null, null, false, null, ADDRESS, null, null, null, true, null ) @@ -286,7 +288,7 @@ describe('Chaos Category A: Network & Dependency Failures', () => { describe('A-7: Slow RPC responses', () => { it('150ms delay on getFeePerKilobyte still completes', async () => { const encoder = makeEncoder(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) encoder.connector.getFeePerKilobyte = async () => { await delay(150) diff --git a/test/chaos/resource-exhaustion.test.js b/test/chaos/resource-exhaustion.test.js index 9c313e0..8a5fd7f 100644 --- a/test/chaos/resource-exhaustion.test.js +++ b/test/chaos/resource-exhaustion.test.js @@ -20,7 +20,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const { - TXID_A, makeSegwitUtxo, makeLegacyUtxo, + TXID_A, makeUtxo, makeLegacyUtxo, makeEncoder, getTestAddress, buildRawTxHex } = require('../integration/helpers/utxoFactory') const actions = require('../integration/helpers/actionFactory') @@ -35,7 +35,7 @@ describe('Chaos Category E: Resource Exhaustion', () => { describe('E-1: Large P2WSH encoding (memory stress)', () => { it('8189-byte payload (compiled 8192, at limit) with P2WSH completes in <5s', async () => { const encoder = makeEncoder(BTC) - const utxo = makeSegwitUtxo(TXID_A, 0, 1000000000) + const utxo = makeUtxo(BTC, TXID_A, 0, 1000000000) // This test measures encoding time, not fee policy. It used to pass a // flat 100000-sat fee and switch off the relative fee-rate cap @@ -60,7 +60,7 @@ describe('Chaos Category E: Resource Exhaustion', () => { it('8193-byte payload exceeds limit → RangeError', async () => { const encoder = makeEncoder(BTC) - const utxo = makeSegwitUtxo(TXID_A, 0, 1000000000) + const utxo = makeUtxo(BTC, TXID_A, 0, 1000000000) await assert.rejects( () => encoder.createTransaction( @@ -88,7 +88,7 @@ describe('Chaos Category E: Resource Exhaustion', () => { for (let i = 0; i < 500; i++) { // Unique txid:vout pairs to avoid dedup const txid = TXID_A.slice(0, 60) + String(i).padStart(4, '0') - utxos.push(makeSegwitUtxo(txid, 0, 1000)) + utxos.push(makeUtxo(DOGE, txid, 0, 1000)) } const start = Date.now() diff --git a/test/e2e/action-pipeline.e2e.js b/test/e2e/action-pipeline.e2e.js index 448b619..9fddae3 100644 --- a/test/e2e/action-pipeline.e2e.js +++ b/test/e2e/action-pipeline.e2e.js @@ -28,7 +28,7 @@ const { } = require('../integration/helpers/deobfuscate') const { TXID_A, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') @@ -37,7 +37,7 @@ const actions = require('../integration/helpers/actionFactory') const NETWORK = 'dogecoin-regtest' function stdUtxo () { - return makeSegwitUtxo(TXID_A, 0, 100000000) + return makeUtxo(NETWORK, TXID_A, 0, 100000000) } /** diff --git a/test/e2e/edge-cases.e2e.js b/test/e2e/edge-cases.e2e.js index 10da314..da26673 100644 --- a/test/e2e/edge-cases.e2e.js +++ b/test/e2e/edge-cases.e2e.js @@ -29,7 +29,7 @@ const { TXID_A, TXID_MULTISIGN, PUBKEY_BUF, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') @@ -38,7 +38,7 @@ const actions = require('../integration/helpers/actionFactory') const NETWORK = 'dogecoin-regtest' function stdUtxo () { - return makeSegwitUtxo(TXID_A, 0, 100000000) + return makeUtxo(NETWORK, TXID_A, 0, 100000000) } async function encodeAndExtract (data, opts = {}) { @@ -257,7 +257,7 @@ describe('E2E-7: Complex Parameter & Edge Case Handling', () => { it('MULTISIGN output uses custom dust value', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_MULTISIGN, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_MULTISIGN, 0, 100000000) const customDust = 1234 const result = await encoder.createTransaction( diff --git a/test/e2e/encoding-boundaries.e2e.js b/test/e2e/encoding-boundaries.e2e.js index 2ad8437..961bb70 100644 --- a/test/e2e/encoding-boundaries.e2e.js +++ b/test/e2e/encoding-boundaries.e2e.js @@ -27,7 +27,7 @@ const { TXID_A, TXID_MULTISIGN, PUBKEY_BUF, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') @@ -35,15 +35,15 @@ const actions = require('../integration/helpers/actionFactory') const NETWORK = 'dogecoin-regtest' -function stdUtxo () { - return makeSegwitUtxo(TXID_A, 0, 100000000) +function stdUtxo (network) { + return makeUtxo(network, TXID_A, 0, 100000000) } async function encode (data, opts = {}) { const network = opts.network || NETWORK const encoder = makeEncoder(network) const address = getTestAddress(network) - const utxo = opts.utxo || stdUtxo() + const utxo = opts.utxo || stdUtxo(network) return encoder.createTransaction( [utxo], address, null, @@ -88,8 +88,8 @@ describe('E2E-3: Encoding Type Selection & Boundaries', () => { // rejects multi-OP_RETURN transactions as non-standard at broadcast. // A payload larger than one 76-byte chunk must be rejected at // construction instead of producing a PSBT that never relays. - // The single-OP_RETURN rejection only fires where singleOpReturnPolicy=true - // (bitcoin); dogecoin/litecoin permit multiple OP_RETURNs, so force bitcoin. + // The rejection fires on every coin, not just bitcoin: singleOpReturnPolicy is + // declared in the coin registry but read nowhere (uuid:0ca8479c). const bigData = 'X'.repeat(200) await assert.rejects( encode(bigData, { encoding: 'OP_RETURN', network: 'bitcoin-regtest' }), @@ -146,7 +146,7 @@ describe('E2E-3: Encoding Type Selection & Boundaries', () => { const result = await encode(MS_DATA, { encoding: 'MULTISIGN', compressedPubKey: PUBKEY_BUF.toString('hex'), - utxo: makeSegwitUtxo(TXID_MULTISIGN, 0, 100000000) + utxo: makeUtxo(NETWORK, TXID_MULTISIGN, 0, 100000000) }) assert.strictEqual(result.encoding, 'MULTISIGN') diff --git a/test/e2e/error-rejection.e2e.js b/test/e2e/error-rejection.e2e.js index 966e2b8..ad4e90a 100644 --- a/test/e2e/error-rejection.e2e.js +++ b/test/e2e/error-rejection.e2e.js @@ -23,7 +23,7 @@ const { TXID_A, TXID_MULTISIGN, PUBKEY_BUF, - makeSegwitUtxo, + makeUtxo, makeLegacyUtxo, makeEncoder, getTestAddress, @@ -75,7 +75,7 @@ describe('E2E-8: Error Handling & Negative Tests', () => { it('propagates RPC error when fee estimation fails', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() encoder.connector.getFeePerKilobyte = async () => { @@ -95,7 +95,7 @@ describe('E2E-8: Error Handling & Negative Tests', () => { it('succeeds when feePerKb provided despite RPC failure', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() encoder.connector.getFeePerKilobyte = async () => { @@ -170,7 +170,7 @@ describe('E2E-8: Error Handling & Negative Tests', () => { it('throws when compressedPubKey is null', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_MULTISIGN, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_MULTISIGN, 0, 100000000) await assert.rejects( () => encoder.createTransaction( @@ -186,7 +186,7 @@ describe('E2E-8: Error Handling & Negative Tests', () => { describe('E2E-8.7: Invalid address for P2SH', () => { it('throws on invalid base58 address', async () => { const encoder = makeEncoder(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeIssueFull('BIGTOKEN') await assert.rejects( @@ -203,7 +203,7 @@ describe('E2E-8: Error Handling & Negative Tests', () => { it('throws descriptive "burn satoshis" error', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() await assert.rejects( @@ -221,7 +221,7 @@ describe('E2E-8: Error Handling & Negative Tests', () => { it('handles empty string without crashing', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) // An empty ACTION string has no specified behavior; either a minimal // valid PSBT or a meaningful error is acceptable. @@ -242,7 +242,7 @@ describe('E2E-8: Error Handling & Negative Tests', () => { it('handles null data gracefully', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) try { const result = await encoder.createTransaction( @@ -261,7 +261,7 @@ describe('E2E-8: Error Handling & Negative Tests', () => { it('does not produce negative-value outputs', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() try { @@ -287,7 +287,7 @@ describe('E2E-8: Error Handling & Negative Tests', () => { const action = actions.makeSend() // 1 sat UTXO -- far too small to cover any fee - const tinyUtxo = makeSegwitUtxo(TXID_A, 0, 1) + const tinyUtxo = makeUtxo(NETWORK, TXID_A, 0, 1) try { const result = await encoder.createTransaction( @@ -307,7 +307,7 @@ describe('E2E-8: Error Handling & Negative Tests', () => { it('returns { psbt: Psbt, encoding: string } with valid enum', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( diff --git a/test/e2e/multi-chain.e2e.js b/test/e2e/multi-chain.e2e.js index fa68ac2..2560fb8 100644 --- a/test/e2e/multi-chain.e2e.js +++ b/test/e2e/multi-chain.e2e.js @@ -24,7 +24,7 @@ const { TXID_A, TXID_MULTISIGN, PUBKEY_BUF, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') @@ -35,8 +35,8 @@ const { MAGIC_WORD } = require('../integration/helpers/deobfuscate') -function stdUtxo () { - return makeSegwitUtxo(TXID_A, 0, 100000000) +function stdUtxo (network) { + return makeUtxo(network, TXID_A, 0, 100000000) } const CHAINS = [ @@ -52,7 +52,7 @@ describe('E2E-6: Multi-Chain Validation', () => { it('creates valid OP_RETURN PSBT with correct dust threshold', async () => { const encoder = makeEncoder(chain.name) const address = getTestAddress(chain.name) - const utxo = stdUtxo() + const utxo = stdUtxo(chain.name) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -79,7 +79,7 @@ describe('E2E-6: Multi-Chain Validation', () => { it('creates P2SH with output value >= chain dust threshold', async () => { const encoder = makeEncoder(chain.name) const address = getTestAddress(chain.name) - const utxo = stdUtxo() + const utxo = stdUtxo(chain.name) const action = actions.makeIssueFull('CHAIN') const result = await encoder.createTransaction( @@ -106,7 +106,7 @@ describe('E2E-6: Multi-Chain Validation', () => { it('creates P2WSH witness output on bitcoin-regtest', async () => { const encoder = makeEncoder('bitcoin-regtest') const address = getTestAddress('bitcoin-regtest') - const utxo = stdUtxo() + const utxo = stdUtxo('bitcoin-regtest') const action = actions.makeFileLarge() const result = await encoder.createTransaction( @@ -131,7 +131,7 @@ describe('E2E-6: Multi-Chain Validation', () => { it('multisig output at 546 sats', async () => { const encoder = makeEncoder('bitcoin-regtest') const address = getTestAddress('bitcoin-regtest') - const utxo = makeSegwitUtxo(TXID_MULTISIGN, 0, 100000000) + const utxo = makeUtxo('bitcoin-regtest', TXID_MULTISIGN, 0, 100000000) const result = await encoder.createTransaction( [utxo], address, null, @@ -155,7 +155,7 @@ describe('E2E-6: Multi-Chain Validation', () => { it('multisig output at 5460 sats', async () => { const encoder = makeEncoder('litecoin-regtest') const address = getTestAddress('litecoin-regtest') - const utxo = makeSegwitUtxo(TXID_MULTISIGN, 0, 100000000) + const utxo = makeUtxo('litecoin-regtest', TXID_MULTISIGN, 0, 100000000) const result = await encoder.createTransaction( [utxo], address, null, @@ -190,7 +190,7 @@ describe('E2E-6: Multi-Chain Validation', () => { it('Litecoin fee floor is 5460, not 546', async () => { const encoder = makeEncoder('litecoin-regtest') const address = getTestAddress('litecoin-regtest') - const utxo = stdUtxo() + const utxo = stdUtxo('litecoin-regtest') const action = actions.makeSend() const result = await encoder.createTransaction( diff --git a/test/e2e/obfuscation-integrity.e2e.js b/test/e2e/obfuscation-integrity.e2e.js index a6a3db0..8fedad1 100644 --- a/test/e2e/obfuscation-integrity.e2e.js +++ b/test/e2e/obfuscation-integrity.e2e.js @@ -31,7 +31,7 @@ const { TXID_B, TXID_MULTISIGN, PUBKEY_BUF, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') @@ -40,7 +40,7 @@ const actions = require('../integration/helpers/actionFactory') const NETWORK = 'dogecoin-regtest' function stdUtxo (txid) { - return makeSegwitUtxo(txid || TXID_A, 0, 100000000) + return makeUtxo(NETWORK, txid || TXID_A, 0, 100000000) } describe('E2E-4: Obfuscation Integrity', () => { @@ -81,7 +81,7 @@ describe('E2E-4: Obfuscation Integrity', () => { const MS_DATA = 'A'.repeat(59) const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_MULTISIGN, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_MULTISIGN, 0, 100000000) const result = await encoder.createTransaction( [utxo], address, null, @@ -180,8 +180,8 @@ describe('E2E-4: Obfuscation Integrity', () => { const action = actions.makeSend() // Provide UTXOs smallest-first; TXID_A is the largest - const small = makeSegwitUtxo(TXID_B, 0, 10000000) - const large = makeSegwitUtxo(TXID_A, 0, 100000000) + const small = makeUtxo(NETWORK, TXID_B, 0, 10000000) + const large = makeUtxo(NETWORK, TXID_A, 0, 100000000) const result = await encoder.createTransaction( [small, large], address, null, diff --git a/test/e2e/p2sh-p2wsh-sequence.e2e.js b/test/e2e/p2sh-p2wsh-sequence.e2e.js index c30eefc..6d4b7c0 100644 --- a/test/e2e/p2sh-p2wsh-sequence.e2e.js +++ b/test/e2e/p2sh-p2wsh-sequence.e2e.js @@ -28,7 +28,7 @@ const { } = require('../integration/helpers/deobfuscate') const { TXID_A, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') @@ -37,7 +37,7 @@ const actions = require('../integration/helpers/actionFactory') const NETWORK = 'dogecoin-regtest' function stdUtxo () { - return makeSegwitUtxo(TXID_A, 0, 100000000) + return makeUtxo(NETWORK, TXID_A, 0, 100000000) } /** diff --git a/test/e2e/round-trip.e2e.js b/test/e2e/round-trip.e2e.js index fc61d97..78b45c8 100644 --- a/test/e2e/round-trip.e2e.js +++ b/test/e2e/round-trip.e2e.js @@ -30,14 +30,14 @@ const { TXID_A, TXID_MULTISIGN, PUBKEY_BUF, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') const actions = require('../integration/helpers/actionFactory') -function stdUtxo () { - return makeSegwitUtxo(TXID_A, 0, 100000000) +function stdUtxo (network) { + return makeUtxo(network, TXID_A, 0, 100000000) } describe('E2E-9: Round-Trip Verification', () => { @@ -47,7 +47,7 @@ describe('E2E-9: Round-Trip Verification', () => { const action = actions.makeSend('JDOG', '42', actions.ADDR_BTC, 'test memo') const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = stdUtxo() + const utxo = stdUtxo('dogecoin-regtest') const result = await encoder.createTransaction( [utxo], address, null, @@ -88,7 +88,7 @@ describe('E2E-9: Round-Trip Verification', () => { }) const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = stdUtxo() + const utxo = stdUtxo('dogecoin-regtest') // tx1 const tx1 = await encoder.createTransaction( @@ -131,7 +131,7 @@ describe('E2E-9: Round-Trip Verification', () => { const MS_DATA = 'A'.repeat(59) const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = makeSegwitUtxo(TXID_MULTISIGN, 0, 100000000) + const utxo = makeUtxo('dogecoin-regtest', TXID_MULTISIGN, 0, 100000000) const compressedPubKey = PUBKEY_BUF.toString('hex') const result = await encoder.createTransaction( @@ -155,7 +155,7 @@ describe('E2E-9: Round-Trip Verification', () => { const action = actions.makeFileLarge() const encoder = makeEncoder('bitcoin-regtest') const address = getTestAddress('bitcoin-regtest') - const utxo = stdUtxo() + const utxo = stdUtxo('bitcoin-regtest') // tx1 const tx1 = await encoder.createTransaction( @@ -197,7 +197,7 @@ describe('E2E-9: Round-Trip Verification', () => { const bigData = 'X'.repeat(200) const encoder = makeEncoder('bitcoin-regtest') const address = getTestAddress('bitcoin-regtest') - const utxo = stdUtxo() + const utxo = stdUtxo('bitcoin-regtest') await assert.rejects( encoder.createTransaction( @@ -219,7 +219,7 @@ describe('E2E-9: Round-Trip Verification', () => { ]) const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = stdUtxo() + const utxo = stdUtxo('dogecoin-regtest') const result = await encoder.createTransaction( [utxo], address, null, @@ -257,7 +257,7 @@ describe('E2E-9: Round-Trip Verification', () => { for (const chain of chains) { const encoder = makeEncoder(chain) const address = getTestAddress(chain) - const utxo = stdUtxo() + const utxo = stdUtxo(chain) const result = await encoder.createTransaction( [utxo], address, null, diff --git a/test/e2e/utxo-fee-change.e2e.js b/test/e2e/utxo-fee-change.e2e.js index a640796..2787da0 100644 --- a/test/e2e/utxo-fee-change.e2e.js +++ b/test/e2e/utxo-fee-change.e2e.js @@ -24,7 +24,7 @@ const { TXID_A, TXID_B, TXID_C, - makeSegwitUtxo, + makeUtxo, makeLegacyUtxo, makeMempoolUtxo, makeEncoder, @@ -44,7 +44,7 @@ describe('E2E-5: UTXO, Fee, and Change Integration', () => { it('1 input, OP_RETURN + change; change = input - fee', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -70,9 +70,9 @@ describe('E2E-5: UTXO, Fee, and Change Integration', () => { const address = getTestAddress(NETWORK) const action = actions.makeSend() - const utxo1 = makeSegwitUtxo(TXID_A, 0, 1000) - const utxo2 = makeSegwitUtxo(TXID_B, 0, 1000) - const utxo3 = makeSegwitUtxo(TXID_C, 0, 1000) + const utxo1 = makeUtxo(NETWORK, TXID_A, 0, 1000) + const utxo2 = makeUtxo(NETWORK, TXID_B, 0, 1000) + const utxo3 = makeUtxo(NETWORK, TXID_C, 0, 1000) const result = await encoder.createTransaction( [utxo1, utxo2, utxo3], address, null, @@ -90,9 +90,9 @@ describe('E2E-5: UTXO, Fee, and Change Integration', () => { const address = getTestAddress(NETWORK) const action = actions.makeSend() - const dup1 = makeSegwitUtxo(TXID_A, 0, 50000000) - const dup2 = makeSegwitUtxo(TXID_A, 0, 50000000) - const unique = makeSegwitUtxo(TXID_B, 1, 30000000) + const dup1 = makeUtxo(NETWORK, TXID_A, 0, 50000000) + const dup2 = makeUtxo(NETWORK, TXID_A, 0, 50000000) + const unique = makeUtxo(NETWORK, TXID_B, 1, 30000000) const result = await encoder.createTransaction( [dup1, dup2, unique], address, null, @@ -110,7 +110,7 @@ describe('E2E-5: UTXO, Fee, and Change Integration', () => { const address = getTestAddress(NETWORK) const action = actions.makeSend() - const confirmed = makeSegwitUtxo(TXID_A, 0, 100000000) + const confirmed = makeUtxo(NETWORK, TXID_A, 0, 100000000) confirmed.confirmations = 6 const mempool = makeMempoolUtxo(TXID_B, 0, 50000000) @@ -151,7 +151,7 @@ describe('E2E-5: UTXO, Fee, and Change Integration', () => { let trackerCalled = false encoder.utxoTrackerConnector.getUtxosFromAddress = async () => { trackerCalled = true - return { utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)] } + return { utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)] } } await encoder.createTransaction( @@ -171,7 +171,7 @@ describe('E2E-5: UTXO, Fee, and Change Integration', () => { let trackerCalled = false encoder.utxoTrackerConnector.getUtxosFromAddress = async () => { trackerCalled = true - return { utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)] } + return { utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)] } } await encoder.createTransaction( @@ -188,7 +188,7 @@ describe('E2E-5: UTXO, Fee, and Change Integration', () => { it('floors fee to dustAmount when computed fee is lower', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -219,7 +219,7 @@ describe('E2E-5: UTXO, Fee, and Change Integration', () => { } const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() // feePerKb is in base units (sat/litoshi/koinu) per kB. 1_000_000 @@ -252,7 +252,7 @@ describe('E2E-5: UTXO, Fee, and Change Integration', () => { it('throws when change address missing and surplus > dust', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() await assert.rejects( @@ -304,7 +304,7 @@ describe('E2E-5: UTXO, Fee, and Change Integration', () => { return buildRawTxHex(100000000, NETWORK) } - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) await encoder.createTransaction( [utxo], address, null, @@ -320,7 +320,7 @@ describe('E2E-5: UTXO, Fee, and Change Integration', () => { it('rbf=true sets sequence 0xfffffffd (RBF armed, BIP68 disabled)', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -335,7 +335,7 @@ describe('E2E-5: UTXO, Fee, and Change Integration', () => { it('rbf=false sets sequence 0xffffffff', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -352,7 +352,7 @@ describe('E2E-5: UTXO, Fee, and Change Integration', () => { it('custom output value deducted from change', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() const fee = 10000 @@ -362,6 +362,9 @@ describe('E2E-5: UTXO, Fee, and Change Integration', () => { null, null, null, true, 0.00001 ) + // Same input on purpose: this compares two builds of one spend, so release + // the first build's reservation rather than double-spend it. + encoder.clearReservations() const resultCustom = await encoder.createTransaction( [utxo], address, [{ address, value: '500000' }], action.data, null, fee, false, null, address, @@ -382,7 +385,7 @@ describe('E2E-5: UTXO, Fee, and Change Integration', () => { } const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -404,7 +407,7 @@ describe('E2E-5: UTXO, Fee, and Change Integration', () => { } const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() await encoder.createTransaction( diff --git a/test/fixtures/action-manifest.json b/test/fixtures/action-manifest.json index 93e02ab..ad21bd2 100644 --- a/test/fixtures/action-manifest.json +++ b/test/fixtures/action-manifest.json @@ -11,7 +11,7 @@ }, "categories": { "wire-user": "user-encodable on-chain action: decoded + indexed + SDK-encodable", - "validator": "validator-broadcast on-chain action: decoded + indexed but NOT user-encodable (ANCHOR/ATTEST/NODEPROOF/SLASH)", + "validator": "validator-broadcast on-chain action: decoded + indexed but NOT user-encodable (ANCHOR/ATTEST/NODEPROOF/ROLLCALL/SLASH)", "mirror-injected": "indexer-injected from the hub mirror, NOT chain-decoded (XCALL/XEXEC/CROSS_SETTLE)", "lifecycle": "system-generated sub-action, never a decoded wire tx (matches/expiries/dispense)", "explorer-legacy-render": "render-only in the explorer (legacy order/dispenser cancel+edit views); no decoder/indexer twin" diff --git a/test/integration/action-encoding.test.js b/test/integration/action-encoding.test.js index 1a7165c..a367ba7 100644 --- a/test/integration/action-encoding.test.js +++ b/test/integration/action-encoding.test.js @@ -28,7 +28,7 @@ const { } = require('./helpers/deobfuscate') const { TXID_A, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('./helpers/utxoFactory') @@ -37,7 +37,7 @@ const actions = require('./helpers/actionFactory') const NETWORK = 'dogecoin-regtest' function makeStandardUtxo () { - return makeSegwitUtxo(TXID_A, 0, 100000000) + return makeUtxo(NETWORK, TXID_A, 0, 100000000) } /** diff --git a/test/integration/custom-outputs.test.js b/test/integration/custom-outputs.test.js index 6748cf1..5657665 100644 --- a/test/integration/custom-outputs.test.js +++ b/test/integration/custom-outputs.test.js @@ -21,7 +21,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const { TXID_A, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('./helpers/utxoFactory') @@ -35,7 +35,7 @@ describe('Category E: Custom Outputs (COINPAY Integration)', () => { it('includes both custom outputs in PSBT', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() const customOutputs = [ @@ -64,7 +64,7 @@ describe('Category E: Custom Outputs (COINPAY Integration)', () => { it('custom output value is deducted from change', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() const fee = 10000 @@ -75,7 +75,9 @@ describe('Category E: Custom Outputs (COINPAY Integration)', () => { null, null, null, true, 0.00001 ) - // With custom output of 500000 sats + // With custom output of 500000 sats. Same input on purpose: release the + // first build's reservation so this is a comparison, not a double-spend. + encoder.clearReservations() const customOutputs = [{ address: address, value: '500000' }] const resultWithCustom = await encoder.createTransaction( [utxo], address, customOutputs, @@ -97,7 +99,7 @@ describe('Category E: Custom Outputs (COINPAY Integration)', () => { it('object instead of array is silently skipped', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -113,7 +115,7 @@ describe('Category E: Custom Outputs (COINPAY Integration)', () => { it('null customOutputs is silently skipped', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -147,8 +149,10 @@ describe('Category E: Custom Outputs (COINPAY Integration)', () => { } async function buildFunding (encoder, address, customOutputs) { - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeIssueFull('BIGTOKEN') + // Each probe respends the one fixture input on the same encoder. + encoder.clearReservations() return await encoder.createTransaction( [utxo], address, customOutputs && customOutputs.map(o => ({ ...o })), action.data, null, 10000, false, null, address, @@ -213,7 +217,7 @@ describe('Category E: Custom Outputs (COINPAY Integration)', () => { it('empty array produces no extra outputs', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( diff --git a/test/integration/encoderStressSweep.test.js b/test/integration/encoderStressSweep.test.js index 27f88a5..a890335 100644 --- a/test/integration/encoderStressSweep.test.js +++ b/test/integration/encoderStressSweep.test.js @@ -14,7 +14,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') -const { makeEncoder, makeSegwitUtxo, getTestAddress, TXID_A, TXID_B } = require('./helpers/utxoFactory') +const { makeEncoder, makeUtxo, makeTrackerEnvelope, getTestAddress, TXID_A, TXID_B } = require('./helpers/utxoFactory') const { deobfuscate } = require('./helpers/deobfuscate') const actions = require('./helpers/actionFactory') @@ -31,9 +31,9 @@ describe('encoder stress-sweep @regression', function () { // The tracker returns two equal-value UTXOs; sort keeps TXID_A first. A prior/ // concurrent create_tx already reserved TXID_A, so the real first input is TXID_B. encoder.utxoTrackerConnector = { - getUtxosFromAddress: async () => ({ - utxos: [makeSegwitUtxo(TXID_A, 0, 100000000), makeSegwitUtxo(TXID_B, 0, 100000000)] - }) + getUtxosFromAddress: async () => makeTrackerEnvelope( + [makeUtxo(NETWORK, TXID_A, 0, 100000000), makeUtxo(NETWORK, TXID_B, 0, 100000000)] + ) } encoder._reserveOutpoint(TXID_A + ':0', Date.now()) @@ -64,7 +64,7 @@ describe('encoder stress-sweep @regression', function () { const address = getTestAddress(NETWORK) const action = actions.makeSend() const result = await encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], address, null, action.data, null, 10000, false, null, address, + [makeUtxo(NETWORK, TXID_A, 0, 100000000)], address, null, action.data, null, 10000, false, null, address, null, null, null, true, 0.00001 ) const ins0Txid = Buffer.from(result.psbt.txInputs[0].hash).reverse().toString('hex') diff --git a/test/integration/encoding-types.test.js b/test/integration/encoding-types.test.js index 428bc73..a760d6a 100644 --- a/test/integration/encoding-types.test.js +++ b/test/integration/encoding-types.test.js @@ -29,7 +29,7 @@ const { TXID_A, TXID_MULTISIGN, PUBKEY_BUF, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('./helpers/utxoFactory') @@ -43,7 +43,7 @@ describe('Category B: Encoding Type Integration', () => { it('output has value=0 and script starts with OP_RETURN (0x6a)', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -65,7 +65,7 @@ describe('Category B: Encoding Type Integration', () => { it('contains obfuscated XCHN-prefixed data', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -83,7 +83,7 @@ describe('Category B: Encoding Type Integration', () => { it('creates P2SH output with value >= dustAmount', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeIssueFull('BIGTOKEN') const result = await encoder.createTransaction( @@ -114,7 +114,7 @@ describe('Category B: Encoding Type Integration', () => { it('has a change output returning remaining funds', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeIssueFull('BIGTOKEN') const result = await encoder.createTransaction( @@ -134,7 +134,7 @@ describe('Category B: Encoding Type Integration', () => { it('creates tx2 with P2SH input and OP_RETURN marker', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeIssueFull('BIGTOKEN') const tx1Result = await encoder.createTransaction( @@ -164,7 +164,7 @@ describe('Category B: Encoding Type Integration', () => { it('tx2 P2SH input has redeemScript containing ACTION data', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeIssueFull('BIGTOKEN') const tx1Result = await encoder.createTransaction( @@ -199,7 +199,7 @@ describe('Category B: Encoding Type Integration', () => { const p2wshNetwork = 'bitcoin-regtest' const encoder = makeEncoder(p2wshNetwork) const address = getTestAddress(p2wshNetwork) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(p2wshNetwork, TXID_A, 0, 100000000) // Use large data that benefits from P2WSH const action = actions.makeFileLarge() @@ -229,7 +229,7 @@ describe('Category B: Encoding Type Integration', () => { const p2wshNetwork = 'bitcoin-regtest' const encoder = makeEncoder(p2wshNetwork) const address = getTestAddress(p2wshNetwork) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(p2wshNetwork, TXID_A, 0, 100000000) const action = actions.makeFileLarge() const tx1Result = await encoder.createTransaction( @@ -269,7 +269,7 @@ describe('Category B: Encoding Type Integration', () => { it('creates 1-of-3 multisig output with correct structure', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_MULTISIGN, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_MULTISIGN, 0, 100000000) const compressedPubKey = PUBKEY_BUF.toString('hex') const result = await encoder.createTransaction( @@ -301,7 +301,7 @@ describe('Category B: Encoding Type Integration', () => { it('third pubkey is the real compressed public key', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_MULTISIGN, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_MULTISIGN, 0, 100000000) const compressedPubKey = PUBKEY_BUF.toString('hex') const result = await encoder.createTransaction( @@ -322,7 +322,7 @@ describe('Category B: Encoding Type Integration', () => { it('uses P2SH when explicitly requested despite data fitting OP_RETURN', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -339,7 +339,7 @@ describe('Category B: Encoding Type Integration', () => { it('rejects forced OP_RETURN when data exceeds a single output', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) // A transaction may carry at most one OP_RETURN output; Bitcoin Core // rejects multi-OP_RETURN transactions as non-standard at broadcast. diff --git a/test/integration/error-handling.test.js b/test/integration/error-handling.test.js index a485e3a..ca786c2 100644 --- a/test/integration/error-handling.test.js +++ b/test/integration/error-handling.test.js @@ -24,7 +24,7 @@ const { TXID_A, TXID_MULTISIGN, PUBKEY_BUF, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress, buildRawTxHex @@ -39,7 +39,7 @@ describe('Category H: Error Handling at Integration Boundaries', () => { it('propagates error when fee estimation RPC fails', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() encoder.connector.getFeePerKilobyte = async () => { @@ -59,7 +59,7 @@ describe('Category H: Error Handling at Integration Boundaries', () => { it('does NOT fail when feePerKb is provided (bypasses RPC)', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() encoder.connector.getFeePerKilobyte = async () => { @@ -143,7 +143,7 @@ describe('Category H: Error Handling at Integration Boundaries', () => { it('throws when compressedPubKey is null', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_MULTISIGN, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_MULTISIGN, 0, 100000000) await assert.rejects( () => encoder.createTransaction( @@ -159,7 +159,7 @@ describe('Category H: Error Handling at Integration Boundaries', () => { describe('H-6: P2SH without valid pubkey address', () => { it('throws when pubkey is not a valid base58 address', async () => { const encoder = makeEncoder(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeIssueFull('BIGTOKEN') await assert.rejects( @@ -176,7 +176,7 @@ describe('Category H: Error Handling at Integration Boundaries', () => { it('returns { psbt, encoding } where psbt is a Psbt instance', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -195,7 +195,7 @@ describe('Category H: Error Handling at Integration Boundaries', () => { it('overrides dust for MULTISIGN output value but not fee floor', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_MULTISIGN, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_MULTISIGN, 0, 100000000) const compressedPubKey = PUBKEY_BUF.toString('hex') const customDust = 1234 const MS_DATA = 'A'.repeat(59) @@ -214,7 +214,7 @@ describe('Category H: Error Handling at Integration Boundaries', () => { it('fee floor still uses network dustAmount, not custom dust', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() // Custom dust = 100, much less than network dust (546) diff --git a/test/integration/helpers/utxoFactory.js b/test/integration/helpers/utxoFactory.js index 6f23021..63ba7cc 100644 --- a/test/integration/helpers/utxoFactory.js +++ b/test/integration/helpers/utxoFactory.js @@ -26,6 +26,49 @@ const PUBKEY_BUF = Buffer.from( 'hex' ) +// The producer contract these fixtures model, pinned byte-for-byte by +// test/conformance/utxoRecordConformance.test.js. Read it rather than +// hand-copying the numbers, so the doubles and the conformance tier cannot +// describe two different producers (the drift that suite's header names). +const CONFORMANCE = require('../../fixtures/utxo-record-conformance.json') + +// Height the confirmed factories report. Arbitrary but fixed, so a record's +// height and confirmations stay self-consistent across tests. +const CONFIRMED_HEIGHT = 100 + +// The tracker emits `value` as an exact decimal SATOSHI string because a DOGE +// consolidation output can exceed 2^53-1 (its LevelUpDb reads the field as a +// BigInt and stringifies it). Callers here still pass Numbers, so accept +// Number, string or BigInt and normalize to the producer's shape. +function toSatoshiString (value) { + if (typeof value === 'bigint') return value.toString() + if (typeof value === 'string') return value + return BigInt(Math.trunc(Number(value))).toString() +} + +// `amount` is the same quantity as an 8-dp decimal COIN string, exactly as the +// tracker derives it (satoshiToDecimalString). +function toCoinAmount (satoshiString) { + const negative = satoshiString.startsWith('-') + const digits = (negative ? satoshiString.slice(1) : satoshiString).padStart(9, '0') + return (negative ? '-' : '') + digits.slice(0, -8) + '.' + digits.slice(-8) +} + +/** + * Wrap records in the tracker's RESPONSE envelope, which carries the `sync` + * freshness sibling beside `utxos`. Without it XChainEncoder's freshness gate + * (`const sync = fetched && fetched.sync`) takes its fail-open branch, so a + * hand-rolled `{ utxos }` stub silently skips that gate. `overrides` shallow- + * merges into the healthy default so a test can make the view stale, halted or + * not-yet-mempool-ready. + */ +function makeTrackerEnvelope (utxos, overrides) { + return { + utxos: utxos || [], + sync: Object.assign({}, CONFORMANCE.sync, overrides || {}) + } +} + // Deterministic TXIDs for reproducible tests const TXID_A = 'a'.repeat(64) const TXID_B = 'b'.repeat(64) @@ -59,10 +102,14 @@ function makeSegwitUtxo (txid, vout, value) { pubkey: PUBKEY_BUF, network: bitcoin.networks.regtest }) + const sats = toSatoshiString(value) return { txid, vout, - value, + value: sats, + amount: toCoinAmount(sats), + height: CONFIRMED_HEIGHT, + coinbase: false, confirmations: 6, scriptPubKey: p2wpkh.output.toString('hex') } @@ -76,21 +123,40 @@ function makeLegacyUtxo (txid, vout, value) { pubkey: PUBKEY_BUF, network: bitcoin.networks.regtest }) + const sats = toSatoshiString(value) return { txid, vout, - value, + value: sats, + amount: toCoinAmount(sats), + height: CONFIRMED_HEIGHT, + coinbase: false, confirmations: 6, scriptPubKey: p2pkh.output.toString('hex') } } +/** + * Create the UTXO fixture the named chain can actually hold. A witness-program + * output only exists where consensus knows segwit, and input selection refuses + * one where it does not, so a suite that names its chain gets a matching input. + */ +function makeUtxo (networkName, txid, vout, value) { + const network = CryptoNetworks.getBitcoinJsNetwork(networkName) + return network.supportsSegwit === false + ? makeLegacyUtxo(txid, vout, value) + : makeSegwitUtxo(txid, vout, value) +} + /** * Create a mempool (unconfirmed) SegWit UTXO fixture. */ function makeMempoolUtxo (txid, vout, value) { const utxo = makeSegwitUtxo(txid, vout, value) utxo.confirmations = 0 + // The tracker sends height null for an unconfirmed output; a fixed confirmed + // height beside confirmations 0 would be a record it never serves. + utxo.height = null return utxo } @@ -118,9 +184,7 @@ function makeEncoder (networkName = 'dogecoin-regtest') { isRegtest: async () => true } encoder.utxoTrackerConnector = { - getUtxosFromAddress: async () => ({ - utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)] - }) + getUtxosFromAddress: async () => makeTrackerEnvelope([makeUtxo(networkName, TXID_A, 0, 100000000)]) } return encoder } @@ -134,7 +198,9 @@ module.exports = { buildRawTxHex, makeSegwitUtxo, makeLegacyUtxo, + makeUtxo, makeMempoolUtxo, + makeTrackerEnvelope, getTestAddress, makeEncoder } diff --git a/test/integration/large-satoshi-tracker.test.js b/test/integration/large-satoshi-tracker.test.js new file mode 100644 index 0000000..e8fab84 --- /dev/null +++ b/test/integration/large-satoshi-tracker.test.js @@ -0,0 +1,109 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * Tracker-fetched UTXO above 2^53-1 satoshis, through the integration tier. + * + * The exact-decimal-string `value` on the tracker's records exists for exactly + * this case: a DOGE consolidation output larger than Number.MAX_SAFE_INTEGER, + * which validateUtxoEntry parses with { allowBig: true } into a BigInt and the + * encoder's money path then carries as one. The integration tier's doubles used + * to serve a JS Number `value`, so nothing here ever reached that branch and + * only the conformance tier covered it. + *********************************************************************/ + +const assert = require('assert') +const { + TXID_A, + makeUtxo, + makeTrackerEnvelope, + makeEncoder, + getTestAddress +} = require('./helpers/utxoFactory') +const actions = require('./helpers/actionFactory') + +const NETWORK = 'bitcoin-regtest' +// The same over-2^53-1 record the vendored conformance fixture carries. +const BIG_SATS = '9007199254740993' + +describe('Category D: tracker-fetched UTXO above 2^53-1 satoshis', () => { + + it('selects a >2^53-1 tracker record without losing precision', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const action = actions.makeSend() + + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => makeTrackerEnvelope([makeUtxo(NETWORK, TXID_A, 0, BIG_SATS)]) + } + + const result = await encoder.createTransaction( + [], address, null, + action.data, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(result.psbt.data.inputs.length, 1) + // The input reached the PSBT as a BigInt, which is the branch a Number + // `value` on the double could never enter. + assert.strictEqual(typeof result.psbt.data.inputs[0].witnessUtxo.value, 'bigint') + assert.strictEqual(result.psbt.data.inputs[0].witnessUtxo.value, BigInt(BIG_SATS)) + + const change = result.psbt.txOutputs.filter(o => o.value > 0) + assert.strictEqual(change.length, 1) + // Exact arithmetic, not an approximate magnitude check: a Number round-trip + // through 9007199254740993 lands on ...992, so an input total that lost its + // last satoshi shows up here as an off-by-one change value. + assert.strictEqual(BigInt(change[0].value), BigInt(BIG_SATS) - 10000n) + }) + + it('reaches the freshness gate, which a sync-less envelope failed open past', async () => { + // Proof the envelope is actually consulted: the same record behind a view + // the tracker flags NOT synced must be refused before any input is chosen. + // With the old `{ utxos }` stub `fetched.sync` was undefined and this path + // could not be entered at all. + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const action = actions.makeSend() + + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => makeTrackerEnvelope( + [makeUtxo(NETWORK, TXID_A, 0, BIG_SATS)], { synced: false } + ) + } + + await assert.rejects( + () => encoder.createTransaction( + [], address, null, + action.data, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ), + /stale/i + ) + }) + + it('serves the producer shape, not a simplified double', async () => { + // Guard on the factory itself: if a record ever loses the exact-decimal + // string or its amount/height/coinbase siblings, the test above would keep + // passing on a record the tracker never emits. + const record = makeUtxo(NETWORK, TXID_A, 0, BIG_SATS) + assert.strictEqual(typeof record.value, 'string') + assert.strictEqual(record.value, BIG_SATS) + assert.strictEqual(record.amount, '90071992.54740993') + assert.strictEqual(typeof record.height, 'number') + assert.strictEqual(record.coinbase, false) + + const envelope = makeTrackerEnvelope([record]) + assert.ok(envelope.sync && typeof envelope.sync === 'object', 'the envelope carries the freshness sibling') + assert.strictEqual(envelope.sync.synced, true) + }) +}) diff --git a/test/integration/multi-chain.test.js b/test/integration/multi-chain.test.js index 880851b..065d0f0 100644 --- a/test/integration/multi-chain.test.js +++ b/test/integration/multi-chain.test.js @@ -24,7 +24,7 @@ const { TXID_A, TXID_MULTISIGN, PUBKEY_BUF, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress, } = require('./helpers/utxoFactory') @@ -43,7 +43,7 @@ describe('Category F: Multi-Chain Network Configs', () => { it('creates valid OP_RETURN transaction', async () => { const encoder = makeEncoder(chain.name) const address = getTestAddress(chain.name) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(chain.name, TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -66,7 +66,7 @@ describe('Category F: Multi-Chain Network Configs', () => { it('creates valid P2SH transaction', async () => { const encoder = makeEncoder(chain.name) const address = getTestAddress(chain.name) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(chain.name, TXID_A, 0, 100000000) const action = actions.makeIssueFull('BIGTOKEN') const result = await encoder.createTransaction( @@ -104,7 +104,7 @@ describe('Category F: Multi-Chain Network Configs', () => { async function buildMultisign (networkName) { const encoder = makeEncoder(networkName) const address = getTestAddress(networkName) - const utxo = makeSegwitUtxo(TXID_MULTISIGN, 0, 100000000) + const utxo = makeUtxo(networkName, TXID_MULTISIGN, 0, 100000000) const result = await encoder.createTransaction( [utxo], address, null, MS_DATA, null, 10000, false, 'MULTISIGN', address, @@ -148,7 +148,7 @@ describe('Category F: Multi-Chain Network Configs', () => { it('P2SH output address uses Dogecoin network params', async () => { const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('dogecoin-regtest', TXID_A, 0, 100000000) const action = actions.makeIssueFull('DOGETOKEN') const result = await encoder.createTransaction( @@ -198,7 +198,7 @@ describe('Category F: Multi-Chain Network Configs', () => { it('Litecoin fee floor is 5460, not 546', async () => { const encoder = makeEncoder('litecoin-regtest') const address = getTestAddress('litecoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('litecoin-regtest', TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( diff --git a/test/integration/obfuscation-roundtrip.test.js b/test/integration/obfuscation-roundtrip.test.js index 500b1d6..1ef4634 100644 --- a/test/integration/obfuscation-roundtrip.test.js +++ b/test/integration/obfuscation-roundtrip.test.js @@ -31,7 +31,7 @@ const { TXID_B, TXID_MULTISIGN, PUBKEY_BUF, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('./helpers/utxoFactory') @@ -45,7 +45,7 @@ describe('Category C: Obfuscation Round-Trip', () => { it('deobfuscated OP_RETURN data has XCHN prefix and original ACTION', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend('JDOG', '42', actions.ADDR_BTC) const result = await encoder.createTransaction( @@ -64,7 +64,7 @@ describe('Category C: Obfuscation Round-Trip', () => { it('raw obfuscated bytes differ from plaintext', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -91,7 +91,7 @@ describe('Category C: Obfuscation Round-Trip', () => { it('deobfuscated multisig data has XCHN prefix and original data', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_MULTISIGN, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_MULTISIGN, 0, 100000000) const compressedPubKey = PUBKEY_BUF.toString('hex') const result = await encoder.createTransaction( @@ -112,7 +112,7 @@ describe('Category C: Obfuscation Round-Trip', () => { it('tx2 OP_RETURN marker deobfuscates to XCHNp2sh', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeIssueFull('BIGTOKEN') const tx1Result = await encoder.createTransaction( @@ -150,7 +150,7 @@ describe('Category C: Obfuscation Round-Trip', () => { const action = actions.makeSend() const encoderA = makeEncoder(NETWORK) - const utxoA = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxoA = makeUtxo(NETWORK, TXID_A, 0, 100000000) const resultA = await encoderA.createTransaction( [utxoA], address, null, action.data, null, 10000, false, null, address, @@ -158,7 +158,7 @@ describe('Category C: Obfuscation Round-Trip', () => { ) const encoderB = makeEncoder(NETWORK) - const utxoB = makeSegwitUtxo(TXID_B, 0, 100000000) + const utxoB = makeUtxo(NETWORK, TXID_B, 0, 100000000) const resultB = await encoderB.createTransaction( [utxoB], address, null, action.data, null, 10000, false, null, address, @@ -192,8 +192,8 @@ describe('Category C: Obfuscation Round-Trip', () => { const action = actions.makeSend() // Provide UTXOs in smallest-first order - const smallUtxo = makeSegwitUtxo(TXID_B, 0, 10000000) // 0.1 BTC - const largeUtxo = makeSegwitUtxo(TXID_A, 0, 100000000) // 1 BTC + const smallUtxo = makeUtxo(NETWORK, TXID_B, 0, 10000000) // 0.1 BTC + const largeUtxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) // 1 BTC const result = await encoder.createTransaction( [smallUtxo, largeUtxo], address, null, @@ -212,7 +212,7 @@ describe('Category C: Obfuscation Round-Trip', () => { it('deobfuscation with wrong TXID produces garbage', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( diff --git a/test/integration/utxo-fee.test.js b/test/integration/utxo-fee.test.js index 1bbae44..762b3e9 100644 --- a/test/integration/utxo-fee.test.js +++ b/test/integration/utxo-fee.test.js @@ -25,9 +25,10 @@ const { TXID_B, TXID_C, PUBKEY_BUF, - makeSegwitUtxo, + makeUtxo, makeLegacyUtxo, makeMempoolUtxo, + makeTrackerEnvelope, makeEncoder, getTestAddress, buildRawTxHex @@ -48,7 +49,7 @@ describe('Category D: UTXO & Fee Integration', () => { it('produces 1 input, 2 outputs (OP_RETURN + change)', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -77,9 +78,9 @@ describe('Category D: UTXO & Fee Integration', () => { // Three small UTXOs: 1000 + 1000 + 1000 = 3000 sats // With fee of 2000, first UTXO (sorted largest=1000) won't cover it, // so encoder must add more - const utxo1 = makeSegwitUtxo(TXID_A, 0, 1000) - const utxo2 = makeSegwitUtxo(TXID_B, 0, 1000) - const utxo3 = makeSegwitUtxo(TXID_C, 0, 1000) + const utxo1 = makeUtxo(NETWORK, TXID_A, 0, 1000) + const utxo2 = makeUtxo(NETWORK, TXID_B, 0, 1000) + const utxo3 = makeUtxo(NETWORK, TXID_C, 0, 1000) const result = await encoder.createTransaction( [utxo1, utxo2, utxo3], address, null, @@ -98,10 +99,10 @@ describe('Category D: UTXO & Fee Integration', () => { const address = getTestAddress(NETWORK) const action = actions.makeSend() - const dup1 = makeSegwitUtxo(TXID_A, 0, 50000000) - const dup2 = makeSegwitUtxo(TXID_A, 0, 50000000) - const dup3 = makeSegwitUtxo(TXID_A, 0, 50000000) - const unique = makeSegwitUtxo(TXID_B, 1, 30000000) + const dup1 = makeUtxo(NETWORK, TXID_A, 0, 50000000) + const dup2 = makeUtxo(NETWORK, TXID_A, 0, 50000000) + const dup3 = makeUtxo(NETWORK, TXID_A, 0, 50000000) + const unique = makeUtxo(NETWORK, TXID_B, 1, 30000000) const result = await encoder.createTransaction( [dup1, dup2, dup3, unique], address, null, @@ -120,7 +121,7 @@ describe('Category D: UTXO & Fee Integration', () => { const address = getTestAddress(NETWORK) const action = actions.makeSend() - const confirmed = makeSegwitUtxo(TXID_A, 0, 100000000) + const confirmed = makeUtxo(NETWORK, TXID_A, 0, 100000000) confirmed.confirmations = 6 const mempool = makeMempoolUtxo(TXID_B, 0, 50000000) @@ -159,7 +160,7 @@ describe('Category D: UTXO & Fee Integration', () => { let trackerCalled = false encoder.utxoTrackerConnector.getUtxosFromAddress = async () => { trackerCalled = true - return { utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)] } + return { utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)] } } await encoder.createTransaction( @@ -179,7 +180,7 @@ describe('Category D: UTXO & Fee Integration', () => { let trackerCalled = false encoder.utxoTrackerConnector.getUtxosFromAddress = async () => { trackerCalled = true - return { utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)] } + return { utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)] } } await encoder.createTransaction( @@ -243,13 +244,11 @@ describe('Category D: UTXO & Fee Integration', () => { isRegtest: async () => true } capped.utxoTrackerConnector = { - getUtxosFromAddress: async () => ({ - utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)] - }) + getUtxosFromAddress: async () => makeTrackerEnvelope([makeUtxo(NETWORK, TXID_A, 0, 100000000)]) } const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() // Use a very high feePerKb that exceeds the cap @@ -277,7 +276,7 @@ describe('Category D: UTXO & Fee Integration', () => { it('floors fee to dustAmount when computed fee is lower', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -297,7 +296,7 @@ describe('Category D: UTXO & Fee Integration', () => { it('throws error about burning satoshis', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() await assert.rejects( @@ -349,7 +348,7 @@ describe('Category D: UTXO & Fee Integration', () => { return { hex: buildRawTxHex(100000000, NETWORK) } } - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) await encoder.createTransaction( [utxo], address, null, @@ -368,8 +367,8 @@ describe('Category D: UTXO & Fee Integration', () => { const address = getTestAddress(NETWORK) const action = actions.makeSend() - const small = makeSegwitUtxo(TXID_A, 0, 10000000) // 0.1 BTC - const large = makeSegwitUtxo(TXID_B, 0, 100000000) // 1 BTC + const small = makeUtxo(NETWORK, TXID_A, 0, 10000000) // 0.1 BTC + const large = makeUtxo(NETWORK, TXID_B, 0, 100000000) // 1 BTC const result = await encoder.createTransaction( [small, large], address, null, @@ -385,7 +384,7 @@ describe('Category D: UTXO & Fee Integration', () => { it('sets sequence to 0xfffffffd when rbf=true (RBF armed, BIP68 disabled)', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -400,7 +399,7 @@ describe('Category D: UTXO & Fee Integration', () => { it('sets sequence to 0xffffffff when rbf=false', async () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -421,7 +420,7 @@ describe('Category D: UTXO & Fee Integration', () => { } const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() // Should not throw because feePerKb is provided @@ -442,7 +441,7 @@ describe('Category D: UTXO & Fee Integration', () => { } const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() await encoder.createTransaction( diff --git a/test/regression/reg-01-encoding-types.test.js b/test/regression/reg-01-encoding-types.test.js index 4f535a1..4473712 100644 --- a/test/regression/reg-01-encoding-types.test.js +++ b/test/regression/reg-01-encoding-types.test.js @@ -30,7 +30,7 @@ const { TXID_A, TXID_MULTISIGN, PUBKEY_BUF, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') @@ -44,7 +44,7 @@ describe('REG-01: Core Encoding Types', function () { it('small SEND auto-selects OP_RETURN encoding', async function () { const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('dogecoin-regtest', TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -59,7 +59,7 @@ describe('REG-01: Core Encoding Types', function () { it('OP_RETURN output has value=0 and script starts with 0x6a', async function () { const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('dogecoin-regtest', TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -76,7 +76,7 @@ describe('REG-01: Core Encoding Types', function () { it('deobfuscated payload starts with XCHN magic', async function () { const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('dogecoin-regtest', TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -92,7 +92,7 @@ describe('REG-01: Core Encoding Types', function () { it('auto-selects OP_RETURN when compiled data + magic <= 80 bytes', async function () { const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('dogecoin-regtest', TXID_A, 0, 100000000) // 75-byte data string → compiled = 76 bytes (1 push opcode + 75) + 4 magic = 80 → fits OP_RETURN const action = actions.makeActionOfSize(75) @@ -110,7 +110,7 @@ describe('REG-01: Core Encoding Types', function () { it('large ISSUE payload auto-selects P2SH', async function () { const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('dogecoin-regtest', TXID_A, 0, 100000000) const action = actions.makeIssueFull('BIGTOKEN') const result = await encoder.createTransaction( @@ -125,7 +125,7 @@ describe('REG-01: Core Encoding Types', function () { it('P2SH output has OP_HASH160 <20-byte-hash> OP_EQUAL structure', async function () { const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('dogecoin-regtest', TXID_A, 0, 100000000) const action = actions.makeIssueFull('BIGTOKEN') const result = await encoder.createTransaction( @@ -148,7 +148,7 @@ describe('REG-01: Core Encoding Types', function () { it('P2SH output value >= dustAmount', async function () { const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('dogecoin-regtest', TXID_A, 0, 100000000) const action = actions.makeIssueFull('BIGTOKEN') const result = await encoder.createTransaction( @@ -172,7 +172,7 @@ describe('REG-01: Core Encoding Types', function () { it('forced P2SH encoding respected even for small data', async function () { const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('dogecoin-regtest', TXID_A, 0, 100000000) const action = actions.makeSend() // small enough for OP_RETURN const result = await encoder.createTransaction( @@ -189,7 +189,7 @@ describe('REG-01: Core Encoding Types', function () { it('P2WSH encoding accepted on bitcoin-regtest', async function () { const encoder = makeEncoder('bitcoin-regtest') const address = getTestAddress('bitcoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000) const action = actions.makeFileLarge() const result = await encoder.createTransaction( @@ -204,7 +204,7 @@ describe('REG-01: Core Encoding Types', function () { it('P2WSH output has OP_0 <32-byte-hash> structure', async function () { const encoder = makeEncoder('bitcoin-regtest') const address = getTestAddress('bitcoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000) const action = actions.makeFileLarge() const result = await encoder.createTransaction( @@ -225,7 +225,7 @@ describe('REG-01: Core Encoding Types', function () { it('P2WSH rejected on dogecoin-regtest (no segwit)', async function () { const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('dogecoin-regtest', TXID_A, 0, 100000000) const action = actions.makeFileLarge() await assert.rejects( @@ -243,7 +243,7 @@ describe('REG-01: Core Encoding Types', function () { it('MULTISIGN produces 1-of-3 multisig output', async function () { const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = makeSegwitUtxo(TXID_MULTISIGN, 0, 100000000) + const utxo = makeUtxo('dogecoin-regtest', TXID_MULTISIGN, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -267,7 +267,7 @@ describe('REG-01: Core Encoding Types', function () { it('third pubkey is the compressedPubKey argument', async function () { const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = makeSegwitUtxo(TXID_MULTISIGN, 0, 100000000) + const utxo = makeUtxo('dogecoin-regtest', TXID_MULTISIGN, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -284,7 +284,7 @@ describe('REG-01: Core Encoding Types', function () { it('deobfuscated MULTISIGN payload has XCHN prefix', async function () { const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = makeSegwitUtxo(TXID_MULTISIGN, 0, 100000000) + const utxo = makeUtxo('dogecoin-regtest', TXID_MULTISIGN, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -302,7 +302,7 @@ describe('REG-01: Core Encoding Types', function () { it('result is { psbt, encoding } with Psbt instance', async function () { const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('dogecoin-regtest', TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -319,7 +319,7 @@ describe('REG-01: Core Encoding Types', function () { it('psbt.toHex() produces a valid hex string', async function () { const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('dogecoin-regtest', TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( diff --git a/test/regression/reg-02-obfuscation.test.js b/test/regression/reg-02-obfuscation.test.js index a5a9fbe..d2161fb 100644 --- a/test/regression/reg-02-obfuscation.test.js +++ b/test/regression/reg-02-obfuscation.test.js @@ -29,7 +29,7 @@ const { const { TXID_A, TXID_B, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') @@ -109,8 +109,8 @@ describe('REG-02: Obfuscation Round-Trip', function () { const action = actions.makeSend() // Provide two UTXOs: TXID_B is larger → should be sorted first → used as key - const utxoSmall = makeSegwitUtxo(TXID_A, 0, 10000) - const utxoLarge = makeSegwitUtxo(TXID_B, 0, 100000000) + const utxoSmall = makeUtxo(NETWORK, TXID_A, 0, 10000) + const utxoLarge = makeUtxo(NETWORK, TXID_B, 0, 100000000) const result = await encoder.createTransaction( [utxoSmall, utxoLarge], address, null, @@ -128,8 +128,8 @@ describe('REG-02: Obfuscation Round-Trip', function () { const address = getTestAddress(NETWORK) const action = actions.makeSend() - const utxoSmall = makeSegwitUtxo(TXID_A, 0, 10000) - const utxoLarge = makeSegwitUtxo(TXID_B, 0, 100000000) + const utxoSmall = makeUtxo(NETWORK, TXID_A, 0, 10000) + const utxoLarge = makeUtxo(NETWORK, TXID_B, 0, 100000000) // Reverse input order const result = await encoder.createTransaction( @@ -148,7 +148,7 @@ describe('REG-02: Obfuscation Round-Trip', function () { it('P2SH tx2 OP_RETURN marker deobfuscates to XCHNp2sh', async function () { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeIssueFull('MARKER') // tx1 @@ -178,7 +178,7 @@ describe('REG-02: Obfuscation Round-Trip', function () { it('P2WSH tx2 OP_RETURN marker deobfuscates to XCHNp2wsh', async function () { const encoder = makeEncoder('bitcoin-regtest') const address = getTestAddress('bitcoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000) const action = actions.makeFileLarge() const tx1 = await encoder.createTransaction( diff --git a/test/regression/reg-03-fee-utxo.test.js b/test/regression/reg-03-fee-utxo.test.js index 4ff246c..39e5a4e 100644 --- a/test/regression/reg-03-fee-utxo.test.js +++ b/test/regression/reg-03-fee-utxo.test.js @@ -24,7 +24,7 @@ const { TXID_A, TXID_B, TXID_C, - makeSegwitUtxo, + makeUtxo, makeLegacyUtxo, makeMempoolUtxo, makeEncoder, @@ -47,8 +47,8 @@ describe('REG-03: Fee & UTXO Selection', function () { const address = getTestAddress(NETWORK) const action = actions.makeSend() - const utxoSmall = makeSegwitUtxo(TXID_A, 0, 10000) - const utxoLarge = makeSegwitUtxo(TXID_B, 0, 100000000) + const utxoSmall = makeUtxo(NETWORK, TXID_A, 0, 10000) + const utxoLarge = makeUtxo(NETWORK, TXID_B, 0, 100000000) const result = await encoder.createTransaction( [utxoSmall, utxoLarge], address, null, @@ -66,8 +66,8 @@ describe('REG-03: Fee & UTXO Selection', function () { const address = getTestAddress(NETWORK) const action = actions.makeSend() - const utxoLarge = makeSegwitUtxo(TXID_A, 0, 100000000) - const utxoSmall = makeSegwitUtxo(TXID_B, 0, 1000) + const utxoLarge = makeUtxo(NETWORK, TXID_A, 0, 100000000) + const utxoSmall = makeUtxo(NETWORK, TXID_B, 0, 1000) const result = await encoder.createTransaction( [utxoLarge, utxoSmall], address, null, @@ -88,9 +88,9 @@ describe('REG-03: Fee & UTXO Selection', function () { const action = actions.makeSend() // Three UTXOs, none individually sufficient for fee=150000 - const utxo1 = makeSegwitUtxo(TXID_A, 0, 100000) - const utxo2 = makeSegwitUtxo(TXID_B, 0, 100000) - const utxo3 = makeSegwitUtxo(TXID_C, 0, 100000) + const utxo1 = makeUtxo(NETWORK, TXID_A, 0, 100000) + const utxo2 = makeUtxo(NETWORK, TXID_B, 0, 100000) + const utxo3 = makeUtxo(NETWORK, TXID_C, 0, 100000) // The burn backstop is 100x the NODE's fair fee, independent of both // maxFeeRateMultiplier and any caller feePerKb (a caller-inflated rate @@ -115,8 +115,8 @@ describe('REG-03: Fee & UTXO Selection', function () { const address = getTestAddress(NETWORK) const action = actions.makeSend() - const utxo1 = makeSegwitUtxo(TXID_A, 0, 100000000) - const utxo2 = makeSegwitUtxo(TXID_A, 0, 100000000) // duplicate + const utxo1 = makeUtxo(NETWORK, TXID_A, 0, 100000000) + const utxo2 = makeUtxo(NETWORK, TXID_A, 0, 100000000) // duplicate const result = await encoder.createTransaction( [utxo1, utxo2], address, null, @@ -134,7 +134,7 @@ describe('REG-03: Fee & UTXO Selection', function () { const address = getTestAddress(NETWORK) const action = actions.makeSend() - const confirmedUtxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const confirmedUtxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const mempoolUtxo = makeMempoolUtxo(TXID_B, 0, 200000000) const result = await encoder.createTransaction( @@ -154,7 +154,7 @@ describe('REG-03: Fee & UTXO Selection', function () { const address = getTestAddress(NETWORK) const action = actions.makeSend() - const smallConfirmed = makeSegwitUtxo(TXID_A, 0, 1000) + const smallConfirmed = makeUtxo(NETWORK, TXID_A, 0, 1000) const largeMem = makeMempoolUtxo(TXID_B, 0, 100000000) const result = await encoder.createTransaction( @@ -176,7 +176,7 @@ describe('REG-03: Fee & UTXO Selection', function () { // its own suite (XChainEncoder.feeRateCap.test.js), disable it here. encoder.maxFeeRateMultiplier = null const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() // The burn backstop is 100x the NODE's fair fee, independent of both @@ -205,7 +205,7 @@ describe('REG-03: Fee & UTXO Selection', function () { throw new Error('getFeePerKilobyte should not be called') } const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() // Should NOT throw because feePerKb is provided @@ -221,7 +221,7 @@ describe('REG-03: Fee & UTXO Selection', function () { it('fee is floored to network dustAmount', async function () { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() // Very low feePerKb that would produce fee below dust @@ -241,7 +241,7 @@ describe('REG-03: Fee & UTXO Selection', function () { it('maxFeeRateKb cap produces lower fee than uncapped encoder', async function () { const XChainEncoder = require('../../src/XChainEncoder') const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() // Uncapped encoder with high feePerKb @@ -280,7 +280,7 @@ describe('REG-03: Fee & UTXO Selection', function () { it('change output value = input - outputs - fee', async function () { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() const explicitFee = 10000 @@ -298,7 +298,7 @@ describe('REG-03: Fee & UTXO Selection', function () { it('no change address throws when changeSatoshis > dust', async function () { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeSend() await assert.rejects( @@ -321,7 +321,7 @@ describe('REG-03: Fee & UTXO Selection', function () { encoder.utxoTrackerConnector.getUtxosFromAddress = async () => { trackerCalled = true - return { utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)] } + return { utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)] } } await encoder.createTransaction( @@ -385,7 +385,7 @@ describe('REG-03: Fee & UTXO Selection', function () { throw new Error('getTransactionHex should not be called for segwit') } - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) // Should NOT throw const result = await encoder.createTransaction( diff --git a/test/regression/reg-04-validator.test.js b/test/regression/reg-04-validator.test.js index e008a53..bd8dd24 100644 --- a/test/regression/reg-04-validator.test.js +++ b/test/regression/reg-04-validator.test.js @@ -546,6 +546,19 @@ describe('REG-04: Validator Functions', function () { assert.throws(() => validateAll('not-an-object'), { name: 'TypeError' }) }) + it('throws a SHAPE error for positional array params, not a missing-field one', function () { + // typeof [] is 'object', so before the Array.isArray clause a JSON-RPC call + // with positional params cleared this gate and died on 'pubkey is required'. + assert.throws(() => validateAll([]), { + name: 'TypeError', + message: 'Request params must be an object' + }) + assert.throws(() => validateAll(['SEND|0|JDOG|1|addr']), { + name: 'TypeError', + message: 'Request params must be an object' + }) + }) + it('throws RangeError for combined data > 65536 bytes', function () { assert.throws( () => validateAll({ data: 'x'.repeat(MAX_DATA_BYTES + 1) }), diff --git a/test/regression/reg-05-multi-chain.test.js b/test/regression/reg-05-multi-chain.test.js index cb14382..4c2ca14 100644 --- a/test/regression/reg-05-multi-chain.test.js +++ b/test/regression/reg-05-multi-chain.test.js @@ -29,7 +29,7 @@ const { TXID_A, TXID_MULTISIGN, PUBKEY_BUF, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') @@ -67,7 +67,7 @@ describe('REG-05: Multi-Chain Network Configs', function () { it('produces valid PSBT with correct encoding', async function () { const encoder = makeEncoder('bitcoin-regtest') const address = getTestAddress('bitcoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -91,7 +91,7 @@ describe('REG-05: Multi-Chain Network Configs', function () { it('produces valid PSBT with correct encoding', async function () { const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('dogecoin-regtest', TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -111,7 +111,7 @@ describe('REG-05: Multi-Chain Network Configs', function () { it('P2WSH encoding throws TypeError', async function () { const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('dogecoin-regtest', TXID_A, 0, 100000000) const action = actions.makeFileLarge() await assert.rejects( @@ -129,7 +129,7 @@ describe('REG-05: Multi-Chain Network Configs', function () { it('produces valid PSBT with correct encoding', async function () { const encoder = makeEncoder('litecoin-regtest') const address = getTestAddress('litecoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('litecoin-regtest', TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -149,7 +149,7 @@ describe('REG-05: Multi-Chain Network Configs', function () { it('fee is floored to 5460 not 546', async function () { const encoder = makeEncoder('litecoin-regtest') const address = getTestAddress('litecoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('litecoin-regtest', TXID_A, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( @@ -174,7 +174,7 @@ describe('REG-05: Multi-Chain Network Configs', function () { it(`${network} P2SH output value >= ${expectedDust}`, async function () { const encoder = makeEncoder(network) const address = getTestAddress(network) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(network, TXID_A, 0, 100000000) const action = actions.makeIssueFull('TOKEN') const result = await encoder.createTransaction( @@ -207,7 +207,7 @@ describe('REG-05: Multi-Chain Network Configs', function () { it(`${network} MULTISIGN output value = ${expectedDust}`, async function () { const encoder = makeEncoder(network) const address = getTestAddress(network) - const utxo = makeSegwitUtxo(TXID_MULTISIGN, 0, 100000000) + const utxo = makeUtxo(network, TXID_MULTISIGN, 0, 100000000) const action = actions.makeSend() const result = await encoder.createTransaction( diff --git a/test/regression/reg-06-p2sh-p2wsh-sequence.test.js b/test/regression/reg-06-p2sh-p2wsh-sequence.test.js index 32e150d..58ba4b1 100644 --- a/test/regression/reg-06-p2sh-p2wsh-sequence.test.js +++ b/test/regression/reg-06-p2sh-p2wsh-sequence.test.js @@ -28,7 +28,7 @@ const { } = require('../integration/helpers/deobfuscate') const { TXID_A, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') @@ -37,8 +37,8 @@ const actions = require('../integration/helpers/actionFactory') const NETWORK_P2SH = 'dogecoin-regtest' const NETWORK_P2WSH = 'bitcoin-regtest' // P2WSH requires segwit support -function stdUtxo () { - return makeSegwitUtxo(TXID_A, 0, 100000000) +function stdUtxo (network) { + return makeUtxo(network, TXID_A, 0, 100000000) } /** @@ -50,7 +50,7 @@ async function createTxPair (action, opts = {}) { const encoding = opts.encoding || null const encoder = makeEncoder(network) const address = getTestAddress(network) - const utxo = stdUtxo() + const utxo = stdUtxo(network) const tx1 = await encoder.createTransaction( [utxo], address, null, @@ -290,7 +290,7 @@ describe('REG-06: P2SH/P2WSH Two-Transaction Sequence', function () { const encoder = makeEncoder(network) const address = getTestAddress(network) - const utxo = stdUtxo() + const utxo = stdUtxo(network) const tx1 = await encoder.createTransaction( [utxo], address, null, action.data, action.rawData, null, false, 'P2WSH', address, diff --git a/test/regression/reg-07-action-pipeline.test.js b/test/regression/reg-07-action-pipeline.test.js index 96e0020..dfa4ae5 100644 --- a/test/regression/reg-07-action-pipeline.test.js +++ b/test/regression/reg-07-action-pipeline.test.js @@ -28,7 +28,7 @@ const { } = require('../integration/helpers/deobfuscate') const { TXID_A, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') @@ -43,7 +43,7 @@ async function encodeAndExtract (actionObj, opts = {}) { const network = opts.network || NETWORK const encoder = makeEncoder(network) const address = getTestAddress(network) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(network, TXID_A, 0, 100000000) const result = await encoder.createTransaction( [utxo], address, null, @@ -82,7 +82,7 @@ describe('REG-07: Action Pipeline Regression', function () { it('full ISSUE (25+ fields) auto-selects P2SH', async function () { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeIssueFull('BIGTOKEN') const result = await encoder.createTransaction( @@ -126,12 +126,12 @@ describe('REG-07: Action Pipeline Regression', function () { describe('REG-07.5: BROADCAST oversized OP_RETURN rejected', function () { it('long broadcast forced to OP_RETURN is rejected', async function () { - // The single-OP_RETURN rejection only fires where singleOpReturnPolicy=true - // (bitcoin); dogecoin/litecoin permit multiple OP_RETURNs, so force bitcoin. + // The rejection fires on every coin, not just bitcoin: singleOpReturnPolicy is + // declared in the coin registry but read nowhere (uuid:0ca8479c). const orNet = 'bitcoin-regtest' const encoder = makeEncoder(orNet) const address = getTestAddress(orNet) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(orNet, TXID_A, 0, 100000000) const action = actions.makeBroadcastLong() // A transaction may carry at most one OP_RETURN output; Bitcoin Core @@ -151,7 +151,7 @@ describe('REG-07: Action Pipeline Regression', function () { it('long broadcast auto-selects P2SH (the valid carrier for oversized data)', async function () { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const action = actions.makeBroadcastLong() const result = await encoder.createTransaction( @@ -168,7 +168,7 @@ describe('REG-07: Action Pipeline Regression', function () { it('large FILE uses P2WSH on segwit-capable network', async function () { const encoder = makeEncoder('bitcoin-regtest') const address = getTestAddress('bitcoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000) const action = actions.makeFileLarge() const result = await encoder.createTransaction( @@ -199,7 +199,7 @@ describe('REG-07: Action Pipeline Regression', function () { it('both data and rawData present in decompiled output', async function () { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) const result = await encoder.createTransaction( [utxo], address, null, diff --git a/test/regression/reg-08-api-contract.test.js b/test/regression/reg-08-api-contract.test.js index 1970fee..81790b8 100644 --- a/test/regression/reg-08-api-contract.test.js +++ b/test/regression/reg-08-api-contract.test.js @@ -23,7 +23,7 @@ const bitcoin = require('bitcoinjs-lib') const { validateAll } = require('../../src/validator') const { TXID_A, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') @@ -73,7 +73,7 @@ describe('REG-08: API Contract Regression', function () { const result = await simulateCreateTx({ data: action.data, pubkey: address, - utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)], + utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)], fee: '10000', change: address, feePerKb: 0.00001 @@ -92,8 +92,8 @@ describe('REG-08: API Contract Regression', function () { data: action.data, pubkey: address, utxos: [ - makeSegwitUtxo(TXID_A, 0, 50000000), - makeSegwitUtxo(TXID_A, 1, 50000000) + makeUtxo(NETWORK, TXID_A, 0, 50000000), + makeUtxo(NETWORK, TXID_A, 1, 50000000) ], fee: '10000', change: address, @@ -111,7 +111,7 @@ describe('REG-08: API Contract Regression', function () { const result = await simulateCreateTx({ data: action.data, pubkey: address, - utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)], + utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)], fee: null, change: address, feePerKb: 0.00001 @@ -131,7 +131,7 @@ describe('REG-08: API Contract Regression', function () { const result = await simulateCreateTx({ data: action.data, pubkey: address, - utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)], + utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)], change: address, feePerKb: 0.00001 }, encoder) @@ -208,7 +208,7 @@ describe('REG-08: API Contract Regression', function () { const result = await simulateCreateTx({ data: action.data, pubkey: address, - utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)], + utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)], fee: '10000', change: address, feePerKb: 0.00001 @@ -226,7 +226,7 @@ describe('REG-08: API Contract Regression', function () { const result = await simulateCreateTx({ data: action.data, pubkey: address, - utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)], + utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)], fee: '10000', change: address, feePerKb: 0.00001 @@ -247,7 +247,7 @@ describe('REG-08: API Contract Regression', function () { const result = await simulateCreateTx({ data: action.data, pubkey: address, - utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)], + utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000)], fee: '10000', change: address, feePerKb: 0.00001 diff --git a/test/regression/reg-09-deepdive-fixes.test.js b/test/regression/reg-09-deepdive-fixes.test.js index 55c77a3..95cdd15 100644 --- a/test/regression/reg-09-deepdive-fixes.test.js +++ b/test/regression/reg-09-deepdive-fixes.test.js @@ -24,7 +24,7 @@ process.env.NODE_PASSWORD = process.env.NODE_PASSWORD || 'test' const assert = require('assert') const { TXID_A, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') @@ -38,7 +38,7 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { it('builds the reveal from p2shHex alone, even when the tracker would fail', async () => { const encoder = makeEncoder('dogecoin-regtest') const address = getTestAddress('dogecoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('dogecoin-regtest', TXID_A, 0, 100000000) // Phase 1 (funding) builds normally from the provided UTXO. const tx1 = await encoder.createTransaction( @@ -75,7 +75,7 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { const encoder = makeEncoder('bitcoin-regtest') // dust = 546 const address = getTestAddress('bitcoin-regtest') // change = 10000 - 9999 = 1 sat, below the 546 dust threshold - const utxo = makeSegwitUtxo(TXID_A, 0, 10000) + const utxo = makeUtxo('bitcoin-regtest', TXID_A, 0, 10000) const result = await encoder.createTransaction( [utxo], address, null, @@ -93,16 +93,18 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { // change == dust (546): emitted. const atDust = await encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 10546)], address, null, + [makeUtxo('bitcoin-regtest', TXID_A, 0, 10546)], address, null, 'test', null, 10000, false, null, address, null, null, null, true, 0.00001 ) assert.ok(atDust.psbt.txOutputs.find(o => o.value === 546), 'change equal to the dust threshold must be emitted') - // change == dust - 1 (545): folded. + // change == dust - 1 (545): folded. Same outpoint as above, so release the + // first build's reservation before rebuilding. + encoder.clearReservations() const belowDust = await encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 10545)], address, null, + [makeUtxo('bitcoin-regtest', TXID_A, 0, 10545)], address, null, 'test', null, 10000, false, null, address, null, null, null, true, 0.00001 ) @@ -117,8 +119,8 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { const address = getTestAddress('bitcoin-regtest') // 600 fetched UTXOs; the largest (vout 0) alone covers the fee. - const many = [makeSegwitUtxo(TXID_A, 0, 100000000)] - for (let i = 1; i < 600; i++) many.push(makeSegwitUtxo(TXID_A, i, 50)) + const many = [makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000)] + for (let i = 1; i < 600; i++) many.push(makeUtxo('bitcoin-regtest', TXID_A, i, 50)) encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ utxos: many }) const result = await encoder.createTransaction( @@ -138,7 +140,7 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { // 600 equal 50-sat UTXOs, fee 25000: covering it needs 501 inputs (>500). const small = [] - for (let i = 0; i < 600; i++) small.push(makeSegwitUtxo(TXID_A, i, 50)) + for (let i = 0; i < 600; i++) small.push(makeUtxo('bitcoin-regtest', TXID_A, i, 50)) encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ utxos: small }) await assert.rejects( @@ -156,7 +158,7 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { it('throws INSUFFICIENT_FUNDS with a required/available payload', async () => { const encoder = makeEncoder('bitcoin-regtest') const address = getTestAddress('bitcoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 1000) + const utxo = makeUtxo('bitcoin-regtest', TXID_A, 0, 1000) await assert.rejects( () => encoder.createTransaction( @@ -178,8 +180,8 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { function twoUtxoTracker (encoder) { encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ utxos: [ - makeSegwitUtxo(TXID_A, 0, 100000000), - makeSegwitUtxo(TXID_A, 1, 100000000) + makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000), + makeUtxo('bitcoin-regtest', TXID_A, 1, 100000000) ] }) } @@ -222,18 +224,22 @@ describe('REG-09: 2026-07-03 deepdive encoder fixes', () => { assert.deepStrictEqual(r.psbt.txInputs.map(i => i.index), [0]) }) - it('caller-supplied UTXOs are never reserved (caller owns coin-control)', async () => { + it('caller-supplied UTXOs are reserved too, and the SDK supplies a tracker-fetched set', async () => { const encoder = makeEncoder('bitcoin-regtest') const address = getTestAddress('bitcoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000) - // Two back-to-back calls with the SAME explicit UTXO both succeed and both - // select it: explicit coin-control is the caller's responsibility. + // Two back-to-back calls with the SAME explicit UTXO: the first selects it + // and reserves it, the second is refused with the reservation named as + // the cause. Without the reservation both succeed and build the same transaction. const r1 = await encoder.createTransaction([utxo], address, null, 'test', null, 10000, false, null, address, null, null, null, true, 0.00001) - const r2 = await encoder.createTransaction([utxo], address, null, 'test', null, 10000, false, null, address, null, null, null, true, 0.00001) assert.deepStrictEqual(r1.psbt.txInputs.map(i => i.index), [0]) - assert.deepStrictEqual(r2.psbt.txInputs.map(i => i.index), [0]) - assert.strictEqual(encoder.outpointReservations.size, 0, 'explicit UTXOs must not be reserved') + assert.strictEqual(encoder.outpointReservations.size, 1, 'explicit UTXOs are reserved on selection') + await assert.rejects( + () => encoder.createTransaction([utxo], address, null, 'test', null, 10000, false, null, address, null, null, null, true, 0.00001), + (err) => err.operational === true && err.xchainCode === 'INSUFFICIENT_FUNDS' && + err.details.reservedCandidates === 1 && /reserved by a transaction built/.test(err.message) + ) }) }) diff --git a/test/regression/reg-10-utxo-tracker-freshness.test.js b/test/regression/reg-10-utxo-tracker-freshness.test.js index 03c3b7b..69ea69e 100644 --- a/test/regression/reg-10-utxo-tracker-freshness.test.js +++ b/test/regression/reg-10-utxo-tracker-freshness.test.js @@ -27,7 +27,7 @@ process.env.NODE_PASSWORD = process.env.NODE_PASSWORD || 'test' const assert = require('assert') const { TXID_A, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') @@ -40,7 +40,7 @@ describe('M-11 (encoder half): utxo-tracker freshness gate', () => { const encoder = makeEncoder('bitcoin-regtest') const address = getTestAddress('bitcoin-regtest') encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ - utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)], + utxos: [makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000)], sync: { tracker_height: 90, node_height: 100, lag: 10, synced: false } }) @@ -64,7 +64,7 @@ describe('M-11 (encoder half): utxo-tracker freshness gate', () => { // tracker's own synced verdict: our default threshold (2) is tighter // than a hypothetical tracker-side verdict that considers lag=5 fine. encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ - utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)], + utxos: [makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000)], sync: { tracker_height: 95, node_height: 100, lag: 5, synced: true } }) @@ -88,7 +88,7 @@ describe('M-11 (encoder half): utxo-tracker freshness gate', () => { encoder.connector = { getFeePerKilobyte: async () => 0.00001, getTransactionHex: async () => null, isRegtest: async () => true } encoder.utxoTrackerConnector = { getUtxosFromAddress: async () => ({ - utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)], + utxos: [makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000)], sync: { tracker_height: 97, node_height: 100, lag: 3, synced: true } }) } @@ -109,7 +109,7 @@ describe('M-11 (encoder half): utxo-tracker freshness gate', () => { describe('unusable-source gates fire', () => { function stubSync(encoder, sync) { encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ - utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)], + utxos: [makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000)], sync }) } @@ -215,7 +215,7 @@ describe('M-11 (encoder half): utxo-tracker freshness gate', () => { const encoder = makeEncoder('bitcoin-regtest') const address = getTestAddress('bitcoin-regtest') encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ - utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)], + utxos: [makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000)], sync: { tracker_height: 99, node_height: 100, lag: 1, synced: true } }) @@ -230,7 +230,7 @@ describe('M-11 (encoder half): utxo-tracker freshness gate', () => { const encoder = makeEncoder('bitcoin-regtest') const address = getTestAddress('bitcoin-regtest') encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ - utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)], + utxos: [makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000)], sync: { tracker_height: 98, node_height: 100, lag: 2, synced: true } }) @@ -247,7 +247,7 @@ describe('M-11 (encoder half): utxo-tracker freshness gate', () => { const encoder = makeEncoder('bitcoin-regtest') const address = getTestAddress('bitcoin-regtest') encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ - utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)] + utxos: [makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000)] // no `sync` key at all }) @@ -262,7 +262,7 @@ describe('M-11 (encoder half): utxo-tracker freshness gate', () => { const encoder = makeEncoder('bitcoin-regtest') const address = getTestAddress('bitcoin-regtest') encoder.utxoTrackerConnector.getUtxosFromAddress = async () => ({ - utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)], + utxos: [makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000)], sync: { tracker_height: -1, node_height: -1, lag: null, synced: true } }) @@ -278,7 +278,7 @@ describe('M-11 (encoder half): utxo-tracker freshness gate', () => { it('never consults or gates on the tracker when UTXOs are explicitly provided', async () => { const encoder = makeEncoder('bitcoin-regtest') const address = getTestAddress('bitcoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeUtxo('bitcoin-regtest', TXID_A, 0, 100000000) let trackerCalled = false encoder.utxoTrackerConnector.getUtxosFromAddress = async () => { trackerCalled = true diff --git a/test/regression/reg-12-ins0-key-binding.test.js b/test/regression/reg-12-ins0-key-binding.test.js index 22f41c9..34f4622 100644 --- a/test/regression/reg-12-ins0-key-binding.test.js +++ b/test/regression/reg-12-ins0-key-binding.test.js @@ -29,7 +29,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const { - makeEncoder, makeSegwitUtxo, getTestAddress, TXID_A, TXID_B + makeEncoder, makeUtxo, getTestAddress, TXID_A, TXID_B } = require('../integration/helpers/utxoFactory') const { deobfuscate } = require('../integration/helpers/deobfuscate') const actions = require('../integration/helpers/actionFactory') @@ -50,7 +50,7 @@ function twoUtxoEncoder () { const encoder = makeEncoder(NETWORK) encoder.utxoTrackerConnector = { getUtxosFromAddress: async () => ({ - utxos: [makeSegwitUtxo(TXID_A, 0, 100000000), makeSegwitUtxo(TXID_B, 0, 100000000)] + utxos: [makeUtxo(NETWORK, TXID_A, 0, 100000000), makeUtxo(NETWORK, TXID_B, 0, 100000000)] }) } return encoder @@ -195,22 +195,26 @@ describe('ins[0] / obfuscation-key binding @regression', function () { `reservation map should not leak entries, saw ${encoder.outpointReservations.size}`) }) - it('caller-supplied UTXOs are untouched: no reservations, ins[0] is utxos[0]', async function () { + it('caller-supplied UTXOs: the key binds to the first UNRESERVED input, which lands at ins[0]', async function () { const encoder = twoUtxoEncoder() const address = getTestAddress(NETWORK) const action = actions.makeSend() + // Caller-supplied sets go through the same reservation path as + // tracker-fetched ones. A foreign hold on TXID_A must push selection to + // TXID_B AND bind the obfuscation key there, or the action decodes to nothing. + encoder.outpointReservations.set(TXID_A + ':0', Date.now() + FIVE_MINUTES) const result = await encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], address, null, + [makeUtxo(NETWORK, TXID_A, 0, 100000000), makeUtxo(NETWORK, TXID_B, 0, 100000000)], address, null, action.data, null, 10000, false, null, address, null, null, null, true, 0.00001 ) - assert.strictEqual(ins0Txid(result), TXID_A) - assert.strictEqual(encoder.outpointReservations.size, 0, - 'caller coin-control must not engage the reservation map') + assert.strictEqual(ins0Txid(result), TXID_B) + assert.ok(encoder.outpointReservations.has(TXID_B + ':0'), + 'the selected caller-supplied input must be reserved') const opReturn = result.psbt.txOutputs.find(o => o.value === 0) const obf = bitcoin.script.decompile(opReturn.script)[1] - assert.strictEqual(deobfuscate(obf, TXID_A).subarray(0, 4).toString('utf8'), MAGIC) + assert.strictEqual(deobfuscate(obf, TXID_B).subarray(0, 4).toString('utf8'), MAGIC) }) }) diff --git a/test/regression/reg-13-reservation-release-on-throw.test.js b/test/regression/reg-13-reservation-release-on-throw.test.js index e5a938f..204ebdd 100644 --- a/test/regression/reg-13-reservation-release-on-throw.test.js +++ b/test/regression/reg-13-reservation-release-on-throw.test.js @@ -21,7 +21,7 @@ const assert = require('assert') const { - makeEncoder, makeSegwitUtxo, getTestAddress, TXID_A, TXID_B + makeEncoder, makeUtxo, getTestAddress, TXID_A, TXID_B } = require('../integration/helpers/utxoFactory') const actions = require('../integration/helpers/actionFactory') @@ -38,7 +38,7 @@ function twoUtxoEncoder (value = 100000000) { const encoder = makeEncoder(NETWORK) encoder.utxoTrackerConnector = { getUtxosFromAddress: async () => ({ - utxos: [makeSegwitUtxo(TXID_A, 0, value), makeSegwitUtxo(TXID_B, 0, value)] + utxos: [makeUtxo(NETWORK, TXID_A, 0, value), makeUtxo(NETWORK, TXID_B, 0, value)] }) } return encoder diff --git a/test/regression/reg-15-uppercase-txid-key-binding.test.js b/test/regression/reg-15-uppercase-txid-key-binding.test.js index 5e7f5f2..e121a61 100644 --- a/test/regression/reg-15-uppercase-txid-key-binding.test.js +++ b/test/regression/reg-15-uppercase-txid-key-binding.test.js @@ -29,7 +29,7 @@ const assert = require('assert') const bitcoin = require('bitcoinjs-lib') const { - makeEncoder, makeSegwitUtxo, getTestAddress, TXID_A + makeEncoder, makeUtxo, getTestAddress, TXID_A } = require('../integration/helpers/utxoFactory') const { deobfuscate } = require('../integration/helpers/deobfuscate') const actions = require('../integration/helpers/actionFactory') @@ -46,7 +46,7 @@ function ins0Txid (result) { // Caller-supplied coin control, uppercased. This path does NOT run validateAll, // so it exercises the encoder-side normalization on its own. function upperCaseUtxos () { - return [makeSegwitUtxo(TXID_A.toUpperCase(), 0, 100000000)] + return [makeUtxo(NETWORK, TXID_A.toUpperCase(), 0, 100000000)] } describe('uppercase-hex txid / obfuscation-key binding @regression', function () { diff --git a/test/regression/reg-16-rawdata-only-warning.test.js b/test/regression/reg-16-rawdata-only-warning.test.js index b9f6a27..cf0c5c5 100644 --- a/test/regression/reg-16-rawdata-only-warning.test.js +++ b/test/regression/reg-16-rawdata-only-warning.test.js @@ -24,7 +24,7 @@ const assert = require('assert') const { - makeEncoder, makeSegwitUtxo, getTestAddress, TXID_A + makeEncoder, makeUtxo, getTestAddress, TXID_A } = require('../integration/helpers/utxoFactory') const actions = require('../integration/helpers/actionFactory') const openrpc = require('../../docs/openrpc.json') @@ -32,7 +32,7 @@ const openrpc = require('../../docs/openrpc.json') const NETWORK = 'dogecoin-regtest' function callerUtxos () { - return [makeSegwitUtxo(TXID_A, 0, 100000000)] + return [makeUtxo(NETWORK, TXID_A, 0, 100000000)] } describe('rawData-only create_tx warning @regression', function () { diff --git a/test/regression/reg-18-chained-send-reservation.test.js b/test/regression/reg-18-chained-send-reservation.test.js new file mode 100644 index 0000000..808c906 --- /dev/null +++ b/test/regression/reg-18-chained-send-reservation.test.js @@ -0,0 +1,163 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Regression: chained sends from one wallet session built the SAME +// transaction twice. +// +// Measured live on BTC testnet4, 2026-08-27: three MINTs fired 1.2s apart from +// one validator address. The first built and broadcast; the second, ~800ms +// later, was handed the same input by the utxo-tracker (which had not yet seen +// the spend) and the encoder built a byte-identical transaction with the same +// txid, which the caller counted as a second success; the third failed. +// +// Root cause: the outpoint reservation map was only engaged for sets the +// encoder fetched from the tracker itself. The SDK fetches the funding set via +// get_utxos and passes it as `utxos`, so the mainstream wallet path was treated +// as "caller coin-control" and never reserved anything. +// +// Contract now: every selected input is reserved regardless of who fetched the +// set, a chained send that re-supplies a reserved input is refused with the +// reservation named as the cause, and a build identical to one produced inside +// the reservation window is refused even if the outpoint map was bypassed. + +const assert = require('assert') +const { + makeEncoder, makeUtxo, getTestAddress, TXID_A, TXID_B, TXID_C +} = require('../integration/helpers/utxoFactory') + +const NETWORK = 'bitcoin-regtest' +const MINT = 'MINT|0|XCHAIN|10000' + +function build (encoder, utxos, action = MINT) { + const address = getTestAddress(NETWORK) + return encoder.createTransaction( + utxos, address, null, action, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) +} + +function txidOf (result) { + const bitcoin = require('bitcoinjs-lib') + return bitcoin.Transaction.fromBuffer(result.psbt.data.globalMap.unsignedTx.toBuffer()).getId() +} + +describe('REG-18: chained sends reserve caller-supplied inputs @regression', function () { + this.timeout(10000) + + it('the measured shape: a second send re-supplying the spent input is refused, not rebuilt', async () => { + const encoder = makeEncoder(NETWORK) + // The tracker view a wallet hands over on a chained send: the previous + // build's input is still listed because the spend has not reached it yet. + const staleView = () => [makeUtxo(NETWORK, TXID_A, 0, 100000000)] + + const first = await build(encoder, staleView()) + const firstTxid = txidOf(first) + + await assert.rejects( + () => build(encoder, staleView()), + (err) => { + assert.strictEqual(err.operational, true) + assert.strictEqual(err.xchainCode, 'INSUFFICIENT_FUNDS') + assert.strictEqual(err.details.reservedCandidates, 1) + assert.match(err.message, /reserved by a transaction built in the last 5 minutes/) + return true + }, + 'the second build must be refused with the reservation named, never returned as a second success' + ) + + // Nothing about the first build leaked or changed. + assert.ok(encoder.outpointReservations.has(TXID_A + ':0'), 'the first build keeps its reservation') + assert.ok(encoder.recentBuilds.has(firstTxid), 'the first build is on record') + }) + + it('three chained sends over a fresh view each produce three DISTINCT transactions', async () => { + // The same guarantee at unit scale: chained sends whose views + // reflect each prior spend select different inputs and hash differently. + const encoder = makeEncoder(NETWORK) + const r1 = await build(encoder, [makeUtxo(NETWORK, TXID_A, 0, 100000000)]) + const r2 = await build(encoder, [makeUtxo(NETWORK, TXID_B, 0, 100000000)]) + const r3 = await build(encoder, [makeUtxo(NETWORK, TXID_C, 0, 100000000)]) + const txids = new Set([txidOf(r1), txidOf(r2), txidOf(r3)]) + assert.strictEqual(txids.size, 3, 'three chained sends must be three distinct transactions') + assert.strictEqual(encoder.outpointReservations.size, 3, 'each selected input stays reserved') + }) + + it('a stale view that still lists the spent input alongside the change selects the change', async () => { + const encoder = makeEncoder(NETWORK) + await build(encoder, [makeUtxo(NETWORK, TXID_A, 0, 100000000)]) + // Tracker now lists the old input AND the new change; the old one is reserved. + const r2 = await build(encoder, [ + makeUtxo(NETWORK, TXID_A, 0, 100000000), + makeUtxo(NETWORK, TXID_B, 1, 99000000) + ]) + const ins = r2.psbt.txInputs.map(i => Buffer.from(i.hash).reverse().toString('hex')) + assert.deepStrictEqual(ins, [TXID_B], 'the reserved input is skipped and the change is spent') + }) + + it('a different transaction over an unreserved input is not mistaken for a duplicate', async () => { + const encoder = makeEncoder(NETWORK) + await build(encoder, [makeUtxo(NETWORK, TXID_A, 0, 100000000)], MINT) + const r2 = await build(encoder, [makeUtxo(NETWORK, TXID_B, 0, 100000000)], 'MINT|0|XCHAIN|5000') + assert.ok(r2.psbt, 'a genuinely different transaction builds') + }) + + it('an identical rebuild is refused as DUPLICATE_TRANSACTION when the outpoint map was bypassed', async () => { + // Defense in depth: clear only the outpoint map (as a lapsed reservation + // would) and rebuild byte-for-byte. The recent-build record still refuses it. + const encoder = makeEncoder(NETWORK) + const first = await build(encoder, [makeUtxo(NETWORK, TXID_A, 0, 100000000)]) + encoder.outpointReservations.clear() + + await assert.rejects( + () => build(encoder, [makeUtxo(NETWORK, TXID_A, 0, 100000000)]), + (err) => { + assert.strictEqual(err.operational, true) + assert.strictEqual(err.xchainCode, 'DUPLICATE_TRANSACTION') + assert.strictEqual(err.details.txid, txidOf(first)) + return true + } + ) + // The refused rebuild hands back the reservation it took, so the map does + // not squat on the outpoint for a build that never happened. + assert.strictEqual(encoder.outpointReservations.size, 0) + }) + + it('an RBF bump of the same input is a different transaction and passes the duplicate gate', async () => { + const encoder = makeEncoder(NETWORK) + const address = getTestAddress(NETWORK) + const utxo = () => makeUtxo(NETWORK, TXID_A, 0, 100000000) + const first = await encoder.createTransaction([utxo()], address, null, MINT, null, 10000, true, null, address, null, null, null, true, 0.00001) + // The bump deliberately respends the same input: only the outpoint map is + // released (the operator's "wait out the TTL"), the recent-build record stays. + encoder.outpointReservations.clear() + const bump = await encoder.createTransaction([utxo()], address, null, MINT, null, 12000, true, null, address, null, null, null, true, 0.00001) + assert.notStrictEqual(txidOf(bump), txidOf(first), 'a higher fee changes the outputs and the txid') + }) + + it('clearReservations releases both the outpoint map and the recent-build record', async () => { + const encoder = makeEncoder(NETWORK) + await build(encoder, [makeUtxo(NETWORK, TXID_A, 0, 100000000)]) + encoder.clearReservations() + assert.strictEqual(encoder.outpointReservations.size, 0) + assert.strictEqual(encoder.recentBuilds.size, 0) + const again = await build(encoder, [makeUtxo(NETWORK, TXID_A, 0, 100000000)]) + assert.ok(again.psbt) + }) + + it('recent-build records expire with the reservation TTL', async () => { + const encoder = makeEncoder(NETWORK) + const first = await build(encoder, [makeUtxo(NETWORK, TXID_A, 0, 100000000)]) + encoder.outpointReservations.clear() + encoder.recentBuilds.set(txidOf(first), Date.now() - 1) + const again = await build(encoder, [makeUtxo(NETWORK, TXID_A, 0, 100000000)]) + assert.strictEqual(txidOf(again), txidOf(first)) + assert.strictEqual(encoder.recentBuilds.size, 1, 'the expired record is evicted and the new one written') + }) +}) diff --git a/test/regression/reg-19-envelope-cancel-reservation.test.js b/test/regression/reg-19-envelope-cancel-reservation.test.js new file mode 100644 index 0000000..4f16fd2 --- /dev/null +++ b/test/regression/reg-19-envelope-cancel-reservation.test.js @@ -0,0 +1,182 @@ +// Copyright © 2025-2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC - https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Regression: the key-path envelope cancel is the one build path outside +// _buildTransaction, and it must still take the outpoint reservation. +// +// createEnvelopeCancelTransaction returns a signable PSBT spending a +// caller-named commit outpoint. Without a claim on that outpoint the +// reservation map is blind to the cancel in both directions: a concurrent +// create_tx whose fetched set still carries the commit output selects and +// reserves it while an unsigned cancel of that same output is outstanding, and +// the cancel cannot see a claim another build already holds. The claim key must +// be lowercase, because this method accepts a mixed-case commitTxid while +// create_tx keys are canonicalized in validator.validateUtxoEntry, and an +// uppercase key can never collide with the reservation it is meant to see. +// +// The cancel is deterministic from the persisted recovery record, so the guard +// must stay retry-safe: re-taking its own live claim succeeds, and duplicate +// refusal is deliberately not applied to this path. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const ecc = require('tiny-secp256k1') +const { ECPairFactory } = require('ecpair') +const XChainEncoder = require('../../src/XChainEncoder') +const actions = require('../integration/helpers/actionFactory') + +bitcoin.initEccLib(ecc) +const ECPair = ECPairFactory(ecc) + +const NETWORK = 'bitcoin-regtest' +const KEY = ECPair.fromPrivateKey(Buffer.alloc(32, 7)) +const PUBKEY_HEX = Buffer.from(KEY.publicKey).toString('hex') + +const COMMIT_TXID = 'a'.repeat(64) +const OTHER_TXID = 'b'.repeat(64) +const TAPLEAF_HASH = 'c'.repeat(64) +const COMMIT_VOUT = 0 +const COMMIT_KEY = COMMIT_TXID + ':' + COMMIT_VOUT +const FIVE_MINUTES = 5 * 60 * 1000 + +function makeEncoder () { + const encoder = new XChainEncoder(NETWORK, '127.0.0.1', '8333', 'rpc', 'rpc', '', '') + encoder.connector = { + getFeePerKilobyte: async () => 0.00001, + getTransactionHex: async () => { throw new Error('unit test: no node') } + } + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => { throw new Error('unit test: no tracker') } + } + return encoder +} + +function callerAddress (network) { + return bitcoin.payments.p2wpkh({ pubkey: Buffer.from(KEY.publicKey), network }).address +} + +function segwitUtxo (network, txid, vout, value) { + const p2wpkh = bitcoin.payments.p2wpkh({ pubkey: Buffer.from(KEY.publicKey), network }) + return { txid, vout, value, confirmations: 6, scriptPubKey: p2wpkh.output.toString('hex') } +} + +// The persisted recovery record a wallet replays a cancel from. +function cancelRecord (encoder, overrides) { + return Object.assign({ + commitTxid: COMMIT_TXID, + commitVout: COMMIT_VOUT, + commitValue: 100000, + internalPubkey: PUBKEY_HEX, + tapleafHash: TAPLEAF_HASH, + destination: callerAddress(encoder.network) + }, overrides || {}) +} + +function ins0Txid (result) { + return Buffer.from(result.psbt.txInputs[0].hash).reverse().toString('hex') +} + +describe('envelope-cancel outpoint reservation @regression', function () { + this.timeout(10000) + + it('claims the commit outpoint on a successful cancel build', async function () { + const encoder = makeEncoder() + await encoder.createEnvelopeCancelTransaction(cancelRecord(encoder)) + assert.ok(encoder.outpointReservations.has(COMMIT_KEY), + 'a built cancel must hold the commit outpoint, or a concurrent create_tx can respend it') + }) + + it('normalizes a mixed-case commitTxid to the lowercase reservation key', async function () { + const encoder = makeEncoder() + await encoder.createEnvelopeCancelTransaction( + cancelRecord(encoder, { commitTxid: COMMIT_TXID.toUpperCase() })) + assert.ok(encoder.outpointReservations.has(COMMIT_KEY), + 'an uppercase key could never collide with the reservations create_tx takes') + assert.strictEqual(encoder.outpointReservations.has(COMMIT_TXID.toUpperCase() + ':0'), false) + }) + + it('a live cancel claim keeps create_tx from selecting the commit outpoint', async function () { + const encoder = makeEncoder() + const address = callerAddress(encoder.network) + const action = actions.makeSend('JDOG', '42', actions.ADDR_BTC) + + await encoder.createEnvelopeCancelTransaction(cancelRecord(encoder)) + + // Equal values keep the sort stable, so the commit outpoint would be a + // candidate for ins[0] if the cancel had left it unreserved. + const utxos = [ + segwitUtxo(encoder.network, COMMIT_TXID, COMMIT_VOUT, 100000000), + segwitUtxo(encoder.network, OTHER_TXID, 0, 100000000) + ] + const result = await encoder.createTransaction( + utxos, address, null, action.data, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) + const spent = result.psbt.txInputs.map( + (i) => Buffer.from(i.hash).reverse().toString('hex')) + assert.ok(!spent.includes(COMMIT_TXID), + 'create_tx must not spend an outpoint an outstanding unsigned cancel already spends') + assert.strictEqual(ins0Txid(result), OTHER_TXID) + }) + + it('refuses a cancel of an outpoint another build holds', async function () { + const encoder = makeEncoder() + // A foreign in-flight create_tx claim on the commit output. + encoder.outpointReservations.set(COMMIT_KEY, Date.now() + FIVE_MINUTES) + + await assert.rejects( + encoder.createEnvelopeCancelTransaction(cancelRecord(encoder)), + (err) => err.operational === true && + err.xchainCode === 'ENVELOPE_CANCEL_OUTPOINT_RESERVED' && + err.details.outpoint === COMMIT_KEY) + }) + + it('stays retry-safe: a repeat cancel rebuilds the byte-identical PSBT', async function () { + const encoder = makeEncoder() + const first = await encoder.createEnvelopeCancelTransaction(cancelRecord(encoder)) + const second = await encoder.createEnvelopeCancelTransaction(cancelRecord(encoder)) + assert.strictEqual(second.psbt.toHex(), first.psbt.toHex(), + 'a cancel replayed from the recovery record must not be refused as a duplicate') + assert.ok(encoder.outpointReservations.has(COMMIT_KEY)) + }) + + it('releases the claim when the build throws', async function () { + const encoder = makeEncoder() + await assert.rejects( + // Commit value below the dust floor: throws after the claim is taken. + encoder.createEnvelopeCancelTransaction(cancelRecord(encoder, { commitValue: 600 })), + (err) => err.xchainCode === 'ENVELOPE_CANCEL_BELOW_DUST') + assert.strictEqual(encoder.outpointReservations.has(COMMIT_KEY), false, + 'a failed cancel must not squat the commit outpoint for the reservation window') + assert.strictEqual(encoder.envelopeCancelClaims.has(COMMIT_KEY), false) + }) + + it('a foreign claim taken after the cancel is never dropped by its release', async function () { + const encoder = makeEncoder() + await encoder.createEnvelopeCancelTransaction(cancelRecord(encoder)) + // The cancel's claim lapses and another build re-reserves the outpoint. + const foreignExpiry = Date.now() + FIVE_MINUTES + 1000 + encoder.outpointReservations.set(COMMIT_KEY, foreignExpiry) + + await assert.rejects( + encoder.createEnvelopeCancelTransaction(cancelRecord(encoder, { commitValue: 600 })), + (err) => err.xchainCode === 'ENVELOPE_CANCEL_OUTPOINT_RESERVED') + assert.strictEqual(encoder.outpointReservations.get(COMMIT_KEY), foreignExpiry, + 'dropping a foreign entry would reopen the double-spend window the map closes') + }) + + it('clearReservations drops the cancel ownership stamps too', async function () { + const encoder = makeEncoder() + await encoder.createEnvelopeCancelTransaction(cancelRecord(encoder)) + encoder.clearReservations() + assert.strictEqual(encoder.envelopeCancelClaims.size, 0, + 'a stale stamp would let a foreign claim read as this path\'s own') + }) +}) diff --git a/test/security/payload-size.test.js b/test/security/payload-size.test.js index 02943e6..83818fe 100644 --- a/test/security/payload-size.test.js +++ b/test/security/payload-size.test.js @@ -23,7 +23,7 @@ const assert = require('assert') const { TXID_A, - makeSegwitUtxo, + makeUtxo, makeEncoder, getTestAddress } = require('../integration/helpers/utxoFactory') @@ -37,7 +37,7 @@ describe('Security: compiled payload-size ceiling', () => { before(() => { encoder = makeEncoder(NETWORK) address = getTestAddress(NETWORK) - utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + utxo = makeUtxo(NETWORK, TXID_A, 0, 100000000) }) it('rejects a payload whose compiled push exceeds the on-chain ceiling', async () => { diff --git a/test/unit/LargeSatoshiAmounts.test.js b/test/unit/LargeSatoshiAmounts.test.js index 0b14305..27bf51b 100644 --- a/test/unit/LargeSatoshiAmounts.test.js +++ b/test/unit/LargeSatoshiAmounts.test.js @@ -33,16 +33,18 @@ const pubkeyBuf = Buffer.from( ) const TXID_A = 'a'.repeat(64) -const DOGE_REGTEST = require('../../src/CryptoNetworks').getBitcoinJsNetwork('dogecoin-regtest') -const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: DOGE_REGTEST }).address +const LTC_REGTEST = require('../../src/CryptoNetworks').getBitcoinJsNetwork('litecoin-regtest') +const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: LTC_REGTEST }).address function makeSegwitUtxo (txid, vout, value) { const p2wpkh = bitcoin.payments.p2wpkh({ pubkey: pubkeyBuf, network: bitcoin.networks.regtest }) return { txid, vout, value, confirmations: 6, scriptPubKey: p2wpkh.output.toString('hex') } } +// Segwit-capable network carrying a dustThreshold: the fixtures here spend a +// P2WPKH input, which a chain without segwit refuses. function makeEncoder () { - const encoder = new XChainEncoder('dogecoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '') + const encoder = new XChainEncoder('litecoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '') encoder.connector = { getFeePerKilobyte: async () => 0.00001, getTransactionHex: async () => { throw new Error('not used in this suite') } @@ -104,7 +106,7 @@ describe('large satoshi amounts (>2^53-1)', () => { const encoder = makeEncoder() const inputValue = '12000000000000000000' // 1.2e19 sats in one UTXO const payValue = '11000000000000000001' // 1.1e19 + 1 sats out - const fee = 100000 + const fee = 10000 const utxo = makeSegwitUtxo(TXID_A, 0, inputValue) validateUtxoEntry(utxo, 0) @@ -130,12 +132,12 @@ describe('large satoshi amounts (>2^53-1)', () => { const result = await encoder.createTransaction( [utxo], TEST_ADDRESS, [{ address: TEST_ADDRESS, value: 100000000 }], - 'test', null, 100000, false, null, TEST_ADDRESS, + 'test', null, 10000, false, null, TEST_ADDRESS, null, null, null, true, 0.00001 ) const reparsed = bitcoin.Psbt.fromHex(result.psbt.toHex()) const values = reparsed.txOutputs.map(o => BigInt(o.value)) - const expectedChange = 12000000000000000000n - 100000000n - 100000n + const expectedChange = 12000000000000000000n - 100000000n - 10000n assert.ok(values.includes(expectedChange), `change ${expectedChange} missing exactly; got ${values}`) }) @@ -145,14 +147,14 @@ describe('large satoshi amounts (>2^53-1)', () => { await assert.rejects( encoder.createTransaction( [utxo], TEST_ADDRESS, [{ address: TEST_ADDRESS, value: HUGE_STR }], - 'test', null, 100000, false, null, TEST_ADDRESS, + 'test', null, 10000, false, null, TEST_ADDRESS, null, null, null, true, 0.00001 ), (err) => { assert.strictEqual(err.xchainCode, 'INSUFFICIENT_FUNDS') // metadata must survive JSON serialization (JSON-RPC error data) assert.doesNotThrow(() => JSON.stringify(err.details)) - assert.strictEqual(err.details.required, '12000000000000100000') + assert.strictEqual(err.details.required, '12000000000000010000') return true } ) diff --git a/test/unit/XChainEncoder.autoEncoding.test.js b/test/unit/XChainEncoder.autoEncoding.test.js index 0cfa759..fd9a1f9 100644 --- a/test/unit/XChainEncoder.autoEncoding.test.js +++ b/test/unit/XChainEncoder.autoEncoding.test.js @@ -172,6 +172,8 @@ describe('size-aware encoding selection (§6)', function () { assert.strictEqual(small.encoding, 'OP_RETURN') assert.strictEqual(small.revealPsbt, undefined) + // Same fixture input again: release the first build's reservation. + e.clearReservations() const large = await e.createTransaction( [segwitUtxo(network)], callerAddress(network), null, FILE_ACTION, compressibleText(6000), null, false, null, callerAddress(network), diff --git a/test/unit/XChainEncoder.consensusPinBoot.test.js b/test/unit/XChainEncoder.consensusPinBoot.test.js new file mode 100644 index 0000000..ce9a2ea --- /dev/null +++ b/test/unit/XChainEncoder.consensusPinBoot.test.js @@ -0,0 +1,49 @@ +'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. + +// Boot guard: the encoder vendors the consensus-critical coin registry and reads +// dustThreshold / supportsSegwit / address prefixes out of it, so it must run the +// same fail-closed pin check every sibling consumer runs (decoder, indexer, hub, +// utxo-tracker). coins-conformance.test.js catches commit-time drift only; it +// cannot catch a stale deployed image, which is what this guard is for. + +const assert = require('assert'); + +const coins = require('../../src/coins'); +const XChainEncoder = require('../../src/XChainEncoder'); + +describe('XChainEncoder consensus-pin boot guard', function(){ + let original; + + beforeEach(function(){ original = coins.verifyConsensusPin; }); + afterEach(function(){ coins.verifyConsensusPin = original; }); + + function build(){ + return new XChainEncoder('bitcoin-regtest', 'http://127.0.0.1', 18443, 'u', 'p', 'http://127.0.0.1', 4000); + } + + it('verifies the vendored bundle at construction, with the net portion of the network key', function(){ + let seen = []; + coins.verifyConsensusPin = function(net){ seen.push(net); return { ok: true, skipped: false }; }; + build(); + assert.deepStrictEqual(seen, ['regtest']); + }); + + it('fails closed: a pin mismatch aborts construction rather than authoring under a drifted bundle', function(){ + coins.verifyConsensusPin = function(){ throw new Error('CONSENSUS CONFIG PIN MISMATCH for BTC/regtest'); }; + assert.throws(build, /CONSENSUS CONFIG PIN MISMATCH/); + }); + + it('the real check passes for the bundle actually vendored here', function(){ + assert.doesNotThrow(build); + }); +}); diff --git a/test/unit/XChainEncoder.createTransaction.test.js b/test/unit/XChainEncoder.createTransaction.test.js index abb69d8..8ca97b7 100644 --- a/test/unit/XChainEncoder.createTransaction.test.js +++ b/test/unit/XChainEncoder.createTransaction.test.js @@ -37,8 +37,8 @@ function buildRawTxHex (value) { const RAW_TX_HEX = buildRawTxHex(100000000) // 1 BTC in sats -// Build segwit UTXO fixtures (use DOGE_REGTEST-compatible scripts won't work for -// p2wpkh since dogecoin doesn't have bech32, so we craft a raw P2WPKH scriptPubKey) +// Build P2WPKH UTXO fixtures. The scriptPubKey is the witness program itself, +// so it is network-independent and valid on any segwit-capable regtest chain. function makeSegwitUtxo (txid, vout, value) { const p2wpkh = bitcoin.payments.p2wpkh({ pubkey: pubkeyBuf, @@ -53,12 +53,27 @@ function makeSegwitUtxo (txid, vout, value) { } } -function makeEncoder () { - // Use dogecoin-regtest because its CryptoNetworks config includes dustThreshold. - // bitcoin-regtest uses the built-in bitcoinjs-lib object which lacks dustThreshold, - // making this.dustAmount undefined unless set via env/constructor. +// P2PKH fixture for the chains without segwit, whose UTXOs can only be legacy. +function makeLegacyUtxo (txid, vout, value) { + const p2pkh = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }) + return { + txid, + vout, + value, + confirmations: 6, + scriptPubKey: p2pkh.output.toString('hex') + } +} + +// Segwit-capable network, because every fixture below spends a P2WPKH input and +// the builder refuses a witness-program input on a chain without segwit. It also +// carries a dustThreshold, which the change math reads. +function makeEncoder (networkName = 'litecoin-regtest') { const encoder = new XChainEncoder( - 'dogecoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '' + networkName, '127.0.0.1', '8333', 'rpc', 'rpc', '', '' ) encoder.connector = { getFeePerKilobyte: async () => 0.00001, @@ -78,10 +93,10 @@ function makeEncoder () { return encoder } -const DOGE_REGTEST = require('../../src/CryptoNetworks').getBitcoinJsNetwork('dogecoin-regtest') +const LTC_REGTEST = require('../../src/CryptoNetworks').getBitcoinJsNetwork('litecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, - network: DOGE_REGTEST + network: LTC_REGTEST }).address describe('XChainEncoder.createTransaction()', () => { @@ -128,7 +143,7 @@ describe('XChainEncoder.createTransaction()', () => { const legacyUtxo = { txid: TXID_B, vout: 1, value: 100000000, confirmations: 6, scriptPubKey: bitcoin.payments.p2pkh({ - pubkey: pubkeyBuf, network: DOGE_REGTEST + pubkey: pubkeyBuf, network: LTC_REGTEST }).output.toString('hex') } const result = await encoder.createTransaction( @@ -271,9 +286,9 @@ describe('XChainEncoder.createTransaction()', () => { // and the absolute burn backstop has its own test below; disable/isolate // both here so only the passthrough behaviour is under test. encoder.maxFeeRateMultiplier = null - // Isolate the custom-fee passthrough from dogecoin-regtest's 100000-koinu - // dust floor (exercised by the dust-floor tests below); a fee at or above - // dust would otherwise be floored up and mask the value being tested. + // Isolate the custom-fee passthrough from the network dust floor (exercised + // by the dust-floor tests below); a fee at or above dust would otherwise be + // floored up and mask the value being tested. encoder.dustAmount = 546 const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) // Kept under the 100x-fair-fee burn backstop (fair fee ~131 sats for this @@ -405,9 +420,8 @@ describe('XChainEncoder.createTransaction()', () => { describe('change output', () => { it('adds change output when there is leftover and change address given', async () => { const encoder = makeEncoder() - // Isolate the change math from dogecoin-regtest's 100000-koinu dust floor - // so the 10000 custom fee flows into change verbatim (dust floor has its - // own suite). + // Isolate the change math from the network dust floor so the 10000 custom fee + // flows into change verbatim (the dust floor has its own suite). encoder.dustAmount = 546 const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) @@ -495,7 +509,7 @@ describe('XChainEncoder.createTransaction()', () => { // address.toOutputScript rejected it as neither valid base58 nor bech32 // (" has no matching Script"), breaking every UTXO-tracker-backed // compose (any call that does not pre-supply `utxos`) for a live wallet. - it('resolves a raw pubkey (not an address) to this network\'s default address before querying the tracker', async () => { + it('resolves a raw pubkey (not an address) to P2WPKH before querying the tracker on a segwit network', async () => { const encoder = makeEncoder() let queriedAddress = null encoder.utxoTrackerConnector.getUtxosFromAddress = async (address) => { @@ -510,10 +524,31 @@ describe('XChainEncoder.createTransaction()', () => { null, null, null, true, 0.00001 ) - // dogecoin-regtest (this suite's network) has supportsSegwit: false, - // so the default address type is P2PKH - matching how TEST_ADDRESS - // itself is derived above. - assert.strictEqual(queriedAddress, TEST_ADDRESS) + // A segwit-capable network's default address type is P2WPKH. + const expected = bitcoin.payments.p2wpkh({ pubkey: pubkeyBuf, network: LTC_REGTEST }).address + assert.strictEqual(queriedAddress, expected) + }) + + // The other branch of the same resolution: a chain without segwit defaults to + // P2PKH, and its tracker query must carry a base58 address. + it('resolves a raw pubkey to P2PKH before querying the tracker on a network without segwit', async () => { + const encoder = makeEncoder('dogecoin-regtest') + let queriedAddress = null + encoder.utxoTrackerConnector.getUtxosFromAddress = async (address) => { + queriedAddress = address + return { utxos: [makeLegacyUtxo(TXID_A, 0, 100000000)] } + } + + const dogeNetwork = require('../../src/CryptoNetworks').getBitcoinJsNetwork('dogecoin-regtest') + const dogeAddress = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: dogeNetwork }).address + const rawPubkeyHex = pubkeyBuf.toString('hex') + await encoder.createTransaction( + null, rawPubkeyHex, null, + 'test', null, 10000, false, null, dogeAddress, + null, null, null, true, 0.00001 + ) + + assert.strictEqual(queriedAddress, dogeAddress) }) }) @@ -755,6 +790,9 @@ describe('XChainEncoder.createTransaction()', () => { // fee output leaves value by definition and needs no change output, so // funding that dust too would buy the caller a third output they never // asked for - which is what REG-14 pins against on this exact shape. + // + // Same input as the baseline on purpose: release its reservation first. + encoder.clearReservations() const withFee = await encoder.createTransaction( [utxo], TEST_ADDRESS, feeOutputs(), bigData, null, 10000, false, null, TEST_ADDRESS, @@ -764,7 +802,7 @@ describe('XChainEncoder.createTransaction()', () => { // Same conversion the encoder applies: feePerKb -> internal coin/byte. const feePerBytes = 0.00001 / 1000 / 1e8 - const feeOutputBytes = TxSizeEstimator.estimateOutputSizeForAddress(TEST_ADDRESS, DOGE_REGTEST) + const feeOutputBytes = TxSizeEstimator.estimateOutputSizeForAddress(TEST_ADDRESS, LTC_REGTEST) const revealByteFee = Math.ceil(feeOutputBytes * feePerBytes * 1e8) assert.strictEqual(feeFunding - baseFunding, FEE_VALUE + revealByteFee - encoder.dustAmount, @@ -1052,3 +1090,146 @@ describe('XChainEncoder.createTransaction() payment-only', () => { assert.strictEqual(opReturnOutputs(result.psbt).length, 1) }) }) + +// The builder is a supported library entry point, and api.js's validateAll sits +// in front of the JSON-RPC surface only. Every payload guard asserted here is +// asserted through encoder.createTransaction() with NO validator call in front +// of it: a suite that only exercises validator.validateAll stays green with the +// guards absent from the builder, which is exactly how these bypasses shipped. +describe('XChainEncoder.createTransaction() payload guards (library boundary)', () => { + + const nulldataOutputs = (psbt) => + psbt.txOutputs.filter((o) => bitcoin.script.toASM(o.script).startsWith('OP_RETURN')) + + // Latin-1 truncation: U+0100 silently became the byte 0x00 on a fee-paid + // transaction, and the compiled-size ceiling cannot see it (the length does + // not change). Written as fromCharCode so the source carries no literal high + // character, matching validator.js's firstNonLatin1. + it('rejects a rawData code unit above U+00FF instead of truncating it', async () => { + const encoder = makeEncoder() + await assert.rejects( + () => encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + 'SEND|0|TOKEN|1|' + TEST_ADDRESS, String.fromCharCode(0x0100), 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ), + (err) => err instanceof RangeError && /U\+00FF/.test(err.message) + ) + }) + + it('rejects a `data` string that is not well-formed Unicode', async () => { + const encoder = makeEncoder() + await assert.rejects( + () => encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + 'SEND|\uD800|x', null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ), + (err) => err instanceof RangeError && /well-formed/.test(err.message) + ) + }) + + // Minimal-op canonicalization: a lone 0x05 compiles to a bare OP_5 and the + // decoder's Buffer.isBuffer element test discards it. + it('rejects a single minimal-opcode rawData byte', async () => { + const encoder = makeEncoder() + await assert.rejects( + () => encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + 'FILE|0|doc', String.fromCharCode(0x05), 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ), + (err) => err instanceof RangeError && /rawData/.test(err.message) + ) + }) + + it('rejects a single minimal-opcode `data` byte', async () => { + const encoder = makeEncoder() + await assert.rejects( + () => encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + String.fromCharCode(0x05), null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ), + (err) => err instanceof RangeError && /data/.test(err.message) + ) + }) + + // The guards must close a data-loss path without narrowing the shapes the + // library already builds. These four are the ones the two placements could + // plausibly have broken. + it('still builds the deliberately-supported rawData-only shape', async () => { + const encoder = makeEncoder() + const result = await encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + null, 'rawpayload', 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + assert.strictEqual(nulldataOutputs(result.psbt).length, 1) + }) + + it('still builds a payment-only transaction with no payload at all', async () => { + const encoder = makeEncoder() + const result = await encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, + [{ address: TEST_ADDRESS, value: '1000000' }], + null, null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + assert.strictEqual(nulldataOutputs(result.psbt).length, 0) + }) + + it('still builds an ordinary multi-byte data + rawData payload', async () => { + const encoder = makeEncoder() + const result = await encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + 'FILE|0|doc', 'file-bytes-here', 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + assert.ok(result.psbt instanceof bitcoin.Psbt) + }) + + // The decodability guard must see Buffer inputs too. A Buffer is copied byte + // for byte, which is why the latin-1 guard can stay string-only, but + // canonicalization is a property of the COMPILED push, so a one-byte Buffer + // in the minimal-op range loses everything the same way its string spelling + // does. Scoping this guard to strings left the hole open on exactly the + // surface the finding is about (direct library callers). + it('rejects a single minimal-opcode rawData byte passed as a Buffer', async () => { + const encoder = makeEncoder() + await assert.rejects( + () => encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + 'FILE|0|doc', Buffer.from([0x05]), 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ), + (err) => err instanceof RangeError && /rawData/.test(err.message) + ) + }) + + it('rejects a single minimal-opcode `data` byte passed as a Buffer', async () => { + const encoder = makeEncoder() + await assert.rejects( + () => encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + Buffer.from([0x05]), null, 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ), + (err) => err instanceof RangeError && /data/.test(err.message) + ) + }) + + // A multi-byte Buffer rawData is copied byte-for-byte by + // Buffer.from(rawData,'binary'), so nothing is lost and nothing here may + // start refusing it: the guards close a data-loss path, they do not become + // argument-shape policy. + it('still accepts a Buffer rawData from a library caller', async () => { + const encoder = makeEncoder() + const result = await encoder.createTransaction( + [makeSegwitUtxo(TXID_A, 0, 100000000)], TEST_ADDRESS, null, + 'FILE|0|doc', Buffer.from('file-bytes-here', 'binary'), 10000, false, null, TEST_ADDRESS, + null, null, null, true, 0.00001 + ) + assert.ok(result.psbt instanceof bitcoin.Psbt) + }) +}) diff --git a/test/unit/XChainEncoder.exactInputs.test.js b/test/unit/XChainEncoder.exactInputs.test.js new file mode 100644 index 0000000..b819dec --- /dev/null +++ b/test/unit/XChainEncoder.exactInputs.test.js @@ -0,0 +1,284 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Exact-input mode. +// +// A batch that stops mining can only be lifted by a child that descends from the +// WHOLE stuck chain, and normal selection cannot build one: it sorts the candidate +// set value-descending and stops as soon as the running total covers the outputs +// plus fee, so a caller naming N outpoints gets one input and a child that descends +// from one leg. The 2026-08-28 Dogecoin rescue had to hand-build those transactions +// outside the encoder for exactly this reason. options.exactInputs turns selection +// off: the caller's list is the input set, in the caller's order, all of it. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../src/XChainEncoder') +const validator = require('../../src/validator') + +const pubkeyBuf = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex' +) +const TXID_BIG = 'a'.repeat(64) +const TXID_MID = 'b'.repeat(64) +const TXID_TINY = 'c'.repeat(64) + +function makeSegwitUtxo (txid, vout, value, confirmations = 6) { + const p2wpkh = bitcoin.payments.p2wpkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }) + return { + txid, + vout, + value, + confirmations, + scriptPubKey: p2wpkh.output.toString('hex') + } +} + +// Segwit-capable network carrying a dustThreshold: every fixture here spends a +// P2WPKH input, and the builder refuses one on a chain without segwit. +function makeEncoder () { + const encoder = new XChainEncoder( + 'litecoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '' + ) + encoder.connector = { + getFeePerKilobyte: async () => 0.00001, + getTransactionHex: async () => { throw new Error('no non-segwit input expected in this suite') } + } + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => { throw new Error('exact-input mode must never reach the tracker') } + } + return encoder +} + +const LTC_REGTEST = require('../../src/CryptoNetworks').getBitcoinJsNetwork('litecoin-regtest') +const TEST_ADDRESS = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: LTC_REGTEST +}).address +const PUBKEY_HEX = pubkeyBuf.toString('hex') + +// The stuck chain's leftovers: one fat root and two dust-sized change outputs that +// value-descending selection reaches only after it has already stopped. +function stuckChainUtxos (confirmations = 0) { + return [ + makeSegwitUtxo(TXID_BIG, 0, 100000000, confirmations), + makeSegwitUtxo(TXID_MID, 1, 6000, confirmations), + makeSegwitUtxo(TXID_TINY, 2, 5000, confirmations) + ] +} + +// Absolute fee for every probe here, held under the fee-rate cap for the smallest +// transaction these tests build so the cap never masks the selection under test. +const RESCUE_FEE = 10000 + +// createTransaction is positional; name the tail so the tests read as intent. +function createTx (encoder, utxos, overrides = {}) { + const o = Object.assign({ + customOutputs: null, data: null, rawData: null, fee: RESCUE_FEE, rbf: false, + encoding: null, change: TEST_ADDRESS, p2shHash: null, p2shHex: null, + compressedPubKey: null, unconfirmed: true, options: null + }, overrides) + return encoder.createTransaction( + utxos, PUBKEY_HEX, o.customOutputs, o.data, o.rawData, o.fee, o.rbf, + o.encoding, o.change, o.p2shHash, o.p2shHex, o.compressedPubKey, + o.unconfirmed, null, null, null, false, null, o.options) +} + +function outpointsOf (psbt) { + return psbt.txInputs.map((i) => + Buffer.from(i.hash).reverse().toString('hex') + ':' + i.index) +} + +describe('XChainEncoder create_tx options.exactInputs', () => { + + it('greedy selection stops at sufficiency, so a named set is not the input set', async () => { + // The defect this mode exists to fix, pinned so a regression is visible: the + // caller named three outpoints and the child descends from one of them. + const encoder = makeEncoder() + const { psbt } = await createTx(encoder, stuckChainUtxos()) + assert.strictEqual(psbt.txInputs.length, 1) + assert.deepStrictEqual(outpointsOf(psbt), [`${TXID_BIG}:0`]) + }) + + it('spends every named outpoint, so a CPFP child descends from the whole chain', async () => { + const encoder = makeEncoder() + const { psbt } = await createTx(encoder, stuckChainUtxos(), + { options: { exactInputs: true } }) + assert.deepStrictEqual(outpointsOf(psbt), [ + `${TXID_BIG}:0`, `${TXID_MID}:1`, `${TXID_TINY}:2` + ]) + }) + + it('keeps the caller order, so ins[0] is the outpoint the caller chose', async () => { + // Not cosmetic: on the OP_RETURN/MULTISIGN path ins[0]'s txid IS the + // obfuscation key, and a value sort would silently rebind it. + const encoder = makeEncoder() + const reordered = [ + makeSegwitUtxo(TXID_TINY, 2, 5000, 0), + makeSegwitUtxo(TXID_BIG, 0, 100000000, 0), + makeSegwitUtxo(TXID_MID, 1, 6000, 0) + ] + const { psbt } = await createTx(encoder, reordered, + { options: { exactInputs: true } }) + assert.deepStrictEqual(outpointsOf(psbt), [ + `${TXID_TINY}:2`, `${TXID_BIG}:0`, `${TXID_MID}:1` + ]) + }) + + it('returns the surplus as change, which is itself the next CPFP handle', async () => { + const encoder = makeEncoder() + const { psbt } = await createTx(encoder, stuckChainUtxos(), + { options: { exactInputs: true } }) + const total = 100000000 + 6000 + 5000 + const outputs = psbt.txOutputs + assert.strictEqual(outputs.length, 1, 'payment-only rescue emits change alone') + assert.strictEqual(outputs[0].address, TEST_ADDRESS) + assert.strictEqual(outputs[0].value, total - RESCUE_FEE) + }) + + it('signals RBF on every input when rbf is set, not just the first', async () => { + // The operator rescue combo: name the whole stuck set AND leave the child + // replaceable, so a first fee guess that still does not clear can be raised. + const encoder = makeEncoder() + const { psbt } = await createTx(encoder, stuckChainUtxos(), + { rbf: true, options: { exactInputs: true } }) + assert.strictEqual(psbt.txInputs.length, 3) + for (const input of psbt.txInputs) { + assert.strictEqual(input.sequence, 0xfffffffd) + } + }) + + describe('reservations, and how exact-input mode interacts with them', () => { + // Every selected outpoint is reserved, so caller-supplied sets + // included. Exact-input mode cannot take the ordinary escape hatch of + // SKIPPING a reserved outpoint: it promised the caller every named outpoint + // is spent, and a CPFP child that quietly drops one descends from less than + // the stuck chain and still does not mine. So it claims the whole set or + // refuses the build, and it never reorders to dodge a hold. + + it('reserves every named outpoint, so a concurrent build cannot take one', async () => { + const encoder = makeEncoder() + const { psbt } = await createTx(encoder, stuckChainUtxos(), + { options: { exactInputs: true } }) + assert.strictEqual(psbt.txInputs.length, 3) + for (const k of [`${TXID_BIG}:0`, `${TXID_MID}:1`, `${TXID_TINY}:2`]) { + assert.ok(encoder.outpointReservations.has(k), `${k} must be reserved`) + } + }) + + it('refuses the whole build when another build holds a named outpoint', async () => { + const encoder = makeEncoder() + encoder.outpointReservations.set(`${TXID_MID}:1`, Date.now() + 5 * 60 * 1000) + await assert.rejects( + () => createTx(encoder, stuckChainUtxos(), { options: { exactInputs: true } }), + (err) => { + assert.strictEqual(err.operational, true) + assert.strictEqual(err.xchainCode, 'INPUT_RESERVED') + assert.deepStrictEqual(err.details.reserved, [`${TXID_MID}:1`]) + assert.match(err.message, /exact-input mode cannot drop them/) + return true + }, + 'a held named outpoint must fail the build, never be silently dropped') + }) + + it('hands back its own claims when the build fails, so a retry is not blocked', async () => { + const encoder = makeEncoder() + encoder.outpointReservations.set(`${TXID_TINY}:2`, Date.now() + 5 * 60 * 1000) + await assert.rejects(() => createTx(encoder, stuckChainUtxos(), + { options: { exactInputs: true } })) + assert.strictEqual(encoder.outpointReservations.size, 1, + 'only the foreign hold should remain; this call must release nothing of its own into the map') + }) + + it('a second identical rescue is refused rather than rebuilt, as on every other path', async () => { + const encoder = makeEncoder() + await createTx(encoder, stuckChainUtxos(), { options: { exactInputs: true } }) + await assert.rejects( + () => createTx(encoder, stuckChainUtxos(), { options: { exactInputs: true } }), + (err) => err.operational === true && err.xchainCode === 'INPUT_RESERVED') + }) + }) + + it('refuses an empty or absent utxos array instead of falling back to the tracker', async () => { + const encoder = makeEncoder() + await assert.rejects( + () => createTx(encoder, [], { options: { exactInputs: true } }), + /exactInputs requires a non-empty utxos array/) + await assert.rejects( + () => createTx(encoder, null, { options: { exactInputs: true } }), + /exactInputs requires a non-empty utxos array/) + }) + + it('refuses to combine with p2shHash, whose inputs never pass through selection', async () => { + const encoder = makeEncoder() + await assert.rejects( + () => createTx(encoder, stuckChainUtxos(), { + p2shHash: TXID_BIG, p2shHex: '00', options: { exactInputs: true } + }), + /exactInputs cannot be combined with p2shHash/) + }) + + it('errors rather than silently dropping a named unconfirmed outpoint', async () => { + // The CPFP-fatal silent failure: unconfirmed=false strips exactly the mempool + // outputs the rescue has to descend from, and the caller would get a child + // that descends from nothing and still does not mine. + const encoder = makeEncoder() + await assert.rejects( + () => createTx(encoder, stuckChainUtxos(0), + { unconfirmed: false, options: { exactInputs: true } }), + new RegExp(`exactInputs names unconfirmed utxo ${TXID_BIG}:0`)) + }) + + it('errors rather than silently deduplicating a repeated outpoint', async () => { + const encoder = makeEncoder() + const dupes = stuckChainUtxos().concat([makeSegwitUtxo(TXID_MID, 1, 6000, 0)]) + await assert.rejects( + () => createTx(encoder, dupes, { options: { exactInputs: true } }), + new RegExp(`exactInputs names outpoint ${TXID_MID}:1 more than once`)) + }) + + it('leaves greedy selection alone when the flag is absent or explicitly false', async () => { + const encoder = makeEncoder() + for (const options of [null, {}, { exactInputs: false }]) { + // Each probe deliberately respends the same fixture set on one encoder to + // compare selection, so release the previous build's reservation + // or the second probe skips the input the first one took. + encoder.clearReservations() + const { psbt } = await createTx(encoder, stuckChainUtxos(), { options }) + assert.strictEqual(psbt.txInputs.length, 1, + `options ${JSON.stringify(options)} must not change selection`) + } + }) + + describe('validator', () => { + it('accepts exactInputs as a real boolean', () => { + const params = validator.validateAll({ + pubkey: PUBKEY_HEX, options: { exactInputs: true } + }) + assert.deepStrictEqual(params.options, { exactInputs: true }) + }) + + it('refuses a stringly-typed exactInputs, which would read as true', () => { + assert.throws( + () => validator.validateAll({ pubkey: PUBKEY_HEX, options: { exactInputs: 'false' } }), + /options\.exactInputs/) + }) + + it('still refuses an unknown options key and names exactInputs as valid', () => { + assert.throws( + () => validator.validateAll({ pubkey: PUBKEY_HEX, options: { exactInput: true } }), + /Unknown options key: "exactInput"\. Valid keys: .*exactInputs/) + }) + }) +}) diff --git a/test/unit/XChainEncoder.extra.test.js b/test/unit/XChainEncoder.extra.test.js index b3262e9..193e246 100644 --- a/test/unit/XChainEncoder.extra.test.js +++ b/test/unit/XChainEncoder.extra.test.js @@ -78,9 +78,13 @@ function makeEncoder (network) { getFeePerKilobyte: async () => 0.00001, getTransactionHex: async () => RAW_TX_HEX } + // Serve the fixture type the chain can actually hold: a witness-program UTXO + // only exists where consensus knows segwit, and the builder refuses one where + // it does not. + const trackerUtxo = encoder.network.supportsSegwit === false ? makeP2pkhUtxo : makeSegwitUtxo encoder.utxoTrackerConnector = { getUtxosFromAddress: async () => ({ - utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)] + utxos: [trackerUtxo(TXID_A, 0, 100000000)] }) } return encoder @@ -151,7 +155,7 @@ describe('XChainEncoder.createTransaction(): P2WSH tx1 (funding)', () => { it('throws TypeError when P2WSH is used on a no-segwit network', async () => { // dogecoin-regtest has supportsSegwit=false const encoder = makeEncoder('dogecoin-regtest') - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) await assert.rejects( () => encoder.createTransaction( @@ -278,7 +282,7 @@ describe('XChainEncoder.createTransaction(): P2WSH tx2 (spending)', () => { // P2SH counterpart to the P2WSH guard above: the reveal branch must bounds- // check voutPsbtIndex against the funding tx's outputs before addInput. const encoder = makeEncoder() // dogecoin-regtest (P2SH, no segwit) - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + 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() @@ -307,7 +311,7 @@ describe('XChainEncoder.createTransaction(): P2WSH tx2 (spending)', () => { describe('XChainEncoder.createTransaction(): payload size guard', () => { it('throws RangeError when compiled payload exceeds MAX_COMPILED_ACTION_DATA_LENGTH (8192)', async () => { const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) // A very large data string that will compile to > 8192 bytes const hugeData = 'X'.repeat(8200) @@ -325,7 +329,7 @@ describe('XChainEncoder.createTransaction(): payload size guard', () => { describe('XChainEncoder.createTransaction() - feeQuote injection', () => { it('adds feeQuote as an extra output when address and amount > 0', async () => { const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) const feeQuote = { address: TEST_ADDRESS, amount: 99000 } const result = await encoder.createTransaction( @@ -343,7 +347,7 @@ describe('XChainEncoder.createTransaction() - feeQuote injection', () => { it('does not add feeQuote when amount is 0', async () => { const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) const feeQuote = { address: TEST_ADDRESS, amount: 0 } const result = await encoder.createTransaction( @@ -360,7 +364,7 @@ describe('XChainEncoder.createTransaction() - feeQuote injection', () => { it('does not add feeQuote when feeQuote has no address', async () => { const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) const feeQuote = { amount: 50000 } // no address const result = await encoder.createTransaction( @@ -391,7 +395,7 @@ describe('XChainEncoder.createTransaction() - maxFeeRateKb cap', () => { getUtxosFromAddress: async () => ({ utxos: [] }) } - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) const result = await encoder.createTransaction( [utxo], TEST_ADDRESS, null, @@ -419,7 +423,7 @@ describe('XChainEncoder.createTransaction() - change edge cases', () => { // burn backstop's effective ceiling here (100x the ~131-sat fair fee is // smaller than the dust floor, so the floor wins the max()). Keep the // UTXO and fee at that ceiling so the fee is not rejected or floored up. - const utxo = makeSegwitUtxo(TXID_A, 0, 100000) + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000) const fee = 100000 const result = await encoder.createTransaction( @@ -441,7 +445,7 @@ describe('XChainEncoder.createTransaction() - change edge cases', () => { // Fee at the dust-floor/burn-backstop ceiling (see comment above), with a // slightly larger UTXO so change lands under dogecoin-regtest's // 100000-koinu dust floor. - const utxo = makeSegwitUtxo(TXID_A, 0, 150000) + const utxo = makeP2pkhUtxo(TXID_A, 0, 150000) const fee = 100000 // leaves 50000 sats change, below the 100000 dust floor // Should not throw; change below dust with no change address is fine (burned as fee) @@ -458,7 +462,7 @@ describe('XChainEncoder.createTransaction() - change edge cases', () => { describe('XChainEncoder.createTransaction() - invalid fee', () => { it('throws RangeError for a NaN fee string', async () => { const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) await assert.rejects( () => encoder.createTransaction( @@ -472,7 +476,7 @@ describe('XChainEncoder.createTransaction() - invalid fee', () => { it('throws RangeError for a negative fee', async () => { const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) await assert.rejects( () => encoder.createTransaction( @@ -596,7 +600,7 @@ describe('XChainEncoder.estimateSpendingP2wshTx()', () => { describe('XChainEncoder.createTransaction() - remaining branch coverage', () => { it('throws RangeError when customOutputs[i].value is not a valid satoshi amount', async () => { const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) await assert.rejects( () => encoder.createTransaction( @@ -610,7 +614,7 @@ describe('XChainEncoder.createTransaction() - remaining branch coverage', () => it('throws RangeError when customOutputs[i].value is negative', async () => { const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) await assert.rejects( () => encoder.createTransaction( @@ -625,7 +629,7 @@ describe('XChainEncoder.createTransaction() - remaining branch coverage', () => it('throws RangeError when a UTXO value is not a valid satoshi amount', async () => { const encoder = makeEncoder() // A UTXO with a NaN value string (parsed inside the UTXO loop) - const badUtxo = makeSegwitUtxo(TXID_A, 0, 'notanumber') + const badUtxo = makeP2pkhUtxo(TXID_A, 0, 'notanumber') await assert.rejects( () => encoder.createTransaction( @@ -640,7 +644,7 @@ describe('XChainEncoder.createTransaction() - remaining branch coverage', () => it('throws when unconfirmed=false strips all UTXOs (line 325 path)', async () => { const encoder = makeEncoder() // All UTXOs have confirmations=0 (mempool), unconfirmed=false strips them all - const mempoolUtxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const mempoolUtxo = makeP2pkhUtxo(TXID_A, 0, 100000000) mempoolUtxo.confirmations = 0 await assert.rejects( @@ -659,7 +663,7 @@ describe('XChainEncoder.createTransaction() - remaining branch coverage', () => describe('XChainEncoder.createTransaction() - rawData parameter', () => { it('accepts rawData and includes it in the compiled payload', async () => { const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) // rawData is a binary string appended after the text data const rawData = '\x01\x02\x03binary content' @@ -676,7 +680,7 @@ describe('XChainEncoder.createTransaction() - rawData parameter', () => { it('rawData does not affect result when null (false branch of rawData != null)', async () => { const encoder = makeEncoder() - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = makeP2pkhUtxo(TXID_A, 0, 100000000) const result = await encoder.createTransaction( [utxo], TEST_ADDRESS, null, diff --git a/test/unit/XChainEncoder.feeRateCap.test.js b/test/unit/XChainEncoder.feeRateCap.test.js index 92a584d..8d16bd0 100644 --- a/test/unit/XChainEncoder.feeRateCap.test.js +++ b/test/unit/XChainEncoder.feeRateCap.test.js @@ -31,10 +31,10 @@ const pubkeyBuf = Buffer.from( ) const TXID_A = 'a'.repeat(64) -const DOGE_REGTEST = require('../../src/CryptoNetworks').getBitcoinJsNetwork('dogecoin-regtest') +const LTC_REGTEST = require('../../src/CryptoNetworks').getBitcoinJsNetwork('litecoin-regtest') const TEST_ADDRESS = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, - network: DOGE_REGTEST + network: LTC_REGTEST }).address const INPUT_VALUE = 100000000 // 1 coin in base units @@ -57,15 +57,14 @@ function makeSegwitUtxo (txid, vout, value) { // multiplier caps the effective rate at 100 sat/byte. function makeEncoder (maxFeeRateKb = null, maxFeeRateMultiplier = undefined) { const encoder = maxFeeRateMultiplier === undefined - ? new XChainEncoder('dogecoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '', maxFeeRateKb) - : new XChainEncoder('dogecoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '', maxFeeRateKb, maxFeeRateMultiplier) + ? new XChainEncoder('litecoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '', maxFeeRateKb) + : new XChainEncoder('litecoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '', maxFeeRateKb, maxFeeRateMultiplier) encoder.connector = { getFeePerKilobyte: async () => 0.00001 } - // dogecoin-regtest's dust floor is 100000 koinu, orders of magnitude above - // the sub-dust fees these rate-cap probes produce (a ~131-byte tx capped at - // ~100 sat/byte pays ~13100). The dust floor is exercised by its own suite; - // override it here so the rate-cap behaviour under test is observable. + // The network dust floor sits above the sub-dust fees these rate-cap probes + // produce (a ~131-byte tx capped at ~100 sat/byte pays ~13100). The floor has + // its own suite; override it here so the rate-cap behaviour is observable. encoder.dustAmount = 546 return encoder } diff --git a/test/unit/XChainEncoder.p2shRevealHeadroom.test.js b/test/unit/XChainEncoder.p2shRevealHeadroom.test.js index 279224b..e52f199 100644 --- a/test/unit/XChainEncoder.p2shRevealHeadroom.test.js +++ b/test/unit/XChainEncoder.p2shRevealHeadroom.test.js @@ -52,6 +52,36 @@ function makeSegwitUtxo (txid, vout, value) { } } +function makeLegacyUtxo (txid, vout, value) { + const p2pkh = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }) + return { + txid, + vout, + value, + confirmations: 6, + scriptPubKey: p2pkh.output.toString('hex') + } +} + +// Pick the fixture the chain can hold: a witness-program UTXO only exists where +// consensus knows segwit, and the builder refuses one where it does not. +function fundingUtxo (encoder, txid, vout, value) { + return encoder.network.supportsSegwit === false + ? makeLegacyUtxo(txid, vout, value) + : makeSegwitUtxo(txid, vout, value) +} + +// The whole previous transaction, which a legacy input carries as nonWitnessUtxo. +function prevTxHex () { + const tx = new bitcoin.Transaction() + tx.addInput(Buffer.alloc(32, 0x11), 0) + tx.addOutput(bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: bitcoin.networks.regtest }).output, 100000000) + return tx.toHex() +} + function makeEncoder (network) { const encoder = new XChainEncoder( network, '127.0.0.1', '8333', 'rpc', 'rpc', '', '' @@ -60,7 +90,7 @@ function makeEncoder (network) { // relative fee cap (x100) far above every caller rate used here. encoder.connector = { getFeePerKilobyte: async () => 0.01, - getTransactionHex: async () => { throw new Error('not needed by these tests') } + getTransactionHex: async () => prevTxHex() } return encoder } @@ -72,8 +102,11 @@ function callerAddress (network) { async function buildFunding (encoder, network, encoding, feePerKb, payloadLen) { const addr = callerAddress(network) + // Each probe respends the one fixture input on the same encoder; release the + // previous build's reservation so this is a comparison, not a double-spend. + encoder.clearReservations() const res = await encoder.createTransaction( - [makeSegwitUtxo(TXID_A, 0, 100000000)], addr, null, + [fundingUtxo(encoder, TXID_A, 0, 100000000)], addr, null, 'x'.repeat(payloadLen), null, null, false, encoding, addr, null, null, null, true, feePerKb ) @@ -199,6 +232,7 @@ describe('XChainEncoder P2SH reveal headroom', () => { assert.ok(reveal.outs[0].script.toString('hex').startsWith('6a')) }) + // The DOGE pin spends a P2PKH input, the only output type that chain holds. // Byte-identity pins for the paths that already work today. The expected // hexes were generated with THIS harness against the pre-fix (git HEAD) // XChainEncoder, so any drift on these lanes fails loudly. @@ -207,7 +241,7 @@ describe('XChainEncoder P2SH reveal headroom', () => { ltcP2wshFunding: '0200000001aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000000000ffffffff02405b00000000000022002050efb27b1cb5812e1bb95788a3ffd8550464371c1d57a36e4649e9b5ae8ed1fe9849f505000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000', btcP2wshReveal: '02000000019d2de3ed9219bd7f9df123398722dac29eef5f38d5c5b038a96dc6678a7066fd0000000000ffffffff0200000000000000000b6a098163302df9a1af23ee22020000000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000', ltcP2wshReveal: '0200000001e681017eda2e84c8f80a5d543af89041236fc9e7549630025e3d1588ef60e5c60000000000ffffffff0200000000000000000b6a09e56b6a299a4ddadb6654150000000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000', - dogeOpReturn: '0200000001aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000000000ffffffff0200000000000000002f6a2d09cb8e09635af4c5aa3a916a26c6652b18482f3a1ec3636bebdfa9f4d1599a8385426040f9baaff90aa6b137f0a854f305000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000' + dogeOpReturn: '0200000001aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0000000000ffffffff0200000000000000002f6a2d09cb8e09635af4c5aa3a916a26c6652b18482f3a1ec3636bebdfa9f4d1599a8385426040f9baaff90aa6b137f0289ff105000000001976a914751e76e8199196d454941c45d1b3a323f1433bd688ac00000000' } it('keeps the BTC default two-phase lane (P2WSH) byte-identical, funding and reveal', async () => { diff --git a/test/unit/XChainEncoder.revealFundingBinding.test.js b/test/unit/XChainEncoder.revealFundingBinding.test.js index 3d54b79..5178c11 100644 --- a/test/unit/XChainEncoder.revealFundingBinding.test.js +++ b/test/unit/XChainEncoder.revealFundingBinding.test.js @@ -38,6 +38,28 @@ function buildRawP2pkhTxHex (value) { return tx.toHex() } +function makeLegacyUtxo (txid, vout, value) { + const p2pkh = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, + network: bitcoin.networks.regtest + }) + return { + txid, + vout, + value, + confirmations: 6, + scriptPubKey: p2pkh.output.toString('hex') + } +} + +// Pick the fixture the chain can hold: a witness-program UTXO only exists where +// consensus knows segwit, and the builder refuses one where it does not. +function utxoFor (encoder, txid, vout, value) { + return encoder.network.supportsSegwit === false + ? makeLegacyUtxo(txid, vout, value) + : makeSegwitUtxo(txid, vout, value) +} + function makeSegwitUtxo (txid, vout, value) { const p2wpkh = bitcoin.payments.p2wpkh({ pubkey: pubkeyBuf, @@ -75,7 +97,9 @@ function makeEncoder (network) { } encoder.utxoTrackerConnector = { getUtxosFromAddress: async () => ({ - utxos: [makeSegwitUtxo(TXID_A, 0, 100000000)] + utxos: [encoder.network.supportsSegwit === false + ? makeLegacyUtxo(TXID_A, 0, 100000000) + : makeSegwitUtxo(TXID_A, 0, 100000000)] }) } return encoder @@ -83,7 +107,7 @@ function makeEncoder (network) { // Build the phase-1 funding tx and hand back {hex, id} for the reveal call. async function buildFunding (encoder, encoding, address) { - const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) + const utxo = utxoFor(encoder, TXID_A, 0, 100000000) const funding = await encoder.createTransaction( [utxo], address, null, 'x'.repeat(80), null, 10000, false, encoding, address, diff --git a/test/unit/XChainEncoder.segwitInputGuard.test.js b/test/unit/XChainEncoder.segwitInputGuard.test.js new file mode 100644 index 0000000..341e975 --- /dev/null +++ b/test/unit/XChainEncoder.segwitInputGuard.test.js @@ -0,0 +1,136 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Input selection refuses a witness-program UTXO on a chain whose consensus +// rules have no segwit. Such an output is anyone-can-spend there, so a +// witnessUtxo input signs nothing the network enforces and the caller pays a +// real fee for a transaction that protects nothing. The per-coin capability +// flag lives in the coin registry; this pins that the builder honours it. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../src/XChainEncoder') +const CryptoNetworks = require('../../src/CryptoNetworks') + +const pubkeyBuf = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex' +) +const TXID_A = 'a'.repeat(64) +const INPUT_VALUE = 100000000 + +const P2PKH_SCRIPT = bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, network: bitcoin.networks.regtest +}).output + +function prevTxHex () { + const tx = new bitcoin.Transaction() + tx.addInput(Buffer.alloc(32, 0x11), 0) + tx.addOutput(P2PKH_SCRIPT, INPUT_VALUE) + return tx.toHex() +} + +function makeEncoder (networkName) { + const encoder = new XChainEncoder( + networkName, '127.0.0.1', '8333', 'rpc', 'rpc', '', '' + ) + encoder.connector = { + getFeePerKilobyte: async () => 0.00001, + getTransactionHex: async () => prevTxHex() + } + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => { throw new Error('these probes supply their own inputs') } + } + return encoder +} + +function utxo (script) { + return { + txid: TXID_A, + vout: 0, + value: INPUT_VALUE, + confirmations: 6, + scriptPubKey: script.toString('hex') + } +} + +const P2WPKH = bitcoin.payments.p2wpkh({ + pubkey: pubkeyBuf, network: bitcoin.networks.regtest +}).output + +// Witness v1 (taproot), to pin that the guard reads the witness version range +// rather than the v0 opcode alone. +const P2TR = bitcoin.script.compile([bitcoin.opcodes.OP_1, pubkeyBuf.subarray(1)]) + +function addressFor (networkName) { + return bitcoin.payments.p2pkh({ + pubkey: pubkeyBuf, network: CryptoNetworks.getBitcoinJsNetwork(networkName) + }).address +} + +function build (encoder, networkName, input) { + const address = addressFor(networkName) + return encoder.createTransaction( + [input], address, null, + 'test', null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) +} + +describe('XChainEncoder input selection: segwit inputs on a chain without segwit', () => { + + it('refuses a P2WPKH input on a network the registry marks as having no segwit', async () => { + const encoder = makeEncoder('dogecoin-regtest') + assert.strictEqual(encoder.network.supportsSegwit, false, 'fixture network must be the no-segwit case') + + await assert.rejects( + () => build(encoder, 'dogecoin-regtest', utxo(P2WPKH)), + { + name: 'TypeError', + message: /carries a witness-program scriptPubKey, which this network does not support/ + } + ) + }) + + it('refuses a witness v1 (taproot) input there too, not only witness v0', async () => { + const encoder = makeEncoder('dogecoin-regtest') + + await assert.rejects( + () => build(encoder, 'dogecoin-regtest', utxo(P2TR)), + { name: 'TypeError', message: /does not support \(no segwit\)/ } + ) + }) + + it('names the offending outpoint, so a coin-control caller can drop it', async () => { + const encoder = makeEncoder('dogecoin-regtest') + + await assert.rejects( + () => build(encoder, 'dogecoin-regtest', utxo(P2WPKH)), + (err) => { + assert.ok(err.message.includes(TXID_A + ':0'), 'got ' + err.message) + return true + } + ) + }) + + it('still spends a legacy input on that same network', async () => { + const encoder = makeEncoder('dogecoin-regtest') + const result = await build(encoder, 'dogecoin-regtest', utxo(P2PKH_SCRIPT)) + assert.strictEqual(result.psbt.data.inputs.length, 1) + assert.ok(Buffer.isBuffer(result.psbt.data.inputs[0].nonWitnessUtxo)) + }) + + it('leaves the same P2WPKH input alone on a segwit-capable network', async () => { + const encoder = makeEncoder('litecoin-regtest') + const result = await build(encoder, 'litecoin-regtest', utxo(P2WPKH)) + assert.strictEqual(result.psbt.data.inputs.length, 1) + assert.ok(result.psbt.data.inputs[0].witnessUtxo, 'a segwit chain keeps the witnessUtxo path') + }) +}) diff --git a/test/unit/compression.test.js b/test/unit/compression.test.js index 5e5496d..a4d76f1 100644 --- a/test/unit/compression.test.js +++ b/test/unit/compression.test.js @@ -259,6 +259,9 @@ describe('encoder FILE payload compression (spec Part B)', function () { null, false, 'TAPROOT', callerAddress(network), null, null, PUBKEY.toString('hex'), true, null, null, null, false, false) + // Same input on purpose: release the first build's reservation so the + // second is a rebuild for comparison, not a refused double-spend. + encoder.clearReservations() const compressed = await encoder.createTransaction( [segwitUtxo(network)], callerAddress(network), null, 'FILE|0|big.txt|text/plain|Big|', raw.toString('binary'), diff --git a/test/unit/corsPreflight.test.js b/test/unit/corsPreflight.test.js new file mode 100644 index 0000000..c3df102 --- /dev/null +++ b/test/unit/corsPreflight.test.js @@ -0,0 +1,176 @@ +/********************************************************************* + * + * Copyright © 2025–2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC – https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * 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 cors() mount has to sit ABOVE the x-api-key gate, and only a test that + * drives the real app can say whether it does. + * + * A browser preflight is an OPTIONS carrying no x-api-key (that header is not + * CORS-safelisted, which is exactly what forces the preflight), so a key gate + * mounted first answers it 401 with no Access-Control-Allow-* headers and the + * browser never sends the real request. A keyed deploy that also sets + * CORS_ORIGIN is then unusable from every wallet shell while a non-preflighted + * curl header dump reads as correctly configured. + * + * Asserting against a hand-built mirror of the middleware stack cannot catch + * that, because a mirror keeps its own ordering. These tests bind the app + * exported from src/api.js. + * + ********************************************************************/ + +'use strict' + +const assert = require('assert') + +const API_PATH = require.resolve('../../src/api.js') +const ORIGIN = 'https://wallet.example' +const API_KEY = 'preflight-test-key' + +// Build the app the way a keyed deploy does. The gate and CORS_ORIGIN are read +// once at module load, so the env has to be in place for a fresh require; the +// cached entry is put back so suites that already hold the app keep their own. +// +// NETWORK is supplied here rather than inherited. A developer checkout carries a +// gitignored .env that sets it, and a CI checkout does not, so a test that reads +// it from the ambient environment passes locally and fails on the venue with +// "Unknown network: undefined" from the encoder constructor. Any valid network +// serves: the mount order under test is network-independent. +function loadKeyedApp (corsOrigin) { + const cached = require.cache[API_PATH] + const priorKey = process.env.API_KEY + const priorOrigin = process.env.CORS_ORIGIN + const priorNetwork = process.env.NETWORK + + process.env.API_KEY = API_KEY + if (!process.env.NETWORK) process.env.NETWORK = 'bitcoin-regtest' + if (corsOrigin === undefined) delete process.env.CORS_ORIGIN + else process.env.CORS_ORIGIN = corsOrigin + delete require.cache[API_PATH] + try { + return require(API_PATH).app + } finally { + delete require.cache[API_PATH] + if (cached) require.cache[API_PATH] = cached + if (priorKey === undefined) delete process.env.API_KEY + else process.env.API_KEY = priorKey + if (priorOrigin === undefined) delete process.env.CORS_ORIGIN + else process.env.CORS_ORIGIN = priorOrigin + if (priorNetwork === undefined) delete process.env.NETWORK + else process.env.NETWORK = priorNetwork + } +} + +async function withServer (app, fn) { + const server = await new Promise(resolve => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)) + }) + try { + return await fn(`http://127.0.0.1:${server.address().port}`) + } finally { + await new Promise(resolve => server.close(resolve)) + } +} + +describe('CORS is mounted above the API-key gate @regression', function () { + let app + + before(function () { + app = loadKeyedApp(ORIGIN) + }) + + it('answers a preflight instead of 401ing it, on a keyed deploy', async function () { + const res = await withServer(app, base => fetch(`${base}/`, { + method: 'OPTIONS', + headers: { + Origin: ORIGIN, + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'x-api-key' + } + })) + + assert.notStrictEqual(res.status, 401, + 'the preflight carries no x-api-key by construction, so a 401 here means the gate is mounted first') + assert.ok(res.status < 300, `preflight should be answered, got ${res.status}`) + assert.strictEqual(res.headers.get('access-control-allow-origin'), ORIGIN) + }) + + // The same ordering decides whether a browser can read the gate's own + // rejections: without CORS headers a 401 arrives as an opaque network error. + it('emits CORS headers on the 401 the gate returns', async function () { + const res = await withServer(app, base => fetch(`${base}/`, { + method: 'POST', + headers: { Origin: ORIGIN, 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'health' }) + })) + + assert.strictEqual(res.status, 401, 'a keyless request must still be rejected') + assert.strictEqual(res.headers.get('access-control-allow-origin'), ORIGIN) + }) + + it('still rejects a wrong key', async function () { + const res = await withServer(app, base => fetch(`${base}/`, { + method: 'POST', + headers: { Origin: ORIGIN, 'Content-Type': 'application/json', 'x-api-key': 'not-the-key' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'health' }) + })) + + assert.strictEqual(res.status, 401) + }) + + it('lets a correctly keyed request past the gate', async function () { + const res = await withServer(app, base => fetch(`${base}/`, { + method: 'POST', + headers: { Origin: ORIGIN, 'Content-Type': 'application/json', 'x-api-key': API_KEY }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'no_such_method' }) + })) + + assert.notStrictEqual(res.status, 401, 'a valid key must not be rejected') + }) +}) + +/* Moving the mount up must not widen anything when CORS is off, which is the + * default and what every production encoder runs today. It does not: handed + * `origin: false`, the cors package builds no origin callback and calls next() + * for every method, OPTIONS included, so the middleware is a pass-through and + * the gate answers exactly as it did with the mount below it. */ +describe('a CORS-disabled keyed deploy is unchanged by the mount position @regression', function () { + let app + + before(function () { + app = loadKeyedApp(undefined) + }) + + it('still 401s an unauthenticated preflight and grants no origin', async function () { + const res = await withServer(app, base => fetch(`${base}/`, { + method: 'OPTIONS', + headers: { + Origin: ORIGIN, + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'x-api-key' + } + })) + + assert.strictEqual(res.status, 401, 'CORS off means the gate still owns OPTIONS') + assert.strictEqual(res.headers.get('access-control-allow-origin'), null) + }) + + it('grants no origin on a keyed request either', async function () { + const res = await withServer(app, base => fetch(`${base}/`, { + method: 'POST', + headers: { Origin: ORIGIN, 'Content-Type': 'application/json', 'x-api-key': API_KEY }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'no_such_method' }) + })) + + assert.strictEqual(res.headers.get('access-control-allow-origin'), null) + }) +}) diff --git a/test/unit/health-serve-readiness.test.js b/test/unit/health-serve-readiness.test.js index 68e9bf7..c4b2eb3 100644 --- a/test/unit/health-serve-readiness.test.js +++ b/test/unit/health-serve-readiness.test.js @@ -138,3 +138,65 @@ describe('health(): tracker_synced is serve-readiness (create_tx parity) @regres } finally { restore(); } }); }); + +/* The ceiling above is the one thing a reader of /status could not see, and the + * public status board guessed at it: it mirrored the tracker's own + * SYNCED_THRESHOLD (3) instead of maxUtxoTrackerLagBlocks (2), so a lag of + * exactly 3 fell past its lag branch and the row blamed mempool reconvergence + * for an encoder that stays 503 until the lag drops to 2. Publishing the number + * ends the mirroring, and matters twice because UTXO_TRACKER_MAX_LAG_BLOCKS + * moves it per deployment, so no constant could have been right everywhere. + * + * /status only. The JSON-RPC health() shape is documented in docs/openrpc.json + * and stays as it is. */ +describe('GET /status publishes the lag ceiling its readiness was gated on @regression', function () { + const http = require('http'); + const { app, encoder } = require('../../src/api'); + + async function statusBody() { + const server = await new Promise((resolve) => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)); + }); + try { + const res = await fetch(`http://127.0.0.1:${server.address().port}/status`); + return await res.json(); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + } + + it('reports tracker_max_lag_blocks equal to the gate create_tx enforces', async function () { + const restore = stubSync({ synced: true, lag: 0 }); + try { + const body = await statusBody(); + assert.strictEqual(body.tracker_max_lag_blocks, encoder.maxUtxoTrackerLagBlocks); + assert.strictEqual(typeof body.tracker_max_lag_blocks, 'number', + 'a board range-checks this field, so it must arrive as a number'); + } finally { restore(); } + }); + + // The whole point of publishing it: an operator who widens the ceiling gets + // the widened number, not the default a board would otherwise hardcode. + it('follows a non-default ceiling rather than reporting the default', async function () { + const orig = encoder.maxUtxoTrackerLagBlocks; + const restore = stubSync({ synced: true, lag: 0 }); + try { + encoder.maxUtxoTrackerLagBlocks = orig + 4; + const body = await statusBody(); + assert.strictEqual(body.tracker_max_lag_blocks, orig + 4); + assert.notStrictEqual(body.tracker_max_lag_blocks, orig); + } finally { + encoder.maxUtxoTrackerLagBlocks = orig; + restore(); + } + }); + + it('leaves the documented JSON-RPC health() shape alone', async function () { + const restore = stubSync({ synced: true, lag: 0 }); + try { + const h = await jsonRpcController.health(); + assert.ok(!('tracker_max_lag_blocks' in h), + 'health() is pinned by docs/openrpc.json; the ceiling rides on /status only'); + } finally { restore(); } + }); +}); diff --git a/test/unit/maintenanceWindow.test.js b/test/unit/maintenanceWindow.test.js new file mode 100644 index 0000000..5fcaef2 --- /dev/null +++ b/test/unit/maintenanceWindow.test.js @@ -0,0 +1,241 @@ +'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. + * + ********************************************************************** + * Scheduled-maintenance window. + * + * The monthly tracker-bootstrap publish takes the UTXO tracker down, so + * /status answers 503 with tracker_reachable:false and the public board + * paints the encoder Degraded for the length of the run. The probe is + * right; what it lacked was the operator's declaration that the outage + * was planned. + * + * Two properties matter more than the happy path here: + * 1. The window is CONTEXT. It never touches tracker_reachable / + * tracker_synced and never moves the 503, so it cannot be used to + * paint an un-serveable encoder green. + * 2. Every malformed, unbounded, or stale sentinel resolves to "no + * maintenance", which puts the row back on Degraded. A publish that + * crashes without cleaning up stops excusing itself at its own + * declared end time instead of hiding the outage forever. + * + ********************************************************************/ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + parseMaintenanceWindow, + readMaintenanceWindow, + sentinelPath, + DEFAULT_SENTINEL, + MAX_SENTINEL_BYTES, + MAX_REASON_CHARS, + MAX_WINDOW_MS +} = require('../../src/maintenanceWindow'); + +const NOW = Date.parse('2026-09-02T12:00:00.000Z'); +const in2h = new Date(NOW + 2 * 3600 * 1000).toISOString(); + +describe('maintenanceWindow: an operator-declared scheduled outage @regression', function () { + + describe('parseMaintenanceWindow()', function () { + + it('reports an open, bounded window as active', function () { + const w = parseMaintenanceWindow(JSON.stringify({ + reason: 'monthly bootstrap publish', since: new Date(NOW - 60000).toISOString(), until: in2h + }), NOW); + assert.ok(w, 'expected an active window'); + assert.strictEqual(w.active, true); + assert.strictEqual(w.reason, 'monthly bootstrap publish'); + assert.strictEqual(w.until, in2h); + }); + + it('accepts epoch-millisecond timestamps as well as ISO strings', function () { + const w = parseMaintenanceWindow(JSON.stringify({ until: NOW + 60000 }), NOW); + assert.ok(w); + assert.strictEqual(w.until, new Date(NOW + 60000).toISOString()); + }); + + // The whole point of requiring an expiry: a publish killed mid-run leaves + // its sentinel behind, and a window with no end would excuse a genuine + // dead encoder for as long as nobody noticed. + it('refuses a window with no expiry', function () { + assert.strictEqual(parseMaintenanceWindow(JSON.stringify({ reason: 'forever' }), NOW), null); + }); + + it('refuses an expired window, so a stale sentinel self-heals to Degraded', function () { + const w = JSON.stringify({ until: new Date(NOW - 1000).toISOString() }); + assert.strictEqual(parseMaintenanceWindow(w, NOW), null); + }); + + it('refuses a window longer than the ceiling', function () { + const tooLong = JSON.stringify({ until: NOW + MAX_WINDOW_MS + 60000 }); + assert.strictEqual(parseMaintenanceWindow(tooLong, NOW), null); + const atCeiling = JSON.stringify({ until: NOW + MAX_WINDOW_MS }); + assert.ok(parseMaintenanceWindow(atCeiling, NOW), 'the ceiling itself is still a window'); + }); + + it('holds a pre-announced window inactive until it opens', function () { + const later = JSON.stringify({ since: new Date(NOW + 3600 * 1000).toISOString(), until: in2h }); + assert.strictEqual(parseMaintenanceWindow(later, NOW), null); + }); + + it('resolves malformed, empty, non-object and oversized sentinels to no maintenance', function () { + for (const bad of ['', 'not json', '[]', 'null', '"just a string"', '42']) { + assert.strictEqual(parseMaintenanceWindow(bad, NOW), null, `expected null for ${JSON.stringify(bad)}`); + } + const huge = JSON.stringify({ until: in2h, reason: 'x'.repeat(MAX_SENTINEL_BYTES) }); + assert.ok(huge.length > MAX_SENTINEL_BYTES); + assert.strictEqual(parseMaintenanceWindow(huge, NOW), null, 'an oversized file is not a sentinel'); + }); + + it('refuses an unparseable date rather than reading it as epoch 0 or NaN', function () { + assert.strictEqual(parseMaintenanceWindow(JSON.stringify({ until: 'tomorrow-ish' }), NOW), null); + assert.strictEqual(parseMaintenanceWindow(JSON.stringify({ until: {} }), NOW), null); + }); + + // The body is public JSON and the reason is free text, so it is bounded + // and stripped before it can leave the process. + it('bounds and sanitizes the reason', function () { + const w = parseMaintenanceWindow(JSON.stringify({ + until: in2h, reason: 'line\nbreakbell ' + 'y'.repeat(MAX_REASON_CHARS) + }), NOW); + assert.ok(w.reason.length <= MAX_REASON_CHARS); + assert.ok(!/[\n]/.test(w.reason), 'control characters are stripped'); + }); + + it('tolerates a missing reason', function () { + const w = parseMaintenanceWindow(JSON.stringify({ until: in2h }), NOW); + assert.strictEqual(w.reason, ''); + assert.strictEqual(w.since, null); + }); + }); + + describe('readMaintenanceWindow()', function () { + let dir; + beforeEach(function () { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'xc-maint-')); }); + afterEach(function () { fs.rmSync(dir, { recursive: true, force: true }); }); + + it('returns null when no sentinel exists (the normal case)', async function () { + assert.strictEqual(await readMaintenanceWindow(NOW, path.join(dir, 'absent.json')), null); + }); + + it('never throws on an unreadable path', async function () { + assert.strictEqual(await readMaintenanceWindow(NOW, dir), null, 'a directory is not a sentinel'); + }); + + it('reads an active window off disk', async function () { + const p = path.join(dir, 'window.json'); + fs.writeFileSync(p, JSON.stringify({ reason: 'bootstrap publish', until: in2h })); + const w = await readMaintenanceWindow(NOW, p); + assert.strictEqual(w.active, true); + assert.strictEqual(w.reason, 'bootstrap publish'); + }); + }); + + describe('sentinelPath()', function () { + // Default lives INSIDE the encoder container so xchain-node can write it + // with a plain `docker exec tee` against an already-running encoder: no + // bind mount, so no container recreate before maintenance can be reported. + it('defaults to the in-container path and honours the env override', function () { + const orig = process.env.ENCODER_MAINTENANCE_FILE; + try { + delete process.env.ENCODER_MAINTENANCE_FILE; + assert.strictEqual(sentinelPath(), DEFAULT_SENTINEL); + assert.match(DEFAULT_SENTINEL, /^\//, 'the default is an absolute path'); + process.env.ENCODER_MAINTENANCE_FILE = '/elsewhere/window.json'; + assert.strictEqual(sentinelPath(), '/elsewhere/window.json'); + } finally { + if (orig === undefined) delete process.env.ENCODER_MAINTENANCE_FILE; + else process.env.ENCODER_MAINTENANCE_FILE = orig; + } + }); + }); +}); + +describe('health()/GET /status carry the window without bending readiness @regression', function () { + const { jsonRpcController, encoder } = require('../../src/api'); + + let dir, sentinel, origEnv; + beforeEach(function () { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'xc-maint-api-')); + sentinel = path.join(dir, 'window.json'); + origEnv = process.env.ENCODER_MAINTENANCE_FILE; + process.env.ENCODER_MAINTENANCE_FILE = sentinel; + }); + afterEach(function () { + if (origEnv === undefined) delete process.env.ENCODER_MAINTENANCE_FILE; + else process.env.ENCODER_MAINTENANCE_FILE = origEnv; + fs.rmSync(dir, { recursive: true, force: true }); + }); + + function stubSync(status) { + const orig = encoder.utxoTrackerConnector.getSyncStatus; + encoder.utxoTrackerConnector.getSyncStatus = async () => status; + return () => { encoder.utxoTrackerConnector.getSyncStatus = orig; }; + } + function declare(minutesAhead) { + fs.writeFileSync(sentinel, JSON.stringify({ + reason: 'utxo-tracker bootstrap publish', + since: new Date().toISOString(), + until: new Date(Date.now() + minutesAhead * 60000).toISOString() + })); + } + + it('reports maintenance null when no window is declared', async function () { + const restore = stubSync({ synced: true, lag: 0 }); + try { + const h = await jsonRpcController.health(); + assert.strictEqual(h.maintenance, null); + } finally { restore(); } + }); + + // The exact shape the monthly publish produces: the tracker is down, the + // probe says so, and the window explains WHY without contradicting it. + it('an unreachable tracker inside a declared window still reads unready', async function () { + const orig = encoder.utxoTrackerConnector.getSyncStatus; + encoder.utxoTrackerConnector.getSyncStatus = async () => { throw new Error('connect ECONNREFUSED'); }; + const restore = () => { encoder.utxoTrackerConnector.getSyncStatus = orig; }; + declare(120); + try { + const h = await jsonRpcController.health(); + assert.strictEqual(h.tracker_reachable, false, 'the probe must not be silenced by a window'); + assert.strictEqual(h.tracker_synced, false); + assert.ok(h.maintenance && h.maintenance.active === true); + assert.strictEqual(h.maintenance.reason, 'utxo-tracker bootstrap publish'); + } finally { restore(); } + }); + + it('a window does not make a lagging tracker read synced', async function () { + const restore = stubSync({ synced: true, lag: encoder.maxUtxoTrackerLagBlocks + 5 }); + declare(60); + try { + const h = await jsonRpcController.health(); + assert.strictEqual(h.tracker_synced, false); + assert.ok(h.maintenance.active); + } finally { restore(); } + }); + + it('an expired sentinel reads as no maintenance', async function () { + const restore = stubSync({ synced: true, lag: 0 }); + declare(-5); + try { + const h = await jsonRpcController.health(); + assert.strictEqual(h.maintenance, null); + } finally { restore(); } + }); +}); diff --git a/test/unit/observability.test.js b/test/unit/observability.test.js index b21a873..616bbaa 100644 --- a/test/unit/observability.test.js +++ b/test/unit/observability.test.js @@ -16,11 +16,14 @@ // log shim that redacts credentials and never throws at a dead collector. // // Ported from the canonical suite at xchain-hub/test/unit/observability.test.js. -// src/observability/ here is a verbatim vendored copy (parity is gated by a -// check across the vendored copies in CI), so this file runs -// the same assertions against xchain-encoder's own copy, express version and -// Node engine. Behaviour changes belong in the canonical suite first; re-port -// rather than hand-editing, or the two drift apart silently. +// src/observability/ here is a verbatim vendored copy, vendored and verified by +// xchain-hub/bin/sync-observability.sh. Parity is gated in the HUB, not here: +// the hub's pre-push gate (bin/ci-full.sh) and the drift-guards job of its +// ci.yml both run that script with --check against all six consumers, so a +// hand-edit to this copy reddens the hub. This file runs the same assertions +// against xchain-encoder's own copy, express version and Node engine. +// Behaviour changes belong in the canonical suite first; re-port rather than +// hand-editing, or the two drift apart silently. const { expect } = require('chai'); const express = require('express'); @@ -372,6 +375,50 @@ describe('observability/logShipper', function () { await log.stop(); expect(log.timer).to.equal(null); }); + + // Exercises the real _post/fetch path. Every other test here injects a + // transport, which is why the unreleased response body below went unseen. + it('releases the response body so a stalled collector cannot pin the socket', async function () { + this.timeout(5000); + let closed = false; + const sockets = new Set(); + const server = http.createServer((req, res) => { + req.resume(); + // Answer with headers and a first chunk, then never end the body. + req.on('end', () => { res.writeHead(200); res.write('ack'); }); + res.socket.on('close', () => { closed = true; }); + }); + server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + + const log = createLogShipper({ + service: 'svc', + env: { + LOG_SHIP_ENABLED: '1', + LOG_SHIP_URL: `http://127.0.0.1:${port}/logs`, + LOG_SHIP_BATCH_SIZE: '1', + LOG_SHIP_TIMEOUT_MS: '400' + }, + console: fakeConsole() + }); + + try { + log.info('one'); + await log.flush(); + // fetch() resolves on headers and the abort timer is cleared with it, + // so an unreleased body leaves nothing that will ever close this + // socket. Measured: released in under 2ms, unreleased still open at 3s. + for (let i = 0; i < 100 && !closed; i++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(closed).to.equal(true); + } finally { + await log.stop(); + for (const s of sockets) s.destroy(); + await new Promise((resolve) => server.close(resolve)); + } + }); }); describe('observability/installObservability', function () { diff --git a/test/unit/packageFeeSizing.test.js b/test/unit/packageFeeSizing.test.js index 0e4d461..80bf0b7 100644 --- a/test/unit/packageFeeSizing.test.js +++ b/test/unit/packageFeeSizing.test.js @@ -238,21 +238,33 @@ describe('XChainEncoder package-aware fee sizing @regression @tier1', () => { afterEach(() => { delete process.env.MAX_CPFP_UPLIFT_SAT }) + // P2PKH, the only output type a chain without segwit holds, so these fixtures + // describe a UTXO the chain under test could actually produce. + const P2PKH_SCRIPT = bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network: bitcoin.networks.regtest }).output + function makeUtxo (txid, confirmations) { - const p2wpkh = bitcoin.payments.p2wpkh({ pubkey: pubkeyBuf, network: bitcoin.networks.regtest }) return { txid, vout: 0, value: INPUT_VALUE, confirmations, - scriptPubKey: p2wpkh.output.toString('hex') + scriptPubKey: P2PKH_SCRIPT.toString('hex') } } + // The whole previous transaction, which a legacy input carries as nonWitnessUtxo. + function prevTxHex () { + const tx = new bitcoin.Transaction() + tx.addInput(Buffer.alloc(32, 0x11), 0) + tx.addOutput(P2PKH_SCRIPT, INPUT_VALUE) + return tx.toHex() + } + function makeEncoder (ancestorPackage) { const encoder = new XChainEncoder('dogecoin-regtest', '127.0.0.1', '8333', 'rpc', 'rpc', '', '') encoder.connector = { getFeePerKilobyte: async () => NODE_RATE_PER_KB, + getTransactionHex: async () => prevTxHex(), // The suggested-rate ceiling on a test chain reads the node's relay floor; // without it the build would clamp to the 20-per-vByte Bitcoin-scale default // and never price a DOGE package at all. @@ -262,14 +274,16 @@ describe('XChainEncoder package-aware fee sizing @regression @tier1', () => { return typeof ancestorPackage === 'function' ? ancestorPackage(txids) : ancestorPackage } } - // dogecoin-regtest's 100000-koinu dust floor sits above the fees these - // probes produce; the floor has its own suite, so lower it here to keep the - // sizing behaviour under test observable. + // The chain dust floor sits above the fees these probes produce; the floor has + // its own suite, so lower it here to keep the sizing behaviour observable. encoder.dustAmount = 546 return encoder } async function create (encoder, utxos) { + // Every probe here respends the same fixture input on one encoder to + // compare fees, so release the previous build's reservation first. + encoder.clearReservations() return encoder.createTransaction( utxos, TEST_ADDRESS, null, 'test', null, null, false, null, TEST_ADDRESS, null, null, null, true, null @@ -405,6 +419,7 @@ describe('XChainEncoder package-aware fee sizing @regression @tier1', () => { // without it the build would clamp to the 20-per-vByte Bitcoin-scale default // and never price a DOGE package at all. getNetworkInfo: async () => ({ relayfee: 0.001 }), + getTransactionHex: async () => prevTxHex(), getUnconfirmedAncestorPackage: async () => ({ size: 2000, fees: 0 }) } encoder.dustAmount = 546 diff --git a/test/unit/singleInstanceGuard.test.js b/test/unit/singleInstanceGuard.test.js index f458d25..21634eb 100644 --- a/test/unit/singleInstanceGuard.test.js +++ b/test/unit/singleInstanceGuard.test.js @@ -1,9 +1,11 @@ /* * Unit tests for the single-instance deploy guard. * - * The outpoint-reservation store is an in-process Map; these tests pin the - * boot-time guards that keep horizontally scaled or duplicate-process deploys - * from silently racing UTXO selections. + * The outpoint-reservation store, the recent-build duplicate refusal behind it + * and the rate-limiter store are all in-process; these tests pin the boot-time + * guards that keep horizontally scaled or duplicate-process deploys from + * silently racing UTXO selections, and pin that both refusal messages name + * every in-process store a shared-store migration has to move. */ const assert = require('assert') @@ -35,6 +37,17 @@ describe('singleInstanceGuard', function () { ) }) + // The refusal is the deploy-time list of what a shared-store migration + // has to move, so it names every in-process store, the recent-build + // duplicate refusal (XChainEncoder `recentBuilds`) included. + it('names every in-process store in the replica refusal', function () { + let message = '' + try { assertSingleInstance({ ENCODER_REPLICAS: '2' }) } catch (err) { message = err.message } + for (const store of [/outpoint-reservation/, /recent-build/, /rate limiter/]) { + assert.match(message, store, 'replica refusal must name ' + store) + } + }) + it('throws on non-integer values', function () { for (const bad of ['0', '-1', 'two', '1.5', 'NaN']) { assert.throws( @@ -100,6 +113,20 @@ describe('singleInstanceGuard', function () { assert.strictEqual(fs.readFileSync(file, 'utf8'), '1') }) + // Same list, second refusal: a same-host conflict is the other way an + // operator meets the constraint, so it names the per-process stores too. + it('names the in-process stores in the lock-conflict refusal', function () { + const file = path.join(dir, 'b-stores.lock') + fs.writeFileSync(file, '1') + let message = '' + try { + acquireInstanceLock(file, {}, { describePid: () => 'node /XChainEncoder/src/api.js' }) + } catch (err) { message = err.message } + for (const store of [/outpoint-reservation/, /recent-build/]) { + assert.match(message, store, 'lock-conflict refusal must name ' + store) + } + }) + // The platform difference above is worth asserting rather than merely // designing around, because it is the whole reason the legacy path needs // an identity test at all.