Skip to content

Phase 1: hosted checkout, CCTP V2 cutover, managed Stellar settlement#136

Merged
0xdevcollins merged 1 commit into
mainfrom
feat/phase-1-checkout-cctp-v2-settlement
May 29, 2026
Merged

Phase 1: hosted checkout, CCTP V2 cutover, managed Stellar settlement#136
0xdevcollins merged 1 commit into
mainfrom
feat/phase-1-checkout-cctp-v2-settlement

Conversation

@0xdevcollins
Copy link
Copy Markdown
Owner

Summary

Three intertwined tracks shipped together — sequenced by dependency, but
they're all needed for the customer-facing pay flow to work end-to-end.

TL;DR for reviewers

  • Replaces the pre-V2 bridge stack (Wormhole + Layerswap + HTLC contracts on 6 EVM chains) with Circle CCTP V2 + Forwarding Service. Net: −15,000 LOC (mostly on-chain contract code we no longer have to audit or deploy)
  • Hosted checkout pay-by-link flow works end-to-end: customer clicks Pay → wallet signs → USDC bridges Base Sepolia → Stellar in ~10s
  • New merchants get a managed Stellar settlement wallet auto-provisioned at signup. Zero friction. Existing merchants get a one-click "Provision settlement wallet" button on /settings.
  • 21 test suites, 202/202 passing

What's in this PR

CCTP V2 cutover (PR A–F + D-follow-up)

  • New `apps/api/src/modules/cctp/` — orchestrator, attestation service, forwarder service, per-chain EVM + Stellar clients, route policy. Real `depositForBurnWithHook` calldata (was zero-address placeholder pre-PR). Per-chain USDC registry.
  • Stellar destination encoding bug fixed: forwarder contract ID becomes the bytes32 `mintRecipient`; merchant strkey rides in the Forwarding Service hook data.
  • Deleted: `apps/api/src/modules/{bridge,relay}/` (2,711 LOC), `contract/evm/` (HTLC contracts + tests + Hardhat config), `contract/soroban/contracts/htlc/`, `contract/starknet/`.
  • Schema migration: drop `Payment.{sourceLockId, stellarLockId, hashlock, htlcSecret, secretRevealed}`, add `cctpNonce + cctpAttestation`.
  • `/readyz` adds `circle` + `betterstack` probes alongside `postgres + redis + stellar`. Status-page runbook updated.
  • Decision doc: Circle Gateway evaluated and deferred (Stellar not on Gateway mainnet; pre-deposit model wrong for one-time checkout) — `apps/api/docs/architecture/circle-gateway-evaluation.md`.

Hosted checkout pay-by-link (PR 5–7.8)

Backend (all public, paymentId/shortCode is the credential):

  • `POST /v1/links/:shortCode` — resolves link, returns customer-safe metadata including merchant branding (logo, brandColor, companyName). 410 Gone enforces inactive/expired/single-use.
  • `POST /v1/checkout/from-link/:shortCode` — creates a pre-quote Payment row, atomically marks the link used (schema made `Payment.{quoteId, sourceChain, sourceAsset, sourceAmount, destAmount}` nullable to support this).
  • `POST /v1/checkout/:id/select-crypto { sourceChain }` — locks a CCTP V2 quote, patches Payment to QUOTE_LOCKED, returns wallet-signable approve + burn calldata (real V2 ABI, per-chain USDC address).
  • `POST /v1/checkout/:id/burn-submitted { sourceTxHash }` — records the burn, enqueues a BullMQ `cctp.observe` job.
  • `GET /v1/checkout/:id/crypto-status` — poll surface with explorer URLs + attestation status.
  • `CctpProcessor` (BullMQ) — wraps `CctpService.observe`, drives SOURCE_LOCKED → PROCESSING → COMPLETED via Iris attestation + Forwarding Service mint detection.
  • Card + bank session creators auto-fill source fields from `destAmount` on link-initiated payments.

Frontend (`apps/checkout`):

  • Full rewrite of `CryptoPayment.tsx` — drops HTLC code, uses wagmi `useSendTransaction` with server-encoded calldata, 5 enabled CCTP V2 chains, two-prompt approve+burn flow, 3s status polling.
  • New hooks: `useCryptoSelect`, `useCryptoStatus`, `useProvisionSettlement`.
  • `apps/checkout/app/l/[linkId]` → `app/l/[shortCode]`; brand color applied as `--primary` CSS variable override.
  • `ApiError` class surfaces HTTP status so 404 vs 410 routes to the right error variant; only triggers refresh-token dance when the failing request actually had a token (was masking wrong-password 401s as "Session expired").

Managed Stellar settlement onboarding (PR 7.9)

Approach A from `apps/api/docs/architecture/merchant-settlement-onboarding.md` — zero-friction onboarding for business merchants who shouldn't have to touch Stellar Laboratory.

  • New `MerchantSettlementKey` table — AES-256-GCM-encrypted seed, per-row IV + authTag, KEK from `SETTLEMENT_KEY_KEK` env.
  • `MerchantSettlementService.provision()` — generates keypair, funds (Friendbot on testnet / sponsored CreateAccount on mainnet stub), adds USDC trustline, encrypts + persists, mirrors `Merchant.settlementAddress`. Idempotent.
  • Hooked into `AuthService.register` — provision runs in try/catch so Horizon outage doesn't block signup.
  • `POST /v1/merchants/me/settlement/provision` — manual retry endpoint.
  • Dashboard `/settings` card with two states: green "Settlement active" with truncated G… address + "managed by Useroutr" badge, or amber "No settlement wallet yet" with one-click provision button.
  • Marketing copy across `apps/www` dropped all "non-custodial by architecture" claims; replaced with "Settlement on-chain · Managed wallets out of the box · Self-custody when you want."

Audit fixes folded in

  • `LinksController` `@UsePipes` at method level was validating the `@CurrentMerchant` string against the body schema → link creation crashed. Moved pipe to `@Body()` param.
  • `PaymentLink` response shape rewritten to match `@useroutr/types` contract (camelCase, derived `status + type + usageCount`, ISO strings) + `status` query filter wired. Was crashing the dashboard `/links` page on `BrandStatusBadge`.
  • Dashboard `lib/auth.ts` refresh URL missing `/v1` prefix, wasn't unwrapping `{ data: ... }` envelope, fallback origin was `:3000` not `:3333` — all three fixed.
  • `getCheckoutPayment` defaults `paymentMethods` to `['card', 'bank', 'crypto']` when merchant doesn't restrict.
  • Stripe `paymentIntents.create` errors now surface real `StripeAPIError.message` instead of generic 500.

Schema migrations (3 new)

  • `20260524000000_drop_htlc_fields_cctp_v2` — drops HTLC columns, adds CCTP V2 columns
  • `20260524190000_nullable_payment_quote_and_source` — makes quoteId + source fields + destAmount nullable for link-initiated payments
  • `20260524220000_add_merchant_settlement_key` — managed Stellar wallet table

Architecture docs added

  • `cctp-v2-migration-plan.md` (PR A sign-off)
  • `circle-gateway-evaluation.md` (PR E decision: pass on Gateway)
  • `crypto-pay-flow.md` (PR 7.8 state machine + endpoints + ABI)
  • `merchant-settlement-onboarding.md` (PR 7.9 Approach A vs B vs C)

New env vars

  • `SETTLEMENT_KEY_KEK` — required. AES-256-GCM key for settlement seed encryption. Auto-seeded in dev with `openssl rand -hex 32`; production goes through a secrets manager.
  • `STELLAR_RESERVE_SPONSOR_SECRET` — optional. Required only for mainnet provisioning (~1.6 XLM per merchant, recoverable). Testnet uses Friendbot.

Test plan

  • `cd apps/api && npx tsc --noEmit` — clean
  • `cd apps/api && npx jest` — 202/202 pass
  • Register a new merchant → `merchant.settlementAddress` populated automatically with a G… address; `MerchantSettlementKey` row exists with `managed=true`, encrypted seed
  • Existing merchant without `settlementAddress` → `/settings` shows amber "Provision settlement wallet" card → one click → green "Settlement active" with the new G… address
  • Create payment link → click Pay on `/l/{shortCode}` → lands on `/{paymentId}` with all three method tiles (card/bank/crypto)
  • Bank tile → returns ACH instructions
  • Crypto tile → select `base` → returns real wallet calldata (~389 bytes, correct opcode, USDC address, TokenMessengerV2 address, Stellar forwarder bytes32, merchant strkey in hook)
  • `GET /v1/checkout/:id/crypto-status` → poll-able status surface
  • Manual smoke (requires testnet ETH + USDC + MetaMask): customer clicks Approve → Pay → page progresses SOURCE_LOCKED → PROCESSING → COMPLETED → `/success` redirect
  • Card flow against a real `sk_test_` Stripe key (current `.env` has a placeholder; surfaces a clear "Invalid API Key" error instead of generic 500)

What's deferred to follow-up PRs

  • Dashboard `/links` list polish + create form + detail page (PRs 8/9)
  • E2E smoke test for the full link → pay flow (PR 10)
  • PR 7.9c — passkey-derived settlement wallet (Approach B from the onboarding doc) as the self-custody upgrade
  • Withdrawal endpoint + UI (the "honest custody" lever)
  • Checkout app type-cleanup of pre-existing drift in `PaymentPageClient` / `ConfirmPageClient` / `CardForm` (spawned task)

🤖 Generated with Claude Code

Three intertwined tracks shipped together, ordered by dependency:

## CCTP V2 cutover (PR A–F + follow-ups)

Replaces the pre-V2 bridge stack (Wormhole + Layerswap + per-EVM HTLC
contracts) with Circle's CCTP V2 + Forwarding Service. Stellar is now
domain 27; USDC-only for cross-chain. Net delta: ~3,800 fewer LOC, no
hot destination wallets, no audited HTLC contracts to maintain.

- New `apps/api/src/modules/cctp/` — attestation, forwarder, router,
  EVM + Stellar clients, ~1,200 LOC + specs. Per-chain USDC registry
  added. EVM `depositForBurnWithHook` calldata is now real (was zeroed
  placeholder). Stellar destination encoding: forwarder contract id as
  bytes32 `mintRecipient`, merchant strkey rides in the hook.
- Deleted `apps/api/src/modules/{bridge,relay}/` (2,711 LOC), all
  on-chain HTLC code (`contract/evm`, `contract/soroban/contracts/htlc`,
  `contract/starknet`).
- Schema: drop `Payment.{sourceLockId, stellarLockId, hashlock,
  htlcSecret, secretRevealed}`, add `cctpNonce + cctpAttestation`.
- Marketing: dropped "non-custodial by architecture" claims across
  apps/www; updated TrustStrip ("Circle CCTP V2", "Bridged in seconds
  not minutes"); rewrote stale HTLC paragraphs in use-cases.
- `/readyz` now probes Iris + Better Stack in addition to Postgres,
  Redis, Stellar. Status-page runbook updated with the new monitor +
  External Dependencies component grouping.
- Decision doc: Circle Gateway evaluated and deferred — Stellar isn't
  on Gateway mainnet, and the pre-deposit model is wrong for one-time
  checkout. See `apps/api/docs/architecture/circle-gateway-evaluation.md`.

## Phase 1 — hosted checkout (PR 5–7.8)

End-to-end customer pay-by-link flow:

- `POST /v1/links/:shortCode` (public) — resolves link, returns
  customer-safe metadata including merchant branding (logo, brandColor,
  companyName). 410 Gone enforces active/expired/single-use.
- `POST /v1/checkout/from-link/:shortCode` — creates a pre-quote
  Payment row, atomically marks the link used. Schema migration makes
  `Payment.{quoteId, sourceChain, sourceAsset, sourceAmount,
  destAmount}` nullable so link-initiated payments can exist before
  method choice.
- `POST /v1/checkout/:id/select-crypto { sourceChain }` — locks a CCTP
  V2 quote, patches Payment to QUOTE_LOCKED, returns wallet-signable
  approve + burn calldata (real CCTP V2 ABI, USDC address per chain).
- `POST /v1/checkout/:id/burn-submitted { sourceTxHash }` — records
  the burn, enqueues a BullMQ `cctp.observe` job.
- `GET /v1/checkout/:id/crypto-status` — poll surface with explorer
  URLs + attestation status.
- `CctpProcessor` (BullMQ) — wraps `CctpService.observe`, drives
  SOURCE_LOCKED → PROCESSING → COMPLETED via Iris attestation +
  Forwarding Service mint detection.
- Card + bank session creators auto-fill source fields from
  destAmount on link-initiated payments (PR 7.7).

Frontend (`apps/checkout`):
- Full rewrite of `CryptoPayment.tsx` — drops HTLC code, uses wagmi
  `useSendTransaction` with server-encoded calldata, 5 enabled CCTP
  V2 chains, two-prompt approve+burn flow, 3s status polling.
- New hooks: `useCryptoSelect`, `useCryptoStatus`,
  `useProvisionSettlement`.
- `apps/checkout/app/l/[linkId]` → `app/l/[shortCode]`; page reads
  brand color as `--primary` CSS variable override.
- `ApiError` class surfaces HTTP status so 404 vs 410 routes to the
  right `LinkError` variant; only triggers refresh-token dance when
  the failing request actually had a token (was masking real 401s
  on /auth/login as "Session expired").

## Managed Stellar settlement onboarding (PR 7.9)

Approach A from `apps/api/docs/architecture/merchant-settlement-onboarding.md`:
zero-friction onboarding for business merchants who shouldn't have to
touch Stellar Laboratory.

- New `MerchantSettlementKey` table — AES-256-GCM-encrypted seed,
  per-row IV + authTag, KEK from `SETTLEMENT_KEY_KEK` env.
- `MerchantSettlementService.provision()` — generates keypair,
  funds (Friendbot on testnet / sponsored CreateAccount on mainnet
  stub), adds USDC trustline, encrypts + persists, mirrors
  `Merchant.settlementAddress`. Idempotent.
- Hook in `AuthService.register` — provision runs in try/catch so
  Horizon outage doesn't block signup.
- `POST /v1/merchants/me/settlement/provision` — manual retry
  endpoint for existing merchants / outage recovery.
- Dashboard settings card with two states ("Settlement active" with
  truncated G… address + "managed by Useroutr" badge, or amber
  "No settlement wallet yet" with one-click provision button).

## Audit fixes folded in

- LinksController `@UsePipes` at method level was validating
  `@CurrentMerchant` string against the body schema. Moved pipe to
  `@Body()` parameter — link creation now works.
- PaymentLink response shape rewritten to match `@useroutr/types`
  contract (camelCase, derived `status` + `type` + `usageCount`,
  ISO strings, `status` query filter wired on the list endpoint).
  Was crashing the dashboard `/links` page on `BrandStatusBadge`.
- Dashboard `lib/auth.ts` refresh URL was missing `/v1` prefix and
  wasn't unwrapping `{ data: ... }` from the API envelope; fallback
  origin was `:3000` (marketing) instead of `:3333` (API). All three
  fixed.
- API `getCheckoutPayment` defaults `paymentMethods` to
  `['card', 'bank', 'crypto']` when merchant doesn't restrict —
  link-initiated payments never carry that metadata.
- Stripe `paymentIntents.create` errors now surface real
  `StripeAPIError.message` instead of generic 500.

## Test coverage

21 suites, 202/202 tests pass. New specs:
- `cctp.processor.spec.ts` (5 tests) — happy / no-forward /
  retry-remaining / retry-exhausted / unknown-job branches
- `public-links.controller.spec.ts` (3 tests) — delegation, 404, 410
- `links.service.spec.ts` extended with status-filter tests
- `payments.service.spec.ts` extended with 6 `createFromLink` tests

## Architecture docs added

- `cctp-v2-migration-plan.md` (PR A sign-off)
- `circle-gateway-evaluation.md` (PR E decision: pass on Gateway)
- `crypto-pay-flow.md` (PR 7.8 state machine + endpoints + ABI)
- `merchant-settlement-onboarding.md` (PR 7.9 Approach A vs B vs C)

## What's still ahead

- PR 8/9/10 — dashboard /links list polish, link detail page, E2E
  smoke test for the full link → pay flow
- PR 7.9c — passkey-derived settlement wallet (Approach B from the
  onboarding doc) as the long-term self-custody upgrade
- Withdrawal endpoint + UI (the "honest custody" lever)
- Checkout app type-cleanup of pre-existing drift in
  PaymentPageClient / ConfirmPageClient / CardForm / etc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 24, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
useroutr Error Error May 24, 2026 9:47pm
useroutr-www Ready Ready Preview, Comment May 24, 2026 9:47pm

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant