Oshun Platform · Features

Crypto Payments — Non-Custodial Entitlement Settlement

A focused page within the Oshun Platform Features documentation. The full map and every sibling page live in the Features hub.

15sections21 minread2tables

On this page

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

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 entriesBTC, 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 tierRAIL_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 trustISSUER_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.

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_extraassertNoTxExtraPaymentId 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_ids 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.
  • partialpayment.invoice.underpaid, with underpaidByAtomic = expectedAmountAtomic − observedAmountAtomic (floored at 0n), grantsEntitlement: false.
  • refundedpayment.refund.broadcast.
  • expired / cancelledpayment.invoice.expired.
  • draft / pendingnull (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/restoredactive, 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.