Oshun Platform · Architecture

Support, Entitlements, Billing, and the Aje Entitlement Bridge

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

9sections23 minread8tables

On this page

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-bus as dependencies but its src/ 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. The sign-up-and-pay-crypto walkthrough 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 SupportCase contract (V1/TODOS.md § 1.2) and route through the admin web surface. SLA is monitored with the libs/shared/queue/src/sla-monitor.ts patterns.
  • 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:

  1. Five new chain modules in Aje for chains the existing @aje/chains and @aje/bitcoin don't cover: Monero, Litecoin, TON, Ergo, and Tron.
  2. 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.json declares @aje/chains, @aje/oracles, @aje/payments, @aje/wallets, @oshun/audit-platform, @oshun/event-bus, and @oshun/identity as dependencies, plus @noble/curves and @noble/hashes. But grep "from '@aje" src/ returns nothing, and so does grep "@oshun/event-bus" src/. The bridge reimplements its own AjeInvoiceStatus vocabulary 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 as chains/src/avalanche/ and chains/src/zksync/. The five V1-added chains (below) are top-level dirs under libs/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:

text
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, no crisis-suppression.ts, and no telegram-handoff.ts in this package. And trust-tier-disclosure is a flat file, not a directory, while oracle-aggregator, cold-spend-queue, and receipt-signer are directories, not flat files. The real homes are: event emission → entitlement-bus/, crisis suppression → customer-surface/telegram-bot-router.ts (gates only the Telegram /upgrade command, not "every invoice-creation path"; a separate, unrelated crisis-suppression lives in the BFF at apps/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 natives btc-onchain, btc-lightning, ltc, xmr, eth-mainnet, eth-base, eth-arbitrum, eth-optimism, matic-polygon, sol, ton, ada, erg; then usdc-{mainnet,base,arbitrum,optimism,polygon,solana}, usdt-{mainnet,base,arbitrum,optimism,polygon,tron,solana,ton}, and dai-{mainnet,base,arbitrum,optimism,polygon}.
  • AJE_INVOICE_STATUSESdraft, pending, paid, partial, refunded, expired, cancelled. This is the bridge's own vocabulary, not Aje's. (Aje's real @aje/payments/merchant InvoiceStatus is draft | sent | viewed | partial | paid | overdue | cancelled | refunded and ConfirmationStatus is pending | confirming | confirmed | failed | finalized.) But the bridge never imports them, so its enum drops sent/viewed/overdue and adds pending/expired.
  • AJE_CONFIRMATION_STATUSESunconfirmed, confirmed, finalized (again, not Aje's five-value confirmation enum).
  • V1_PAYMENT_EVENT_TYPESpayment.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.ts defines payment.invoice.confirmed, payment.invoice.settled, payment.invoice.underpaid, payment.invoice.expired, and payment.refund.broadcast, and CryptoEntitlementEmitter gained emitUnderpaid / emitExpired (tests assert the topic strings equal the mapper's emitted types exactly). The admin invoice timeline also gained the previously-unrepresentable underpaid state with legal-predecessor validation. Still true and worth knowing: the "5-stage lifecycle" with seen / overpaid stages described in older companion docs remains aspirational — state-mapper.ts models no seen state and no overpaid terminal (an overpaid invoice settles; the paid-branch now enforces an underpaid-deficit invariant instead of trusting Aje's paid status 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:

json
{
  "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_ASSETS was previously 17 chain-ambiguous symbols; it is now the full 32-member V1PaymentAsset vocabulary with a compiler-enforced total mapping (RECEIPT_ASSET_FOR_V1_ASSET) and a separate display-symbol table, so a USDC-on-Base payment receipts as usdc-base, and verification-snippet.ts names 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. MoneroPaymentProof is exactly { txKey, address } (from get_tx_proof). txId is a separate top-level ReceiptPayload field, 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).

PaymentRail is 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. The fiat-stripe rail is now actually emitted by @oshun/fiat-payments.

Customer surface — src/customer-surface/#

  • telegram-bot-router.tsrouteUpgradeCryptoCommand handles /upgrade --crypto <asset>. It applies crisis-state suppression first: when crisisState === 'active' it returns { action: 'suppress', reason: 'crisis-active' } (no payment surface during a crisis-flagged conversation); otherwise it validates the asset against PAYWALL_ASSETS and returns a render-paywall directive with the resolved chains, or a show-help.
  • paywall-spec.tsPaywallRenderable construction is gate-aware: the public path is buildPaywall(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 renamed buildUngated* (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 form ethereum:<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 Monero tx_amount renders 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 uses REMINDER_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.ts is 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.ts imports listSupportedAssets from @oshun/payments-bridge and 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.ts and payments/quote-builder.ts import ReceiptSigner and V1PaymentAsset from the bridge and wire a single PaymentsRuntime. The BFF payments/ dir also holds invoice-store.ts (with terminal-invoice retention), settlement-route.ts, and settlement-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's ReceiptPayload (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 recompute entitlementTierForSubscription and persist it via customerAuthStateStore.updatePlan — the exact field the entitlement middleware reads — including downgrades to free on refund/revoke/expiry.
  • The fiat rail registers at payments/fiat-routes.ts (/v1/payments/fiat/*): Stripe Billing checkout + webhook (raw-byte Stripe-Signature verification), 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 at GET /v1/payments/fiat/events. See Fiat Payments.
  • @aje appears 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-wallet is 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>, Ergo m/44'/429'/0'/0/<i>, Tron per-invoice address, TON per-invoice subwallet_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.