Skip to content

v0.2.0

Latest

Choose a tag to compare

@github-actions github-actions released this 11 Aug 18:10
· 2 commits to main since this release

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.