BTCPay Integration Layer — Design Document¶
Module scope: everything between our FastAPI service and (a) BTCPay Server Greenfield API, (b) the TRON network for USDT withdrawals (forced on us by plugin limitations — see Decision Flags). This document follows the verified fact sheet wherever it contradicts the original brief, and each contradiction is called out inline.
0. Decision Flags (require user sign-off; contradictions with the brief)¶
| # | Brief said | Reality (fact sheet) | This design's decision |
|---|---|---|---|
| D1 | "Withdrawals: Greenfield payouts" for both assets | USDt plugin has no payout handler at all — USDT cannot be withdrawn through BTCPay, not even manually via its UI | MVP: manual USDT withdrawals via an admin-approval queue + operator runbook (send from TronLink/hot wallet, paste txid into admin API). Phase 2: automated tron_sender.py worker using tronpy + TronGrid. The per-asset withdrawal backend abstraction is designed in from day one so Phase 2 is a drop-in. |
| D2 | Implied stable deposit addresses per user | No permanent per-user addresses on either asset (BTC: no-address-reuse; USDT: shared reserved address pool) | Deposits are invoice-per-deposit-intent: platform requests a deposit, gets a fresh invoice with an N-minute window. Late/expired payments handled via afterExpiration webhooks + reconciliation, credited after admin review (see 1.4). |
| D3 | Webhook-driven crediting is sufficient | Redelivery is finite (~8 tries over ~1 hour) | Webhooks are the fast path only; a reconciliation poller is the source of completeness (Section 4). |
| D4 | Fees deducted from user amount "or configurable" | Greenfield payout has no fee-deduction field; BTCPay pays miner fees from the hot wallet on top of payout amount | Our service estimates the fee, deducts it from the user's balance, and creates the payout for amount - fee_estimate. Config flag WITHDRAWAL_FEE_MODE = deduct | absorb (Section 2.3). |
| D5 | (not mentioned) | USDT invoice concurrency bounded by the manually provisioned TRON address pool | Documented ops constraint; API returns 503 DEPOSIT_TEMPORARILY_UNAVAILABLE mapped from BTCPay's failure when the pool is exhausted; ops doc says "provision ≥ N addresses for N concurrent USDT deposits" (Section 7). |
1. Deposit flow¶
1.1 Invoice type per asset¶
Both assets: top-up invoices (amount omitted in POST /api/v1/stores/{storeId}/invoices).
Rationale:
- Our ledger is crypto-native; the platform's deposit UX is "user sends whatever they want, we credit what arrives". Top-up semantics ("any payment is a full payment") match exactly — no paidPartial limbo, no PaidOver special-casing.
- A fixed-amount invoice would put a partial payment into Expired(paidPartial) where funds arrived but BTCPay never settles — with top-up, any amount settles.
- The fact sheet leaves "multiple sequential payments to one top-up invoice" UNVERIFIED, so we design for one credit per invoice (one row in deposit_intents, one settlement) and let reconciliation catch anything weirder. If a second payment lands on an already-settled invoice, it surfaces as an InvoicePaymentSettled after our intent is CREDITED — routed to the manual-review queue (1.4), never silently dropped.
We do not use fixed-amount invoices even when the platform knows the intended amount, because the ledger credits actual received crypto, not a quoted amount. If the platform wants "expected amount" UX it passes expected_amount in metadata for display/analytics only.
Per-invoice checkout options: checkout.expirationMinutes from config (DEPOSIT_INVOICE_EXPIRY_MIN, default 60 for BTC, 30 for USDT — shorter for USDT to recycle pool addresses faster). Restrict checkout.paymentMethods to the single asset requested (["BTC-CHAIN"] or the USDt plugin's payment method id, discovered at startup from GET /api/v1/stores/{storeId}/payment-methods and cached — the exact id string is version-dependent, so never hardcode it).
1.2 Metadata contract (the attribution backbone)¶
Every invoice we create carries:
{
"metadata": {
"cpapi": true,
"cpapi_version": 1,
"external_user_id": "<opaque platform user id>",
"deposit_intent_id": "<our UUIDv7>",
"asset": "BTC"
}
}
metadatais echoed back in every invoice webhook — this is how a webhook maps to a user without any BTCPay-side state.cpapi: truelets the webhook handler and reconciler ignore invoices created by anything else touching the same store (admin UI experiments, other tools). Non-cpapiinvoices are logged and skipped.deposit_intent_idis generated by us before calling BTCPay and stored indeposit_intentsin stateCREATING; the BTCPay call is made after the row commits. If the HTTP call times out ambiguously, reconciliation matches bydeposit_intent_idin metadata (search viaGET .../invoices+ client-side metadata match, oradditionalSearchTerms=["cpapi:<intent_id>"]set at creation to make BTCPay's text search find it).
1.3 Deposit intent state machine¶
Table deposit_intents: id (uuid), external_user_id, asset, btcpay_invoice_id (nullable, unique), state, address (denormalized from invoice for display), checkout_link, expires_at, credited_amount_units (bigint, nullable), credited_ledger_tx_id (nullable), timestamps.
States and drivers:
CREATING ──(BTCPay 200)──▶ PENDING ──(InvoiceSettled)──▶ CREDITED [terminal, ledger credited]
│ │
│ (BTCPay error) ├─(InvoiceProcessing)──▶ CONFIRMING (informational; funds seen, awaiting confs)
▼ │ └─(InvoiceSettled)──▶ CREDITED
FAILED [terminal] ├─(InvoiceExpired, no payment flags)──▶ EXPIRED [terminal]
├─(InvoiceExpired + partiallyPaid/afterExpiration)──▶ REVIEW
└─(InvoiceInvalid)──▶ REVIEW
REVIEW ──(admin resolve: credit X units)──▶ CREDITED
REVIEW ──(admin resolve: dismiss)──▶ DISMISSED [terminal]
Event → transition mapping (only these events mutate deposit state):
| Webhook event | Action |
|---|---|
InvoiceCreated |
Ignore (we created it; state already PENDING). Log only. |
InvoiceReceivedPayment |
No ledger effect. Update intent last_payment_seen_at; if afterExpiration=true, flip intent to REVIEW. Optional platform callback "payment detected". |
InvoiceProcessing |
PENDING → CONFIRMING. No ledger effect. |
InvoicePaymentSettled |
If afterExpiration=true (payment confirmed after invoice expiry — BTCPay will NOT emit InvoiceSettled for these): flip to REVIEW with payment details. Otherwise no-op (normal path waits for InvoiceSettled). |
InvoiceSettled |
The only auto-credit trigger. Fetch the invoice + payment methods via Greenfield (never trust amounts from the webhook payload — it carries ids and flags, not authoritative amounts), compute received amount in integer units, write ledger credit idempotently keyed on (deposit_intent_id), state → CREDITED. If manuallyMarked=true, route to REVIEW instead of auto-credit (a human marked it settled in the BTCPay UI; verify before crediting). If overPaid=true, still credit actual received total (top-up invoices shouldn't set this, but harmless). |
InvoiceExpired |
If partiallyPaid=true → REVIEW; else → EXPIRED. |
InvoiceInvalid |
→ REVIEW (never auto-credit, never silently drop). |
Credit amount source of truth: on InvoiceSettled, call GET /api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods and sum totalPaid for the asset's payment method, converting the decimal string to integer units (satoshis / micro-USDT) with decimal.Decimal — never floats. This is what gets credited, not any quoted amount.
1.4 Late payments and the REVIEW queue¶
Because BTC addresses keep working after invoice expiry (funds arrive, invoice shows Expired(paidLate)), and because USDT addresses are reused across invoices/users, late payments are dangerous:
- BTC late payment: the address still belongs unambiguously to the original invoice → the
afterExpirationsettlement in REVIEW can be safely admin-credited to the originalexternal_user_id. Admin endpointPOST /admin/deposits/{intent_id}/resolvewith{action: "credit", amount_units: N}. - USDT late payment: the pool address may already be reserved by a different invoice/user. Attribution is only safe if BTCPay itself attributed the payment to this invoice (it monitors per-invoice windows). Anything flagged
afterExpirationon USDT goes to REVIEW and the runbook tells the operator to check the TRON txid timestamp against address reservation windows before crediting. This is an accepted MVP sharp edge; document it loudly.
Platform-facing API sketch:
- POST /v1/deposits {external_user_id, asset} → {deposit_intent_id, address, checkout_link, expires_at} (address extracted from the created invoice's payment methods; checkout_link offered too so the platform can just iframe BTCPay's checkout).
- GET /v1/deposits/{intent_id} → current state.
- Optional outbound callback to the platform on CREDITED (out of scope for this module; the ledger emits it).
2. Withdrawal flow¶
2.1 Per-asset backend abstraction (consequence of D1)¶
class WithdrawalBackend(Protocol):
async def initiate(self, w: Withdrawal) -> BackendRef: ...
async def poll_status(self, ref: BackendRef) -> BackendStatus: ... # -> txid, phase
async def cancel(self, ref: BackendRef) -> bool: ... # best-effort
Implementations: BtcpayPayoutBackend (BTC), ManualTronBackend (USDT, MVP), later TronSenderBackend (Phase 2, tronpy). Selected by asset from a registry in config.
2.2 Shared withdrawal state machine (ledger side, asset-agnostic)¶
Table withdrawals: id (uuid), external_user_id, asset, requested_amount_units, fee_units, net_amount_units, destination_address, state, backend, backend_ref (BTCPay payoutId / manual ticket id / tron txid), txid (nullable), idempotency_key (unique per platform request), timestamps, approved_by (nullable).
REQUESTED ─validate─▶ (auto if ≤ per-asset limit) ──▶ APPROVED
│ (above limit) ──▶ AWAITING_ADMIN ──admin──▶ APPROVED / REJECTED
▼ validation fails
REJECTED [terminal, nothing was locked]
APPROVED ──backend.initiate──▶ SUBMITTED ──▶ BROADCAST (txid known) ──▶ CONFIRMED [terminal]
│
└─(backend cancel/failure before broadcast)──▶ FAILED → funds unlocked
Ledger interaction (owned by the ledger module, invoked here):
1. On REQUESTED: atomically check balance ≥ requested_amount_units and move it available → locked in the same DB transaction that inserts the withdrawal row. Fee is deducted from requested_amount_units (D4), so the lock equals the full requested amount.
2. On CONFIRMED: burn locked amount (debit), record fee_units actually charged.
3. On REJECTED-after-lock / FAILED / CANCELLED: unlock back to available.
2.3 BTC backend: Greenfield payouts¶
Creation — POST /api/v1/stores/{storeId}/payouts:
{
"amount": "<net_amount as decimal string>",
"paymentMethod": "BTC-CHAIN",
"destination": "<address>",
"approved": true,
"metadata": {"cpapi": true, "withdrawal_id": "<uuid>", "external_user_id": "..."}
}
approved: true because approval policy lives in our service (auto-vs-admin limit), not BTCPay's. BTCPay's AwaitingApproval stage is redundant for us; every payout we create is already approved on our side. (Scope caveat: the exact permission for store-level payout creation is UNVERIFIED per the fact sheet — verify against the deployed version's permission list during implementation; fall back to btcpay.store.canmanagepullpayments if a dedicated payout scope doesn't exist.)
- Whether payout metadata is supported on the deployed version must be checked in the swagger at pin time; if absent, correlation is via storing payoutId → withdrawal_id locally, which we do anyway (backend_ref). Metadata is a convenience, not a dependency.
Fee handling (D4): BTCPay pays miner fees from the store hot wallet on top of amount. So:
- fee_units = estimate(feeTargetBlock) — MVP estimate: query a fee source (BTCPay's own recommended-fee endpoint if exposed on the deployed version, else mempool.space API, else static config BTC_FALLBACK_FEE_SAT) × configured typical vsize (e.g. 200 vB, conservative; batching by the processor usually makes real cost lower — the surplus stays in the hot wallet, which is the safe direction for solvency).
- net_amount_units = requested - fee_units; reject if net ≤ dust threshold (546 sats config).
- WITHDRAWAL_FEE_MODE=absorb skips deduction (platform eats fees); default deduct.
Payout processor (one-time store setup, automated by our setup CLI command via PUT /api/v1/stores/{storeId}/payout-processors/OnChainAutomatedPayoutSenderFactory/BTC-CHAIN):
Learning txid + confirmation: payout webhooks (PayoutCreated, PayoutApproved, PayoutUpdated) drive fast-path transitions — PayoutUpdated to state InProgress → our BROADCAST, Completed → our CONFIRMED. Because the fact sheet does not verify that payout webhook payloads carry the txid, the handler always follows up with GET /api/v1/stores/{storeId}/payouts/{payoutId} and extracts the transaction id from paymentProof (field shape pinned at implementation time against the deployed swagger). The withdrawal poller (Section 4) does the same on a timer as backstop. "Confirmation" for MVP = BTCPay payout state Completed; we do not independently count confirmations.
Cancellation: admin DELETE /api/v1/payouts/{payoutId} is only attempted while payout state is AwaitingPayment; once InProgress we treat it as unstoppable.
2.4 USDT backend (MVP): manual with ledger discipline¶
Flow: REQUESTED → (limit check; all USDT withdrawals go to AWAITING_ADMIN in MVP regardless of amount — flagged as a config default USDT_AUTO_WITHDRAW=false, user may loosen later) → admin approves in our admin API → state SUBMITTED with backend_ref = manual:<uuid> → operator sends USDT from the TRON hot wallet using their wallet software, then calls POST /admin/withdrawals/{id}/mark-broadcast {txid} → BROADCAST → a lightweight TronGrid poller (GET /v1/transactions/{txid} equivalent via TronGrid, or wallet/gettransactioninfobyid) confirms inclusion + success → CONFIRMED. Fee handling: TRC20 transfers cost TRX energy/bandwidth, not USDT — so fee_units for USDT is a flat configurable USDT-denominated service fee (USDT_WITHDRAWAL_FEE_MICROS, default ≈ 1_000_000 = 1 USDT) covering gas cost, deducted from the user amount; the TRX gas itself comes from the hot wallet's TRX balance (the brief's ~$10–20 TRX float).
Phase 2 (tron_sender.py, tronpy): same state machine, initiate() builds/signs/broadcasts the TRC20 transfer() with a private key from env/secret store, single worker with a DB advisory lock to prevent double-send, nonce-free (TRON has no account nonce race like EVM, but still serialize sends), and TronGrid free-tier budgeting (100K req/day is generous for confirmation polling at 1 poll/30s per pending tx).
3. Webhook receiver¶
3.1 Endpoint¶
POST /webhooks/btcpay — one endpoint for all event types (BTCPay sends every subscribed event to one URL). Unauthenticated at the API-key layer (BTCPay can't send our platform API key); authenticated exclusively by HMAC signature. Mounted on the public app but excluded from the platform API-key middleware. Path randomization is not security (HMAC is), but the deployment doc recommends a Cloudflare rule limiting this path to the BTCPay server's IP as defense-in-depth.
3.2 Verification — order of operations¶
- Read raw body bytes (FastAPI:
await request.body()before any Pydantic parsing — HMAC is over raw bytes, and re-serialized JSON will not match). - Compute
hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest(), compare toBTCPay-Sigheader value after strippingsha256=prefix, usinghmac.compare_digest. On mismatch:401, log at WARN with source IP, no body logging (don't let attackers use our logs). - Parse JSON; extract
deliveryId,originalDeliveryId,isRedelivery,type,invoiceId/payoutId,storeId. - Reject (200-with-ignore, not error) events whose
storeId!= configured store, and invoice events whosemetadata.cpapi != true.
3.3 Dedup + async processing (the critical reliability decision)¶
Table webhook_events: dedup_key (unique), delivery_id, event_type, invoice_id/payout_id, raw_payload (jsonb), received_at, processed_at (nullable), processing_error (nullable).
- Dedup key =
originalDeliveryIdif present elsedeliveryId— redeliveries shareoriginalDeliveryId, so retries of the same event dedupe naturally. Insert withON CONFLICT DO NOTHING; if the row already existed, return200immediately. - Return
200as soon as the row is durably inserted (ack-then-process). Processing happens in a background worker loop (same process, asyncio task polling unprocessed rows, or triggered immediately after insert). Rationale: BTCPay's retry budget is tiny (~8 tries/~1 hour); we must never 500 because a downstream handler is slow. If processing fails, our retry loop owns it, not BTCPay's. - The only non-200 responses are: 401 bad signature, 400 unparseable body. Everything else — unknown event type, unknown invoice, handler exception — is 200 + stored + logged.
3.4 Dispatch and out-of-order handling¶
Dispatcher maps type → handler (deposit_events.py for Invoice*, withdrawal_events.py for Payout*). Out-of-order safety comes from two rules, not from ordering guarantees:
- State-machine transitions are monotonic and guarded. Each handler does
UPDATE ... WHERE state IN (allowed_prior_states)inside a transaction holdingSELECT ... FOR UPDATEon the intent/withdrawal row. AnInvoiceProcessingarriving afterInvoiceSettledfinds state=CREDITED, matches no allowed prior state, and is a recorded no-op. - Terminal effects re-fetch truth. The credit handler never acts on the event's implied state alone — it fetches the current invoice from Greenfield at processing time. So even if events arrive scrambled, the credit decision is made against BTCPay's current state, and idempotency (unique ledger credit per
deposit_intent_id) makes double-processing harmless.
Events referencing an invoice we don't know (metadata says cpapi but no intent row — e.g. DB restored from backup) go to a orphaned_events review list.
4. Reconciliation poller¶
Purpose: correctness does not depend on webhooks at all; webhooks only add latency reduction (D3).
Job A — deposit sweep (every RECONCILE_DEPOSIT_INTERVAL, default 120s):
- Query local intents in non-terminal states (PENDING, CONFIRMING, plus EXPIRED/REVIEW younger than 7 days to catch late payments).
- For each (batched, newest first, capped per cycle): GET /api/v1/stores/{storeId}/invoices/{invoiceId}; feed the result through the same transition functions the webhook handlers use (apply_invoice_state(intent, invoice)), so there is exactly one state-transition code path. Webhook vs poller is just two triggers of one function → conflicts are impossible by construction; the row lock + guarded UPDATE serializes concurrent webhook/poll races.
- Safety net for total local loss of intents: nightly GET /api/v1/stores/{storeId}/invoices?startDate=... page-through, flag any cpapi invoice with no matching intent row → orphan review.
Job B — withdrawal sweep (every 60s): all withdrawals in SUBMITTED/BROADCAST → backend.poll_status() (BTC: GET .../payouts/{payoutId}; USDT: TronGrid tx lookup) → same shared transition function.
Job C — invariant check (hourly): sum of ledger balances per asset vs BTCPay wallet balance (BTC: GET /api/v1/stores/{storeId}/payment-methods/BTC-CHAIN/wallet) / TronGrid account balance (USDT). Alert (log ERROR + optional webhook to ops) if wallet_balance < sum(user_balances) — insolvency signal, purely observational in MVP.
Scheduling: a single asyncio scheduler task inside the API process (this is a 4GB-VPS single-tenant service — no Celery/Redis; APScheduler or a hand-rolled loop with asyncio.sleep + per-job Postgres advisory lock so a second replica, if ever run, doesn't double-poll).
5. BTCPay client module (app/gateways/btcpay_client.py)¶
Plain httpx.AsyncClient with a thin typed wrapper — per fact sheet, no official Greenfield Python SDK exists and the third-party one is unvetted. Zero SDK dependencies.
Shape:
class BTCPayClient:
def __init__(self, base_url: str, api_key: str, store_id: str, http: httpx.AsyncClient): ...
# invoices
async def create_invoice(self, req: CreateInvoiceReq) -> Invoice: ...
async def get_invoice(self, invoice_id: str) -> Invoice: ...
async def get_invoice_payment_methods(self, invoice_id: str) -> list[InvoicePaymentMethod]: ...
async def list_invoices(self, *, start_date=None, statuses=None, skip=0, take=50) -> list[Invoice]: ...
# payouts
async def create_payout(self, req: CreatePayoutReq) -> Payout: ...
async def get_payout(self, payout_id: str) -> Payout: ...
async def cancel_payout(self, payout_id: str) -> None: ...
# setup/health
async def get_store(self) -> Store: ...
async def get_payment_methods(self) -> list[StorePaymentMethod]: ...
async def upsert_onchain_payout_processor(self, cfg: PayoutProcessorCfg) -> None: ...
async def create_webhook(self, url: str, secret: str, events: list[str]) -> Webhook: ...
Authorization: token <api_key> (Greenfield API-key scheme).
- All request/response models are Pydantic v2 with extra="ignore" (BTCPay adds fields across versions); all monetary fields are str in transport models and converted to int units only at the service layer via Decimal.
Error taxonomy:
| Class | Trigger | Retryable? |
|---|---|---|
BTCPayUnavailable |
connect error, timeout, 502/503/504 | Yes — retry with backoff |
BTCPayRateLimited |
429 | Yes — honor Retry-After |
BTCPayAuthError |
401/403 | No — config bug; alert |
BTCPayNotFound |
404 | No — caller decides (may mean deleted invoice) |
BTCPayValidation |
400/422 | No — our bug; log payload |
BTCPayServerError |
other 5xx | Yes, capped |
Retry policy: retries live in the client for GETs only (idempotent): 3 attempts, exponential backoff 0.5s/2s/8s + jitter. POSTs are never auto-retried inside the client — invoice/payout creation on an ambiguous timeout could double-create. Instead the service layer handles ambiguity: our own id is in metadata, state stays CREATING, and reconciliation resolves it (Section 1.2). Timeouts: connect 5s, read 30s (BTCPay behind its own reverse proxy can be slow on cold NBXplorer queries).
Testability: define BTCPayGateway as a typing.Protocol mirroring the method set; BTCPayClient implements it; unit tests inject FakeBTCPay (in-memory dict of invoices/payouts with helper methods fake.settle_invoice(id, amount) that also synthesizes webhook payloads with valid HMACs so webhook-handler tests and client tests share one fake). Integration tests hit the regtest stack. FastAPI dependency-injects the gateway, so nothing imports BTCPayClient concretely except the wiring module.
6. Regtest dev stack¶
docker-compose.regtest.yml (hand-rolled minimal stack rather than the btcpayserver-docker generator — the generator is production-oriented; for dev we want explicit services):
services:
postgres-api: # our ledger DB (Postgres 16)
postgres-btcpay: # BTCPay + NBXplorer DB (separate instance, mirrors prod isolation)
bitcoind: # image: btcpayserver/bitcoin; regtest=1, txindex, rpc exposed to nbxplorer only
nbxplorer: # image: nbxplorer/nbxplorer; NBXPLORER_NETWORK=regtest, chains=btc
btcpayserver: # image: btcpayserver/btcpayserver; BTCPAY_NETWORK=regtest, BTCPAY_CHAINS=btc
api: # our FastAPI app, BTCPAY_URL=http://btcpayserver:23000
docker-compose.nile.override.yml env file points the USDt plugin at Nile (https://nile.trongrid.io/jsonrpc) for networked USDT testing; the default regtest stack is BTC-only and fully offline.
- The api container gets BTCPAY_WEBHOOK_URL=http://api:8000/webhooks/btcpay — container-to-container, no tunnel needed.
- A scripts/dev/mine.sh helper wraps docker compose exec bitcoind bitcoin-cli -regtest -generate N.
Smoke test outline (scripts/dev/smoke_test.py) — also the template for CI integration tests:
1. Bootstrap: wait for BTCPay health; create admin user + store via Greenfield (or POST /api/v1/users on first run); create a hot wallet for the store; create restricted API key with the scopes in Section 7; create webhook pointed at the api container; configure the on-chain payout processor (intervalSeconds: 5 for fast tests); write ids/secrets into the api container's env (idempotent bootstrap script, safe to re-run).
2. Fund: mine 101 blocks to a bitcoind-owned address.
3. Deposit cycle: POST /v1/deposits {user "u1", asset BTC} → get address → bitcoind sendtoaddress 0.5 BTC → assert intent goes CONFIRMING on next webhook → mine 1–2 blocks (regtest settlement confirmations per store policy; set store speed policy to 1-conf in bootstrap) → poll our API until intent CREDITED and GET /v1/balances/u1 shows 50_000_000 sats.
4. Withdrawal cycle: POST /v1/withdrawals {u1, BTC, 10_000_000 sats, dest: fresh bitcoind address} (below auto-limit) → assert state SUBMITTED then BROADCAST (processor fires within 5s) → mine 1 block → poll until CONFIRMED → assert balance decremented by exactly 10_000_000 and fee recorded → assert bitcoind sees net_amount at destination.
5. Failure drills: expired-invoice path (create deposit, mine nothing, fast-forward via short expirationMinutes, pay after expiry, assert REVIEW not auto-credit); webhook-outage path (stop api container, pay invoice, restart, assert reconciler credits within one poll cycle).
7. BTCPay-side setup to document (docs/btcpay-setup.md)¶
- Store creation — one store serves both assets. Store settings: invoice expiration default (overridden per-invoice anyway), speed policy = 1 confirmation for BTC settlement (document the tradeoff; 2+ for larger deployments).
- BTC wallet — must be a HOT wallet (created/stored in BTCPay), not a watch-only xpub import. Contradiction with the brief's implied "xpub wallet import": the automated payout processor requires signing capability. Document the risk posture: hot wallet holds working float only; ops should periodically sweep excess to cold storage (manual runbook step; a target-float number in the doc, e.g. keep < X BTC hot).
- Webhook creation — URL
https://<api-host>/webhooks/btcpay, generate a strong secret (our setup CLI does this viaPOST /api/v1/stores/{storeId}/webhooks), enable automatic redelivery, subscribe to:InvoiceReceivedPayment, InvoiceProcessing, InvoicePaymentSettled, InvoiceSettled, InvoiceExpired, InvoiceInvalid, PayoutCreated, PayoutApproved, PayoutUpdated(skipInvoiceCreated— noise). Note the version-dependent webhook paths (store-scoped on older versions vs/api/v1/webhooks/{id}on current master) — the client pins to the deployed version's swagger. - Restricted API key — created in BTCPay UI (or
/api-keys/authorizeflow), scopes, exact names: btcpay.store.cancreateinvoicebtcpay.store.canviewinvoicesbtcpay.store.webhooks.canmodifywebhooks(setup CLI only; can be a separate short-lived key)btcpay.store.canmanagepullpayments(payout create/approve/cancel; additionally verify whether the deployed version exposes a narrower payout scope — UNVERIFIED per fact sheet)btcpay.store.canviewstoresettings+btcpay.store.canmodifystoresettings(payout-processor config; PUT scope UNVERIFIED — confirm at pin time) All keys restricted to the single store id. Neverunrestricted.- USDt plugin — install
BTCPayServer.Plugins.USDt(note repo home isbtcpayserver-tether/…), configure TRON with a TronGrid API key (free tier: 100K req/day @ 15 QPS; without a key throttling is unpredictable — require the key), mainnet JSON-RPChttps://api.trongrid.io/jsonrpc. Provision the address pool: generate N TRON addresses (N = max concurrent USDT deposit intents; recommend 20 for MVP), fund the ops TRX wallet with ~$10–20 TRX for withdrawal gas. Document D5: pool exhaustion → deposit creation fails → our API surfaces 503; monitoring should watch pool utilization. - Version pinning — the docs and compose files pin an exact BTCPay image tag; the client's assumptions (webhook paths, payout metadata support, payout permission names,
paymentProofshape) are validated against that tag's swagger, and amake check-btcpay-compatscript (GET swagger, assert required paths/fields) runs in CI.
Module layout (planned)¶
app/
gateways/
btcpay_client.py # httpx Greenfield wrapper + error taxonomy + Protocol
btcpay_models.py # Pydantic transport models (extra="ignore")
tron_sender.py # Phase 2 USDT sender (tronpy); MVP: trongrid confirmation poller only
webhooks/
btcpay_webhook.py # endpoint: raw-body HMAC verify, dedup insert, 200-fast
dispatch.py # type -> handler map, background processing loop
services/
deposits.py # intent creation, apply_invoice_state (shared webhook/poller path)
withdrawals.py # state machine + WithdrawalBackend protocol + BTC/USDT backends
reconciliation.py # jobs A/B/C, advisory-locked scheduler
fees.py # BTC fee estimation, USDT flat fee
admin/
review.py # REVIEW queue resolve, manual USDT withdrawal endpoints
scripts/
bootstrap_btcpay.py # idempotent store/wallet/webhook/key/processor setup
dev/smoke_test.py
docker-compose.regtest.yml
docs/btcpay-setup.md
Critical Files for Implementation¶
- E:\codespace_claude_code_swift-punk-projects\crypto-processing-api\app\gateways\btcpay_client.py
- E:\codespace_claude_code_swift-punk-projects\crypto-processing-api\app\webhooks\btcpay_webhook.py
- E:\codespace_claude_code_swift-punk-projects\crypto-processing-api\app\services\deposits.py
- E:\codespace_claude_code_swift-punk-projects\crypto-processing-api\app\services\withdrawals.py
- E:\codespace_claude_code_swift-punk-projects\crypto-processing-api\app\services\reconciliation.py