---
path: /billing/crypto
surface: customer
domain: account
auth: signed-in
source: apps/oshun/web/src/app/billing/crypto/page.tsx
status: walked
last_walked:
  '2026-05-29 automated runtime walk (Playwright headless) — render, /v1 data
  (2xx), console/page-errors, expected content, screenshot verified; manual
  screen-reader and real telemetry-delivery checks still need an AT pass.
  Evidence: WALKTHROUGH/results/runtime-sweep-2026-05-29.md; content re-verified
  2026-06-03 against current source; 2026-06-28 billing-crypto-paywall
  Playwright pass covered anonymous redirect, sanitization pending-notice
  variants, pending return/support actions with 44 px targets, real BFF-issued
  BTC and USDC/Base invoices, visible disclosure version, trust-tier disclosure
  ack, copy/QR/address, signed settlement webhook, entitlement readback,
  confirmed-invoice no-stale-address render, idempotent webhook re-delivery, and
  failed settlement receiver paths with no ledger/plan mutation, plus
  issued-paywall billing-support actions in both disclosure-gated and
  address-visible states.'
---

# Billing · Pay with Crypto

## Purpose

Non-custodial crypto-settlement paywall for a single invoice. Sanitises the
`invoice`, `asset`, and `chain` query params, fetches a renderable invoice from
`fetchInvoiceRenderable`, derives the rail descriptor via
`@oshun/payments-bridge`, and renders either `<CryptoPaywall>` (live address +
countdown + send-amount) or `<PendingIssuanceNotice>` (no invoice id, or
not-found).

## Entry points

- Email link from invoice issuance flow:
  `?invoice=<id>&asset=<token>&chain=<token>`
- In-product upgrade flow (Yemaya AAA, premium tier) that proposes crypto as a
  settlement option
- Cross-domain link from `/profile` billing row (when crypto rail is enabled for
  the user)
- Direct URL / bookmark — yes; auth required (middleware gating verified for
  `/billing/crypto`)

## Layout regions

`page.tsx` is a server component with `export const dynamic = 'force-dynamic'`.
Decision logic:

1. If `invoice` is missing or sanitises to null → return
   `<PendingIssuanceNotice />` (no `invoiceId` arg)
2. Otherwise, call `fetchInvoiceRenderable({ invoiceId, assetHint, chainHint })`
3. If `lookup.kind === 'not-found'` → return
   `<PendingIssuanceNotice invoiceId={invoiceId} />`
4. Otherwise → render
   `<CryptoPaywall renderable={...} hasCentralIssuer={...} disclosureVersion={...} disclosureCopy={rail.disclosureCopyEn} />`

Two terminal layouts:

- **PendingIssuanceNotice**: rendered when the page can't show a paywall yet (no
  invoice param, or invoice not yet issued / found). Layout region details live
  in `apps/oshun/web/src/app/billing/crypto/PendingIssuanceNotice.tsx`
- **CryptoPaywall**: the real paywall composition with rail-specific disclosure
  copy and central-issuer trust class flag. Layout regions in
  `apps/oshun/web/src/app/billing/crypto/CryptoPaywall.tsx`

## States

- [x] **No invoice param** — `<PendingIssuanceNotice />` (no invoice id
      argument)
- [x] **Invalid invoice param sanitisation** — short-circuits to
      pending-issuance notice (input failed regex/length)
- [x] **Invoice not found** — fetch returns `kind === 'not-found'` →
      `<PendingIssuanceNotice invoiceId={invoiceId} />`
- [x] **Native rail** (e.g., BTC, ETH on L1) —
      `rail.issuerTrustClass === 'native'` → `hasCentralIssuer === false`
- [x] **Issued asset (stablecoin)** — `rail.issuerTrustClass !== 'native'` →
      `hasCentralIssuer === true`; disclosure copy emphasises central issuer
      trust
- [x] **Disclosure version** — paywall surfaces `disclosureVersion` visibly in
      the paywall header (`data-testid="crypto-disclosure-version"`)
- [x] **Loading** — server-side fetch blocks until renderable resolved; no
      client-side loading skeleton from this page itself
- [ ] **Error (unrecoverable)** — `fetchInvoiceRenderable` throw is caught by
      segment `error.tsx`
- [x] **Anonymous user** — `/billing/crypto` redirects to `/welcome` before a
      paywall or pending notice renders
- [x] **Search engines** — `metadata.robots = { index: false, follow: false }`;
      browser coverage asserts the rendered `noindex, nofollow` meta tag

## Interactions

Page-level: none (server component); all interactions inside the two child
compositions.

### PendingIssuanceNotice (per page contract)

- [x] **Pending state message** — copy varies based on whether `invoiceId` is
      known
- [x] **Return-to-billing CTA / contact support link** — `Return to billing`
      targets `/billing` and the support link targets
      `mailto:billing@oshun.app`; both are asserted as at least 44 px touch
      targets

### CryptoPaywall (per page contract)

- [x] **Deposit address** (mono text) — copyable via a copy button
- [x] **Copy address** (button) — copies address to clipboard
- [x] **Amount in asset** (mono text + locale-formatted) — denominated per the
      rail
- [x] **QR code** (if rendered) — encodes the payment URI
- [x] **Disclosure copy block** — `rail.disclosureCopyEn` rendered as legal
      disclosure
- [x] **Disclosure version marker** — visible legal version string rendered
      above the payment controls
- [x] **Central-issuer warning** — visible when `hasCentralIssuer` is true
- [x] **Help / support link** — issued paywall exposes the same
      `mailto:billing@oshun.app` action as the pending state in both
      disclosure-gated and address-visible states; browser coverage asserts it
      as a single 44 px touch target.
- [x] **Settlement-status update contract** — no client polling subscription
      exists in `CryptoPaywall.tsx`; after a signed settlement flips the live
      BFF invoice to `confirmed`, the by-id fetch returns `status: "confirmed"`
      and the server route renders `PendingIssuanceNotice` instead of a stale
      address.

## E2E coverage

- [`apps/oshun/web/e2e/billing-crypto-paywall.spec.ts`](../../../apps/oshun/web/e2e/billing-crypto-paywall.spec.ts)
  covers anonymous redirect, sanitization and not-found pending states, pending
  return/support actions with 44 px targets, route robots metadata, real
  BFF-issued BTC and USDC/Base invoices through the live by-id fetch, visible
  disclosure version, asset/network controls, trust-tier badges, QR/address
  block, clipboard copy, billing-support action in both issued-paywall states,
  central-issuer disclosure ack, signed settlement webhook, purchaser plan
  readback, confirmed-invoice stale-address removal, and idempotent webhook
  re-delivery. It also posts invalid-signature, malformed signed, and
  unknown-invoice settlement webhooks over real HTTP and proves the original
  invoice remains pending with no `txId` and the purchaser plan remains free.

## Data & contracts

- **Reads**:
  - `fetchInvoiceRenderable({ invoiceId, assetHint, chainHint })` from
    `./invoice-loader` — returns
    `{ kind: 'found' | 'not-found', renderable?, v1Asset?, disclosureVersion? }`
  - `getRailDescriptor(v1Asset)` from `@oshun/payments-bridge` — returns
    `{ issuerTrustClass, disclosureCopyEn, ... }`
- **Writes**: no network writes from `page.tsx` or `CryptoPaywall`; user actions
  are client-local clipboard write and disclosure-ack state. Settlement is
  driven by the BFF webhook receiver outside this page.
- **Realtime**: no client polling or subscription exists in `CryptoPaywall.tsx`;
  the route is re-rendered from the live by-id invoice status on navigation.
- **Caching**: `export const dynamic = 'force-dynamic'` — page is re-rendered
  per request; never cached
- **Auth/role check**: middleware behaviour verified; anonymous direct visits to
  `/billing/crypto` redirect to `/welcome`
- **Metadata**: `title: 'Oshun · Pay with crypto'`,
  `description: 'Settle your subscription on-chain. Non-custodial, address fresh per invoice.'`,
  `robots: { index: false, follow: false }`,
  `alternates.canonical: '/billing/crypto'`
- **Sanitisers**: `sanitiseInvoiceId` (1–64 alnum/_-) and `sanitiseToken` (1–32
  alnum/_-) — strict regex; null-on-invalid

## Cross-references

- Parent: [`profile.md`](./profile.md) (billing settings row)
- Upgrade flow: [`../13-system/aaa-upgrade.md`](../13-system/aaa-upgrade.md)
- Shell auth: [`shell/04-auth-session.md`](../../shell/04-auth-session.md)
- Component sources:
  - `apps/oshun/web/src/app/billing/crypto/CryptoPaywall.tsx`
  - `apps/oshun/web/src/app/billing/crypto/PendingIssuanceNotice.tsx`
  - `apps/oshun/web/src/app/billing/crypto/invoice-loader.ts`
  - `libs/oshun/payments-bridge/` (package `@oshun/payments-bridge`) —
    `getRailDescriptor` lives in
    `libs/oshun/payments-bridge/src/trust-tier-disclosure.ts`;
    `makeOnchainAddressPaywall`

## Open questions / known gaps

- [x] Read `CryptoPaywall.tsx` and `PendingIssuanceNotice.tsx` to confirm every
      interactive element covered by the pending/issued-invoice browser specs.
- [x] Confirm the settlement polling contract after Aje events are available in
      the harness: there is no in-place polling in the shipped paywall; the
      browser route is re-rendered from the live by-id invoice status and hides
      confirmed invoice addresses.
- [x] Confirm failed settlement receiver POSTs do not mutate the payable
      invoice: invalid HMAC, malformed signed payload, and unknown invoice ids
      are covered by `billing-crypto-paywall.spec.ts` against the live BFF.
- [x] Confirm middleware gating: `/billing/crypto` is signed-in-only; anonymous
      direct visits redirect to `/welcome`.
- [ ] `makeOnchainAddressPaywall` is re-exported from this `page.tsx` — confirm
      whether that export is used by any tests / tooling (typically pages don't
      re-export business utilities)
- [ ] Confirm what the user does after settlement: no success route or in-place
      success transition is currently shipped; a confirmed invoice re-renders as
      not payable.
- [ ] Document the rails supported in V1 (BTC / ETH / USDC etc.)
- [x] Verify the `disclosureVersion` is surfaced visibly so it's attestable in
      legal review; the paywall header now renders `Disclosure version vN` and
      the E2E spec asserts it for native and issued-asset invoices.
- [x] The issued paywall now exposes the same billing-support mailto as the
      pending notice; `billing-crypto-paywall.spec.ts` asserts the BTC
      address-visible state plus the USDC disclosure-gated and post-ack states.
