Skip to content

Changelog

Notable changes to this project. The format follows Keep a Changelog, and this project uses semantic versioning.

0.2.0 - 2026-08-11

Breaking / Migration

Nothing on the wire breaks. Every v0.1.1 response is byte-identical under v0.2.0 — tests/integration/test_wire_bytes.py compares the raw bytes — with one value-level exception: /healthz and the OpenAPI document now report version 0.2.0.

Upgrading a v0.1.x deployment:

  1. Pull the v0.2.0 image.
  2. Run alembic upgrade head and restart. One new migration, 0006, is additive: new columns on assets with server defaults that reproduce v0.1.x behaviour exactly. CI upgrades a frozen, seeded v0.1.1 database on every build, so this exact path is tested permanently, not once.

One startup behaviour changed deliberately: an asset that payment-method discovery disabled is re-enabled at the next startup if the store serves a matching payment method again. Turning an asset off on purpose is done by disabling its payment method in BTCPay; the sync honours that in both directions.

Added

  • A worked platform integration, examples/platform-demo/. One FastAPI file with Jinja2 and HTMX, using the Python client, doing the whole loop against the regtest stack: sign in, deposit BTC, watch it credit, withdraw. It is written to be read rather than copied, so it stores no balances at all — every number on every page was read from the API a moment before it was rendered — and its /platform-webhook handler is numbered 1:1 with the five-step contract in docs/integrating. It runs as an opt-in compose profile on the regtest stack, and with the profile off nothing about that stack changes.
  • The example proves itself every night. scripts/dev/example_loop.py drives the demo's own pages headlessly and the nightly end-to-end job runs it after the drills, so a tutorial cannot quietly stop working and then fail in front of somebody meeting the project for the first time. It is also the only end-to-end coverage of the outbound webhook path: a signed delivery reaching a real receiver, verifying, and being acted on only after the resource was re-read.
  • Two client libraries: crypto-processing-client for Python and @oliverd25/crypto-processing-client for TypeScript. The request layer of each is generated from the committed OpenAPI document and regenerated by CI, which fails on any difference, so a route change forces both clients to change in the same pull request. Around it, about 300 lines per language do the two things codegen cannot: an Idempotency-Key minted per logical call and reused on every retry of it, and webhook verification over the raw body bytes in constant time inside the 300-second window. Amounts and timestamps stay strings in both. Neither is published yet — see sdks/README.md for the two registry accounts that have to exist first.
  • Cross-language signature vectors, sdks/signature-vectors.json. Generated by the server's own signer and asserted by the server's test suite and both clients' — an HMAC scheme with three implementations otherwise drifts in silence, because each one passes its own tests and the disagreement only shows up as an integrator's endpoint rejecting real traffic. The matrix includes cases that must be refused, since a file of passing cases alone is satisfied by a verifier that returns true unconditionally.
  • Lightning as a new asset, BTC_LN, off by default. One assets row and one registry entry, added through the extension contract without changing it. LightningPayoutBackend inherits every method the money path uses from BtcpayPayoutBackend and passes the published conformance suite unmodified, which is the acceptance test the contract was written for. Deposits are instant, withdrawals go to a BOLT11 invoice, custody is outbound channel liquidity.
  • Fee-drift journalling. The settle entry now books what the rail actually charged rather than what was estimated, with the difference landing on hot_wallet. When the two agree the extra posting is zero and omitted, so on-chain BTC produces the same entries as before. Lightning routing fees are read from the node and rounded up to whole satoshis.
  • A deadline for payouts that will never complete. BTCPay parks an unroutable Lightning payout in InProgress, never marks it failed, and refuses to cancel it (DELETE answers 400 invalid-state), so a stuck withdrawal held the user's balance with nothing anywhere saying why. LN_PAYOUT_TIMEOUT_SECONDS ends it. The hold is then returned automatically only on proof that no retry can still spend it — for a cancelled payout, the node's verdict; for one still live, the node's verdict and an expired BOLT11, which nobody can pay. Where neither holds the balance stays held for an admin, exactly as before, and one alert says so.
  • Two optional backend capabilities (ReportsActualFee, ProvesDefinitiveFailure) and two optional profile hooks (submission_guard, submitted_timeout_seconds). All four are additive; a backend that implements none behaves exactly as it did.
  • Drills 8–11 on the regtest stack, behind a Lightning compose overlay with three LND nodes pinned by digest.
  • A response model and a stable operation_id on every route. Every endpoint declared response_model=None, so the OpenAPI document described requests and nothing else and any generated client would have typed every response as Any. The Idempotency-Key header is now a documented parameter rather than a lookup on request.headers, and each route lists the error codes it can actually return.
  • docs/reference/openapi.json and docs/reference/webhook-events.json, generated by scripts/export_openapi.py (make contracts), committed, and gated in CI: --check fails a build whose committed contracts are not what the code produces.
  • Typed models for all eight outbound webhook payloads, in services/events.py. The emit sites build the models rather than dicts, so a field that is not declared is dropped instead of shipped — the payloads were the one public contract with nothing watching it, and two SDKs are about to parse them.
  • A raw-bytes wire corpus, tests/fixtures/wire/responses.json, compared byte for byte by tests/integration/test_wire_bytes.py. Captured from the routes before any response model existed. It exists because a response_model that types a timestamp as datetime re-emits +00:00 as Z, and every test in the suite that compares parsed JSON stays green while it happens.
  • A community surface: issue forms for bugs, features and operator reports, a pull-request template that inlines the nine ledger invariants, CODE_OF_CONDUCT.md, ROADMAP.md with an explicit "not planned" list, Dependabot, and docs/reference/versioning.md.
  • USDT is live-verified, and the log says so with transaction ids. The TRON Nile session ran on 2026-08-11: a real deposit credited to the micro-USDT, a real withdrawal verified against TronGrid and confirmed 39 blocks deep, the same transaction id refused a second time with a 409, and both USDT contracts read off their own chain answering USDT / 6. It is recorded in docs/operating/verification-log.md, and the "format-verified only, NOT confirmed against a live node" caveat is gone from the five places that carried it. Mainnet was read and never written to: no mainnet transaction has been created, sent or verified from here, and the comments say exactly that.
  • Nine recorded TronGrid payloads, in tests/fixtures/tron/. The live session diffed what TronGrid sent against tests/fake_tron.py and found twenty differences: the fake invented a receipt.net_fee, and it was missing contractResult, contract_address, two more receipt counters and the whole echoed transaction object that comes back from every constant call. All twenty are closed, the fake now models both receipt shapes a TRC-20 transfer can have, and tests/unit/test_tron_payload_corpus.py parses the recorded bodies through the production client and re-runs that comparison on every test run. There is no TRON regtest, so this is the only thing standing between the fake and a shape only this repository has ever seen.

Changed

  • The README leads with what this is for and who should not use it, including a comparison against raw BTCPay, a hosted processor and building it yourself. The honest row is custody: this project does not remove custody risk, it gives you the controls and the books.
  • The Nile override runs the stack's BTC side on a peerless testnet. The USDt plugin cannot load on a regtest BTCPay — it maps the Bitcoin chain to a TRON network before reading any setting and crashes on regtest — so the verification stack now boots bitcoind on testnet with zero peers, frozen at the genesis block plus one CPU-mined minimum-difficulty block that ends initial block download. Every compose command in the runbook gained --env-file .env, because compose only auto-loads a .env sitting next to the first compose file, never the repository root's.

Fixed

  • A withdrawal refused by the dust re-check at submission stayed stuck. The submitter released the hold with no attestation while the row was already in submitting, which the release-legality matrix correctly refuses, so ReleaseNotPermitted was raised inside the worker and the row sat there with the user's balance held. It now records the attestation the situation warrants: no payout was created and none can exist.
  • The payout workers built their backend instead of asking the registry. Harmless while every automated asset was one class, and wrong the moment one was not.
  • An asset disabled by payment-method discovery stayed disabled forever. The startup sync skipped disabled rows, so the safety-disable at first boot was a one-way door: configure the USDt plugin any time after the service first started and the asset stayed off through every restart, with no endpoint to turn it back on. Found live in the Nile session, minutes after the runbook's promise that discovery re-enables the asset turned out to be false. The sync now follows the store in both directions; rows without a registry profile are never touched either way.

Operators

Nothing changes unless you set LIGHTNING_ENABLED=true. With it unset, the BTCPay scopes requested, the registered assets and the seeded assets rows are identical to v0.1.1, and a test asserts that rather than a comment claiming it.

Before turning it on, read the Lightning note in the README: BTC_LN is a separate float, so users get a second non-fungible BTC balance, and enabling it makes the bootstrap request one server-level BTCPay permission. SEED_LN_WITHDRAWAL_DAILY_CAP is a second loss ceiling and the BTC cap says nothing about it. Turning the flag back off once the row exists is refused at startup, on purpose.

0.1.1 - 2026-08-10

Three live defects found by an adversarial review of v0.1.0. All three are silent: nothing raised, the test suite was green, and the damage showed as an error drip in a log, a held balance that never moved, or an alarm that never fired. No schema change, no API change.

Fixed

  • The BTCPay payout submitter and Job B claimed manual-TRON withdrawals. Both queries filtered on status alone, so a USDT withdrawal was quoted a Bitcoin fee, offered to Greenfield — which has no handler for the token — rejected, returned to approved by stuck-resolution, and picked up again ten seconds later. Job B separately asked Greenfield about manual:<uuid>, which is not a payout id. Both queries now filter on the backend.
  • USDT_AUTO_WITHDRAW=true is refused at startup. It created withdrawals already in approved, and the only code that hands a manual withdrawal to an operator requires pending_approval — so the row had no legal next step. The flag never bought anything else: there is no automated TRON signer. Fixing the filter above without this would have turned loud thrashing into a silent stall with the user's balance held.
  • The hourly invariant job never computed USDT custody. build_jobs called check_invariants without the TronGrid gateway, so the parameter defaulted to None and insolvent short-circuited to False for USDT. The insolvency alarm was wired to nothing for that asset on every deployment; only the on-demand admin endpoint was correct.

Operators

If you ran v0.1.0 with USDT_AUTO_WITHDRAW=true, you may hold withdrawals stranded in approved or submitting with a manual backend. They will not move on their own. docs/operating/runbook-usdt-withdrawals.md has the query to find them and the statement to repair them; it touches no ledger row and leaves the hold exactly as it is. Set the flag back to false before restarting — the service now refuses to start with it on.

0.1.0 - 2026-08-10

First release. A complete custodial deposit and withdrawal path for BTC and USDT-TRC20 on top of BTCPay Server.

Added

Ledger - Append-only double-entry journal in PostgreSQL. Integer amounts in smallest units; no floats anywhere in the money path. - post_entry as the only writer of postings: locks accounts in ascending id order, asserts zero-sum before flush, updates materialized balances in the same transaction. - Database-enforced invariants — a deferred trigger for per-entry zero-sum, BEFORE UPDATE/DELETE triggers making history immutable, CHECK constraints preventing overdraft, and a unique (kind, source_ref) making a replayed effect impossible rather than merely unlikely. - user_deficit, so a reorg loss on an already-spent balance can be booked at all.

Deposits - BTCPay top-up invoices with a metadata contract for attribution; the row commits before the API call so an ambiguous timeout is recoverable. - One shared transition function for the webhook and the poller, so the two cannot disagree. - Per-payment crediting, with everything ambiguous routed to a review queue rather than credited or dropped. - Reconciliation sweeps including settled deposits inside their monitoring window, plus a wallet-level scan for receives matching no deposit — the only detector for a payment to an address BTCPay has stopped watching.

Withdrawals - Balance check, limit decision and hold in one transaction, serialized on the asset's hot-wallet row so the rolling 24-hour velocity cap survives concurrency. - Fees fixed at submission with a live estimate, a mempool.space fallback and a static floor; dust refused before any hold is placed. - BTCPay payouts with metadata.withdrawal_id as the correlation key, verified live against BTCPay 2.4.2. - In-flight accounting through payouts_in_flight, making the insolvency tolerance derived rather than tuned. - Release after submission requires an admin attestation recorded on the row. - Bitcoin address validation (BIP 173, BIP 350, base58check) and TRON base58check, implemented rather than imported.

USDT-TRC20 - Operator-sent withdrawals with full-tuple on-chain verification: contract, sender, recipient, exact amount, receipt result and the Transfer event. - Address reservation windows, an amount-tolerance policy and pool-exhaustion handling, because the plugin reuses addresses across users. - TRX gas monitoring.

Platform interface - API keys with scopes, Idempotency-Key on every mutating endpoint including a staleness takeover, and deposit, withdrawal, balance, transaction and asset reads. - Signed outbound webhooks with retry, dead-letter and admin redelivery.

Operations - /healthz for process and database; /readyz for components and worker heartbeats. - Hourly invariant and custody check, exposed on demand at /v1/admin/reconciliation. - Alerts to ntfy or Telegram with stable codes. - Deployment assets, backup documentation with a restore drill, a threat model, and runbooks for USDT withdrawals, USDT attribution and reorgs. - Multi-architecture images (amd64 and arm64) with SBOM and provenance. - A compatibility check asserting the BTCPay endpoints and fields this service depends on still exist in the pinned tag.

Known limitations

  • USDT withdrawals are manual. The BTCPay USDt plugin registers no payout handler of any kind, so a signer is Phase 2.
  • USDT deposit attribution is heuristic, because the plugin reuses pool addresses. See docs/operating/runbook-usdt-attribution.md.
  • Inbound authentication is a bearer key; HMAC request signing is deferred, and the key prefix is versioned so it can be added without breaking clients.
  • Single-tenant. One platform, one store.
  • No external audit.

Released versions, with their artifacts, are on the releases page. What a release is allowed to change, and what it never will, is versioning and releases.