Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

<p align="center">
<img src="https://img.shields.io/badge/version-0.16.0-blue" alt="Version">
<img src="https://img.shields.io/badge/tests-1%2C663%2B%20passing-brightgreen" alt="Tests">
<img src="https://img.shields.io/badge/tests-1%2C683%2B%20passing-brightgreen" alt="Tests">
<img src="https://img.shields.io/badge/node-%3E%3D22-green" alt="Node">
<img src="https://img.shields.io/badge/license-AGPL--3.0--or--later-blue" alt="License">
</p>
Expand Down Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions docs/openrpc.build.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
86 changes: 86 additions & 0 deletions docs/openrpc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
}
Expand Down Expand Up @@ -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"
}
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading
Loading