Non-custodial stablecoin checkout + payment links on Stellar, with a deliberately swappable off-ramp seam so the seller can later cash out to local currency without a rewrite.
The loop, end to end:
- A seller creates a payment link in the dashboard (title + amount + asset).
- The buyer opens the checkout page, scans a QR (or taps a wallet deep-link), and pays USDC straight to the seller's own Stellar wallet — nothing is custodied in between.
- A backend worker watches the ledger, matches the incoming payment to the link by memo, marks it paid, and fires any registered webhooks.
- When the seller wants cash, they trigger a seller-initiated cash-out to local currency through the off-ramp adapter.
This is the non-custodial version of a hosted checkout (think Stripe-style PaymentIntent), built on the chain whose anchor network can actually settle to local rails.
The link + checkout + on-chain payment is the easy, commodity part. The off-ramp is the hard 80% and the whole moat — and it isn't a step you bolt on, it's a corridor walking back in: FX rate risk in flight, KYC on the payout, reconciliation that proves local currency landed, recovery when the anchor is down.
So two deliberate boundaries are baked into the architecture:
-
Off-ramp runs
seller_initiated, notinline. The seller receives the stablecoin to a wallet they control and cashes out as a separate, authorized action. Custody stays at the edges.inlinemode (value routed through the anchor mid-flight, seller receives local currency directly) is what merchants ultimately want — and it is the mode that puts you in the money-transmission / custody box. TheOffRampPortalready models both modes; do not flip toinlineuntil a licensed anchor relationship and a compliance story are real. -
Ports-and-adapters everywhere. The domain never imports a chain SDK.
RailPort,WatcherPort, andOffRampPortare the seams. Today: a Stellar (SEP-7 + Horizon) rail and a mock anchor. Tomorrow: the samePaymentIntentspine behind anadapter-gateway(Arc/Circle) or a different chain — without touching the domain or the worker.
packages/
core/ Domain brain — entities, status machine, money math, SEP-7 builder,
the pure payment matcher, port interfaces, zod schemas. (29 unit tests)
stellar/ Stellar adapter — SEP-7 rail + Horizon polling watcher (RailPort/WatcherPort).
offramp/ Off-ramp adapter — MockAnchorOffRamp (OffRampPort, seller_initiated). *** mock ***
apps/
api/ Hono API + Drizzle (libSQL) + the ledger-watching worker.
web/ Next.js (App Router) seller dashboard + buyer checkout page.
core is the only package with business logic worth unit-testing in isolation, and it is:
money is compared in integer stroops (never floats), the status machine rejects illegal
transitions, the SEP-7 builder is spec-checked, and the matcher is exhaustively tested for
paid / overpaid / underpaid / wrong-asset / no-memo / unknown-reference.
Requirements: Node 20+ and pnpm 9.
pnpm install
cp .env.example .envTwo processes (two terminals):
# 1) API + ledger watcher → http://localhost:8787
pnpm --filter @checkout/api dev
# 2) Web dashboard + checkout → http://localhost:3000
pnpm --filter @checkout/web devOn first boot with no DEFAULT_SELLER_WALLET set, the API generates a throwaway testnet
keypair, prints it, and gives you a Friendbot link to fund it. Set DEFAULT_SELLER_WALLET
in .env to a wallet you control to reuse a stable address across restarts.
Then: open the dashboard, create a link, open its checkout page, and pay the displayed amount of USDC with the shown memo from any Stellar testnet wallet. Within a poll interval the dashboard flips the link to paid; hit Cash out to NGN to exercise the off-ramp seam.
Useful scripts (from the repo root):
pnpm typecheck # all packages
pnpm test # core unit tests
pnpm build # builds the web app| Piece | Status |
|---|---|
| SEP-7 payment-request URIs | Real, spec-correct (native vs issued asset, memo ≤28 bytes, %20 encoding, network passphrase). |
| Horizon payment watching + memo matching | Real logic against the Stellar SDK v16 API. Polling (restart-safe), idempotent via persisted cursor + processed-tx ledger. |
| Status lifecycle, webhooks (HMAC-SHA256 signed) | Real. |
| Persistence | Real, libSQL/SQLite for zero-config local dev (swap the DATABASE_URL for Turso/Postgres). Tables self-initialize on boot. |
Off-ramp (@checkout/offramp) |
MOCK. MockAnchorOffRamp simulates an FX quote and a payout with a fake rate. It exists so the seam is exercisable end-to-end; it moves no money. |
| Auth | Not implemented. Single hard-coded demo seller, no API keys / login. Fine for a demo, not for production. |
- Verify the USDC issuer.
.env.exampleships placeholder Circle issuers for testnet and public. Confirm the current issuer for your network before relying on it — a wrong issuer silently matches nothing (or the wrong asset). - Get a real anchor relationship first. A checkout that dead-ends in USDC isn't the
product. Replace
MockAnchorOffRampwith an adapter implementing the sameOffRampPortagainst a licensed Nigerian anchor's SEP endpoints (quote→ SEP-38,initiate→ SEP-24/31,status→ poll to settlement). Validate the anchor will actually onboard you and pay out before building further. - Don't enable
inlineoff-ramp without legal review. See the boundary note above. - Add auth (API keys per seller + a real login) before anyone but you touches it.
- Multiple sellers / scale: the watcher polls per active destination account; for many
sellers you may want a streaming
WatcherPortimplementation (the interface already allows it).
This README is engineering guidance, not legal advice. Money transmission is the box you do not want to back into by accident.
- HTTP API reference — endpoints, request/response shapes, and webhook delivery.
- Contributing — setup, the check suite, and PR guidelines.
- Security policy — how to report a vulnerability privately.
- Code of conduct.
Licensed under the Apache License 2.0.