SolidPeer — Payment gateway
How USD-denominated tier prices and top-ups are settled in BCH on-chain. Companion to BILLING.md (which defines what the customer is paying for); this doc defines how the money moves.
1. Model in one sentence
A USD price is quoted in the customer's chosen settlement currency — native BCH (FX-snapshotted at quote time) or a BCH CashToken stablecoin (PUSD or MUSD, both pegged 1:1 USD) — the customer is given a fresh HD-wallet-derived deposit address valid for 30 minutes for the first deposit, mainnet-js watches the address summing every matching UTXO that lands on it, and the system reconciles the running total against the quote: exact or overpaid → apply the bundle (overpayment held as change owed); underpaid → stay open and prompt the customer to top up the same address until the total reaches the quote; only fully-late or fully-abandoned partial deposits result in a refund. All payouts (change / refund) are sent back in the same currency the customer paid in.
Accepted payment methods
payment_method |
Settlement asset | USD peg | Decimals | Quote source |
|---|---|---|---|---|
bch |
native Bitcoin Cash | floating | 8 (sats) | price feed (§6) |
pusd |
CashToken 2469acc5afa4b10cb5b5c04afb89c3a3ffd61c5da9c01e26d00951cae2a02544 |
1:1 (assumed) | 2 | peg, no feed |
musd |
CashToken b38a33f750f84c5c169a6f23cb873e6e79605021585d4f3408789689ed87f366 |
1:1 (assumed) | 2 | peg, no feed |
The category IDs are constants (defined in gateway/src/billing/payment-methods.ts or equivalent) — these are the on-chain identifiers for the token genesis transactions and never change. Adding a new accepted stablecoin = appending a row here. CashTokens require the BCH May 2023 upgrade, which is long activated.
2. Architecture
┌──────────────────────────────────────────────────────────────────┐
│ Payment lifecycle │
└──────────────────────────────────────────────────────────────────┘
1. Customer clicks "Subscribe" / "Top up" / "Upgrade"
│
▼
2. Gateway creates PaymentRequest row:
────────────────────────────────────
- customer chose payment_method ∈ {bch, pusd, musd}
- allocate next deposit_derivation_index from HD wallet state
- derive deposit_address at m/44'/145'/0'/0/<index>
(token-aware cashaddr — accepts BCH AND CashTokens)
- if bch: fetch USD/BCH from price feed → quote_amount_native (sats)
else: quote_amount_native = amount_usd × 100 (token units)
- expires_at = now + 30 min
- status = 'pending'
│
▼
3. Customer is shown:
deposit address, amount + currency, expires_at, qr code
│
▼
4. mainnet-js watcher (one process, subscribes to wallet xpub
or a per-address subscription) detects incoming UTXO(s) and
identifies their currency (BCH or CashToken category)
│
▼
5. Reconciler matches UTXO → PaymentRequest by deposit_address.
Only UTXOs whose currency matches PaymentRequest.payment_method
contribute to received_amount_native (running sum). Wrong-currency
UTXOs branch to a separate 'wrong_currency' Payout (§9).
The running total is compared against quote_amount_native ± tol:
┌─────────────────────────────────────────────────────────┐
│ total ≥ quote, within tol → 'received_exact' → apply │
│ total > quote+tol → 'received_over' → apply │
│ + queue│
│ change │
│ total < quote-tol AND → 'partial' (transient) │
│ first deposit was within keep address open, prompt │
│ the 30-min window customer to send remainder│
│ late (first deposit ever → 'expired_paid' → queue │
│ arrived past expires_at) refund │
└─────────────────────────────────────────────────────────┘
│
▼
6. Apply event hits BILLING.md: subscribe / upgrade / topup as
specified by PaymentRequest.purpose. (Or, if status='partial',
no apply yet — continue at step 4 for the next deposit.)
│
▼
7. (If refund/change owed, or a 'partial' request hits the
abandonment timeout) Payout row created with status
'awaiting_address'. Customer is notified; on submission,
Payout.customer_address set, status='queued', signer dispatches
a tx from the deposit_derivation_index UTXO, status='sent'
once the txid lands in mempool.
Two long-running processes:
- price-feed-poller — keeps a fresh USD/BCH rate cached for quote generation (see §6).
- payment-watcher — single mainnet-js subscription to the HD wallet xpub, fans out UTXO-arrivals to the reconciler (see §10).
Both are stateless workers; all durable state lives in Postgres (PaymentRequest, Payout, HDWalletState).
3. Schema
payment_requests
CREATE TABLE payment_requests (
payment_request_id uuid PRIMARY KEY,
account_id text NOT NULL REFERENCES accounts(account_id),
purpose text NOT NULL, -- 'subscribe' | 'upgrade' | 'topup' | 'renewal'
target_tier text, -- nullable for topup
target_term text, -- 'monthly' | 'annual', nullable for topup
target_topup_usd numeric(12, 2), -- nullable except for topup
-- Quote (locked at creation time)
amount_usd numeric(12, 2) NOT NULL,
payment_method text NOT NULL, -- 'bch' | 'pusd' | 'musd' (see §1)
quote_amount_native bigint NOT NULL, -- units depend on payment_method:
-- bch: satoshis (8 decimals)
-- pusd: 1/100 USD (2 decimals); $9.00 = 900
-- musd: same as pusd
fx_rate numeric(20, 8), -- USD per BCH; null for stablecoins (peg=1)
fx_source text, -- 'median:[kraken,coingecko,bitfinex]'; null for stablecoins
quote_at timestamptz NOT NULL,
-- Deposit address
deposit_address text NOT NULL UNIQUE,
deposit_derivation_index bigint NOT NULL UNIQUE,
expires_at timestamptz NOT NULL, -- quote_at + 30 min
-- Settlement state
status text NOT NULL, -- see §4
received_amount_native bigint NOT NULL DEFAULT 0, -- running sum, same units as quote_amount_native (only counts UTXOs whose currency matches payment_method)
received_at timestamptz, -- first deposit confirmation
last_partial_at timestamptz, -- most recent UTXO time; resets the partial-window timer
received_txids text[] NOT NULL DEFAULT '{}',
applied_at timestamptz, -- when billing-side state was updated
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON payment_requests (account_id, created_at DESC);
CREATE INDEX ON payment_requests (status, expires_at); -- expiry sweeper hot path
payouts
A row per refund/change to be sent back to a customer. One PaymentRequest can have at most one Payout (a payment is either underpaid → refund OR overpaid → change OR neither — never both).
CREATE TABLE payouts (
payout_id uuid PRIMARY KEY,
payment_request_id uuid NOT NULL REFERENCES payment_requests(payment_request_id),
kind text NOT NULL, -- 'refund' | 'change' | 'wrong_currency'
payout_method text NOT NULL, -- 'bch' | 'pusd' | 'musd' — same currency the customer sent
amount_native bigint NOT NULL, -- gross amount owed in payout_method's native units (before fee)
customer_address text, -- supplied by customer; null until submitted (must be token-aware cashaddr for stablecoin payouts)
submitted_at timestamptz, -- when customer submitted address
status text NOT NULL, -- see §4
txid text,
fee_satoshis bigint, -- network fee actually paid (always in BCH satoshis; deducted from BCH amount, or eaten by operator for stablecoin payouts since the token amount is integer-precise)
sent_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON payouts (status);
CREATE INDEX ON payouts (payment_request_id);
hd_wallet_state
Single-row table tracking the next derivation index. Concurrency-safe via row lock + bump on allocation.
CREATE TABLE hd_wallet_state (
id int PRIMARY KEY DEFAULT 1 CHECK (id = 1), -- enforce singleton
next_derivation_index bigint NOT NULL,
xpub text NOT NULL,
derivation_account integer NOT NULL DEFAULT 0, -- m/44'/145'/<account>'/0/<index>
updated_at timestamptz NOT NULL DEFAULT now()
);
The xpub is the public key for the watch-only side; the corresponding xprv lives in the operator's signer (HSM / encrypted file / cloud KMS — operator decision, not in this schema).
4. State machines
PaymentRequest.status
┌─────────────┐
───▶│ pending │── first deposit ──┬──── total ≥ quote-tol ─────┐
└─────────────┘ (within │ │
│ 30 min) │ total < quote-tol │
30min │ │ ┌──────────────────┐ │
no deposit │ │ │ │ │
▼ │ ▼ │ │
┌─────────────┐ │ ┌─────────┐ more │ │
│ expired │ │ │ partial │ ─deposit─┘ │
│(no deposits)│ │ └─────────┘ arrives │
└─────────────┘ │ │ │
│ │ partial-window │
│ │ timeout (e.g. │
│ │ 24h since first │
│ │ partial deposit) │
│ ▼ │
│ ┌──────────────┐ │
│ │ abandoned_ │ │
│ │ partial │ │
│ └──────────────┘ │
│ │ │
│ ▼ │
│ Payout │
│ kind=refund │
│ (received-so-far) │
│ │
▼ ▼
┌──────────────┐ ┌──────────────────┐
│received_exact│ │ received_over │
└──────────────┘ └──────────────────┘
│ │
▼ ▼
applied applied + Payout
kind=change
(orthogonal: first deposit arrives AFTER expires_at with no
partial yet → 'expired_paid' → Payout kind=refund)
Terminal statuses: applied, expired (no deposit ever), expired_paid (single late deposit), abandoned_partial (partial that never completed). All four are kept for audit even after any Payout is sent — the PaymentRequest itself terminates, the Payout has its own lifecycle.
The 30-minute clock applies only to the first deposit. Once the customer has put real BCH on the address, the request enters partial and stays open with a longer (and configurable) abandonment window — typical default 24 hours from the most recent partial deposit, refreshed on each top-up. Rationale: pre-deposit, the customer is just sitting on a quote and the operator's drift exposure is on a promise; once any deposit lands, the customer is committed and the natural completion path is "send the rest" rather than "get all your money back, get a fresh quote, send all of it again." Operator absorbs whatever drift accumulates during the partial window — the trade-off is a much smoother UX.
Payout.status
┌──────────────────┐ customer submits ┌─────────┐ signer dispatches ┌──────┐
│ awaiting_address │ ─────address─────────▶ │ queued │ ────────────────────▶ │ sent │
└──────────────────┘ └─────────┘ └──────┘
│ │
│ │ signer error
│ ▼
│ ┌─────────┐ operator retry ┌────────┐
│ │ failed │ ───────────────────▶ │ queued │
│ └─────────┘ └────────┘
│
│ N years inactivity (TBD)
▼
┌──────────────────┐
│ reclaimed │ (no longer owed; ops escheatment)
└──────────────────┘
awaiting_address is the hold state — the funds sit on the deposit address until the customer submits a destination. There's no automatic timeout here; we hold indefinitely (with periodic reminder emails) until the customer claims, or until a long-tail reclaim policy fires (§13 — open).
5. Quote generation
generate_payment_request(account, purpose, target, payment_method):
amount_usd = compute_amount(purpose, target) # see BILLING.md §5/§7/§7a
if payment_method == 'bch':
fx_rate = price_feed.current_usd_per_bch() # §6, snapshotted now
amt = ceil(amount_usd / fx_rate * 1e8) # satoshis, round UP
fx_src = price_feed.source_label
elif payment_method in ('pusd', 'musd'):
fx_rate = NULL # 1:1 peg, no feed
amt = round(amount_usd * 100) # 2-decimal token units; $9.00 → 900
fx_src = NULL
idx = hd_wallet_state.allocate_next_index() # SELECT FOR UPDATE + bump
addr = derive_cashaddr(xpub, idx, token_aware=true) # token-aware cashaddr accepts both BCH and CashTokens
return PaymentRequest{
amount_usd,
payment_method,
quote_amount_native = amt,
fx_rate, fx_source = fx_src,
quote_at = now,
deposit_address = addr,
deposit_derivation_index = idx,
expires_at = now + 30 min,
status = 'pending',
}
Why round up on the BCH satoshi conversion: avoids the customer receiving an "underpaid by 1 sat" status when their wallet rounds down. Stablecoin amounts are exact (USD has 2 decimals, tokens have 2 decimals — no rounding needed).
The 30-minute window matters most for BCH (price drift); for stablecoins it's still useful as a hygiene measure (don't let stale payment requests pile up) but the price-drift exposure is zero. Customers paying in stablecoins get the same 30-min/24h window mechanics as BCH for schema simplicity.
The deposit address must be a token-aware cashaddr (the variant that lets the receiving party know the address's wallet supports CashTokens). Modern BCH wallets emit token-aware cashaddrs for CashToken-supporting accounts. The HD wallet derivation tree is identical regardless of method — token-awareness is a property of the address-encoding, not the underlying key/path.
6. Price feed (BCH only)
Stablecoin payments don't touch this section — they assume a 1:1 USD peg and skip the feed entirely. The feed exists to translate USD ↔ BCH at quote time for payment_method='bch'.
Configuration:
PRICE_FEED_SOURCES = ['kraken', 'coingecko', 'bitfinex'] # ordered preference
PRICE_FEED_QUOTE_FRESHNESS_MS = 60_000 # cache TTL
PRICE_FEED_MEDIAN_REQUIRED = 2 # need ≥N agreeing sources
PRICE_FEED_DEVIATION_LIMIT = 0.02 # reject quote if sources disagree by >2%
Behavior:
- The poller fetches USD/BCH from each source on a 30-second cadence.
- On a quote request, take the median of the freshest reading from each source (≤60s old per source).
- Require at least
PRICE_FEED_MEDIAN_REQUIRED = 2sources reporting fresh values; fewer → fail the quote (HTTP 503 to the customer with "price feed unavailable, please retry"). - Reject the quote if the spread between min and max across sources exceeds
PRICE_FEED_DEVIATION_LIMIT = 2%— defends against single-exchange spikes.
The bch_quote_source field on PaymentRequest records the literal source set used (e.g., "median:[kraken,coingecko]" if bitfinex was stale). Operationally this lets us alert when one source is consistently dropping out.
Stretch later: lean on Oracle Network attestations / median-of-DEX feeds. Not in launch scope — centralized exchange APIs are sufficient and have multi-decade reliability for BCH/USD.
7. Tolerance band
Define what counts as "exact" vs. over/under against the running sum of in-currency UTXOs on the deposit address (deposits in the wrong currency are tracked separately — see §9). Tolerance is method-specific because BCH has FX drift while stablecoins are integer-precise:
payment_method |
Tolerance | Reason |
|---|---|---|
bch |
0.5% (relative) | wallet fee/rounding noise + 30-min FX drift |
pusd, musd |
1 token unit (1 cent, absolute) | 2-decimal precision, integer math, no FX |
running_total = sum(in-currency UTXOs on deposit_address)
# bch:
partial = running_total < quote × (1 - 0.005)
exact = quote × (1 - 0.005) ≤ running_total ≤ quote × (1 + 0.005)
overpaid = running_total > quote × (1 + 0.005)
# pusd / musd:
partial = running_total < quote − 1
exact = quote − 1 ≤ running_total ≤ quote + 1
overpaid = running_total > quote + 1
A 0.5% BCH tolerance is generous for honest wallet rounding but tight enough that intentional shaving (paying 90% and hoping it slips through) is caught. Configurable per-tier or per-amount-bucket if needed.
Each new in-currency UTXO triggers a re-evaluation of the running total. So a customer who sends 60% then 30% then 12% in PUSD lands at 102% of quote → received_over after the third tx, with a small change payout. They never had to coordinate a single perfectly-sized payment.
8. Apply behavior per branch
| Branch | Trigger | Account action | Payout |
|---|---|---|---|
received_exact |
running total within tolerance of quote | apply purpose (subscribe/upgrade/topup) per BILLING.md | none |
received_over |
running total > quote + tolerance | apply purpose | kind='change', amount=running_total−quote |
partial (transient) |
running total < quote − tolerance, within partial window | no apply yet — keep listening | none yet |
abandoned_partial |
partial window elapsed without completion | no apply | kind='refund', amount=running_total |
expired_paid |
first deposit arrived after expires_at |
no apply | kind='refund', amount=running_total |
expired |
no deposit ever | none | none |
The over- and under-payment paths are asymmetric, but in different directions than a naive design:
- Overpayment is taken as completion + a small surplus to refund as change. Customer gets the bundle.
- Underpayment is treated as progress, not failure. The address stays open and the customer is shown the deficit; they top up to the same address until the running total reaches the quote. Only if they walk away from it for the abandonment window does the partial get refunded.
The "no apply yet" on partial means: the billing-side subscribe / upgrade event does NOT fire while in partial. Account stays on whatever state it was before the payment request was created; tier doesn't change. Once the running total crosses the threshold, the apply fires the same way as if the deposit had arrived in one tx.
This avoids the back-and-forth of refund-then-resubmit when a customer's wallet rounds slightly low or they paid in two transactions; the natural completion path is simply "send the difference."
The remaining-amount hint shown to the customer is in the quote currency's native units against the original quote: remaining = quote_amount_native − running_total. For BCH that's "send X more sats"; for PUSD/MUSD that's "send Y more cents". We don't re-quote at the current spot rate during the partial window — the customer is paying off the original quote, and (for BCH) the operator absorbs whatever BCH/USD drift accumulates between the first partial and the final top-up. That drift is bounded by the partial-window timeout (default 24h since last partial), which is what makes the operator-side exposure tractable. Stablecoin partials have no drift exposure since the peg is fixed.
9. Refund / change workflow
A Payout row is created in four cases (none of them in the happy path of underpayment-then-top-up):
received_over→kind='change',payout_method = PaymentRequest.payment_method,amount_native = running_total − quoteexpired_paid→kind='refund',payout_method = PaymentRequest.payment_method,amount_native = running_total(whole received amount; customer should re-quote)abandoned_partial→kind='refund',payout_method = PaymentRequest.payment_method,amount_native = running_total(whole received amount; partial sat past the abandonment window)- Wrong-currency arrival →
kind='wrong_currency',payout_method = the currency the customer actually sent,amount_native = received amount in that currency. The PaymentRequest's reconciliation is unaffected — the customer can still complete the right-currency deposit at the same address. Wrong-currency arrivals are tracked but never count toward the running total.
The payout is always sent in the same currency the customer paid in. A BCH-quoted request that's overpaid generates a BCH change payout; a PUSD-quoted request that's overpaid generates a PUSD change payout. We don't auto-convert between currencies — that would expose the customer to an FX trade they didn't ask for.
In all four cases the Payout starts with status='awaiting_address' and the customer is notified (email + in-app banner). The customer's required input is a cashaddr; for stablecoin payouts it must be a token-aware cashaddr so their wallet can receive the CashTokens. The dashboard validates the address format matches the payout currency (token-aware required for PUSD/MUSD; either form accepted for BCH). No identity verification — the customer is whoever sends/receives BCH at the addresses they control; we don't add a KYC layer on the refund path.
On submission:
record customer_address, set status='queued'
sign tx: inputs = UTXO(s) at deposit_derivation_index (signed via xprv child)
outputs depend on payout_method:
bch → customer_address: (amount_native - fee_satoshis) BCH
pusd → customer_address: amount_native PUSD tokens
+ dust BCH (≈546 sats)
the BCH fee + dust is taken from the deposit's BCH
accompaniment (token UTXOs always carry some BCH)
musd → same shape as pusd, MUSD category
broadcast via mainnet-js
record txid, set status='sent'
Network fee:
- BCH payouts: deducted from the payout amount (customer-pays). Default fee target: 1 sat/byte. For tiny refunds where fee > 5% of amount, the system warns the customer at address-submission time: "fee will consume 7% of your refund — submit anyway?".
- Stablecoin payouts: token amount is integer-precise — we can't shave fractional cents off it the way we shave sats. The fee + token-UTXO dust are paid from the deposit's BCH-side balance (every CashToken UTXO carries BCH alongside it; the deposit address typically has enough). If that's insufficient, the operator tops up from the hot wallet. Operator absorbs this small overhead as the cost of accepting stablecoins.
Below dust (BCH only): if amount_satoshis < dust_limit + min_fee (≈546 + ~250 = ~800 sats, ~$0.003 worth), the refund cannot be sent on-chain. Two options: (a) hold as account credit added to the customer's CC balance at the current tier_rate_per_cc, (b) waive (write off). Default to (a) since it's customer-friendly and trivially small. Stablecoin payouts don't have a below-dust floor in the same way (token amounts are integer-precise; even 1 cent is a valid token-bearing UTXO with a small BCH dust attached) — but the operator-paid BCH dust + fee for sending a 1-cent stablecoin refund can exceed the refund's value. Same fallback applies: tiny stablecoin refunds become CC credit at the customer's tier rate.
Address validation: cashaddr checksum + non-mainnet prefix rejection (no testnet/regtest addresses on a mainnet payment system). For stablecoin payouts, additionally enforce that the address is a token-aware cashaddr — sending CashTokens to a non-token-aware address is technically valid on-chain but the receiving wallet may not surface them, leading to support tickets. Ensure the address isn't on an internal sanctions list before queuing.
Holding period: indefinite by default. Customers reclaim whenever they like. Operator may set a long-tail policy (e.g., > 5 years inactive → escheatment) but that's a legal/policy decision (§13 open).
10. mainnet-js integration
The watcher process subscribes to the wallet's xpub and consumes incoming-UTXO events. Each event must surface both the BCH amount AND the CashToken category+amount on the UTXO, so the reconciler can route to the right currency:
import { Wallet } from "mainnet-js";
const wallet = await Wallet.fromXPub(HD_WATCH_XPUB);
wallet.watchAddressTransactions(async (tx) => {
const idx = await db.findIndexForAddress(tx.address);
if (idx === null) return; // not a known deposit address
// mainnet-js parses CashToken data from the UTXO; the watcher fans out
// events by detected currency.
for (const utxo of tx.outputs.filter(o => o.address === tx.address)) {
await reconciler.handleUtxo({
deposit_derivation_index: idx,
txid: tx.hash,
output_index: utxo.vout,
currency: utxo.token
? matchTokenCategory(utxo.token.category) // 'pusd' | 'musd' | 'unknown_token'
: 'bch',
amount_native: utxo.token
? utxo.token.amount // token units (integer)
: utxo.satoshis, // BCH satoshis
confirmations: tx.confirmations,
});
}
});
(Sketch only; the real implementation pulls per-address subscriptions or watches the xpub block-by-block depending on what mainnet-js exposes most efficiently in our deployed version. The key invariant: surface currency and amount_native on each UTXO so the reconciler can match it to the PaymentRequest's expected payment_method.)
The reconciler's branch logic:
incoming UTXO at deposit_address, currency=X, amount=N
if X == PaymentRequest.payment_method:
update received_amount_native += N, last_partial_at = now
re-evaluate against quote (§7) → exact / over / partial
elif X is a recognized currency we accept (e.g., user sent PUSD when they
quoted in BCH):
create Payout(kind='wrong_currency', payout_method=X, amount_native=N)
PaymentRequest reconciliation continues; this UTXO does NOT count
toward running_total
else: # unknown token category, or a non-accepted token
operator alert; UTXO sits in the wallet for manual handling
Confirmation policy — process-scope, configurable via CONFIRMATION_POLICY:
zero(default) — apply on first observation regardless of amount. BCH 0-conf is reliable in practice (fast block times, double-spend-proof / DSP support throughout the network), and the customer-experience gain ("subscription active immediately") materially outweighs the residual risk for the typical bundle sizes we charge.tiered— the safer-but-slower step function:amount_usd confirmations required < $20 0-conf $20 – $200 1-conf > $200 2-conf Use this if a deployment's risk model requires waiting on confirmations for larger payments.
The policy lives in gateway/billing-service/src/config.ts; flipping it doesn't require code changes — operator deploys a new env value and restarts the watcher. Apply happens as soon as the threshold is crossed; if a reorg invalidates the tx (very rare on BCH), the apply is reversed by an operator-side compensating action — schema doesn't currently model this (open §13).
Failure modes:
- mainnet-js connection lost → poller falls back to direct BCHN RPC / Fulcrum query against each pending PaymentRequest's address. Reconciler runs the same logic regardless of feed source.
- Watcher process crash → on restart, replays from the last-seen tx ID stored in a separate
payment_watcher_cursorrow. UTXOs already-credited are deduplicated by(address, txid)constraint.
11. HD wallet conventions
BIP44 path: m / 44' / 145' / 0' / 0 / <deposit_derivation_index>
^ ^ ^ ^
| | | change=0 (external/receiving)
| | account=0 (allow more if multiple billing entities)
| coin_type=145 (BCH)
purpose=44 (BIP44)
145 is the registered SLIP-0044 coin type for BCH. Account 0 is the default; derivation_account in hd_wallet_state allows operator to bump if they want multiple wallets (e.g., per-region partitioning or for migration).
deposit_derivation_index is monotonically increasing, never reused. Even after a PaymentRequest is fully resolved (applied or refunded), its index stays bound to that request — so historical refunds can still be signed by deriving the same path. This is why the index is UNIQUE in the schema.
One address, multiple currencies. A single derived cashaddr accepts BCH, PUSD, MUSD, and any other CashToken on the same UTXO set — the address-key relationship is currency-agnostic. The reconciler is what decides which UTXOs count toward a given PaymentRequest's quote (matching by payment_method); the wallet/key derivation is unchanged.
xprv custody is out of scope for this doc. Options the operator must pick from: cloud KMS (AWS/GCP), HSM (YubiHSM, CloudHSM), encrypted-on-disk with operator-controlled passphrase. The signer is a separate process from the gateway/watcher; both query it via a narrow signing API. Compromise of the xprv compromises every deposit address ever generated, so this is a high-risk component.
12. Operational
Sweeping
UTXOs accumulate on deposit addresses after apply. They should be swept periodically to a hot-wallet receiving address (or directly to cold storage) so the operator's working capital isn't fragmented across thousands of tiny addresses.
A nightly sweep job:
for each PaymentRequest where status='applied' AND swept_at IS NULL:
if UTXO at deposit_address still exists:
consolidate into sweep_tx (multiple inputs, single output to operator hot wallet)
mark swept_at = now
Schema: add swept_at: timestamptz and swept_txid: text to PaymentRequest. (Not in §3 yet — added as a follow-up.)
The sweep tx fee is operator-paid out of the swept amount.
Reconciliation alarms
- Watcher cursor drift: if
payment_watcher_cursor.last_seen_blockis more than N blocks behind the chain tip, alert. - Partial too long: PaymentRequest with
status='partial'whoselast_partial_atis older than the abandonment window (default 24h) → reconciler should have already transitioned it toabandoned_partial; if not, the sweeper does so and queues the refund Payout. Alert on entries that linger past the window without state change (signals a sweeper bug). - Refund unclaimed: Payout with
status='awaiting_address'for > 7 days → reminder email cadence; for > 30 days → operator review (customer probably not coming back). - Payout queue stuck: Payout
status='queued'for > 1h → signer service health alert.
Rate-limit on quote generation
generate_payment_request is rate-limited per account (e.g., 10/hour) to defend against an attacker exhausting the HD-wallet derivation index range or the price-feed API quota. The HD wallet's index space is 2^31 (BIP32 unhardened range) so functionally unlimited, but the rate limit is good hygiene.
13. Open
These are tracked in TODO.md:
- Stablecoin peg break / depeg policy. PUSD and MUSD are assumed 1:1 USD; in a depeg event (issuer insolvency, oracle attack, redemption freeze) the operator may want to suspend acceptance, refund pending stablecoin payments at the pre-depeg rate, or temporarily quote in BCH only. Schema doesn't currently model "currency disabled" — adding
payment_methods.enabled: boolper-method config is a future option. - Reorg handling. Schema and reconciler don't currently un-apply if a deposit-confirming block gets reorged out. BCH reorgs at ≥1-conf are rare but not impossible. A
payment_request_reverted: boolfield + manual-review queue may suffice. - Long-tail Payout reclaim policy. Currently we hold refund/change balances indefinitely. Decide: 5-year inactive → operator-claim, or hold forever. Jurisdiction-dependent escheatment may apply.
- xprv custody. §11 enumerates the options; pick before launch.
- Per-region wallets. §11's
derivation_accountfield lets the operator partition (e.g., EU vs. US billing entities) but the implementation glue isn't in scope here. - Quote pre-funding — power users may want to pre-deposit a balance against future tier charges (a customer-side BCH wallet linked to their account). Different flow from the per-request deposit address; separate doc if/when prioritized.
- Subscription auto-renewal. Crypto-only billing means the customer must initiate each renewal payment (no card auto-charge). Renewal flow: notify N days before
cycle_ends_at, generate a renewal PaymentRequest, customer pays. If they don't pay beforecycle_ends_at, status moves toexpiredper BILLING.md §8.2. The notification cadence and grace period semantics are a UX decision separate from this gateway. - Stablecoin discount? PUSD/MUSD acceptance has near-zero operational cost (no FX risk, simpler reconciliation) — a small discount on stablecoin payments could be a worthwhile incentive. Currently quoted at the same USD price as BCH. Commercial decision, listed in
TODO.mdunder BCH-native payment mechanics.
14. End-to-end worked examples
A — Exact payment, subscribe Hobby monthly. Customer clicks subscribe. Quote: $9 USD = 30 000 sats at 1 BCH = $300. Customer sends exactly 30 000 sats within 12 minutes; mainnet-js fires; reconciler matches, status='received_exact'; billing applies subscribe → tier=hobby, balance=100M, cycle_started_at=now. No payout.
B — Overpayment, subscribe Build monthly with surplus. Quote $39 = 130 000 sats (BCH). Customer's wallet rounds up and sends 135 000 sats. Reconciler: received_over (>0.5% above quote). Billing applies subscribe (Build, full bundle). Payout row: kind='change', payout_method='bch', amount_native=5000. Customer is emailed: "We received 5000 sats over your subscription price; submit a BCH address to receive your change". On submission, signer dispatches 5000 − fee → customer's address.
C — Underpayment, completed by top-up. Quote $39 = 130 000 sats. Customer's wallet sends 100 000 sats first (rounded weirdly, or two-step send-from-cold-wallet plan). Reconciler: status='partial', no apply. Customer's dashboard shows "you've paid 100 000 of 130 000 sats; send 30 000 sats more to received_exact → billing applies the Build subscription. No payout.
C2 — Underpayment, abandoned. Same setup as C, but the customer never tops up. After 24h since the last partial deposit, the partial-window timeout fires → status='abandoned_partial' → Payout row created with kind='refund', payout_method='bch', amount_native=100 000, awaiting customer's refund address.
D — Quote expired, deposit arrives late. Quote $9 = 30 000 sats; customer sent 30 000 sats but 45 minutes after generation (broken wallet, slow sign-and-send). Reconciler: status='expired_paid'. Refund the full 30 000 sats once the customer submits an address. Customer can request a fresh quote at the new spot rate.
E — Below dust refund. Customer overpays by 600 sats. amount=600, dust + fee threshold ≈800. Cannot send on-chain. Schema records the 600 sats as account credit at the customer's tier_rate_per_cc (so ~$0.0036 worth of CC added to balance). Payout row marked status='reclaimed' with note 'below_dust_credited'.
F — Customer never submits refund address. PaymentRequest reached abandoned_partial 6 months ago; Payout still awaiting_address. Periodic reminder email sent monthly. Funds remain on the deposit_derivation_index UTXO until the customer either claims or operator escheatment policy fires (§13 — open). swept_at stays null until claim.
G — Two-step deposit, eventually overpays. Quote $9 = 30 000 sats. Customer sends 25 000 sats first (status='partial', dashboard says "send 5 000 sats more"). Customer sends a second tx with 8 000 sats. Running total = 33 000 sats > quote + 0.5% tolerance → received_over → billing applies Hobby + Payout kind='change', amount=3000 queued for the surplus. The first deposit's role as "underpayment" is invisible in the final outcome; customer just sees "subscribed + small change to claim."
H — Stablecoin (PUSD) subscribe. Customer subscribes annual Hobby and chooses PUSD. Quote: amount_usd = $90, payment_method = 'pusd', quote_amount_native = 9000 (90 × 100 since PUSD has 2 decimals), fx_rate = NULL, fx_source = NULL. Customer sends 9000 PUSD tokens to the deposit address. Reconciler: in-currency UTXO matched, running_total = 9000, within tolerance → received_exact → billing applies annual Hobby. No payout. No price-feed exposure throughout — the customer paid USD-equivalent regardless of BCH price movements.
I — Stablecoin (MUSD) overpayment. Quote $39 = 3900 MUSD. Customer's wallet sends 4000 MUSD by accident. Running_total = 4000 > 3900 + 1 (tolerance) → received_over → billing applies Build subscription + Payout kind='change', payout_method='musd', amount_native=100 (1.00 MUSD owed back). Customer submits a token-aware MUSD-receiving cashaddr; signer constructs a CashToken-bearing tx sending 100 MUSD tokens (with a small BCH dust attached for the UTXO).
J — Wrong currency arrival. Customer creates a quote in PUSD ($9 = 900 PUSD), then accidentally sends $9 in BCH (~30 000 sats) to the same address. Reconciler: incoming UTXO is BCH, but PaymentRequest expects PUSD → not counted toward running_total. Payout(kind='wrong_currency', payout_method='bch', amount_native=30000) created; customer notified. Meanwhile the PaymentRequest is still waiting for the actual PUSD deposit — customer can either complete it correctly or let the 30-min quote window expire (and end up with two refunds: the BCH wrong-currency payout, and an expired PaymentRequest with no payout since no in-currency funds arrived).