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:
- Pull the v0.2.0 image.
- Run
alembic upgrade headand restart. One new migration,0006, is
additive: new columns onassetswith 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-webhookhandler is numbered 1:1
with the five-step contract indocs/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-clientfor Python and
@oliverd25/crypto-processing-clientfor 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: anIdempotency-Keyminted 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.mdfor 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. Oneassetsrow and
one registry entry, added through the extension contract without changing it.
LightningPayoutBackendinherits every method the money path uses from
BtcpayPayoutBackendand 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 inInProgress, never marks it failed, and
refuses to cancel it (DELETEanswers 400invalid-state), so a stuck
withdrawal held the user's balance with nothing anywhere saying why.
LN_PAYOUT_TIMEOUT_SECONDSends 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_idon every route. Every
endpoint declaredresponse_model=None, so the OpenAPI document described
requests and nothing else and any generated client would have typed every
response asAny. TheIdempotency-Keyheader is now a documented parameter
rather than a lookup onrequest.headers, and each route lists the error
codes it can actually return. docs/reference/openapi.jsonanddocs/reference/webhook-events.json,
generated byscripts/export_openapi.py(make contracts), committed, and
gated in CI:--checkfails 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 bytests/integration/test_wire_bytes.py. Captured from the
routes before any response model existed. It exists because a
response_modelthat types a timestamp asdatetimere-emits+00:00as
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.mdwith an explicit "not planned" list,
Dependabot, anddocs/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 a409, and
both USDT contracts read off their own chain answeringUSDT/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 againsttests/fake_tron.pyand found
twenty differences: the fake invented areceipt.net_fee, and it was missing
contractResult,contract_address, two more receipt counters and the whole
echoedtransactionobject that comes back from every constant call. All
twenty are closed, the fake now models both receipt shapes a TRC-20 transfer
can have, andtests/unit/test_tron_payload_corpus.pyparses 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.envsitting 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
ReleaseNotPermittedwas 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.