This page specifies how V1 supports customers, gates capabilities behind tiers,
and takes money — with crypto as the V1-primary rail. It serves three audiences:
support operators working cases and refunds, the entitlement and billing
engineers who wire payment settlement to product access, and the security
reviewers who need to know exactly which crypto-settlement claims are
implemented versus provider-gated / aspirational. It sits downstream of
the Aje payment substrate and upstream of the entitlement
gate enforced at the BFF; the connective tissue is the @oshun/payments-bridge
library and the @oshun/billing-support entitlement linkage.
Backlog: V1/TODOS.md § 23. The crypto-settlement work is
tracked at § 23.1; the hub is ../ARCHITECTURE.md.
Candor up front. Real and exercised: the bridge's deterministic core (asset enum, confirmation policy, trust-tier disclosure gate, Ed25519 receipt signer, QR encoder, admin and security surfaces), the five new Aje chain modules with chain-valid address derivation and regtest/testnet e2e tests, and — since 2026-07-04 — the cross-rail entitlement-event parity with the fiat rail (see Fiat Payments). Not wired: the bridge declares
@aje/*and@oshun/event-busas dependencies but itssrc/imports none of them — it reimplements the model rather than adapting Aje (the ERC-20 paywall contract registry mirrors, with matching addresses, rather than imports@aje/payments/stablecoins). And live wallet settlement is unexercisable headless — it depends on a documented external merchant provisioner (BTCPay / OpenNode) that is not injected in-repo, so the BFF crypto-quote route is deliberately fail-closed. Thesign-up-and-pay-cryptowalkthrough is therefore graded "pass (surfaces) / partial (settlement)". Where this page describes a path that the code does not yet contain, it says so.
Support and customer operations#
- Support case substrate. Cases use the
SupportCasecontract (V1/TODOS.md§ 1.2) and route through the admin web surface. SLA is monitored with thelibs/shared/queue/src/sla-monitor.tspatterns. - Customer operations. The billing entry point lives in profile/settings. Subscription history, renewal, cancellation, restore, premium-persona access, and refund flows route through admin support. The crypto-specific admin surface (explorer links, invoice timeline, node-health panel, refund initiation) is described in Admin billing surface below.
Entitlements#
Per-tier capability gates are evaluated at the BFF. The domain registry's
DomainAuthPolicy.scopes interlocks with entitlements, and premium personas,
voices, avatars, and generated media are gated through the Isis release-gate
model (see Isis — Generation Control Substrate and
Persona, Avatar, and Voice Packs).
The concrete entitlement linkage — @oshun/billing-support#
The architecture historically described entitlement granting only abstractly.
The real mechanism is libs/oshun/billing-support/src/billing-aje-bridge.ts,
which the library's own header documents as wiring a previously-islanded lib
("zero importers, no Aje reference, a second entitlement vocabulary") to the
canonical product tier. Two things happen there:
1. Class → tier collapse. Six billing EntitlementClass values collapse
onto three canonical OshunEntitlementTier values (from @oshun/auth-client),
so the product reads one tier vocabulary instead of two parallel models. The
exact map (TIER_BY_CLASS):
EntitlementClass |
OshunEntitlementTier |
|---|---|
free |
free |
starter |
pro |
plus |
pro |
pro |
premium |
scholar |
premium |
institutional |
premium |
2. Settlement → state machine. applyPaymentSettlementToSubscription
advances the billing subscription state machine from a normalized
AjePaymentSettlement
({ status: 'confirmed' | 'failed' | 'refunded'; reference: string }). The
state set (SUBSCRIPTION_STATES) is trial, active, past-due, grace,
paused, canceled, lapsed, restored, and only the entitling states —
ENTITLING_STATES = {trial, active, grace, restored} — actually grant the
class. A subscription in any non-entitling state falls back to free so a
lapsed payment immediately de-entitles rather than stranding a stale tier. The
settlement-driven transitions (nextStateForSettlement):
Settlement status |
From state | To state |
|---|---|---|
confirmed |
trial / past-due / grace / paused / restored |
active |
confirmed |
canceled / lapsed |
restored |
confirmed |
active |
no-op |
failed |
active |
past-due |
failed |
(any other) | no-op |
refunded |
active / paused / trial / restored |
canceled |
Every target is validated again by transitionSubscription against the declared
VALID_TRANSITIONS table, so an illegal move is rejected rather than applied; a
settlement with no transition for the current state is a changed: false no-op.
This is the bridge between "money confirmed on chain" and "product access
granted," and it is real, deterministic code.
Billing — rail-agnostic, crypto-primary#
Billing is payment-rail-agnostic. The V1 primary rail is non-custodial crypto
via the Aje domain (libs/aje/, the existing library-only Web3
infrastructure, named for the Yoruba orisha of wealth and commerce) plus the V1
entitlement bridge at libs/oshun/payments-bridge/. The V1.x optional fiat rail
uses Stripe-class providers via
libs/shared/inbound-integrations/src/payment.ts.
Both rails publish to the same entitlement event topics, so billing-action
reversal, entitlement suspension, trial-expiry behavior, conversion prompts, and
dunning logic are identical across rails. This was historically a design goal
only; it is now wired fact: the V1 fiat rail (@oshun/fiat-payments — Stripe
Billing plus Apple Pay / Google Pay, see
Fiat Payments — Stripe Billing, Apple Pay, and Google Pay)
emits the bridge's PaymentBusEvent topics on rail: 'fiat-stripe' through a
FiatEntitlementEmitter that mirrors CryptoEntitlementEmitter
field-for-field, and a cross-rail test asserts structural equivalence per topic.
The older payment.ts connector-capability model in
libs/shared/inbound-integrations (whose PaymentStatus is
requires_action | authorized | captured | refunded | failed) remains a generic
multi-tenant connector framework and still does not emit bus topics itself — the
V1 fiat rail is @oshun/fiat-payments, not payment.ts.
Crypto payment substrate — Aje and the V1 entitlement bridge#
V1 promotes Aje into the V1-critical-path set (it had previously been out-of-scope). V1 contributes two things on top of what Aje already ships:
- Five new chain modules in Aje for chains the existing
@aje/chainsand@aje/bitcoindon't cover: Monero, Litecoin, TON, Ergo, and Tron. - An Oshun-side bridge at
libs/oshun/payments-bridge/(@oshun/payments-bridge, v0.1.0, private) that owns the V1-specific concerns: the asset enum, per-tier confirmation policy, trust-tier disclosure gate, Ed25519 receipt signing, oracle aggregation, the cold-spend refund queue, customer/admin surfaces, and security gates.
The bridge is functionally standalone. Its
package.jsondeclares@aje/chains,@aje/oracles,@aje/payments,@aje/wallets,@oshun/audit-platform,@oshun/event-bus, and@oshun/identityas dependencies, plus@noble/curvesand@noble/hashes. Butgrep "from '@aje" src/returns nothing, and so doesgrep "@oshun/event-bus" src/. The bridge reimplements its ownAjeInvoiceStatusvocabulary and never calls Aje. The "Aje stays library-only, consumed through this bridge" framing describes a consumption path the code does not yet contain. The real consumer that wires the bridge is the BFF (see BFF integration).
What Aje already provides#
| Aje library | What V1 reuses without change |
|---|---|
@aje/core |
Address types, hex utilities, audited crypto primitives (@noble/@scure). |
@aje/chains |
EVM L1/L2 and other account chains. The actual chains/src/ tree contains abstraction/, arbitrum/, avalanche/, cardano/, ethereum/, optimism/, polygon/, solana/, zksync/. The abstraction/ submodule provides UnifiedProvider, ChainRegistry, NonceTracker for chain-agnostic invoice polling. |
@aje/bitcoin |
Lightning (LND/CLN/Phoenixd), LSP, BitVM, sBTC, Stacks, Ordinals, Runes, RGB. |
@aje/wallets |
HD wallet (BIP32/BIP39/BIP44), hardware wallet (Trezor/Ledger/Coldcard), MPC, key management, account abstraction, paymaster, session keys, social login, WalletConnect. |
@aje/payments/merchant |
Invoice, PaymentRequest, InvoiceLineItem, PaymentConfirmation, Refund, RecurringPayment, Subscription, payment links, QR codes, escrow, milestones, notifications, receipts. |
@aje/payments/stablecoins |
USDC, USDT, DAI, FRAX, GHO with risk + swap + aggregation. |
@aje/payments/streaming |
Payroll, flows, grants, batch (used by V1 metered billing top-ups). |
@aje/payments/circle |
Circle Mint and Cross-Chain Transfer Protocol (CCTP) for USDC. |
@aje/payments/fiat-ramps |
On/off-ramp integrations (V1.x). |
@aje/nodes |
Full-node runner abstractions, light client, validator, RPC client primitives, Cardano node, Avalanche node. |
@aje/privacy |
Privacy pools and advanced-privacy primitives. |
@aje/oracles |
Pyth, Chainlink, RedStone, API3 oracle adapters. |
@aje/settlement-escrow |
Escrow primitives reused by V1 refundable-flow invoices. |
Staleness note (corrected here). Earlier copies of this table named Avalanche and zkSync as separate top-level
@aje/chains/<chain>/modules. They are not top-level chain dirs; they live aschains/src/avalanche/andchains/src/zksync/. The five V1-added chains (below) are top-level dirs underlibs/aje/chains/.
Five new chain modules added to Aje for V1#
These five exist as real, separate packages under libs/aje/chains/, each with
its own provider.ts, node/RPC client, types.ts, and an e2e test against a
real testnet/regtest/stagenet/sandbox. The cryptographically-sensitive work runs
in the upstream node process; the TypeScript module owns the RPC client and the
watch-only address derivation.
| Module | What it derives, indexes, and confirms |
|---|---|
libs/aje/chains/monero/ |
monerod JSON-RPC + monero-wallet-rpc view-only client (spend key never reaches TypeScript); per-invoice subaddress allocator (subaddress-allocator/); 10-block unlock policy; get_tx_proof-backed payment proofs (payment-proof.ts); subaddress-hash telemetry only; stagenet-e2e.test.ts. |
libs/aje/chains/litecoin/ |
Litecoin Core JSON-RPC; BIP84 native SegWit (LTC_BIP84_DERIVATION_PREFIX = m/84'/2'/0', P2WPKH), watch-only xpub enforced at the correct depth; per-invoice receiving addresses at …/0/<i>; 1 / 3 / 6 confirmation tiers; rbf-detector.ts for fee-bump detection; regtest-e2e.test.ts. |
libs/aje/chains/ton/ |
v4r2 per-invoice subwallet derivation (subwallet.ts): a TON address is workchain:sha256(StateInit cell) where the v4r2 StateInit data is seqno(u32) ‖ subwallet_id(u32) ‖ public_key(256) ‖ plugins; per-invoice subwallet_id offsets from the v4 default (698983191) via a monotonic counter; Cell.fromBoc (@ton/core) verifies the BoC CRC32C; jetton (USDT-TON) Transfer indexing; Telegram @wallet deep links. Trust-tier C. sandbox-e2e.test.ts. |
libs/aje/chains/ergo/ |
ergo-node + ergo-wallet-api watch-only client; BIP44 P2PK allocator (p2pk-allocator.ts: ERG_BIP44_PURPOSE=44, ERG_SLIP44_COIN_TYPE=429, ERG_BIP44_DERIVATION_PREFIX = m/44'/429'/0'/0) deriving real secp256k1 P2PK addresses via @scure/bip32 HDKey; UTXO-set polling; 5 / 10 / 30 confirmation tiers; testnet-e2e.test.ts. |
libs/aje/chains/tron/ |
Real base58check address derivation (tron-address.ts): keccak_256 of the secp256k1 uncompressed pubkey X‖Y, last 20 bytes, 0x41 network prefix, double-SHA-256 checksum. TRC-20 Transfer indexing scoped to USDT-TRC20 only (USDT_TRC20_CONTRACT='TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', Nile testnet TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj, TRON_USDT_DECIMALS=6); native TRX not accepted in V1; TRON_CONFIRMATION_DEPTH=19. Trust-tier C; invoice creation rejects unacknowledged. nile-testnet-e2e.test.ts. |
V1 entitlement bridge — libs/oshun/payments-bridge/#
The bridge's real src/ tree is larger than the legacy "thin and Oshun-aware"
table implied. The directory layout is the ground truth:
src/
state-mapper.ts trust-tier-disclosure.ts (flat files)
oracle-aggregator/ cold-spend-queue/ receipt-signer/ entitlement-bus/
customer-surface/ admin-surface/ security-gates/
index.ts
Module-name corrections. There is no
webhook-router.ts, nocrisis-suppression.ts, and notelegram-handoff.tsin this package. Andtrust-tier-disclosureis a flat file, not a directory, whileoracle-aggregator,cold-spend-queue, andreceipt-signerare directories, not flat files. The real homes are: event emission →entitlement-bus/, crisis suppression →customer-surface/telegram-bot-router.ts(gates only the Telegram/upgradecommand, not "every invoice-creation path"; a separate, unrelated crisis-suppression lives in the BFF atapps/oshun/bff/src/safety/), Telegram/QR rendering →customer-surface/{telegram-bot-router,qr-matrix,qr-svg,paywall-spec}.ts.
State mapper — src/state-mapper.ts#
The state mapper is the heart of the bridge and is fully self-contained: it defines its own status vocabulary rather than importing Aje's. Its exported enums:
V1_PAYMENT_ASSETS— exactly 32 assets: the nativesbtc-onchain,btc-lightning,ltc,xmr,eth-mainnet,eth-base,eth-arbitrum,eth-optimism,matic-polygon,sol,ton,ada,erg; thenusdc-{mainnet,base,arbitrum,optimism,polygon,solana},usdt-{mainnet,base,arbitrum,optimism,polygon,tron,solana,ton}, anddai-{mainnet,base,arbitrum,optimism,polygon}.AJE_INVOICE_STATUSES—draft,pending,paid,partial,refunded,expired,cancelled. This is the bridge's own vocabulary, not Aje's. (Aje's real@aje/payments/merchantInvoiceStatusisdraft | sent | viewed | partial | paid | overdue | cancelled | refundedandConfirmationStatusispending | confirming | confirmed | failed | finalized.) But the bridge never imports them, so its enum dropssent/viewed/overdueand addspending/expired.AJE_CONFIRMATION_STATUSES—unconfirmed,confirmed,finalized(again, not Aje's five-value confirmation enum).V1_PAYMENT_EVENT_TYPES—payment.invoice.settled,payment.invoice.underpaid,payment.invoice.expired,payment.refund.broadcast.
Functions: mapAjeStateToV1, mapAjeStateToV1WithAudit,
requiredConfirmations, toAuditRecord. The mapping rule is:
| Aje status | Emitted event | Grants entitlement? |
|---|---|---|
paid (depth met) |
payment.invoice.settled |
yes, iff entitlementId !== null |
paid (depth not met) |
(null — wait for more confs) | — |
partial |
payment.invoice.underpaid |
no (carries underpaidByAtomic) |
refunded |
payment.refund.broadcast |
no |
expired / cancelled |
payment.invoice.expired |
no |
draft / pending |
(null) | — |
mapAjeStateToV1WithAudit wraps the same logic and appends an immutable
V1PaymentStateAuditRecord (kind: 'payment-state-transition',
immutable: true) through an injected V1PaymentStateAuditAppender before
returning.
Vocabulary reconciliation (2026-07-04). The state mapper's emitted types and the entitlement-bus topics are now one vocabulary:
entitlement-bus/topics.tsdefinespayment.invoice.confirmed,payment.invoice.settled,payment.invoice.underpaid,payment.invoice.expired, andpayment.refund.broadcast, andCryptoEntitlementEmittergainedemitUnderpaid/emitExpired(tests assert the topic strings equal the mapper's emitted types exactly). The admin invoice timeline also gained the previously-unrepresentableunderpaidstate with legal-predecessor validation. Still true and worth knowing: the "5-stage lifecycle" withseen/overpaidstages described in older companion docs remains aspirational —state-mapper.tsmodels noseenstate and nooverpaidterminal (an overpaid invoice settles; the paid-branch now enforces an underpaid-deficit invariant instead of trusting Aje'spaidstatus blindly).
Confirmation policy — CONFIRMATION_POLICY#
A per-asset, per-amount-tier minimum confirmation depth. Amount tiers are
micro / standard / high-value. A paid invoice only settles once
observedConfirmations >= required and the confirmation status is
confirmed or finalized. Representative depths:
| Asset | micro | standard | high-value | Notes |
|---|---|---|---|---|
btc-onchain |
0 | 1 | 3 | 0-conf accepted for micro |
btc-lightning |
0 | 0 | 0 | instant off-chain |
ltc |
1 | 3 | 6 | |
xmr |
10 | 10 | 20 | 10-block Monero unlock |
eth-mainnet |
12 | 12 | 30 | |
ada |
15 | 15 | 30 | |
erg |
5 | 10 | 30 | |
usdt-tron |
19 | 19 | 19 | matches TRON_CONFIRMATION_DEPTH |
matic-polygon |
256 | 256 | 256 | Heimdall checkpoint window |
sol |
-1 | -1 | -2 | commitment sentinels, not depths |
Solana has no integer block depth, so the policy encodes RPC commitment levels
as sentinels: SOLANA_COMMITMENT_CONFIRMED = -1 and
SOLANA_COMMITMENT_FINALIZED = -2. computeConfirmationMeetsRequirement reads
the sentinel and checks the confirmation status string instead of an integer
count — -1 accepts confirmed or finalized, -2 requires finalized. The
Base/Arbitrum/Optimism EVM L2s carry tall constant depths (150 / 50 / 120) that
reflect each rollup's reorg surface; these apply identically to their
USDC/USDT/DAI variants.
Trust-tier disclosure — src/trust-tier-disclosure.ts#
A single flat file holding the RAIL_REGISTRY (a RailDescriptor per asset)
and the invoice-creation disclosure gate. Each descriptor carries a tier
(RAIL_TIERS = ['A','B','C']), an issuerTrustClass
(ISSUER_TRUST_CLASSES = ['native','decentralized-issuer','central-issuer-with-freeze']),
an English disclosureCopyEn, and requiresDisclosureAcknowledgement.
| Tier | Meaning | Example assets | Ack required? |
|---|---|---|---|
| A | Native UTXO / native account chain, no central freeze | btc-onchain, btc-lightning, ltc, xmr, eth-mainnet, ada, erg |
no |
| B | Rollup-secured L2 (sequencer can pause) or central-issuer | sol, eth-{base,arbitrum,optimism}, matic-polygon, all USDC/USDT/DAI on B-rails |
yes |
| C | Single-validator-set chain (TON) or Tron central-issuer | ton, usdt-tron, usdt-ton |
yes |
sol is B despite being native (validator concentration + outage history);
ton is C (TON Foundation governance). All USDC/USDT are
central-issuer-with-freeze; DAI is decentralized-issuer (MakerDAO, but its
collateral basket includes freezable USDC). Every Tier-B / Tier-C rail and every
centrally-issued asset sets requiresDisclosureAcknowledgement: true and ships
a plain-English warning that Lilith policy reviews each release (see
Lilith — Contemplative Policy Substrate).
The gate is gateInvoiceCreation(request). For a rail that requires
acknowledgement, if disclosureAcknowledgedAtUnixSeconds is null or <= 0 it
returns:
{
"verdict": "block",
"reason": "disclosure-acknowledgement-required",
"disclosureCopy": "USDT on Tron is issued by Tether and can be frozen at the issuer's discretion. The Tron network is operated by a small set of Super Representatives."
}
Otherwise it returns { "verdict": "allow" }. The address is never rendered for
a blocked invoice. listSupportedAssets() (consumed by the BFF, see below)
returns V1_PAYMENT_ASSETS in deterministic order.
Oracle aggregator — src/oracle-aggregator/#
price-feed.ts composes a fiat-rate median across Kraken REST, CoinGecko
REST, and an on-chain Uniswap v3 30-minute TWAP (the TWAP window mitigates
flash-loan manipulation), alongside the @aje/oracles adapters (Chainlink /
Pyth / RedStone). Quotes are validated before medianing — non-finite or
non-positive rates are dropped (a NaN-parsing feed can no longer sail through
the divergence gate), and fewer than two valid sources yields a
rejected-insufficient-sources verdict that carries no canonical rate. The
median is the canonical rate; the pair-wise spread (max − min) / median is
captured per invoice for monitoring. tor-egress.ts routes the off-Aje HTTP
sources through a SOCKS proxy so price queries don't leak server egress;
rate-lock.ts is the per-invoice rate-lock cache so a customer's quoted rate is
frozen for the invoice lifetime.
Cold-spend refund queue — src/cold-spend-queue/#
Every refund or sweep that needs an offline signing step lands on this queue.
types.ts enumerates the chain families (COLD_SPEND_CHAIN_FAMILIES) and their
unsigned-tx formats (COLD_SPEND_TX_FORMATS): psbt-v2, eip1559-rlp,
xmr-unsigned, solana-versioned, ton-external-message, cardano-cbor,
ergo-unsigned, tron-raw. The queue (queue.ts) carries entries through a
ColdSpendStatus lifecycle; sweep-policy.ts consolidates receipts above the
sweep threshold to a 2-of-3 multisig vault per chain family (BTC/LTC P2WSH,
EVM Safe, Monero MMS, Solana SPL/Squads, TON multisig, Cardano Plutus-script,
Ergo P2S/Sigma); hw-signing-fixture.ts models the hardware co-signer; and
audit-attestation.ts emits an audit-platform attestation per signing event.
The vault topology, air-gapped signing station, and hardware co-signers are policy/spec described in code (types + comments) and tested with fixtures, not provable as a running operational deployment in-repo. See Operational invariants.
Receipt signer — src/receipt-signer/#
A customer holding only the receipt and access to the chain can verify Oshun's
claim without trusting Oshun's API. receipt-signer.ts signs the
ReceiptPayload with Ed25519 (@noble/curves/ed25519) over a canonical
JSON encoding: keys sorted, bigints serialized as decimal strings (so
amounts survive JSON). verifyReceipt re-derives the canonical bytes and checks
the signature against the audit-platform public key. The ReceiptPayload
(types.ts) carries invoiceId, merchantId, customerLocale, txId,
blockHash, blockHeight, asset (one of RECEIPT_ASSETS, 17 entries),
amount (smallest-unit bigint), fiatAmountMinor (cents bigint),
confirmedAtUnixSeconds, a tax: TaxBreakdown, a verificationSnippet, and an
optional moneroPaymentProof.
Receipt assets are now chain-qualified (2026-07-04).
RECEIPT_ASSETSwas previously 17 chain-ambiguous symbols; it is now the full 32-memberV1PaymentAssetvocabulary with a compiler-enforced total mapping (RECEIPT_ASSET_FOR_V1_ASSET) and a separate display-symbol table, so a USDC-on-Base payment receipts asusdc-base, andverification-snippet.tsnames per-chain RPC placeholders (<base-rpc-endpoint>etc.) instead of a generic endpoint that pointed L2 customers at the wrong chain. Fiat tax rendering (locale-tax.ts) is currency-exponent-aware (JPY/KRW 0-decimal, KWD/BHD 3-decimal) and bigint-exact.
Monero proof is a 2-tuple, not a 3-tuple.
MoneroPaymentProofis exactly{ txKey, address }(fromget_tx_proof).txIdis a separate top-levelReceiptPayloadfield, not part of the proof tuple.
Two customer-facing extras are real and worth naming: verification-snippet.ts
(buildVerificationSnippet) produces a curl/shell command a customer can run to
reproduce the on-chain check, and locale-tax.ts reuses the fiat receipt's
TaxBreakdown formatting (subtotalMinor, taxMinor, totalMinor, taxRate,
taxJurisdiction) so crypto receipts carry the same tax disclosure as fiat
ones.
Entitlement bus — crypto/fiat parity by design#
entitlement-bus/topics.ts defines three topics — payment.invoice.confirmed,
payment.invoice.settled, payment.refund.broadcast — whose event shapes are
structurally identical across rails so the entitlement service need not know
whether a payment came from Stripe/Adyen/PayPal or from a crypto rail. The
rail field on the envelope (PaymentRail) distinguishes them; events carry
schemaVersion: 1. emitter.ts exposes CryptoEntitlementEmitter with
emitConfirmed / emitSettled / emitRefundBroadcast; it has no I/O of its
own — a publish function is injected, and it never imports
@oshun/event-bus. eventsStructurallyEquivalent is the helper that asserts a
crypto event matches a fiat event of the same topic field-for-field (ignoring
rail).
PaymentRailis 14 crypto + 4 fiat. (btc-onchain,btc-lightning,ltc,evm-ethereum,evm-base,evm-arbitrum,evm-optimism,evm-polygon,xmr,sol,ton,cardano,ergo,tron) plus 4 fiat (fiat-stripe,fiat-adyen,fiat-paypal,fiat-generic). The doc-comment's old "thirteen crypto rails" off-by-one was corrected. Thefiat-striperail is now actually emitted by@oshun/fiat-payments.
Customer surface — src/customer-surface/#
telegram-bot-router.ts—routeUpgradeCryptoCommandhandles/upgrade --crypto <asset>. It applies crisis-state suppression first: whencrisisState === 'active'it returns{ action: 'suppress', reason: 'crisis-active' }(no payment surface during a crisis-flagged conversation); otherwise it validates the asset againstPAYWALL_ASSETSand returns arender-paywalldirective with the resolved chains, or ashow-help.paywall-spec.ts—PaywallRenderableconstruction is gate-aware: the public path isbuildPaywall(input), which runs the trust-tier disclosure gate first and returns either{ blocked: true }carrying only disclosure copy (no address, no QR — the "address is never rendered for a blocked invoice" invariant is now enforced by construction, not by caller discipline) or the renderable. The low-level per-target builders were renamedbuildUngated*(buildUngatedOnchainAddressPaywall, Lightning BOLT11, Monero subaddress, Solana Pay URL, TON deep link, EVM EIP-681) and are documented as internal plumbing for DTO rehydration. Two long-standing URI bugs were fixed here: ERC-20 assets now render the EIP-681 token formethereum:<tokenContract>@<chainId>/transfer?address=…&uint256=…(backed by an EIP-55-verified per-asset contract registry mirroring@aje/payments/stablecoins) instead of paying native wei, and Monerotx_amountrenders decimal XMR instead of piconero.- QR encoder —
qr-matrix.ts/qr-svg.ts. A from-scratch ISO/IEC 18004 QR Code Model 2 byte-mode encoder (smallest fitting version 1–40 at the requested error-correction level). Apps consume the boolean matrix directly: web renders one<rect>per module, mobile one<View>per module, Telegram emits an SVG attachment. This is a real V1 artifact, not a re-export of@aje/payments/merchant's QR. reminder-cadence.ts— the renewal-window reminder engine. It is pure (computes due times, no I/O) and usesREMINDER_OFFSETS_SECONDS = { sevenDays, twentyFourHours, oneHour }— the 7 d / 24 h / 1 h cadence the companion docs describe.asset-chain-filter.ts— maps a paywall asset to the chains Oshun accepts it on;disclosure-gate.tsis the customer-side mirror of the trust-tier acknowledgement gate.
Admin billing surface for crypto — src/admin-surface/#
Entirely real and previously undocumented in the bridge table:
explorer-urls.ts (DEFAULT_EXPLORERS per chain + txExplorerUrl /
addressExplorerUrl), invoice-timeline.ts, node-health-panel.ts,
refund-initiation.ts, and invariant-guards.ts. This is the operator surface
behind admin support's refund and reconciliation flows.
Security gates — src/security-gates/#
Also real and previously only alluded to as "tests cover":
build-time-invariants.ts (grep-style assertions that forbid on-chain
customer identifiers: no OP_RETURN writes in BTC/LTC tx-build paths, no Monero
tx_extra payment-ID writes, no Solana memo-program customer IDs; plus
assertMoneroWalletRpcIsViewOnly), chaos-tester.ts, disclosure-audit.ts,
node-health-probes.ts, and tabletop.ts. These implement the build-time
invariant scanning, chaos/tabletop testing, and node-health probing that the
architecture had only gestured at.
BFF integration — the real consumer#
The architecture implied apps consume Aje "through the bridge" without naming
the layer that actually does it. That layer is the BFF
(apps/oshun/bff/src/):
routes/domain-stubs.tsimportslistSupportedAssetsfrom@oshun/payments-bridgeand serves the real crypto-asset catalog. The crypto-quote route is bound to a server-side plan price book (payments/plan-price-book.ts, env-overridable): the quoted amount is derived from the plan, a client-echoed mismatch is a 400, and settlement re-verifies paid ≥ book price before any entitlement grant (a below-book invoice settles the money but withholds the grant with an operator-visible support flag).payments/payments-composition.tsandpayments/quote-builder.tsimportReceiptSignerandV1PaymentAssetfrom the bridge and wire a singlePaymentsRuntime. The BFFpayments/dir also holdsinvoice-store.ts(with terminal-invoice retention),settlement-route.ts, andsettlement-receipt.ts.- The settlement webhook verifies its HMAC over the exact raw request bytes
with a signed timestamp (±300 s) — not a re-serialized body — and runs a
settlement-reference replay guard. Every confirmation now really uses the
runtime's Ed25519
ReceiptSigner: the route builds the bridge'sReceiptPayload(chain-qualified asset, verification snippet), signs it, persists it on the invoice, and returns it; without a bound signer it refuses (503) rather than confirming receipt-less. - App-store IAP (Apple verify + App Store Server Notifications V2 at
/v1/billing/app-store/apple/notifications, Play purchases + RTDN) and the fiat rail both recomputeentitlementTierForSubscriptionand persist it viacustomerAuthStateStore.updatePlan— the exact field the entitlement middleware reads — including downgrades tofreeon refund/revoke/expiry. - The fiat rail registers at
payments/fiat-routes.ts(/v1/payments/fiat/*): Stripe Billing checkout + webhook (raw-byteStripe-Signatureverification), the wallet server legs (Apple Pay merchant validation behind the SSRF allowlist + mTLS seam, Google Pay gateway config, Stripe-tokenized wallet attach), and the parity-event feed atGET /v1/payments/fiat/events. See Fiat Payments. @ajeappears in the BFF only as comments — there is no live Aje call.
The crypto-quote route is fail-closed by design. server.ts binds
resolvePaymentsRuntime(process.env); in-repo no settlement provisioner is
injected, so the runtime is null and the quote route returns 503 with no
address issued. The log line is explicit: "payments: crypto quoting
fail-closed (no settlement provisioner) — quote route returns 503." The comment
at server.ts:930 documents that a real deploy "constructs the provisioner
(BTCPay/OpenNode/@aje) + binds a real runtime here." This is an
honest fail-loud seam, not a stub: it refuses
to fabricate an address rather than pretend to settle.
Operational invariants#
Applied across Aje + the bridge (spec/policy described in code, fixture-tested, not provable as a running deployment in-repo):
- The application server holds view keys and watch-only xpubs only for every
chain. Spend keys live on an air-gapped signing station behind hardware-wallet
co-signers (Trezor / Ledger / Coldcard).
@aje/wallets/hardware-walletis the only library allowed to touch a spend key. - Sweeps consolidate to a 2-of-3 multisig vault per chain family using each
chain's native multisig primitive (see
cold-spend-queue/sweep-policy.ts). - Per-invoice fresh derivation on every chain (Litecoin
m/84'/2'/0'/0/<i>, Ergom/44'/429'/0'/0/<i>, Tron per-invoice address, TON per-invoicesubwallet_id, Monero per-invoice subaddress). Address reuse is rejected. - Telemetry stores only the hash of any per-invoice identifier (Monero subaddress, Solana reference pubkey, TON subwallet contract address).
- No on-chain customer identifiers — enforced at the build layer by
security-gates/build-time-invariants.ts. - Trust-tier disclosures are mandatory on every Tier-B / Tier-C rail and
centrally-issued asset; the acknowledgement timestamp is recorded before any
address is rendered (
gateInvoiceCreation). Disclosure copy is reviewed by Lilith policy each release.
Honest gaps — implemented vs. aspirational#
| Claim | Reality |
|---|---|
"The bridge adapts Aje's merchant invoice contracts / depends on @aje/*." |
Aspirational. src/ imports zero @aje/* (and zero @oshun/event-bus). The bridge reimplements its own AjeInvoiceStatus vocabulary and never calls Aje. The @aje/* deps are declared but unused. |
| "Both rails publish identical-schema entitlement events." | Wired (2026-07-04). @oshun/fiat-payments emits the bridge's PaymentBusEvent topics on fiat-stripe via FiatEntitlementEmitter; a cross-rail test asserts per-topic structural equivalence and key-set equality. The generic payment.ts connector framework remains schema-separate but is not the V1 fiat rail. |
| "Sign up and pay with crypto, end to end." | Pass (surfaces) / partial (settlement). /billing/crypto and /aaa-upgrade render; /v1/payments/* and /v1/entitlements/aaa respond. Live wallet settlement needs a real merchant provisioner (BTCPay/OpenNode), a documented external dependency; v1-completeness-audit-2026-06-22 lists sign-up-and-pay-crypto as partial. The BFF quote route is fail-closed (503) until a provisioner is injected. |
| "Self-hosted nodes, air-gapped signing, hardware co-signers, multisig vaults, Tor egress." | Spec/policy + fixtures, not a verifiable running deployment. The code (cold-spend-queue/, oracle-aggregator/tor-egress.ts, security-gates/) describes and fixture-tests these; no running node or live settlement is verifiable in-repo. |
| "Event lifecycle: created → seen → confirmed → settled → entitlement_granted, with overpaid terminal." | Partially resolved. Mapper/bus vocabularies are reconciled (underpaid/expired topics + emitter methods exist; the admin timeline models underpaid with legal-predecessor checks). Still aspirational: no seen state and no overpaid terminal in state-mapper.ts (overpayment settles; the paid branch now enforces an underpaid-deficit invariant). |
The honest framing: the deterministic core is real and tested (chain-valid
derivation with regtest/testnet e2e, confirmation policy, disclosure gate,
Ed25519 receipts, QR encoder, admin/security surfaces, and the
@oshun/billing-support entitlement state machine). What remains gated is the
operational settlement plane — a live merchant provisioner and the
node/signing infrastructure — which is correctly represented in-repo as a
fail-closed seam rather than faked success.
Related#
- Fiat Payments — Stripe Billing, Apple Pay, and Google Pay
- Aje — Non-Custodial Payment Substrate
- Trust, Safety, and Privacy
- Lilith — Contemplative Policy Substrate
- Isis — Generation Control Substrate
- Customer-Facing Domains
- Security, Privacy, and Compliance
- Messaging Channels
- Subsystem Glossary
- Hub: ../ARCHITECTURE.md