Skip to content

Releases: OliverD25/crypto-processing-api

v0.2.0

Choose a tag to compare

@github-actions github-actions released this 11 Aug 18:10

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...
Read more

v0.1.1

Choose a tag to compare

@github-actions github-actions released this 10 Aug 18:53

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/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

Choose a tag to compare

@OliverD25 OliverD25 released this 10 Aug 17:35

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/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.