Skip to content
determined-001Public

About

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

747 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Stellar Checkout

CI Anchor Probe

Stellar Checkout is the open-source, non-custodial merchant checkout for the Stellar anchor network — the inbound counterpart to the Stellar Disbursement Platform.

Live on Stellar mainnet since 2026-09-06, payments-only. The first real payment settled in ledger 64303816 (3e8dfd1…): buyer wallet straight to merchant wallet, correlated by the link reference carried in the transaction memo. Nothing was custodied in between, which is checkable from that link without asking this service anything.

Cash-out to local currency is not enabled on mainnet (OFFRAMP=none) — sellers move their own funds. The off-ramp exists and runs a real SEP-10 → SEP-38 → SEP-6 flow against testanchor.stellar.org on the testnet deployment (dashboard · API), which is where to try it: create a link, pay it from any testnet wallet with the shown memo, watch it flip to paid. No real money moves there.

API uptime Web uptime Synthetic check Checked by .github/workflows/uptime.yml — which asks for every 5 minutes and, being a scheduled workflow on a free public repo, actually gets somewhere between 30 minutes and 3 hours. It publishes to the status branch rather than committing to main every 5 minutes. The badges above and the 90-day history read from there; the copy of docs/STATUS.md on main is a snapshot and will lag.

The loop, end to end:

  1. A seller creates a payment link in the dashboard (title + amount + asset).
  2. 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.
  3. A backend worker watches the ledger, matches the incoming payment to the link by memo, marks it paid, and fires any registered webhooks.
  4. 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.


Quickstart (5-Minute Integration)

1. Install the Widget

Embed the lightweight modal checkout script tag in your HTML and attach it to any button:

<!-- Include widget script -->
<script src="https://quay-web.vercel.app/widget.js"></script>

<!-- Pay button bound to link ID -->
<button data-stellar-checkout="lnk_123">Pay with USDC</button>

<!-- Or trigger programmatically in JavaScript -->
<script>
  StellarCheckout.open("lnk_123");
</script>

Self-hosting the widget? Point the script tag at your own deployment and the widget infers the host from its own <script src> automatically - no extra config needed. If you load widget.js some other way (bundled, inlined, injected without a matching <script src="...widget.js"> tag), Quay.open() cannot detect the host and will not guess — pass it explicitly:

Quay.open({ linkId: "lnk_123", host: "https://checkout.your-domain.com" });

A widget that can't determine its host throws a clear error rather than silently pointing at someone else's deployment.

2. Create a Link via API

Both write endpoints require authentication. Mint an API key from the dashboard (API keys → Create, after signing in with your Stellar wallet) and send it as a bearer token — the plaintext key is shown once, at creation.

Keys are scoped. The default set (links:read, links:write, webhooks:manage) covers everything in this quickstart; offramp:initiate moves money and must be requested explicitly.

Generate a payment link from your backend server:

curl -X POST https://quay-api.onrender.com/links \
  -H "Authorization: Bearer ak_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "T-shirt",
    "amount": "10.50",
    "assetCode": "USDC"
  }'

Response (201 Created):

{
  "link": {
    "id": "lnk_123",
    "reference": "ref_abc",
    "status": "active",
    "title": "T-shirt",
    "amount": "10.50",
    "asset": { "code": "USDC", "issuer": "GBBD456..." },
    "destination": "GAHK789..."
  },
  "request": {
    "uri": "web+stellar:pay?destination=GAHK789...&amount=10.50&memo=ref_abc",
    "memo": "ref_abc",
    "memoType": "text"
  }
}

3. Receive the Webhook

Register your endpoint to receive real-time JSON notifications when payments land on-chain:

curl -X POST https://quay-api.onrender.com/webhooks \
  -H "Authorization: Bearer ak_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://your-domain.com/api/webhooks/checkout" }'

Verify incoming HMAC-SHA256 signatures (x-checkout-signature: sha256=<hex>) in your webhook route:

const crypto = require("crypto");

function verifyWebhookSignature(rawBody, signatureHeader, secret) {
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}

Where this is going, and the argument for it, is in ROADMAP.md.

Why it's shaped this way

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, not inline. The seller receives the stablecoin to a wallet they control and cashes out as a separate, authorized action. Custody stays at the edges. inline mode (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. The OffRampPort already models both modes; do not flip to inline until a licensed anchor relationship and a compliance story are real.

  • Ports-and-adapters everywhere. The domain never imports a chain SDK. RailPort, WatcherPort, and OffRampPort are the seams. Today: a Stellar (SEP-7 + Horizon) rail and three off-ramp adapters — a real SEP-10/38/6 flow against the testnet reference anchor, a mock for offline dev, and a disabled mode for mainnet. Tomorrow: the same PaymentIntent spine behind an adapter-gateway (Arc/Circle) or a different chain — without touching the domain or the worker.


Custody model

"Non-custodial" should be a claim you can check, not one you have to trust. This is everything the Quay server holds, where it lives in the code, and what it can do — verify each row against the cited file:

Held by the server Where Can it move a seller's funds?
SERVER_SIGNING_SECRET — signs SEP-10 login challenges for the dashboard apps/api/src/env.ts (serverSigningSecret), used in apps/api/src/services/container.ts No. It is an identity key for authentication; it is not a signer on any seller account
JWT_SECRET — signs Quay session JWTs apps/api/src/env.ts (jwtSecret) No
Each seller's anchor SEP-10 JWT, AES-256-GCM encrypted at rest anchor_sessions.token_encrypted in apps/api/src/db/schema.ts; encryption in apps/api/src/repos/index.ts No. See the paragraph below for exactly what it can do
Seller SEP-12 KYC fields, encrypted with KYC_ENCRYPTION_KEY kyc_records.fields_encrypted in apps/api/src/db/schema.ts No
Seller payout fields (bank details) sellers.payout_fields_json in apps/api/src/db/schema.ts — currently plaintext, masked in the dashboard; encrypting it at rest is tracked in #245 No
Webhook signing secrets (current and previous), encrypted webhooks.secret_encrypted / previous_secret_encrypted in apps/api/src/db/schema.ts No

The rule all of this adds up to: no key on the server can sign a payment from a seller's account or a withdrawal transfer — sellers sign with their own wallet, in the browser (apps/web/lib/wallet.ts, sendAnchorTransfer).

The one credential that deserves elaboration is the anchor SEP-10 JWT. It can read and update the seller's KYC record at the anchor and start a withdrawal (SEP-6), which yields payment instructions. It cannot move money: the on-chain leg of a cash-out is a Stellar transaction that only the seller's wallet can sign. The token is also never returned to the browser — the API relays the anchor challenge for the seller's wallet to sign, then keeps the resulting JWT server-side, encrypted (apps/api/src/routes/anchor-auth.ts).

You can check the core claim without trusting this README or this service: the mainnet transaction linked at the top of this file shows the buyer's wallet paying the seller's wallet directly, with no intermediate account. That is the whole payment path — checkout pages expose the seller's own destination address (apps/api/src/routes/links.ts, toCheckoutView) and buyers pay it straight from their wallet.

Threat handling and reporting live in SECURITY.md; the full component and flow detail is in docs/ARCHITECTURE.md.


Monorepo layout

packages/
  core/        Domain brain — entities, status machine, money math, SEP-7 builder,
               the pure payment matcher, port interfaces, zod schemas.
  stellar/     Stellar adapter — SEP-7 rail + Horizon polling watcher (RailPort/WatcherPort).
  offramp/     Off-ramp adapters (OffRampPort, seller_initiated) — TestAnchorOffRamp
               (real SEP-10 → SEP-38 → SEP-6 against testanchor.stellar.org), MockAnchorOffRamp
               (offline dev), DisabledOffRamp (mainnet), plus TestAnchorKyc (SEP-12) and
               SellerAnchorAuth (per-seller SEP-10 anchor sessions).
  widget/      Source of the embeddable checkout widget (built and served as widget.js).
apps/
  api/         Hono API + Drizzle (libSQL) + the ledger-watching worker.
  web/         Next.js (App Router) seller dashboard + buyer checkout page + widget.js.

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.


Run it locally

Requirements: Node 20+ and pnpm 9.

pnpm install
cp .env.example .env

Two 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 dev

On first boot on testnet with no DEFAULT_SELLER_WALLET set, the API generates a throwaway keypair, prints it, and gives you a Friendbot link to fund it — so pnpm dev needs no configuration. Set DEFAULT_SELLER_WALLET in .env to reuse a stable address across restarts.

The variable is optional everywhere and names nobody's funds: Quay is multi-tenant, so a seller signs in with their own wallet over SEP-10 and every link they create is paid straight to that address. Setting it only gives /health one wallet whose USDC trustline it can report.

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 the Cash out button to exercise the off-ramp seam (labelled with NEXT_PUBLIC_OFFRAMP_CURRENCY — NGN with the default OFFRAMP=mock; set it to USD when OFFRAMP=testanchor, since the testnet anchor only quotes USDC against USD/CAD).

Useful scripts (from the repo root):

pnpm typecheck      # all packages
pnpm test           # every package's test suite, via turbo
pnpm test:coverage  # the same tests, with the CI coverage gate applied
pnpm build          # builds the web app
pnpm sweep          # pre-entry ritual: uptime + synthetic checks against the live demo

Coverage gating. pnpm test:coverage fails if any package drops below the floor in its vitest.config.ts, and CI runs it on every push. Those floors are a ratchet: each was set to that package's measured coverage when gating (or a later raise) landed — the per-package vitest.config.ts is the source of truth for the current numbers, so none are repeated here to drift. Raise a floor when you raise the coverage; never lower one to make a build pass — that is the single move the gate exists to prevent. The run also uploads an HTML report as a CI artifact and prints a per-package table to the job summary.

Demo seed (pre-populated dashboard)

Instead of starting from a blank screen, seed the dashboard with real on-chain data in about a minute. It reads STELLAR_NETWORK the same way the API does (defaulting to testnet), so it works against a mainnet or self-hosted deployment too — not just testnet:

# With the API already running on http://localhost:8787:
pnpm demo:seed

Prerequisite: DEFAULT_SELLER_SECRET in .env (matching the DEFAULT_SELLER_WALLET the API is running with). Since auth landed, every /links route is seller-authenticated, so the script logs in as the configured demo seller via SEP-10 and creates the links under that same seller — otherwise the dashboard you already know would have nothing to show.

What it does:

  1. Generates a fresh buyer keypair. On testnet, funds it via Friendbot (XLM) and the testanchor USDC dispenser. On any other network neither exists, so this step is skipped with a clear message — fund the printed buyer address yourself, or the payment step below fails with the real Horizon error instead of an obscure one.
  2. Authenticates as the demo seller (SEP-10 challenge → session token).
  3. Creates several payment links via POST /links (flagged as demo data). The /demo storefront page's "Pay with Quay" button reads the first of these from GET /demo/link, so it points at a link that actually exists rather than a hardcoded id.
  4. Submits real Stellar payments from the buyer to the seller wallet using each link's memo so the on-chain watcher can match them.
  5. Waits for the watcher to mark the links paid, then triggers a cash-out on one so the dashboard shows an offramp_settled row.

Every seeded row is real on-chain data — nothing is written directly to the database. Demo rows are labelled with a demo badge in the dashboard so they are easy to tell apart from links you create yourself.

# Remove all demo-flagged rows:
pnpm demo:reset

demo:reset calls POST /demo/reset on the running API. It's testnet-only (returns 403 on the public network) and requires a seller session, so the script logs in as the same demo seller using DEFAULT_SELLER_SECRET.


Docker image (apps/api)

apps/api/Dockerfile is a 3-stage build (deps → build → runtime): the runtime stage carries only apps/api's real npm production dependencies (all workspace source is bundled into one compiled dist/index.js via esbuild - see the build script in apps/api/package.json) and runs as the non-root node user.

  • Base image is pinned by digest (node:22-alpine@sha256:..., see the ARG BASE_IMAGE at the top of the Dockerfile) - bump it at least monthly, or immediately on a disclosed CVE, per that same comment.
  • Signals: tini runs as PID 1 (ENTRYPOINT) so SIGTERM actually reaches the Node process, which drains in-flight HTTP requests before exiting (apps/api/src/index.ts).
  • Health: /health is liveness (process is up); /ready is readiness (database is actually reachable) - the HEALTHCHECK and Render's healthCheckPath both use /ready.
  • Size target: under 200 MB, enforced in CI (.github/workflows/ci.yml's docker job fails the build if it isn't). Not independently measured outside CI in this change - no Docker daemon was available in the environment this change was authored in, so the size shown above is a target the CI job checks on every push, not a number hand-verified here.
  • Security scanning: the same CI job runs a Trivy scan, failing on any HIGH/CRITICAL finding.
  • Read-only root filesystem: the app writes nothing to disk in production (remote Turso database, no local files), so the image is compatible with docker run --read-only --tmpfs /tmp or an orchestrator's equivalent read-only-root setting - Render's blueprint format has no field for this today, so it isn't set in render.yaml, but nothing in the image requires a writable root filesystem.

What's real vs. stubbed

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. One Horizon request per page (join=transactions for the memo lookup, not one-plus-N), and a transaction-fetch failure retries the tick rather than silently parking a matchable payment as no_memo. Every Horizon call retries transient failures (3 attempts, exponential backoff + full jitter, honors Retry-After on 429) and can fail over to HORIZON_URL_FALLBACK; sustained failure shows up in GET /health instead of silently going idle.
Status lifecycle, webhooks (HMAC-SHA256 signed) Real.
Persistence Real, libSQL/SQLite — zero-config locally (file:./local.db), and the same DATABASE_URL points at Turso in production. Note this is libSQL specifically, not "any SQL": the schema is drizzle-orm/sqlite-core and the client is @libsql/client, so a postgres:// URL does not work without porting the schema, the bootstrap DDL and the backup scripts. Tables self-initialize on boot. Encrypted backups (pnpm db:backup) and a tested restore path (pnpm db:restore) exist — see the runbook for the honest RPO/RTO (nightly backups ⇒ up to 24h RPO, not continuous protection).
Account/trustline preflight Real. POST /links checks the seller's wallet actually exists and (for USDC) has an authorized, under-limit trustline before the link goes live — 422 destination_cannot_receive otherwise, with a SEP-7 deep link to add the trustline. Re-checked in GET /health so a revoked trustline shows up in ops, not as a dead checkout page.
Off-ramp (@checkout/offramp) Real, opt-in. Set OFFRAMP=testanchor for a genuine SEP-10 → SEP-38 → SEP-6 flow against the public Stellar testnet anchor (https://testanchor.stellar.org). Defaults to OFFRAMP=mock (MockAnchorOffRamp, fake FX rate, no money moves) for offline dev — the dashboard labels the cash-out button "(simulated)" whenever mock mode is active.
Metrics Real. GET /metrics (Prometheus text format, METRICS_TOKEN-gated) — payment/webhook/anchor counters, watcher-lag and latency histograms, a circuit breaker around the off-ramp adapter. See docs/API.md and docs/grafana-dashboard.json.
Embeddable widget (/widget.js) Real, lightweight embeddable script rendering modal checkout.
Auth Real, and enforced. SEP-10 wallet login (GET/POST /auth) issues a short-lived session JWT (sub, sellerId, jti, exp ≤ 24h) and sets it as an httpOnly cookie; requireSeller middleware gates every /links and /webhooks route (401 unauthenticated, 403 wrong seller), POST /auth/logout revokes a token by jti. Wallet-connect is wired in the dashboard and is how a seller signs in: the address they authenticate with becomes both their identity and their payout destination, so POST /links sets destination from the authenticated seller rather than from configuration. See docs/API.md.

Before you go live (the parts code can't do)

  1. Verify the USDC issuer. .env.example ships 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).
  2. Get a real anchor relationship first. A checkout that dead-ends in USDC isn't the product. packages/offramp/src/testanchor.ts is a real SEP-10 → SEP-38 → SEP-6 adapter, but against Stellar's public testnet reference sandbox — not a licensed anchor. Fork its shape for a production adapter against a licensed Nigerian anchor's SEP endpoints, and validate the anchor will actually onboard you and pay out before building further.
  3. Don't enable inline off-ramp without legal review. See the boundary note above.
  4. Multiple sellers / scale: the watcher polls per active destination account; for many sellers you may want a streaming WatcherPort implementation (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.


Docs & contributing

  • Roadmap — where this is going and why: the off-ramp as the actual product, who the buyer really is, and the parts that are built but undersold.
  • Architecture — package graph, the three ports, sequence diagrams for each flow, the status machine, and how to add a new chain/anchor/rail.
  • Seller-signed anchor threat model — assets, trust boundaries, implemented controls, failure paths, and open risks in anchor authentication and withdrawals.
  • Triage & review SLAs — issue taxonomy, 48h labelling SLA, and the stale-issue policy.
  • HTTP API reference — endpoints, request/response shapes, and webhook delivery.
  • Runbook — deploy, rollback, database backup/restore, key rotation, anchor outage, anchor incidents, watcher-stuck, stuck off-ramp jobs, and the incident template.
  • Mainnet cutover — choosing a production anchor, generating and funding real keys, the public-network guardrails and why each refuses to boot, and the pre-announcement verification list.
  • Anchor due diligence — custody, credential scope, seller data flows and storage, retention, and incident coordination for production anchors.
  • Privacy notice & data map — NDPA 2023 compliance: data map, lawful bases, recipients, retention, rights, breach handling, and notice version.
  • SCF Build proposal — the problem, the wedge, milestones, budget, traction, and risk register.
  • 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.

About

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages