# Crypto Payments — Non-Custodial Entitlement Settlement

> **Release: V1.0.** This is the only payment rail V1.0 charges through. Card,
> bank, wallet, PayPal, mobile-money, and the app-store rails are deferred to
> V1.1 — see [Support and Billing](./support-billing.md) and
> [`V1/BRAND.md`](../BRAND.md).

V1 accepts payment for entitlements without custody, without KYC at the payment
layer, and without routing funds through a centralized processor. Oshun never
holds spending keys for customer-paid funds. Every supported chain runs against
a self-hosted full node and a watch-only (or view-only) wallet, so the hot path
of the application server cannot move money even if fully compromised. This page
documents both the **spec** (the operational and security posture V1 commits to)
and the **shipped code**. It is candid about where the two diverge, because
several pieces of this subsystem are real, tested code while others are
provider-gated or operational concerns that cannot be exercised inside the repo.
It serves customers paying with crypto, operators running the settlement
infrastructure, and the entitlement/billing machinery described in
[Support, Entitlements, Billing, and Customer Operations](./support-billing.md).

## Where this sits in V1

There are three distinct layers, and keeping them straight is the key to reading
the rest of this page honestly:

1. **Aje** (`libs/aje/`, the Yoruba orisha of wealth and commerce) — the
   monorepo's blockchain/Web3 library family. It supplies per-chain providers,
   address derivation, node/RPC clients, and a merchant invoice contract
   (`@aje/payments/merchant`). V1's contribution to Aje is the **five new chain
   modules** Aje did not previously cover as dedicated packages: Monero,
   Litecoin, TON, Ergo, and Tron
   (`libs/aje/chains/{monero,litecoin,ton,ergo,tron}`).
2. **The bridge** (`libs/oshun/payments-bridge`, package
   `@oshun/payments-bridge` v0.1.0, private) — the Oshun-aware layer that
   defines the V1 payment asset catalog, confirmation policy, trust-tier
   disclosure registry, receipt signing, an entitlement-event emitter, a
   cold-spend refund queue, and the customer/admin surfaces.
3. **The BFF** (`apps/oshun/bff/src/payments/`) — the running consumer that
   wires the bridge into HTTP routes that the paywall and admin surfaces call.

A point that the source docs got wrong and this page corrects: the bridge's
`package.json` **declares** `@aje/chains`, `@aje/oracles`, `@aje/payments`, and
`@aje/wallets` as workspace dependencies (alongside `@noble/curves`,
`@noble/hashes`, `@oshun/audit-platform`, `@oshun/event-bus`, and
`@oshun/identity`). Yet `grep -rn "from '@aje" libs/oshun/payments-bridge/src/`
returns **nothing**. The bridge does not import Aje. It is functionally
standalone — it **reimplements** its own invoice-status vocabulary and event
model rather than adapting Aje's types. The "Aje stays library-only, consumed
through this bridge" narrative describes an intended consumption path that the
current code does not contain. The bridge is real, tested code; it simply is not
yet wired to Aje.

## The asset catalog — 32 rails

The bridge's single source of truth for what V1 accepts is `V1_PAYMENT_ASSETS`
in `libs/oshun/payments-bridge/src/state-mapper.ts`, a frozen tuple of exactly
**32 entries** (the type `V1PaymentAsset` is its `(typeof …)[number]`). It
covers the top payment cryptocurrencies by real-world volume plus Cardano and
Ergo for their decentralization posture:

| Group  | Assets                                                                                                                                               |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| Native | `btc-onchain`, `btc-lightning`, `ltc`, `xmr`, `eth-mainnet`, `eth-base`, `eth-arbitrum`, `eth-optimism`, `matic-polygon`, `sol`, `ton`, `ada`, `erg` |
| USDC   | `usdc-mainnet`, `usdc-base`, `usdc-arbitrum`, `usdc-optimism`, `usdc-polygon`, `usdc-solana`                                                         |
| USDT   | `usdt-mainnet`, `usdt-base`, `usdt-arbitrum`, `usdt-optimism`, `usdt-polygon`, `usdt-tron`, `usdt-solana`, `usdt-ton`                                |
| DAI    | `dai-mainnet`, `dai-base`, `dai-arbitrum`, `dai-optimism`, `dai-polygon`                                                                             |

`listSupportedAssets()` (in `trust-tier-disclosure.ts`) returns this tuple in
its deterministic declaration order, and the BFF's `GET /v1/payments/methods`
route maps directly over it — so the catalog the customer sees is the literal
code constant, not a separately maintained list.

> A separate vocabulary lives in the receipt signer: `RECEIPT_ASSETS`
> (`receipt-signer/types.ts`) has **17 entries** — `BTC`, `BTC-LN`, `LTC`,
> `ETH`, `MATIC`, `USDC`, `USDT`, `DAI`, `XMR`, `SOL`, `USDC-SOL`, `USDT-SOL`,
> `TON`, `USDT-TON`, `ADA`, `ERG`, `USDT-TRC20`. These collapse the per-chain
> variants of a token into one receipt asset where the on-chain verification
> recipe is the same.

## Trust tiers and issuer-trust classes

The catalog is classified two ways, independently, so the trade-offs are honest
at the acceptance layer rather than buried. Both classifications live in
`RAIL_REGISTRY` (`trust-tier-disclosure.ts`), a per-asset `RailDescriptor` keyed
by `V1PaymentAsset`, where each descriptor carries `tier`, `issuerTrustClass`,
`disclosureCopyEn`, and `requiresDisclosureAcknowledgement`.

**Chain decentralization tier** — `RAIL_TIERS = ['A', 'B', 'C']`:

- **Tier A — credibly neutral, censorship-resistant, self-hosted full node
  mandatory at launch.** In the registry: `btc-onchain`, `btc-lightning`, `ltc`,
  `xmr`, `eth-mainnet`, `ada`, `erg`.
- **Tier B — decentralized with documented caveats.** `sol` (validator
  concentration and a history of outages), the OP-Stack / Nitro rollups
  `eth-base`/`eth-arbitrum`/`eth-optimism`, and `matic-polygon`, plus **every
  USDC, USDT, and DAI rail not on TON or Tron** (the issuer caveat pushes them
  to B even on otherwise-A chains).
- **Tier C — centralized trust required, accepted explicitly.** `ton`,
  `usdt-tron`, `usdt-ton`.

**Asset-issuer trust** —
`ISSUER_TRUST_CLASSES = ['native', 'decentralized-issuer', 'central-issuer-with-freeze']`:

- **`native`** (no issuer): BTC, LTC, XMR, ETH, SOL, TON, ADA, ERG.
- **`decentralized-issuer`**: every DAI rail (MakerDAO; the registry copy notes
  the collateral basket includes USDC "that may themselves be frozen, indirectly
  impacting DAI" — there is no single-entity freeze authority over the protocol
  itself).
- **`central-issuer-with-freeze`**: every USDC (Circle) and USDT (Tether) rail.
  The disclosure copy states plainly that the asset "can be frozen at the
  issuer's discretion."

Each registry entry ships ready-to-render English disclosure copy. For example
`ton.disclosureCopyEn` reads: _"TON transactions are irreversible. TON is not
issued by any company. The TON network is governed by the TON Foundation and its
validator set is more centralized than Bitcoin or Ethereum."_ The L2 entries
even spell out the 7-day challenge period and the sequencer operator by name
(Coinbase for Base, Offchain Labs for Arbitrum, OP Labs for Optimism), and
`matic-polygon` notes finality is "probabilistic until the next Heimdall
checkpoint (≈256 blocks)."

### The disclosure acknowledgement gate

Honesty is enforced, not merely displayed. `gateInvoiceCreation(request)`
returns a discriminated union:

```ts
type InvoiceCreateDisclosureGate =
  | { verdict: 'allow' }
  | {
      verdict: 'block';
      reason: 'disclosure-acknowledgement-required';
      disclosureCopy: string;
    };
```

If the requested asset's `requiresDisclosureAcknowledgement` is `true` (every
Tier-B/C rail and every centrally issued stablecoin) and the request's
`disclosureAcknowledgedAtUnixSeconds` is `null` or `<= 0`, the gate **blocks**
and hands back the exact `disclosureCopyEn` string so the surface can render the
disclosure the customer must accept. Tier-A native rails
(`railRequiresDisclosure(asset)` is `false`) pass straight through. This gate is
the bridge's real, tested admission control for risky rails. The customer-facing
companion is `customer-surface/disclosure-gate.ts`. Whether a _tenant_ permits a
given rail at all is a separate policy concern resolved upstream in
[Tenant, Institution, and Operator Toolkit](./tenant-toolkit.md).

## Per-chain settlement detail

V1's five new chain modules (`libs/aje/chains/{monero,litecoin,ton,ergo,tron}`)
each ship a `provider.ts`, a node/RPC client, `types.ts`, real chain-valid
address derivation, a confirmation policy, and an end-to-end test against a real
test network. The remaining chains (Bitcoin, Ethereum L1, the EVM L2s, Polygon,
Cardano, Solana) are covered by pre-existing Aje modules under
`libs/aje/chains/src/` and the dedicated `@aje/bitcoin`.

- **Bitcoin on-chain (BTC)** — Tier A. BIP84 (native SegWit) per-invoice
  addresses from a watch-only xpub; never reused. Confirmations gate by amount
  tier: zero-conf only for sub-threshold invoices with RBF fee-bump detection, 1
  for standard, 3 for high-value (see `CONFIRMATION_POLICY` below).
- **Bitcoin Lightning (BTC)** — Tier B in the spec narrative (Tier A in the
  registry, since the asset itself carries no issuer trust). BOLT11 invoices via
  self-hosted LND/CLN/Phoenixd through BTCPay Server; sub-second settlement, so
  its confirmation policy is `{0, 0, 0}`.
- **Litecoin (LTC)** — Tier A. Module `libs/aje/chains/litecoin`. Watch-only
  xpub enforced at the correct depth (the client rejects an xpub whose depth is
  not 3) and per-invoice P2WPKH addresses derived from
  `LTC_BIP84_DERIVATION_PREFIX = m/84'/2'/0'/0` (coin type 2 per SLIP-0044). A
  dedicated `rbf-detector.ts` handles fee-bump detection; a
  `regtest-e2e.test.ts` exercises the full flow. 1 / 3 / 6 confirmations by
  tier.
- **Monero (XMR)** — Tier A, strongest privacy posture in the catalog. Module
  `libs/aje/chains/monero`. Self-hosted `monerod` for trustless validation;
  `monero-wallet-rpc` runs **view-only** with the secret spend key cold. Outputs
  unlock after `XMR_DEFAULT_UNLOCK_BLOCKS = 10` (≈20 min), configurable up to
  `XMR_HIGH_VALUE_UNLOCK_BLOCKS = 20` for high-value tiers
  (`confirmation-policy.ts`). Per-invoice subaddresses (account index 0, fresh
  `subaddress_index`) keep paying customers unlinkable. `invariants.ts` actively
  _forbids_ a `payment_id` in `tx_extra` — `assertNoTxExtraPaymentId` throws
  `MoneroPaymentIdInvariantError` because "the subaddress is the only on-chain
  correlator." Telemetry (`telemetry.ts`) stores only a BLAKE2b-256 digest of
  the subaddress (`hashSubaddress`) and `sanitizeLogPayloadForXmr` scrubs
  Monero-shaped strings (95/106-char base58 starting 8/B) from logs. A
  `stagenet-e2e.test.ts` runs the real flow.
- **Ether (ETH)** — Tier A on L1; Tier B on Base/Arbitrum/Optimism/Polygon.
  BIP44 per-invoice addresses from a watch-only xpub; multi-RPC consensus for
  L2s.
- **Cardano (ADA)** — Tier A. `cardano-node` and `cardano-wallet` watch-only;
  CIP-1852 per-invoice addresses; 15 confirmations (≈5 min) standard, ≥30
  high-value.
- **Ergo (ERG)** — Tier A. Module `libs/aje/chains/ergo`. `p2pk-allocator.ts`
  derives real Ergo P2PK addresses from a watch-only xpub (via `@scure/bip32`
  `HDKey`) at `ERG_BIP44_DERIVATION_PREFIX = m/44'/429'/0'/0`
  (`ERG_BIP44_PURPOSE = 44`, `ERG_SLIP44_COIN_TYPE = 429`); a
  `testnet-e2e.test.ts` validates it. 5 / 10 / 30 confirmations by tier.
- **Solana (SOL)** — Tier B. Multi-RPC consensus (full validator is impractical
  for a payment use case — an honest trade-off). Confirmation is encoded not as
  a depth but as an RPC **commitment sentinel**: `confirmed` for default,
  `finalized` for high-value (mechanism below).
- **Toncoin (TON)** — Tier C. Module `libs/aje/chains/ton`. `subwallet.ts`
  derives **genuine v4r2 contract addresses**, not synthetic digests — an audit
  caught that the original per-invoice account id was a dressed-up
  `sha256(publicKey ‖ walletId ‖ subwalletId)` corresponding to no deployable
  contract (funds would have been unsweepable). The real address is
  `workchain:sha256(StateInit cell)`, where the v4r2 StateInit data cell is
  `seqno=0(u32) ‖ subwallet_id(u32) ‖ public_key(256) ‖ plugins(1 bit)`.
  Distinct `subwallet_id`s under one watch-only public key yield distinct
  addresses without rotating keys, so the allocator assigns per-invoice
  subwallet ids from a monotonic counter (`walletId + index`, base
  `TON_WALLET_V4_DEFAULT_SUBWALLET_ID = 698983191`). The code cell loads from
  the canonical v4r2 BoC via `Cell.fromBoc` from `@ton/core`, whose embedded
  CRC32C check throws on a transcription error rather than silently producing a
  wrong address; a `sandbox-e2e.test.ts` confirms the flow. Telegram @wallet is
  the primary customer surface.
- **Tron (TRX network, USDT only)** — Tier C. Module `libs/aje/chains/tron`.
  `tron-address.ts` derives real base58check addresses the proper way: take
  `keccak_256` of the secp256k1 _uncompressed_ pubkey (`X ‖ Y`, i.e.
  `uncompressed.subarray(1)`), keep the last 20 bytes, prefix the network byte
  `TRON_ADDRESS_PREFIX = 0x41`, and base58check-encode with a double-SHA-256
  checksum (`base58check(sha256)` from `@scure/base`). Only **USDT-TRC20** is
  accepted — native TRX is not. `usdt-trc20.ts` pins the contracts
  (`USDT_TRC20_CONTRACT = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t'`,
  `USDT_TRC20_NILE_CONTRACT = 'TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj'` for
  testnet), with `TRON_USDT_DECIMALS = 6` and `TRON_CONFIRMATION_DEPTH = 19`; a
  `nile-testnet-e2e.test.ts` runs it.

### Stablecoin rails at a glance

- **USDC** (Circle) — accepted on Ethereum L1, Base, Arbitrum, Optimism,
  Polygon, and Solana. Every rail carries the Circle freeze-authority
  disclosure.
- **USDT** (Tether) — accepted on Ethereum L1, Base, Arbitrum, Optimism,
  Polygon, Tron, Solana, and TON (the eight chains covering essentially all
  real-world USDT volume). Tron and TON additionally carry a chain-specific
  centralization note.
- **DAI** (MakerDAO) — accepted on Ethereum L1, Base, Arbitrum, Optimism, and
  Polygon. No protocol freeze authority, but the real-world-asset / USDC
  collateral exposure is disclosed.

## Confirmation policy

`CONFIRMATION_POLICY` in `state-mapper.ts` maps every `V1PaymentAsset` to a
`ConfirmationPolicy { micro, standard, highValue }` — the minimum confirmations
a `paid` invoice must observe before the bridge will emit a settlement event and
grant the entitlement. `requiredConfirmations(asset, tier)` reads it for one of
the three `V1_AMOUNT_TIERS = ['micro', 'standard', 'high-value']`. Selected real
values:

| Asset                                 | micro | standard | high-value | Rationale                                         |
| ------------------------------------- | ----- | -------- | ---------- | ------------------------------------------------- |
| `btc-onchain`                         | 0     | 1        | 3          | zero-conf only sub-threshold (with RBF detection) |
| `btc-lightning`                       | 0     | 0        | 0          | off-chain, instant                                |
| `ltc`                                 | 1     | 3        | 6          | ~2.5-min blocks                                   |
| `xmr`                                 | 10    | 10       | 20         | output unlock window                              |
| `eth-mainnet`                         | 12    | 12       | 30         | also USDC/USDT/DAI mainnet                        |
| `eth-arbitrum`                        | 50    | 50       | 50         | rollup re-org tolerance                           |
| `eth-optimism`                        | 120   | 120      | 120        | OP-Stack                                          |
| `eth-base`                            | 150   | 150      | 150        | OP-Stack                                          |
| `matic-polygon`                       | 256   | 256      | 256        | Heimdall checkpoint depth                         |
| `ada`                                 | 15    | 15       | 30         | ~20-s slots                                       |
| `erg`                                 | 5     | 10       | 30         | ~2-min blocks                                     |
| `usdt-tron`                           | 19    | 19       | 19         | DPoS finality                                     |
| `ton` / `usdt-ton`                    | 1     | 1        | 3          | masterchain finality                              |
| `sol` / `usdc-solana` / `usdt-solana` | -1    | -1       | -2         | commitment sentinels                              |

Solana (and SPL stables on Solana) cannot express settlement as a block depth,
so the policy uses **sentinels**: `SOLANA_COMMITMENT_CONFIRMED = -1` and
`SOLANA_COMMITMENT_FINALIZED = -2`. The internal
`computeConfirmationMeetsRequirement` helper branches on these: a `-1` required
value is satisfied by an `AjeConfirmationStatus` of `confirmed` _or_
`finalized`; `-2` requires `finalized`; `0` requires only
`observedConfirmations >= 0`; any positive depth requires both
`observedConfirmations >= required` **and** a confirmation status of
`confirmed`/`finalized`. This is why the same code path handles every rail
without special-casing Solana downstream.

## State mapping — what the bridge actually models

The bridge defines its **own** invoice and confirmation vocabularies; it does
not consume Aje's. The source architecture doc claimed the bridge maps Aje's
`InvoiceStatus`/`ConfirmationStatus`, but the real `state-mapper.ts` uses:

- `AJE_INVOICE_STATUSES = ['draft', 'pending', 'paid', 'partial', 'refunded', 'expired', 'cancelled']`
- `AJE_CONFIRMATION_STATUSES = ['unconfirmed', 'confirmed', 'finalized']`

Aje's _actual_ merchant types (`libs/aje/payments/src/merchant/types.ts`) are
different:
`InvoiceStatus = draft | sent | viewed | partial | paid | overdue | cancelled | refunded`
and `ConfirmationStatus = pending | confirming | confirmed | failed | finalized`
(and Aje's merchant package exports `Invoice`, `PaymentRequest`,
`InvoiceLineItem`, `PaymentConfirmation`, `Refund`, `RecurringPayment`,
`Subscription`, plus links/qrcode/escrow). The bridge's vocabulary drops
`sent`/`viewed`/`overdue`, adds `pending`/`expired`, and reduces confirmation to
a three-state ladder. The two are **not** the same vocabulary, and the bridge
never imports Aje to reconcile them.

`mapAjeStateToV1(input: AjeInvoiceStateInput)` is the core function. It produces
a `V1PaymentEvent` whose `type` is one of `V1_PAYMENT_EVENT_TYPES`:

```text
payment.invoice.settled | payment.invoice.underpaid
payment.invoice.expired | payment.refund.broadcast
```

The mapping:

- **`paid`** → only emits `payment.invoice.settled` _if_
  `computeConfirmationMeetsRequirement` passes; otherwise returns `null` (the
  invoice is paid but not yet deep enough). The event's `grantsEntitlement` is
  `true` iff `entitlementId !== null`.
- **`partial`** → `payment.invoice.underpaid`, with
  `underpaidByAtomic = expectedAmountAtomic − observedAmountAtomic` (floored at
  `0n`), `grantsEntitlement: false`.
- **`refunded`** → `payment.refund.broadcast`.
- **`expired`** / **`cancelled`** → `payment.invoice.expired`.
- **`draft`** / **`pending`** → `null` (no event).

`mapAjeStateToV1WithAudit(input, appendAuditRecord)` wraps that with an audit
append: `toAuditRecord` builds an immutable `V1PaymentStateAuditRecord`
(`kind: 'payment-state-transition'`, `immutable: true`) capturing the source
status, source confirmation status, the emitted event type, the entitlement
decision, and the observed confirmation count — the durable trail behind every
state change. Amounts flow as `bigint` atomic units throughout.

### An internal event-vocabulary split worth knowing

There are two distinct event vocabularies inside the bridge, and the code does
**not** reconcile them — a real inconsistency, not a doc error:

- `state-mapper.ts` emits `settled` / `underpaid` / `expired` /
  `refund.broadcast`.
- `entitlement-bus/topics.ts` defines `PAYMENT_INVOICE_CONFIRMED_TOPIC`
  (`payment.invoice.confirmed`), `PAYMENT_INVOICE_SETTLED_TOPIC`
  (`payment.invoice.settled`), and `PAYMENT_REFUND_BROADCAST_TOPIC`
  (`payment.refund.broadcast`).

So `payment.invoice.confirmed` exists only on the entitlement bus and is never
produced by the state mapper, while `underpaid`/`expired` exist only in the
state mapper. The two modules speak overlapping but non-identical languages.

## The entitlement bus

`entitlement-bus/topics.ts` declares the on-the-wire event shapes that the
entitlement service consumes regardless of rail. The discriminated union
`PaymentBusEvent` is
`PaymentInvoiceConfirmedEvent | PaymentInvoiceSettledEvent | PaymentRefundBroadcastEvent`,
all carrying `schemaVersion: 1`, an `invoiceId`, `merchantId`, a `rail`, a
`fiatEquivalent: { amountMinor: bigint; currency: string }`, and nullable
`chainAsset`/`chainAmount`. The `rail` field is a `PaymentRail` union of **4
fiat** values (`fiat-stripe`, `fiat-adyen`, `fiat-paypal`, `fiat-generic`) plus
**14 crypto** values (`crypto-btc-onchain`, `crypto-btc-lightning`,
`crypto-ltc`, `crypto-evm-ethereum`, `crypto-evm-base`, `crypto-evm-arbitrum`,
`crypto-evm-optimism`, `crypto-evm-polygon`, `crypto-xmr`, `crypto-sol`,
`crypto-ton`, `crypto-cardano`, `crypto-ergo`, `crypto-tron`). The module's own
doc-comment still says "thirteen crypto rails" — that is stale; the union lists
14 (it gained `crypto-cardano`).

`CryptoEntitlementEmitter` (`entitlement-bus/emitter.ts`) wraps a
constructor-injected `publish: (event: PaymentBusEvent) => Promise<void>` and
exposes `emitConfirmed`, `emitSettled`, and `emitRefundBroadcast`. It
deliberately has **no I/O of its own** — the consumer supplies the fan-out to
the real bus (Kafka / NATS / SNS / etc.). Notably, it does **not** import
`@oshun/event-bus` even though the package declares it as a dependency; the
`publish` seam is the only coupling point. This is an honest injectable seam,
not a stub.

The `rail` field is what makes the entitlement service payment-rail-agnostic:
the same dunning, grace, and renewal machinery applies whether the envelope says
`fiat-stripe` or `crypto-xmr`. The bridge ships a test helper
`eventsStructurallyEquivalent(a, b)` that asserts a crypto event and a fiat
event of the same topic match field-for-field _except_ `rail`.

> **Correction to the source docs:** there is no `src/webhook-router.ts`, and
> the claim that crypto events use the _identical schema_ to the fiat adapter in
> `libs/shared/inbound-integrations/src/payment.ts` is **false**. That fiat file
> is a connector-capability model with
> `PaymentStatus = requires_action | authorized | captured | refunded | failed`
> — it shares no topic schema with the bridge's `PaymentBusEvent`. The bridge's
> events are _designed_ to mirror a fiat emission so the consumer's contract is
> rail-agnostic, but no live fiat emitter produces these exact `PaymentBusEvent`
> shapes today.

## Receipts — verifiable without trusting Oshun

A crypto receipt carries enough material that a customer holding only the
receipt and the chain can verify Oshun's claim independently. The
`ReceiptPayload` (`receipt-signer/types.ts`) carries `invoiceId`, `merchantId`,
`customerLocale`, `txId`, `blockHash`, `blockHeight`, `asset` (a
`ReceiptAsset`), `amount` (bigint, chain smallest unit —
sat/wei/lovelace/atomic), `fiatCurrency`, `fiatAmountMinor` (bigint cents at
confirmation), `confirmedAtUnixSeconds`, an optional `moneroPaymentProof`, a
`tax: TaxBreakdown`, and a `verificationSnippet`.

> **Correction:** the Monero proof is a **2-tuple**, not 3.
> `MoneroPaymentProof = { txKey, address }`. The `txId` is a separate top-level
> field on `ReceiptPayload`, not part of the proof tuple — so a receipt records
> the transaction id once and attaches the `(tx_key, address)` pair as the
> cryptographic evidence the customer reproduces against a remote node.

`ReceiptSigner` (`receipt-signer/receipt-signer.ts`) signs with **Ed25519**
(`@noble/curves/ed25519`) using the audit-platform private key (it validates the
key is exactly 32 bytes at construction). The signature is taken over a
**canonical encoding**: deterministic JSON with **sorted keys** and bigints
serialized as **decimal strings** so they survive JSON round-tripping. `sign`
returns a `SignedReceipt { payload, signatureHex, auditKeyId }`; the standalone
`verifyReceipt({ receipt, auditPublicKey })` re-derives the canonical bytes from
the payload and checks the signature, returning `{ ok, reason }`. Because the
canonicalization is deterministic and the key order is sorted, any verifier —
the customer, an auditor, a court — produces identical bytes and the check is
trustless against Oshun's API.

The `verificationSnippet` is a real, shipped customer-facing feature
(`receipt-signer/verification-snippet.ts`): `buildVerificationSnippet(input)`
emits a `curl`/shell invocation tailored to the asset — e.g. a Monero
`check_tx_key` call against `<monero-rpc>/json_rpc`, a Solana `getTransaction`
against `<solana-rpc>`, a TON `<ton-http-api>/jsonRPC` call, an Ergo
`/blockchain/transaction/byId/<txId>` GET, or a Tron `gettransactioninfobyid`
POST. This lets a customer confirm the on-chain payment without trusting any
Oshun endpoint. `locale-tax.ts` supplies the `TaxBreakdown` (`subtotalMinor`,
`taxMinor`, `totalMinor`, `taxRate`, `taxJurisdiction`), reused from the fiat
billing receipt formatter so crypto and fiat receipts present tax identically.

## Fiat price lock and the oracle aggregator

The `oracle-aggregator/` module is real and has three parts: `price-feed.ts`,
`rate-lock.ts`, and `tor-egress.ts`. At invoice creation the fiat-equivalent
rate is locked for the invoice expiry window (the spec defaults are 15 min for
L1, 30 min for L2, 60 min for Lightning, configurable). Rates are aggregated
across multiple sources and a median is taken; the source spread is recorded for
fraud analysis (the spec names Kraken, CoinGecko, and a DEX-derived median such
as a Uniswap v3 TWAP for EVM stables, with the median winning). `tor-egress.ts`
backs the spec commitment that, where supported, rate queries route over Tor and
the wallet-RPC instances do not egress to chain-analysis APIs.

## Cold-spend refund queue

`cold-spend-queue/` implements the offline refund workflow: `queue.ts` (queue an
unsigned transaction), `sweep-policy.ts` (consolidation above the sweep
threshold), `hw-signing-fixture.ts` (a hardware-wallet co-signer test fixture),
and `audit-attestation.ts` (the broadcast attestation). The spec posture is: the
application server queues an _unsigned_ refund, and an operator with a
hardware-wallet (Trezor/Ledger/Coldcard) co-signer authorizes it on an
air-gapped signing station. The broadcast is then logged in the audit platform,
after which the bridge emits `payment.refund.broadcast`. Customer-initiated
refunds require an explicit refund address on Monero (there is no return-address
field in a Monero transaction by design) and default to the originating address
with override for BTC/EVM.

> The cold/air-gapped signing station, hardware co-signers, 2-of-3 multisig
> vaults, self-hosted nodes, and Tor egress are described in code comments,
> types, and the `cold-spend-queue` fixtures, but they are **operational
> concerns not provable in-repo**. The queue logic, sweep policy, and
> attestation builder are real code; the physical signing infrastructure is not
> something the repository can exercise.

## Customer and admin surfaces

These are real bridge modules the source architecture doc under-described.

**`customer-surface/`** is the paywall side:

- `asset-chain-filter.ts` resolves which chains an asset is accepted on
  (`PAYWALL_ASSETS`, `ASSET_TO_CHAINS`).
- `qr-matrix.ts` and `qr-svg.ts` are a **from-scratch QR encoder** (with
  `qr-format-bits.test.ts`) — the bridge ships its own QR matrix and SVG
  renderer for the customer's payment QR rather than depending on Aje's QR
  helper.
- `paywall-spec.ts` describes the paywall the surface renders.
- `disclosure-gate.ts` is the customer-side companion to `gateInvoiceCreation`.
- `reminder-cadence.ts` implements the renewal-window reminders: subscription
  renewals create a _new_ invoice at renewal time (never pulling a stored
  credential), and `REMINDER_OFFSETS_SECONDS` fires at `sevenDays`
  (`7*24*60*60`), `twentyFourHours` (`24*60*60`), and one hour before expiry.
  The cadence engine is pure — it computes the next due reminder from
  `nowUnixSeconds` and the invoice's `expiresAtUnixSeconds`.
- `telegram-bot-router.ts` handles the `/upgrade --crypto <asset>` command.
  `routeUpgradeCryptoCommand` returns `render-paywall` (with the asset's
  chains), `show-help` (`unknown-asset` / `missing-asset`), or — critically —
  `{ action: 'suppress', reason: 'crisis-active' }` when
  `crisisState === 'active'`. This is the bridge's crisis-state suppression:
  **no payment surfacing during an active crisis**, identical to the Telegram
  Payments rule. Note that this gates the _Telegram /upgrade command
  specifically_, not "every invoice-creation path." A separate, unrelated
  crisis-suppression facility lives in the BFF at `apps/oshun/bff/src/safety/`.
  (There is no `src/crisis-suppression.ts` or `src/telegram-handoff.ts` in the
  bridge.)

**`admin-surface/`** is the operator billing console for crypto, entirely real:
`explorer-urls.ts` (per-chain block-explorer links), `invoice-timeline.ts` (the
per-invoice event timeline), `node-health-panel.ts`, `refund-initiation.ts`
(operator-driven refund start), and `invariant-guards.ts`.

**`security-gates/`** backs the spec's "tests cover" claims with running code:
`build-time-invariants.ts` (build-time invariant scanning), `chaos-tester.ts`,
`node-health-probes.ts`, `disclosure-audit.ts`, and `tabletop.ts`
(tabletop-exercise harness). These implement reorg/chaos/tabletop testing and
node-health probing rather than merely asserting them in prose.

## How the BFF wires it together

`apps/oshun/bff/src/payments/` is the real consumer that turns the bridge into a
running surface:

- `payments-composition.ts` builds a single `PaymentsRuntime`, importing
  `ReceiptSigner` and `V1PaymentAsset` from `@oshun/payments-bridge`.
- `quote-builder.ts` builds crypto quotes (also importing from the bridge) and
  retains the full DTO so `GET /v1/payments/invoices/:id` can serve it.
- `invoice-store.ts` persists invoices.
- `settlement-route.ts` mounts `POST /v1/payments/crypto/settlements` and is
  **fail-closed**: it returns `503` with
  `reason: 'settlement_receiver_not_configured'` when
  `OSHUN_CRYPTO_SETTLEMENT_WEBHOOK_SECRET` is unset, and `401` on a bad
  signature.
- `routes/domain-stubs.ts` imports `listSupportedAssets` from the bridge and
  serves `GET /v1/payments/methods` (the real catalog),
  `POST /v1/payments/invoices`, `GET /v1/payments/invoices/:invoiceId`, and
  `POST /v1/payments/crypto/quote`.

`server.ts` binds the runtime via
`bindPaymentsRuntime(resolvePaymentsRuntime( process.env))`. **In-repo, no
settlement provisioner is injected**, so the runtime is `null` and the
crypto-quote route is fail-closed (503, no address issued). The comment at
`server.ts:930` is explicit that a real deployment "constructs the provisioner
(BTCPay/OpenNode/@aje) + binds a real runtime here" — `@aje` appears in the
codebase only in this comment, never as an import. This is honest fail-loud
behavior, not a stub: the system refuses to issue an address it cannot actually
settle.

## Entitlement linkage — the billing bridge

The concrete tie from a settled payment to a granted product tier lives in a
_different_ package: `@oshun/billing-support`'s `billing-aje-bridge.ts` (which
an audit noted was previously an "island" with zero importers). It does two
things:

1. **Tier collapse.** `TIER_BY_CLASS` maps the six `EntitlementClass` values
   onto the three `OshunEntitlementTier` values: `free → free`;
   `starter`/`plus → pro`; `pro`/`scholar`/`institutional → premium`.
   `toOshunEntitlementTier` exposes it, and `entitlementTierForSubscription`
   returns the tier _only_ when the subscription is in an `ENTITLING_STATES` set
   (`trial`, `active`, `grace`, `restored`); otherwise it falls back to `free`
   so a lapsed payment immediately de-entitles instead of stranding a stale
   tier.
2. **State advance.** `applyPaymentSettlementToSubscription` advances the
   subscription state machine (the full `SUBSCRIPTION_STATES` are `trial`,
   `active`, `past-due`, `grace`, `paused`, `canceled`, `lapsed`, `restored`)
   from an
   `AjePaymentSettlement { status: 'confirmed' | 'failed' | 'refunded'; reference }`.
   A `confirmed` settlement drives
   `trial`/`past-due`/`grace`/`paused`/`restored` → `active`, with every target
   re-validated by `transitionSubscription`.

## What is real vs. aspirational

In keeping with the candor of these docs:

**Real, non-stub, tested code:**

- The five V1 chain modules with chain-valid address derivation (verified TRON
  keccak256+base58check, Ergo secp256k1 P2PK, Litecoin BIP84, TON v4r2
  StateInit, Monero subaddress), confirmation policies, and e2e tests against
  regtest/testnet/stagenet/sandbox.
- The bridge's `CONFIRMATION_POLICY` (32 assets), `RAIL_REGISTRY` and
  `gateInvoiceCreation`, the Ed25519 `ReceiptSigner` with deterministic
  canonical JSON and verification snippet, the oracle aggregator, the cold-spend
  queue, the entitlement-bus emitter/topics, and the
  customer/admin/security-gate surfaces.
- The BFF actually consumes the bridge (`listSupportedAssets`, `ReceiptSigner`,
  `V1PaymentAsset`, quote-builder, settlement-route, invoice-store).
- The `@oshun/billing-support` entitlement linkage.

**Aspirational / not-wired:**

- The bridge **does not import Aje** despite declaring it as a dependency; it
  reimplements the model. The "consumed through this bridge" path is not in the
  code.
- **Live wallet/merchant settlement is unexercisable headless.** The
  sign-up-and-pay-crypto walkthrough verdict is **"pass (surfaces) / partial
  (settlement)"**: `/billing/crypto` and `/aaa-upgrade` render and the
  `/v1/payments/*` and `/v1/entitlements/aaa` BFF endpoints respond. But live
  wallet settlement needs a real merchant integration (a documented external
  dependency — BTCPay/OpenNode), and the `v1-completeness-audit-2026-06-22`
  likewise lists it as `partial`.
- Self-hosted nodes, the air-gapped signing station, hardware co-signers, 2-of-3
  multisig vaults, and Tor egress are policy/spec, described in code comments
  and types, but are **operational concerns** that cannot be proven inside the
  repo. No running node or live settlement could be verified.
- Jurisdictional gating is enforced by the entitlement layer (not the payment
  layer); the rails themselves accept payment from any address.

## Related

- [Support, Entitlements, Billing, and Customer Operations](./support-billing.md)
- [Tenant, Institution, and Operator Toolkit](./tenant-toolkit.md)
- [Telegram Surfaces — Bot, Mini Apps, Channels, Inline, Auth, Payments](./telegram-surfaces.md)
- [Review, Compliance, and Trust & Safety](./review-trust-safety.md)
- [Architecture, Platform Foundations, and Security](./platform-foundations-and-security.md)
- [Subsystem Glossary](./glossary.md)
- Companion architecture:
  [Support, Billing, and Crypto](../architecture/support-billing-and-crypto.md)
- Hub: [../features.md](../features.md). Backlog: §23.1.
