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:
- 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}). - The bridge (
libs/oshun/payments-bridge, package@oshun/payments-bridgev0.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. - 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 rollupseth-base/eth-arbitrum/eth-optimism, andmatic-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:
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_POLICYbelow). - 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 fromLTC_BIP84_DERIVATION_PREFIX = m/84'/2'/0'/0(coin type 2 per SLIP-0044). A dedicatedrbf-detector.tshandles fee-bump detection; aregtest-e2e.test.tsexercises 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-hostedmonerodfor trustless validation;monero-wallet-rpcruns view-only with the secret spend key cold. Outputs unlock afterXMR_DEFAULT_UNLOCK_BLOCKS = 10(≈20 min), configurable up toXMR_HIGH_VALUE_UNLOCK_BLOCKS = 20for high-value tiers (confirmation-policy.ts). Per-invoice subaddresses (account index 0, freshsubaddress_index) keep paying customers unlinkable.invariants.tsactively forbids apayment_idintx_extra—assertNoTxExtraPaymentIdthrowsMoneroPaymentIdInvariantErrorbecause "the subaddress is the only on-chain correlator." Telemetry (telemetry.ts) stores only a BLAKE2b-256 digest of the subaddress (hashSubaddress) andsanitizeLogPayloadForXmrscrubs Monero-shaped strings (95/106-char base58 starting 8/B) from logs. Astagenet-e2e.test.tsruns 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-nodeandcardano-walletwatch-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.tsderives real Ergo P2PK addresses from a watch-only xpub (via@scure/bip32HDKey) atERG_BIP44_DERIVATION_PREFIX = m/44'/429'/0'/0(ERG_BIP44_PURPOSE = 44,ERG_SLIP44_COIN_TYPE = 429); atestnet-e2e.test.tsvalidates 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:
confirmedfor default,finalizedfor high-value (mechanism below). - Toncoin (TON) — Tier C. Module
libs/aje/chains/ton.subwallet.tsderives genuine v4r2 contract addresses, not synthetic digests — an audit caught that the original per-invoice account id was a dressed-upsha256(publicKey ‖ walletId ‖ subwalletId)corresponding to no deployable contract (funds would have been unsweepable). The real address isworkchain:sha256(StateInit cell), where the v4r2 StateInit data cell isseqno=0(u32) ‖ subwallet_id(u32) ‖ public_key(256) ‖ plugins(1 bit). Distinctsubwallet_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, baseTON_WALLET_V4_DEFAULT_SUBWALLET_ID = 698983191). The code cell loads from the canonical v4r2 BoC viaCell.fromBocfrom@ton/core, whose embedded CRC32C check throws on a transcription error rather than silently producing a wrong address; asandbox-e2e.test.tsconfirms the flow. Telegram @wallet is the primary customer surface. - Tron (TRX network, USDT only) — Tier C. Module
libs/aje/chains/tron.tron-address.tsderives real base58check addresses the proper way: takekeccak_256of the secp256k1 uncompressed pubkey (X ‖ Y, i.e.uncompressed.subarray(1)), keep the last 20 bytes, prefix the network byteTRON_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.tspins the contracts (USDT_TRC20_CONTRACT = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',USDT_TRC20_NILE_CONTRACT = 'TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj'for testnet), withTRON_USDT_DECIMALS = 6andTRON_CONFIRMATION_DEPTH = 19; anile-testnet-e2e.test.tsruns 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:
payment.invoice.settled | payment.invoice.underpaid
payment.invoice.expired | payment.refund.broadcast
The mapping:
paid→ only emitspayment.invoice.settledifcomputeConfirmationMeetsRequirementpasses; otherwise returnsnull(the invoice is paid but not yet deep enough). The event'sgrantsEntitlementistrueiffentitlementId !== null.partial→payment.invoice.underpaid, withunderpaidByAtomic = expectedAmountAtomic − observedAmountAtomic(floored at0n),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.tsemitssettled/underpaid/expired/refund.broadcast.entitlement-bus/topics.tsdefinesPAYMENT_INVOICE_CONFIRMED_TOPIC(payment.invoice.confirmed),PAYMENT_INVOICE_SETTLED_TOPIC(payment.invoice.settled), andPAYMENT_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 inlibs/shared/inbound-integrations/src/payment.tsis false. That fiat file is a connector-capability model withPaymentStatus = requires_action | authorized | captured | refunded | failed— it shares no topic schema with the bridge'sPaymentBusEvent. 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 exactPaymentBusEventshapes 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 }. ThetxIdis a separate top-level field onReceiptPayload, 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-queuefixtures, 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.tsresolves which chains an asset is accepted on (PAYWALL_ASSETS,ASSET_TO_CHAINS).qr-matrix.tsandqr-svg.tsare a from-scratch QR encoder (withqr-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.tsdescribes the paywall the surface renders.disclosure-gate.tsis the customer-side companion togateInvoiceCreation.reminder-cadence.tsimplements the renewal-window reminders: subscription renewals create a new invoice at renewal time (never pulling a stored credential), andREMINDER_OFFSETS_SECONDSfires atsevenDays(7*24*60*60),twentyFourHours(24*60*60), and one hour before expiry. The cadence engine is pure — it computes the next due reminder fromnowUnixSecondsand the invoice'sexpiresAtUnixSeconds.telegram-bot-router.tshandles the/upgrade --crypto <asset>command.routeUpgradeCryptoCommandreturnsrender-paywall(with the asset's chains),show-help(unknown-asset/missing-asset), or — critically —{ action: 'suppress', reason: 'crisis-active' }whencrisisState === '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 atapps/oshun/bff/src/safety/. (There is nosrc/crisis-suppression.tsorsrc/telegram-handoff.tsin 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.tsbuilds a singlePaymentsRuntime, importingReceiptSignerandV1PaymentAssetfrom@oshun/payments-bridge.quote-builder.tsbuilds crypto quotes (also importing from the bridge) and retains the full DTO soGET /v1/payments/invoices/:idcan serve it.invoice-store.tspersists invoices.settlement-route.tsmountsPOST /v1/payments/crypto/settlementsand is fail-closed: it returns503withreason: 'settlement_receiver_not_configured'whenOSHUN_CRYPTO_SETTLEMENT_WEBHOOK_SECRETis unset, and401on a bad signature.routes/domain-stubs.tsimportslistSupportedAssetsfrom the bridge and servesGET /v1/payments/methods(the real catalog),POST /v1/payments/invoices,GET /v1/payments/invoices/:invoiceId, andPOST /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:
- Tier collapse.
TIER_BY_CLASSmaps the sixEntitlementClassvalues onto the threeOshunEntitlementTiervalues:free → free;starter/plus → pro;pro/scholar/institutional → premium.toOshunEntitlementTierexposes it, andentitlementTierForSubscriptionreturns the tier only when the subscription is in anENTITLING_STATESset (trial,active,grace,restored); otherwise it falls back tofreeso a lapsed payment immediately de-entitles instead of stranding a stale tier. - State advance.
applyPaymentSettlementToSubscriptionadvances the subscription state machine (the fullSUBSCRIPTION_STATESaretrial,active,past-due,grace,paused,canceled,lapsed,restored) from anAjePaymentSettlement { status: 'confirmed' | 'failed' | 'refunded'; reference }. Aconfirmedsettlement drivestrial/past-due/grace/paused/restored→active, with every target re-validated bytransitionSubscription.
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_REGISTRYandgateInvoiceCreation, the Ed25519ReceiptSignerwith 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-supportentitlement 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/cryptoand/aaa-upgraderender and the/v1/payments/*and/v1/entitlements/aaaBFF endpoints respond. But live wallet settlement needs a real merchant integration (a documented external dependency — BTCPay/OpenNode), and thev1-completeness-audit-2026-06-22likewise lists it aspartial. - 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
- Tenant, Institution, and Operator Toolkit
- Telegram Surfaces — Bot, Mini Apps, Channels, Inline, Auth, Payments
- Review, Compliance, and Trust & Safety
- Architecture, Platform Foundations, and Security
- Subsystem Glossary
- Companion architecture: Support, Billing, and Crypto
- Hub: ../features.md. Backlog: §23.1.