diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8efafe..b6c5a77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,16 @@ on: jobs: ci: uses: XChain-Platform/.github/.github/workflows/ci-reusable.yml@6f4d39ae85787fc31e90a31588d87610a2c33103 # pin: XChain-Platform/.github @ master 2026-08-14; bump deliberately + with: + # On a release or hotfix PR, check the siblings out at the PR's own + # branch so a release-branch-only change to a file a cross-repo guard + # reads is tested against the train, not against develop. The shared + # workflow otherwise pins siblings at develop for any pull request, + # which is what let this repo's release PRs go red on commits that + # touch neither the guard nor the file it reads. Empty everywhere + # else, which keeps the existing behaviour. Same expression as + # xchain-indexer and xchain-explorer. + siblings-ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || '' }} # Override the Node version for a repo if ever needed: # with: # node-version: "20" @@ -85,6 +95,12 @@ jobs: # and same layout as the shared workflow uses. - name: Check out declared sibling repositories uses: XChain-Platform/.github/actions/checkout-siblings@master # one definition for every call site; see the action for why this tracks master + # The ratchet re-runs the unit suite, so it needs the same release/hotfix + # awareness as the gate's own siblings-ref above, or it measures a suite + # whose cross-repo guards disagree with the gate for a reason that exists + # nowhere but in CI. + with: + ref: ${{ (startsWith(github.head_ref, 'release/') || startsWith(github.head_ref, 'hotfix/')) && github.head_ref || '' }} - name: Use Node.js 22 uses: actions/setup-node@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1be559e..a010265 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ 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). +## [Unreleased] + +## [0.17.0] - 2026-09-10 + +### Added +- `release_inputs` releases held UTXOs, and a shortfall error names the reserved candidates, so held inputs are no longer read as insufficient funds. + +### Fixed +- The written compression bytes are reported as `compression.data` and `compression.rawData`, so a consumer can confirm what the PSBT carries. +- Both CI call sites pass the siblings ref, so a release PR checks its siblings at the train branch rather than at develop. + ## [0.16.0] - 2026-09-08 ### Added diff --git a/README.md b/README.md index 9baf3a2..702c54e 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Version - Tests + Tests Node License

@@ -165,20 +165,20 @@ neither source sets one, so these defaults hold on an unconfigured box: | `npm run build` | Production browser bundle (minified) -> `dist/xchain_encoder.min.js` | | `npm run build:dev` | Development browser bundle (unminified) | | `npm run smoke-test` | Smoke tests (~52 tests, <1s) | -| `npm run test:unit` | Unit tests (798 tests) | +| `npm run test:unit` | Unit tests (815 tests) | | `npm run test:integration` | Integration tests (115 tests) | | `npm run test:boundary` | Boundary condition tests (~101 tests) | | `npm run test:security` | Security tests (63 tests) | | `npm run test:fuzz` | Property-based fuzz tests (6 suites, 6 tests) | | `npm run test:chaos` | Chaos engineering tests (65 tests) | | `npm run test:e2e` | End-to-end tests (~158 tests) | -| `npm run test:regression` | Regression tests (299 tests) | +| `npm run test:regression` | Regression tests (302 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, 798 tests) | +| `npm test` | Unit tests (hermetic, no external services, 815 tests) | | `npm run test:regtest` | Regtest integration tests (requires local bitcoind) | ## Test Suite diff --git a/docs/openrpc.build.js b/docs/openrpc.build.js index 4d7a1c3..25d68ca 100644 --- a/docs/openrpc.build.js +++ b/docs/openrpc.build.js @@ -73,7 +73,7 @@ const METHODS = [ { 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. 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' } } } } } } }, + 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' } } } }, reservation: { type: 'object', description: 'receipt for the input reservations this build kept: {id, outpoints, expiresAt}. Present whenever the build claimed at least one outpoint. Pass id to release_inputs the moment you know you will not broadcast, so an abandoned compose does not hold the address\'s inputs for the rest of the reservation window.', properties: { id: str('reservation ticket id (32-char lowercase hex)'), outpoints: { type: 'array', items: { type: 'string' } }, expiresAt: int('epoch ms the last of these reservations lapses at') } } } } }, }, { name: 'create_envelope_cancel_tx', @@ -91,7 +91,21 @@ const METHODS = [ { name: 'feePerKb', schema: int('fee rate in base units per kB (clamped to the fee-rate cap)') }, { name: 'replacebyfee', schema: bool('signal replace-by-fee') }, ], - result: { name: 'cancel', schema: { type: 'object', properties: { psbt: str('unsigned key-path sweep PSBT, hex'), encoding: str('"TAPROOT"'), cancel: bool('always true'), fee: int('miner fee funded, base units') } } }, + result: { name: 'cancel', schema: { type: 'object', properties: { psbt: str('unsigned key-path sweep PSBT, hex'), encoding: str('"TAPROOT"'), cancel: bool('always true'), fee: int('miner fee funded, base units'), reservation: { type: 'object', description: 'receipt for the commit-outpoint reservation this cancel kept: {id, outpoints, expiresAt}; pass id to release_inputs if the cancel is abandoned', properties: { id: str('reservation ticket id (32-char lowercase hex)'), outpoints: { type: 'array', items: { type: 'string' } }, expiresAt: int('epoch ms the reservation lapses at') } } } } }, + }, + { + name: 'release_inputs', + summary: 'Hand back the input reservations one earlier build took, named by its reservation ticket id.', + description: 'A successful create_tx / create_envelope_cancel_tx keeps its inputs reserved for five minutes, ' + + 'because the caller is expected to sign and broadcast them. A wallet that composes when its send modal ' + + 'opens abandons most of those builds, and each abandoned one holds the address\'s inputs for the rest of ' + + 'the window, so the next compose on a few-UTXO address reports insufficient funds against its own money. ' + + 'Call this as soon as you know you will not broadcast. Ownership is enforced by the ticket: the id is ' + + 'unguessable and every outpoint is freed only while it still carries the stamp that build wrote, so a ' + + 'claim another call has since taken over is reported in `retained` and left in place. Idempotent and ' + + 'never an error: an unknown, expired or already-released ticket returns found:false with empty lists.', + params: [{ name: 'reservationId', required: true, schema: str('reservation ticket id from result.reservation.id (32-char lowercase hex)') }], + result: { name: 'release', schema: { type: 'object', properties: { reservationId: str('the ticket id that was presented'), found: bool('the ticket was live and has now been consumed; false for an unknown, expired or already-released ticket'), released: { type: 'array', description: 'outpoints ("txid:vout") this call freed', items: { type: 'string' } }, retained: { type: 'array', description: 'outpoints on the ticket whose reservation had already lapsed and been re-taken by another build, so they were left alone', items: { type: 'string' } } } } }, }, { name: 'broadcast_tx', diff --git a/docs/openrpc.json b/docs/openrpc.json index 22f846d..c0ba594 100644 --- a/docs/openrpc.json +++ b/docs/openrpc.json @@ -358,6 +358,26 @@ } } } + }, + "reservation": { + "type": "object", + "description": "receipt for the input reservations this build kept: {id, outpoints, expiresAt}. Present whenever the build claimed at least one outpoint. Pass id to release_inputs the moment you know you will not broadcast, so an abandoned compose does not hold the address's inputs for the rest of the reservation window.", + "properties": { + "id": { + "type": "string", + "description": "reservation ticket id (32-char lowercase hex)" + }, + "outpoints": { + "type": "array", + "items": { + "type": "string" + } + }, + "expiresAt": { + "type": "integer", + "description": "epoch ms the last of these reservations lapses at" + } + } } } } @@ -454,6 +474,72 @@ "fee": { "type": "integer", "description": "miner fee funded, base units" + }, + "reservation": { + "type": "object", + "description": "receipt for the commit-outpoint reservation this cancel kept: {id, outpoints, expiresAt}; pass id to release_inputs if the cancel is abandoned", + "properties": { + "id": { + "type": "string", + "description": "reservation ticket id (32-char lowercase hex)" + }, + "outpoints": { + "type": "array", + "items": { + "type": "string" + } + }, + "expiresAt": { + "type": "integer", + "description": "epoch ms the reservation lapses at" + } + } + } + } + } + } + }, + { + "name": "release_inputs", + "summary": "Hand back the input reservations one earlier build took, named by its reservation ticket id.", + "description": "A successful create_tx / create_envelope_cancel_tx keeps its inputs reserved for five minutes, because the caller is expected to sign and broadcast them. A wallet that composes when its send modal opens abandons most of those builds, and each abandoned one holds the address's inputs for the rest of the window, so the next compose on a few-UTXO address reports insufficient funds against its own money. Call this as soon as you know you will not broadcast. Ownership is enforced by the ticket: the id is unguessable and every outpoint is freed only while it still carries the stamp that build wrote, so a claim another call has since taken over is reported in `retained` and left in place. Idempotent and never an error: an unknown, expired or already-released ticket returns found:false with empty lists.", + "paramStructure": "by-name", + "params": [ + { + "name": "reservationId", + "required": true, + "schema": { + "type": "string", + "description": "reservation ticket id from result.reservation.id (32-char lowercase hex)" + } + } + ], + "result": { + "name": "release", + "schema": { + "type": "object", + "properties": { + "reservationId": { + "type": "string", + "description": "the ticket id that was presented" + }, + "found": { + "type": "boolean", + "description": "the ticket was live and has now been consumed; false for an unknown, expired or already-released ticket" + }, + "released": { + "type": "array", + "description": "outpoints (\"txid:vout\") this call freed", + "items": { + "type": "string" + } + }, + "retained": { + "type": "array", + "description": "outpoints on the ticket whose reservation had already lapsed and been re-taken by another build, so they were left alone", + "items": { + "type": "string" + } } } } diff --git a/package-lock.json b/package-lock.json index acfecf4..b92aa45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-encoder", - "version": "0.16.0", + "version": "0.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-encoder", - "version": "0.16.0", + "version": "0.17.0", "license": "AGPL-3.0-or-later", "dependencies": { "axios": "^1.18.1", diff --git a/package.json b/package.json index 044c046..f865c32 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.16.0", + "version": "0.17.0", "license": "AGPL-3.0-or-later", "repository": { "type": "git", diff --git a/src/XChainEncoder.js b/src/XChainEncoder.js index 6989dd1..cc7c0f0 100644 --- a/src/XChainEncoder.js +++ b/src/XChainEncoder.js @@ -425,6 +425,14 @@ class XChainEncoder { // 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() + // reservation ticket id -> the claims ONE successful build kept, with the + // expiry stamp each of them wrote plus the recent-build record that build + // registered. This is what makes an explicit release possible without + // handing callers a way to free somebody else's inputs: the ticket id is + // unguessable and the per-claim stamp is re-checked at release time, so a + // caller can only ever drop entries its own build still owns. + // See releaseReservation. + this.reservationTickets = new Map() } // Sweep the recent-build map the same way _evictExpiredReservations sweeps @@ -457,6 +465,12 @@ class XChainEncoder { ) } this.recentBuilds.set(txid, now + RESERVATION_TTL_MS) + // Returned so the caller can put the txid (and therefore this record) on + // the build's reservation ticket: an explicit release has to retire the + // duplicate refusal alongside the outpoints, or a wallet that composes, + // releases and recomposes the SAME transaction is refused for the rest of + // the window by the guard instead of the reservation map. + return txid } // A reserved outpoint is one an in-flight selection has claimed and not yet @@ -529,6 +543,101 @@ class XChainEncoder { } } + // Sweep expired tickets so the ticket map cannot outgrow the reservation map + // it describes. A ticket expires with the LAST of its claims. + _evictExpiredReservationTickets(now) { + for (const [id, ticket] of this.reservationTickets) { + if (ticket.expiry <= now) this.reservationTickets.delete(id) + } + } + + // Mint the receipt for the claims a SUCCESSFUL build kept, so the caller can + // hand them back the moment it knows it will not broadcast (the wallet closes + // its compose modal) instead of stranding a funded address for the rest of + // RESERVATION_TTL_MS. Returns null when the build claimed nothing still live, + // and the result then simply carries no reservation block: a release RPC that + // has nothing to release must not mint a ticket that pretends otherwise. + // + // Only claims whose stamp is still the map's value go on the ticket. Anything + // else already lapsed and was re-taken by another call, and putting it on this + // ticket would hand this caller a lever over a foreign claim. + _mintReservationTicket(callReservations, now) { + this._evictExpiredReservationTickets(now) + const live = [] + for (const claim of callReservations) { + if (this.outpointReservations.get(claim.key) === claim.expiry) live.push({ key: claim.key, expiry: claim.expiry }) + } + if (live.length === 0) return null + const id = crypto.randomBytes(16).toString('hex') + let expiry = 0 + for (const claim of live) if (claim.expiry > expiry) expiry = claim.expiry + const txid = callReservations.buildTxid || null + this.reservationTickets.set(id, { + claims: live, + expiry, + txid, + // The recent-build record's own ownership stamp, read back rather than + // recomputed so a rebuild that re-registered the txid under a later + // clock is left alone by this ticket's release. + txidExpiry: txid ? (this.recentBuilds.get(txid) || null) : null + }) + return { id, outpoints: live.map(c => c.key), expiresAt: expiry } + } + + // Explicit, ownership-stamped release of ONE build's reservations, the + // counterpart of the receipt _mintReservationTicket put on that build's + // result. The wallet composes when its send modal opens and most of those + // builds are never broadcast, so without this every abandoned compose held a + // few-UTXO address's whole balance for five minutes and the next compose read + // as insufficient funds. Idempotent and never an error: an unknown, already + // released or lapsed ticket returns found:false rather than throwing, because + // a wallet firing this from a modal-close handler cannot usefully react to a + // failure and must not be taught to retry one. + // + // Ownership is enforced twice: the ticket id is 16 unguessable bytes handed + // only to the build that took the claims, and each claim is dropped only while + // the map still holds the exact expiry that build wrote. A claim whose stamp + // moved lapsed and was re-taken by somebody else, so it is REPORTED (retained) + // and left in place; dropping it would reopen the same-address double-spend + // window the reservation map exists to close. + releaseReservation(reservationId) { + if (typeof reservationId !== 'string' || !/^[0-9a-f]{32}$/.test(reservationId)) { + throw new TypeError('reservationId must be a 32-character lowercase hex string, as returned in create_tx result.reservation.id') + } + const now = Date.now() + this._evictExpiredReservations(now) + this._evictExpiredEnvelopeCancelClaims(now) + this._evictExpiredRecentBuilds(now) + this._evictExpiredReservationTickets(now) + const ticket = this.reservationTickets.get(reservationId) + if (!ticket) return { reservationId, found: false, released: [], retained: [] } + // A ticket is single-use: the claims it names are either freed now or were + // already taken over by another call, and either way it has no further use. + this.reservationTickets.delete(reservationId) + const released = [] + const retained = [] + for (const claim of ticket.claims) { + if (this.outpointReservations.get(claim.key) === claim.expiry) { + this.outpointReservations.delete(claim.key) + // The cancel path's stamp mirrors the reservation it wrote, so it + // has to go with it or the outpoint reads free to selection while + // the cancel path still believes it owns the claim. + if (this.envelopeCancelClaims.get(claim.key) === claim.expiry) this.envelopeCancelClaims.delete(claim.key) + released.push(claim.key) + } else { + retained.push(claim.key) + } + } + // Retire the duplicate-build refusal for this build too, under the same + // stamp check. Releasing the inputs without it just moves the five-minute + // wall: the recompose that follows a release is byte-identical by + // construction and would be refused DUPLICATE_TRANSACTION instead. + if (ticket.txid && ticket.txidExpiry != null && this.recentBuilds.get(ticket.txid) === ticket.txidExpiry) { + this.recentBuilds.delete(ticket.txid) + } + return { reservationId, found: true, released, retained } + } + // Explicit release of all reservations and recent-build records. The encoder // holds no durable state, so this is primarily a test seam; production // relies on the TTL. @@ -536,6 +645,7 @@ class XChainEncoder { this.outpointReservations.clear() this.recentBuilds.clear() this.envelopeCancelClaims.clear() + this.reservationTickets.clear() } isSegwitUTXO(utxo) { @@ -860,14 +970,21 @@ class XChainEncoder { // so a concurrent call's entries are never dropped; see // _releaseCallReservations. The success path keeps its reservations on purpose: // the caller is about to sign and broadcast those inputs. + // A successful build's kept claims come back as a `reservation` receipt on the + // result, which the caller hands to releaseReservation the moment it knows it + // will not broadcast; see _mintReservationTicket. async createTransaction(...args){ const callReservations = [] + let result try { - return await this._buildTransaction(callReservations, ...args) + result = await this._buildTransaction(callReservations, ...args) } catch (err) { this._releaseCallReservations(callReservations) throw err } + const reservation = this._mintReservationTicket(callReservations, Date.now()) + if (reservation && result && typeof result === 'object') result.reservation = reservation + return result } async _buildTransaction(callReservations, utxos, pubkey, customOutputs, data, rawData, fee, replacebyfee, @@ -2335,10 +2452,22 @@ class XChainEncoder { // and a negative "change" is expected and harmless. if (!p2shHash && changeSatoshis < 0) { const required = outputSatoshis + BigInt(estimatedFee) + // Name the reserved candidates when there were any: the shortfall then + // comes from inputs another build of the last RESERVATION_TTL_MS still + // holds, not from an under-funded address, and the caller's fix is to + // broadcast that build or wait, not to fund the address. Found live on + // TDOGE: two spendable outputs plus dust, two un-broadcast + // builds, and the third build reported the dust output as the whole + // balance, which the wallet rendered as "not enough funds". Same + // wording as the zero-selected branch above so one reader handles both. + const held = reservedCandidates > 0 + ? `; ${reservedCandidates} candidate input(s) are reserved by a transaction built in the last ` + + `${Math.round(RESERVATION_TTL_MS / 60000)} minutes; broadcast that transaction or wait for the reservation to lapse` + : '' throw new OperationalError( 'INSUFFICIENT_FUNDS', - `insufficient funds: selected inputs total ${inputSatoshis} but ${required} is required (outputs ${outputSatoshis} + fee ${estimatedFee})`, - { required: jsonSafeSat(required), available: jsonSafeSat(inputSatoshis), outputs: jsonSafeSat(outputSatoshis), fee: estimatedFee } + `insufficient funds: selected inputs total ${inputSatoshis} but ${required} is required (outputs ${outputSatoshis} + fee ${estimatedFee})${held}`, + { required: jsonSafeSat(required), available: jsonSafeSat(inputSatoshis), outputs: jsonSafeSat(outputSatoshis), fee: estimatedFee, reservedCandidates } ) } @@ -2534,7 +2663,9 @@ class XChainEncoder { // 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()) + // The txid rides back on the per-call reservation ledger so the ticket + // minted for this build can retire this record too (see releaseReservation). + callReservations.buildTxid = this._refuseDuplicateBuild(psbt, Date.now()) let result = {"psbt":psbt,"encoding":preparedData["encoding"]} @@ -2561,6 +2692,27 @@ class XChainEncoder { storedLength: compressionResult.storedLength, reason: compressionResult.reason } + // THE BYTES, not just the verdict. A boolean plus two lengths is not + // enough to rebuild this transaction: the compression pass rewrote the + // COMPRESSION field and replaced the payload in place, so a caller + // holding only its own pre-compression request can neither state what + // these bytes say (its confirm check reads a string the PSBT does not + // carry, and refuses its own transaction as tampered) nor rebuild the + // phase-2 reveal from them (re-deriving the marker without the deflated + // payload compiles a DIFFERENT carrier, and a reveal that does not + // reproduce the commit's chunks can never spend the commit - the funds + // are stranded, after the money is spent). + // + // So both halves ride back exactly as they went in: `data` is the action + // string written into the carrier, and `rawData` the stored payload in + // the same Latin-1 one-char-per-byte form every rawData parameter takes, + // so it can be handed straight back to create_tx/spendP2sh. Present only + // when compression actually fired; an untouched payload is already the + // caller's own. + if (compressionResult.compressed){ + result.compression.data = compressionResult.data + result.compression.rawData = compressionResult.rawData.toString('binary') + } } if (preparedData["encoding"] === Encoding.P2SH || preparedData["encoding"] === Encoding.P2WSH){ result.carrierScripts = (preparedData["dataBufferArray"] || []).map(b => b.toString('hex')) @@ -2752,14 +2904,21 @@ class XChainEncoder { // Public entry point. Like createTransaction it owns the per-call reservation // ledger: a build that throws for any reason hands the commit outpoint back at // once instead of squatting it until RESERVATION_TTL_MS. + // A successful cancel keeps its commit-outpoint claim and gets the same + // `reservation` receipt create_tx does, so an abandoned cancel can hand the + // commit outpoint back instead of blocking every rebuild for the whole TTL. async createEnvelopeCancelTransaction(params = {}){ const callReservations = [] + let result try { - return await this._buildEnvelopeCancelTransaction(callReservations, params) + result = await this._buildEnvelopeCancelTransaction(callReservations, params) } catch (err) { this._releaseEnvelopeCancelClaims(callReservations) throw err } + const reservation = this._mintReservationTicket(callReservations, Date.now()) + if (reservation && result && typeof result === 'object') result.reservation = reservation + return result } async _buildEnvelopeCancelTransaction(callReservations, { commitTxid, commitVout, commitValue, internalPubkey, tapleafHash, destination, feePerKb = null, replacebyfee = false } = {}){ diff --git a/src/api.js b/src/api.js index e2c870a..4801412 100644 --- a/src/api.js +++ b/src/api.js @@ -430,6 +430,43 @@ const jsonRpcController = { result.psbt = result.psbt.toHex() return result }, + // Hand back the input reservations ONE earlier build took, named by the + // ticket id that build's result carried. The wallet composes when its send + // modal opens and abandons most of those builds, and each abandoned one held + // the address's inputs for the full reservation window, so the next compose + // on a few-UTXO address reported insufficient funds against its own money. + // Ownership lives in the ticket (unguessable id plus a per-claim stamp + // re-checked at release time), so this method can never free another + // caller's inputs; see XChainEncoder.releaseReservation. + // + // Deliberately never an error for an unknown, expired or already-released + // ticket: a modal-close handler cannot act on a failure, and reporting one + // would push clients to retry a call that has nothing left to do. found + // tells them which it was. + async release_inputs(rawParams) { + let reservationId + try { + if (typeof rawParams !== 'object' || rawParams === null || Array.isArray(rawParams)) { + throw new TypeError('Request params must be an object') + } + reservationId = validator.validateReservationId(rawParams.reservationId) + } catch (err) { + const e = new Error(err.message) + e.code = -32602 + throw e + } + try { + return encoder.releaseReservation(reservationId) + } catch (err) { + const isKnown = err instanceof TypeError || err instanceof RangeError + if (!isKnown) { + console.error('Encoder error:', err) + } + const e = new Error(isKnown ? err.message : 'Internal encoder error') + e.code = isKnown ? -32602 : -32603 + throw e + } + }, async broadcast_tx(rawParams) { let tx_hex = rawParams && rawParams.tx_hex if (!tx_hex) { diff --git a/src/validator.js b/src/validator.js index e86d503..43cda0e 100644 --- a/src/validator.js +++ b/src/validator.js @@ -763,6 +763,18 @@ function validateAddress(address, label = 'address') { return address } +// Reservation-ticket id for release_inputs: exactly the 16 random bytes +// _mintReservationTicket emits, lowercase hex. Shape-checked here so a +// non-string, an oversized blob or a wrong-length id is a precise -32602 rather +// than reaching the encoder, and so the id can never be used as an unbounded +// key into the ticket map. +function validateReservationId(reservationId) { + if (typeof reservationId !== 'string' || !/^[0-9a-f]{32}$/.test(reservationId)) { + throw new TypeError('reservationId must be a 32-character lowercase hex string, as returned in create_tx result.reservation.id') + } + return reservationId +} + function validateChange(change) { if (change == null) return null return validateAddress(change) @@ -902,6 +914,7 @@ module.exports = { validateCompressedPubKey, validateChange, validateAddress, + validateReservationId, validateAll, parseSatoshiAmount, MAX_SATOSHI_U64, diff --git a/test/regression/reg-31-shortfall-names-reserved-inputs.test.js b/test/regression/reg-31-shortfall-names-reserved-inputs.test.js new file mode 100644 index 0000000..b177faa --- /dev/null +++ b/test/regression/reg-31-shortfall-names-reserved-inputs.test.js @@ -0,0 +1,101 @@ +/********************************************************************* + * + * Copyright © 2025-2026 Dankest, LLC + * Based on XChain Platform by Dankest, LLC - https://dankest.llc + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This file is part of XChain Platform. Licensed under the GNU Affero + * General Public License v3.0 or later; see LICENSE.md. A commercial + * license (without AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * + * REG-31: a shortfall caused by RESERVED inputs names them. + * + * Measured on TDOGE 2026-09-08 from an address holding 1072 + 950 DOGE and one + * 0.001 DOGE dust output: two successful builds (each reserving one large + * output for RESERVATION_TTL_MS) and the third build selected only the dust + * output and threw "insufficient funds: selected inputs total 100000 but + * 2910000 is required", with nothing in the message or details saying that the + * other two candidates were on hold. The wallet rendered it as "You don't have + * enough funds", to a user holding 2,000 DOGE. + * + * The zero-selected branch already said "reserved by a transaction built in + * the last N minutes"; this pins the same wording and a reservedCandidates + * count on the partial-selection branch, so one reader handles both. + *********************************************************************/ +const assert = require('assert') +const { + makeEncoder, makeUtxo, getTestAddress, TXID_A, TXID_B, TXID_C +} = require('../integration/helpers/utxoFactory') + +const NETWORK = 'bitcoin-regtest' +const SEND = 'SEND|0|XCHAIN|1|' + getTestAddress(NETWORK) + +function build (encoder, utxos) { + const address = getTestAddress(NETWORK) + return encoder.createTransaction( + utxos, address, null, SEND, null, 10000, false, null, address, + null, null, null, true, 0.00001 + ) +} + +describe('REG-31: a shortfall caused by reserved inputs names them @regression', function () { + this.timeout(10000) + + // Two spendable outputs and one dust output, the measured TDOGE shape. + function view () { + return [ + makeUtxo(NETWORK, TXID_A, 0, 100000000), + makeUtxo(NETWORK, TXID_B, 0, 1000), + makeUtxo(NETWORK, TXID_C, 0, 90000000) + ] + } + + it('third build after two un-broadcast builds: dust-only selection, reserved candidates named', async () => { + const encoder = makeEncoder(NETWORK) + const first = await build(encoder, view()) + const second = await build(encoder, view()) + assert.strictEqual(first.psbt.txInputs.length, 1) + assert.strictEqual(second.psbt.txInputs.length, 1) + assert.strictEqual(encoder.outpointReservations.size, 2, 'the two large outputs are on hold') + + await assert.rejects( + () => build(encoder, view()), + (err) => { + assert.strictEqual(err.operational, true) + assert.strictEqual(err.xchainCode, 'INSUFFICIENT_FUNDS') + // The partial selection is still reported (the dust output was taken)... + assert.strictEqual(err.details.available, 1000) + // ...and the hold is now visible in both the details and the message. + assert.strictEqual(err.details.reservedCandidates, 2) + assert.match(err.message, /2 candidate input\(s\) are reserved by a transaction built in the last 5 minutes/) + assert.match(err.message, /broadcast that transaction or wait/) + return true + } + ) + }) + + it('a genuinely under-funded address reports zero reserved candidates and no hold wording', async () => { + const encoder = makeEncoder(NETWORK) + await assert.rejects( + () => build(encoder, [makeUtxo(NETWORK, TXID_B, 0, 1000)]), + (err) => { + assert.strictEqual(err.xchainCode, 'INSUFFICIENT_FUNDS') + assert.strictEqual(err.details.reservedCandidates, 0) + assert.doesNotMatch(err.message, /reserved by a transaction built/) + return true + } + ) + }) + + it('the failed third build released its own dust claim, leaving only the two live holds', async () => { + const encoder = makeEncoder(NETWORK) + await build(encoder, view()) + await build(encoder, view()) + await assert.rejects(() => build(encoder, view())) + assert.strictEqual(encoder.outpointReservations.size, 2) + }) +}) diff --git a/test/unit/XChainEncoder.releaseReservations.test.js b/test/unit/XChainEncoder.releaseReservations.test.js new file mode 100644 index 0000000..103590c --- /dev/null +++ b/test/unit/XChainEncoder.releaseReservations.test.js @@ -0,0 +1,236 @@ +// 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. + +// Ownership-stamped release of input reservations. +// +// A successful build keeps its inputs reserved for five minutes because the +// caller is expected to sign and broadcast them. The wallet composes when its +// send modal OPENS, and most of those builds are abandoned, so on a few-UTXO +// address the next compose met its own money already claimed and reported +// insufficient funds (observed live on TDOGE). create_tx now hands back a +// reservation receipt and release_inputs gives those inputs back on demand, +// without giving any caller a lever over another call's claims. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../src/XChainEncoder') + +const pubkeyBuf = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex' +) +const TXID_ONE = 'a'.repeat(64) +const TXID_TWO = 'b'.repeat(64) + +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') + +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 test chain, both upstreams stubbed: nothing here should reach a +// node or the tracker, and a call that tries to is a defect in the test. +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('these builds supply their own utxos') } + } + return encoder +} + +// 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: 10000, 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) +} + +// The shape the live defect had: a funded address whose whole balance is one +// spendable output, so the second compose has nothing left to select. +function oneFundedUtxo () { + return [makeSegwitUtxo(TXID_ONE, 0, 5000000)] +} + +describe('XChainEncoder reservation release', () => { + + it('a successful build returns a receipt naming the outpoints it reserved', async () => { + const encoder = makeEncoder() + const result = await createTx(encoder, oneFundedUtxo()) + assert.ok(result.reservation, 'build kept reservations but returned no receipt') + assert.deepStrictEqual(result.reservation.outpoints, [`${TXID_ONE}:0`]) + assert.match(result.reservation.id, /^[0-9a-f]{32}$/) + assert.ok(result.reservation.expiresAt > Date.now()) + // The receipt describes real state, not a label: that outpoint is claimed. + assert.strictEqual(encoder._isOutpointReserved(`${TXID_ONE}:0`, Date.now()), true) + }) + + it('releasing frees the inputs, so the next compose funds instead of failing', async () => { + const encoder = makeEncoder() + const first = await createTx(encoder, oneFundedUtxo()) + + // Without the release this is the live defect, pinned here so the fix is + // measured against the real failure and not against a flag. + await assert.rejects( + () => createTx(encoder, oneFundedUtxo(), { fee: 11000 }), + (err) => err.operational === true && err.xchainCode === 'INSUFFICIENT_FUNDS' + ) + + const release = encoder.releaseReservation(first.reservation.id) + assert.strictEqual(release.found, true) + assert.deepStrictEqual(release.released, [`${TXID_ONE}:0`]) + assert.deepStrictEqual(release.retained, []) + + const second = await createTx(encoder, oneFundedUtxo(), { fee: 11000 }) + assert.strictEqual(second.psbt.txInputs.length, 1) + }) + + it('releasing retires the duplicate-build refusal for that same build', async () => { + // Freeing the inputs alone just moves the five-minute wall: the recompose + // after a release is byte-identical by construction, so the duplicate guard + // would refuse it for the rest of the window. + const encoder = makeEncoder() + const first = await createTx(encoder, oneFundedUtxo()) + encoder.releaseReservation(first.reservation.id) + const again = await createTx(encoder, oneFundedUtxo()) + assert.strictEqual(again.psbt.txInputs.length, 1) + }) + + it('a foreign ticket id releases nothing and leaves the claims standing', async () => { + const encoder = makeEncoder() + await createTx(encoder, oneFundedUtxo()) + const out = encoder.releaseReservation('f'.repeat(32)) + assert.strictEqual(out.found, false) + assert.deepStrictEqual(out.released, []) + assert.strictEqual(encoder._isOutpointReserved(`${TXID_ONE}:0`, Date.now()), true) + // And the address is still held, which is the point of refusing the release. + await assert.rejects( + () => createTx(encoder, oneFundedUtxo(), { fee: 11000 }), + (err) => err.xchainCode === 'INSUFFICIENT_FUNDS' + ) + }) + + it('a claim another call has since re-taken is retained, never dropped', async () => { + // The stamp is the ownership proof. Once this build's claim lapses and a + // concurrent build re-reserves the outpoint against a later clock, the map + // value no longer matches the ticket, and dropping it would reopen the + // same-address double-spend window the reservation map exists to close. + const encoder = makeEncoder() + const first = await createTx(encoder, oneFundedUtxo()) + const key = `${TXID_ONE}:0` + const foreignExpiry = encoder.outpointReservations.get(key) + 60000 + encoder.outpointReservations.set(key, foreignExpiry) + + const out = encoder.releaseReservation(first.reservation.id) + assert.strictEqual(out.found, true) + assert.deepStrictEqual(out.released, []) + assert.deepStrictEqual(out.retained, [key]) + assert.strictEqual(encoder.outpointReservations.get(key), foreignExpiry, + 'a foreign claim was dropped by another caller\'s release') + }) + + it('is idempotent: a second release of the same ticket reports nothing to do', async () => { + const encoder = makeEncoder() + const first = await createTx(encoder, oneFundedUtxo()) + assert.strictEqual(encoder.releaseReservation(first.reservation.id).found, true) + const second = encoder.releaseReservation(first.reservation.id) + assert.strictEqual(second.found, false) + assert.deepStrictEqual(second.released, []) + }) + + it('releases only the presented build\'s claims, not a concurrent build\'s', async () => { + const encoder = makeEncoder() + const utxos = [makeSegwitUtxo(TXID_ONE, 0, 5000000), makeSegwitUtxo(TXID_TWO, 1, 4000000)] + const first = await createTx(encoder, utxos) + const firstKey = first.reservation.outpoints[0] + // The second build skips the reserved outpoint and claims the other one. + const second = await createTx(encoder, utxos, { fee: 11000 }) + const secondKey = second.reservation.outpoints[0] + assert.notStrictEqual(firstKey, secondKey) + + const out = encoder.releaseReservation(first.reservation.id) + assert.deepStrictEqual(out.released, [firstKey]) + assert.strictEqual(encoder._isOutpointReserved(firstKey, Date.now()), false) + assert.strictEqual(encoder._isOutpointReserved(secondKey, Date.now()), true) + }) + + it('refuses a malformed ticket id with a TypeError, before touching any state', async () => { + const encoder = makeEncoder() + await createTx(encoder, oneFundedUtxo()) + for (const bad of [null, undefined, 42, {}, 'nothex', 'A'.repeat(32), 'f'.repeat(31)]) { + assert.throws(() => encoder.releaseReservation(bad), TypeError, `accepted ${String(bad)}`) + } + assert.strictEqual(encoder._isOutpointReserved(`${TXID_ONE}:0`, Date.now()), true) + }) + + it('mints no receipt when the build kept no claims', async () => { + // The p2sh/p2wsh reveal funds itself from phase-1 outputs and runs no input + // selection, so there is nothing to release and no ticket to hand out. + const encoder = makeEncoder() + const ticket = encoder._mintReservationTicket([], Date.now()) + assert.strictEqual(ticket, null) + assert.strictEqual(encoder.reservationTickets.size, 0) + }) + + it('sweeps expired tickets so the ticket map cannot outgrow the reservations', async () => { + const encoder = makeEncoder() + const first = await createTx(encoder, oneFundedUtxo()) + assert.strictEqual(encoder.reservationTickets.size, 1) + const ticket = encoder.reservationTickets.get(first.reservation.id) + encoder._evictExpiredReservationTickets(ticket.expiry + 1) + assert.strictEqual(encoder.reservationTickets.size, 0) + assert.strictEqual(encoder.releaseReservation(first.reservation.id).found, false) + }) +}) + +describe('release_inputs JSON-RPC method', () => { + const { jsonRpcController, encoder } = require('../../src/api') + + afterEach(() => encoder.clearReservations()) + + it('releases the claims the presented ticket owns', async () => { + const claims = [] + const key = `${TXID_TWO}:7` + encoder._claimOutpoint(claims, key, Date.now()) + const receipt = encoder._mintReservationTicket(claims, Date.now()) + const out = await jsonRpcController.release_inputs({ reservationId: receipt.id }) + assert.strictEqual(out.found, true) + assert.deepStrictEqual(out.released, [key]) + assert.strictEqual(encoder._isOutpointReserved(key, Date.now()), false) + }) + + it('answers an unknown ticket with found:false rather than an error', async () => { + const out = await jsonRpcController.release_inputs({ reservationId: '0'.repeat(32) }) + assert.strictEqual(out.found, false) + assert.deepStrictEqual(out.released, []) + }) + + it('maps a malformed or missing reservationId to invalid params', async () => { + for (const params of [{}, { reservationId: 'zz' }, { reservationId: 7 }, [], null]) { + await assert.rejects( + () => jsonRpcController.release_inputs(params), + (err) => err.code === -32602, + `accepted ${JSON.stringify(params)}` + ) + } + }) +}) diff --git a/test/unit/compression.test.js b/test/unit/compression.test.js index a4d76f1..4ec2b1f 100644 --- a/test/unit/compression.test.js +++ b/test/unit/compression.test.js @@ -333,6 +333,100 @@ describe('encoder FILE payload compression (spec Part B)', function () { }) }) + // Compression rewrites the action string and replaces the payload + // in place, and a boolean plus two lengths does not let anyone rebuild the + // result: the caller's confirm check described a string the PSBT does not + // carry (so a compressible FILE upload refused itself as tampered), and the + // phase-2 reveal was re-derived from the caller's uncompressed payload, + // compiling a carrier that hashes to nothing the commit created. The report + // now carries the WRITTEN bytes, both halves, so the rebuild is a copy + // rather than a re-derivation. + describe('the compression report carries the bytes that were written', function () { + + it('reports the written action string and the stored payload', async function () { + const encoder = makeEncoder() + const network = encoder.network + const raw = compressibleBytes(4000) + + const result = await encoder.createTransaction( + [segwitUtxo(network)], callerAddress(network), null, PUBLIC_FILE, + raw.toString('binary'), + null, false, 'P2WSH', callerAddress(network)) + + assert.strictEqual(result.compression.compressed, true) + assert.strictEqual(compression.compressionFieldOf(result.compression.data), '1', + 'the reported string is the one carrying the marker') + assert.strictEqual(result.compression.data, + compression.withCompressionField(PUBLIC_FILE, '1'), + 'and it is the caller\'s own string with nothing but COMPRESSION set') + // The stored payload, in the same Latin-1 form every rawData + // parameter takes, and it really is the caller's bytes deflated. + const stored = Buffer.from(result.compression.rawData, 'binary') + assert.strictEqual(stored.length, result.compression.storedLength) + assert.ok(zlib.inflateRawSync(stored).equals(raw)) + }) + + it('says nothing extra when compression did not fire', async function () { + const encoder = makeEncoder() + const network = encoder.network + const result = await encoder.createTransaction( + [segwitUtxo(network)], callerAddress(network), null, + 'FILE|0|photo.jpg|image/jpeg|Photo|', crypto.randomBytes(3000).toString('binary'), + null, false, 'P2WSH', callerAddress(network)) + assert.strictEqual(result.compression.compressed, false) + assert.strictEqual(result.compression.data, undefined, + 'an untouched action string is already the caller\'s own') + assert.strictEqual(result.compression.rawData, undefined) + }) + + it('a reveal REBUILT FROM THE REPORT reproduces the commit carrier byte for byte', async function () { + // The decisive property: phase 2 chunks script.compile([data, rawData]) + // and must land on the same carriers phase 1 committed to, or the + // reveal cannot spend the commit and the funds are stranded. + const encoder = makeEncoder() + const network = encoder.network + const raw = compressibleBytes(4000).toString('binary') + + const commit = await encoder.createTransaction( + [segwitUtxo(network)], callerAddress(network), null, PUBLIC_FILE, raw, + null, false, 'P2WSH', callerAddress(network)) + assert.ok(commit.carrierScripts.length > 1, 'a multi-carrier payload, as a real FILE is') + + // Rebuild from what the encoder said it wrote, compression off: this + // is exactly what the SDK now hands spendP2sh. + encoder.clearReservations() + const rebuilt = await encoder.createTransaction( + [segwitUtxo(network)], callerAddress(network), null, + commit.compression.data, commit.compression.rawData, + null, false, 'P2WSH', callerAddress(network), null, null, null, + true, null, null, null, false, false) + assert.deepStrictEqual(rebuilt.carrierScripts, commit.carrierScripts, + 'the reveal reproduces the commit\'s chunks exactly') + }) + + it('the marker WITHOUT the stored bytes builds a different carrier (why both halves ride)', async function () { + // The withdrawn wallet-side remedy, measured: re-derive the marker + // locally and keep the caller's uncompressed payload, and the reveal + // compiles carriers the commit never created. This is the stranding. + const encoder = makeEncoder() + const network = encoder.network + const raw = compressibleBytes(4000).toString('binary') + + const commit = await encoder.createTransaction( + [segwitUtxo(network)], callerAddress(network), null, PUBLIC_FILE, raw, + null, false, 'P2WSH', callerAddress(network)) + + encoder.clearReservations() + const rederived = await encoder.createTransaction( + [segwitUtxo(network)], callerAddress(network), null, + commit.compression.data, raw, + null, false, 'P2WSH', callerAddress(network), null, null, null, + true, null, null, null, false, false) + assert.notDeepStrictEqual(rederived.carrierScripts, commit.carrierScripts, + 'a reveal built this way could never spend the commit') + }) + }) + describe('validator', function () { it('accepts compress as an optional boolean; absent means "use the deployment default"', function () { const base = {