SolidPeer

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:

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

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):

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:

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:

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:


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

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:

  1. 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: bool per-method config is a future option.
  2. 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: bool field + manual-review queue may suffice.
  3. 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.
  4. xprv custody. §11 enumerates the options; pick before launch.
  5. Per-region wallets. §11's derivation_account field lets the operator partition (e.g., EU vs. US billing entities) but the implementation glue isn't in scope here.
  6. 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.
  7. 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 before cycle_ends_at, status moves to expired per BILLING.md §8.2. The notification cadence and grace period semantics are a UX decision separate from this gateway.
  8. 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.md under 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 ". 18 minutes later the customer sends 30 000 sats. Reconciler: running_total = 130 000 = quote → 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).