Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
04a3c68
docs: the README badges and test counts describe what the repo ships
jdogresorg Sep 1, 2026
35c13fa
Merge master into develop
jdogresorg Sep 1, 2026
a44d255
feat(encoder): add an exactInputs createtransaction option that spend…
jdogresorg Sep 3, 2026
3b15b8e
feat(encoder): surface the operator maintenance window through /statu…
jdogresorg Sep 3, 2026
7e1b0eb
fix(encoder): reserve every named input at build time so chained send…
jdogresorg Sep 3, 2026
8dbb922
test(encoder): describe how the vendored log shim stays in parity
jdogresorg Sep 2, 2026
409b6b1
chore(coins): sync the vendored coin registry
jdogresorg Sep 3, 2026
b18cd5f
fix(encoder): verify the consensus pin at boot and tighten validateAll
jdogresorg Sep 4, 2026
46ceab5
fix(encoder): the integration test double now serves what the tracker…
jdogresorg Sep 5, 2026
fd44a82
fix(encoder): payload guards on the direct library path
jdogresorg Sep 6, 2026
153e6ad
fix(encoder): refuse a witness-program input on a chain without segwit
jdogresorg Sep 6, 2026
104bccb
fix(observability): release the response body so a stalled collector …
jdogresorg Sep 6, 2026
d3792a1
fix(api): answer a CORS preflight instead of 401ing it on a keyed deploy
jdogresorg Sep 6, 2026
79f918b
fix(encoder): the envelope cancel takes the reservation guards it was…
jdogresorg Sep 6, 2026
55974e6
fix(encoder): the single-instance rationale counts every in-process s…
jdogresorg Sep 6, 2026
203ad21
release: v0.15.0
jdogresorg Sep 4, 2026
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 39 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
# XChain Platform Encoder

<p align="center">
<img src="https://img.shields.io/badge/version-0.12.0-blue" alt="Version">
<img src="https://img.shields.io/badge/tests-1%2C561%2B%20passing-brightgreen" alt="Tests">
<img src="https://img.shields.io/badge/version-0.15.0-blue" alt="Version">
<img src="https://img.shields.io/badge/tests-1%2C604%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 All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions docs/openrpc.build.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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' } } } } } } },
},
Expand Down
26 changes: 25 additions & 1 deletion docs/openrpc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
}
Expand Down Expand Up @@ -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."
}
}
],
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.12.0",
"version": "0.15.0",
"license": "AGPL-3.0-or-later",
"repository": {
"type": "git",
Expand Down
Loading
Loading