Releases: OliverD25/crypto-processing-api
Release list
v0.2.0
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...
v0.1.1
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 toapprovedby stuck-resolution, and picked up again ten
seconds later. Job B separately asked Greenfield aboutmanual:<uuid>, which
is not a payout id. Both queries now filter on the backend. USDT_AUTO_WITHDRAW=trueis refused at startup. It created withdrawals
already inapproved, and the only code that hands a manual withdrawal to an
operator requirespending_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_jobscalled
check_invariantswithout the TronGrid gateway, so the parameter defaulted
toNoneandinsolventshort-circuited toFalsefor 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/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.
v0.1.0
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_entryas 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_idas 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-Keyon 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
/healthzfor process and database;/readyzfor 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. Seedocs/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.