PSBT encoding service for the XChain Platform. Takes an ACTION string, a set of UTXOs, and a public key, and returns an unsigned Partially Signed Bitcoin Transaction (PSBT) ready for the caller to sign and broadcast. The encoder is fully stateless: no database, no persistent connections, every call is independent.
- Four encoding formats: OP_RETURN (76B), P2SH (476B), P2WSH (476B), and multisig (~61B/key); auto-selected by payload size
- AES-128-CTR obfuscation: derives key and IV from the first input's txid;
XCHNmagic prefix on all payloads - Two-transaction P2SH/P2WSH: automatic tx1 (fund) -> tx2 (spend/reveal) orchestration with marker OP_RETURN
- UTXO selection: largest-first selection, duplicate removal, optional unconfirmed filtering, automatic change output
- Fee estimation: byte-accurate transaction size estimation per format via
TxSizeEstimator; dust floor enforcement - Fee rate caps: caller-supplied
fee/feePerKbis capped atMAX_FEE_RATE_MULTIPLIERx the node's own fee estimate (default 100x), so a hostile or buggy request cannot drain inputs into miner fee;MAX_FEE_RATE_KBadds an optional absolute cap - Input validation: centralized parameter validation (
validator.js) with typed errors for all 15createTransactionparameters - Multi-chain support: Bitcoin, Litecoin, and Dogecoin today on mainnet, testnet, and regtest (9 network configs)
- Replace-By-Fee: optional RBF signaling via sequence number
- Custom outputs: arbitrary address/value outputs (e.g., COINPay native coin payments)
- Token-gated content support: encodes FILE v1 gated files and
BATCH(FILE, MESSAGE)issuer-publish flows; ciphertext travels asrawDatavia 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_REPLICASdeclares 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
Full encoder documentation is available in the xchain-documentation repository:
| Document | Description |
|---|---|
| README | Overview, encoding process, format details, API, testing, configuration |
| Format Selection | Decision guide for encoding formats with size limits and trade-offs |
git clone https://github.com/XChain-Platform/xchain-encoder.git
cd xchain-encoder
npm installCreate a .env file:
NETWORK=bitcoin-regtest
NODE_URL=127.0.0.1
NODE_PORT=8332
NODE_USER=rpcuser
NODE_PASSWORD=rpcpass
ENCODER_API_PORT=3000Start the encoder:
npm run api| Variable | Required | Default | Description |
|---|---|---|---|
NETWORK |
Yes | (none) | Coin and network (bitcoin-mainnet, dogecoin-testnet, litecoin-regtest, etc.) |
NODE_URL |
Yes | (none) | Coin node RPC host (e.g., 127.0.0.1) |
NODE_PORT |
Yes | (none) | Coin node RPC port |
NODE_USER |
Yes | (none) | RPC username |
NODE_PASSWORD |
Yes | (none) | RPC password |
ENCODER_API_PORT |
No | 3000 |
JSON-RPC API port |
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. 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 |
DUST_AMOUNT |
No | Coin default | Floor in base units on every value output the encoder authors (funding legs, data outputs, change). Only raises the floor: the coin's consensus dust threshold and its relay-policy soft-dust floor (Dogecoin: 0.01 DOGE, below which each output adds the whole limit to the required relay fee) already apply |
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 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 |
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 |
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:
{ "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).
A Prometheus /metrics endpoint and a structured log shim ship with this
service and stay inert unless switched on: with no env set, no route is
registered, no timer starts and no socket opens. Turn the endpoint on with
METRICS_ENABLED=1 (add METRICS_TOKEN to gate the scrape on a reachable
box), and ship logs with LOG_SHIP_ENABLED=1 plus LOG_SHIP_URL. Full
variable list and the exported metric names are in
src/observability/README.md.
The module is vendored byte-identically from xchain-hub. Edit it there
and re-run xchain-hub/bin/sync-observability.sh; a local edit fails the
parity check CI runs across the vendored copies.
These four names configure the shim itself. The fleet deploy path carries them
into the container: xchain-node forwards any of them set in the module config
store or in the deploy host's environment (ModuleService.resolveObservabilityEnv),
and the validator compose files name them outright. Nothing is fabricated when
neither source sets one, so these defaults hold on an unconfigured box:
| Variable | Default | Effect |
|---|---|---|
LOG_LEVEL |
info |
Lowest level emitted. debug | info | warn | error; an unrecognised value falls back to info. |
LOG_FORMAT |
text |
text emits <iso-ts> <level> [<service>] <msg> key=value; json emits one NDJSON record per line. |
METRICS_ENABLED |
false |
Registers the /metrics route. The counter registry is built either way, so counters are collected whether or not the route is exposed. |
XCHAIN_LOG_PATCH |
1 |
Routes bare console.* calls through the shim so they carry the level and service prefix. 0 leaves console untouched, which is what the test bootstrap sets. |
| Command | Description |
|---|---|
npm run api |
Start the JSON-RPC API server |
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 (870 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 (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, 870 tests) |
npm run test:regtest |
Regtest integration tests (requires local bitcoind) |
| Type | Tests | Description |
|---|---|---|
| Unit | 541 | XChainEncoder.createTransaction, prepareData, obfuscate, dataToPubkey, isSegwitUTXO, TxSizeEstimator, CryptoNetworks |
| Integration | 112 | ACTION encoding fidelity, encoding type selection, obfuscation round-trip, UTXO/fee interaction, multi-chain, custom outputs, error handling |
| E2E | ~158 | Full pipeline: API layer, P2SH/P2WSH two-tx orchestration, round-trip encode/decode, multi-chain, edge cases |
| Smoke | ~52 | Module loading, instantiation, network configs, basic PSBT creation, API startup |
| Boundary | ~100 | Payload size limits, chunk boundaries, fee calculation edges, UTXO values, change address, custom outputs, obfuscation |
| Security | 57 | Concurrency/reservation gates, payload-size caps, obfuscation-key handling, input validation |
| Fuzz | 6 | Property-based generative tests over prepareData, dataToPubkey, obfuscate |
| Chaos | 62 | Network failures, input corruption, library monkey-patching, arithmetic edge cases, resource exhaustion, API resilience |
| Mutation | StrykerJS | 896 mutants across XChainEncoder.js, validator.js, TxSizeEstimator.js, CryptoNetworks.js |
| Regression | 264 | Curated critical-path suite: encoding types, obfuscation, fee/UTXO, validator, multi-chain, P2SH/P2WSH, ACTION pipeline, API contract |
| Performance | 3 suites | Baseline benchmarks, full benchmarks with JSON, sustained soak tests |
| Total | 1330+ |
Copyright © 2025-2026 Dankest, LLC
Based on XChain Platform by Dankest, LLC – https://dankest.llc
Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0-or-later) with a commercial license available for proprietary use.
You may use, modify, and distribute this material under the terms of the License. See LICENSE and NOTICE for full terms. See the licensing overview.