Skip to content

Commit 7d71357

Browse files
pengyingclaude
andcommitted
feat(grid-wallet-prod): live Grid API — real passkey auth, funding, and signed cash-out (Phase 2) (#740)
## Summary Wires `components/grid-wallet-prod` from fully-scripted to the **real Grid API** (public sandbox), per the Phase 2 spec (`docs/superpowers/specs/2026-07-22-grid-wallet-prod-live-api-design.md`). The API panel now renders the requests/responses that actually went over the wire. **The full money loop is live-verified end to end:** passkey sign-in → Add $20 (real platform on-ramp, `COMPLETED`) → Cash out $5 (quote → browser-signed `Grid-Wallet-Signature` execute → polled to `COMPLETED` in ~62s), with the displayed balance tracking the real account throughout. ### What's in here - **Server proxy** (`/api/grid/[...path]`): allow-listed paths only, Basic-auth injected server-side, `{request,response}` envelopes with redacted auth — the panel logs real traffic. - **Browser crypto** (`gridCrypto.ts`): TEK keygen, HPKE encrypt/decrypt, Turnkey stamps via `@turnkey/*` — session keys never leave the client; cross-checked against `scripts/embedded-wallet-sign.js`. - **Real auth** (`gridSession.ts`): first-run EMAIL_OTP bootstrap (magic `000000` in sandbox) → registers a real Grid `PASSKEY` credential via WebAuthn → returning sign-ins are pure passkey (challenge → `.get()` → verify). - **Webhook receiver** (`/api/webhooks`): `X-Grid-Signature` P-256 verification against `GRID_WEBHOOK_PUBKEY`, event ring buffer + poll route. - **Live money**: balance/activity reads, Add money via the real platform on-ramp (the customer-wallet sandbox fund endpoint mints unspendable book balance — discovered live), outbound quote/stamp/execute/poll with completion checkmarks gated on `COMPLETED`. - 38 vitest tests across the pure modules; every task browser-verified against the live sandbox. ### Env contract `GRID_CLIENT_ID/SECRET`, `GRID_API_BASE_URL`, `GRID_CUSTOMER_ID`, `GRID_WEBHOOK_PUBKEY` in `.env.local` (git-ignored). Prod cutover = swap those values; first tap re-runs passkey registration against the production customer. ### Tracked pre-prod follow-ups (not in this PR) - ~~Gate `GET /api/webhooks/events`~~ — route removed; deliveries are pushed over SSE instead of polled. - Proxy customer-scoping before production keys (today it's a single-customer sandbox gateway). - ~~Wire real activity rows onto the phone~~ — done below. - Phone-side failure states for execute errors (panel-only today). - Report upstream: `POST /quotes` returns undocumented 200 for platform-sourced quotes. --- ## Update — funding flows, webhook-driven arrivals, sandbox gating A second pass over the same component. Everything below was verified against the live sandbox in a browser; the API panel now shows **only** real traffic. ### Auth follows the documented bootstrap order - Sign-in leads with the credential a Global Account is actually born with (`EMAIL_OTP`), with the entry step prefilled from the credential's own `nickname` — no invented address. - A passkey is added **later**, from the wallet, as its own signed action: one device ceremony authorized by the live session (it used to register *and* immediately authenticate, so a single tap asked for approval twice). - The CTA advertises the credential `signIn` will really use. Passkeys are device-bound, so a passkey listed on the account but registered elsewhere no longer sends this browser into an assertion it can't satisfy. - A lapsed session (15 min) can now re-authenticate in place: the OTP sheet has a surface on the wallet screen, and the WebAuthn call runs inside the tap's user activation instead of behind the re-auth round-trip. ### Funding uses the endpoints Grid actually supports - **Add money → country → deposit instructions**: the customer's own account/routing/rails/reference, read from `GET /customers/internal-accounts`. (The EUR block is a clearly-marked placeholder IBAN in `data/placeholderDeposit.ts` until the customer has an EUR account — the read already returns one section per fiat account, so a real one appears with no code change.) - **…or "add an account"** to pull from. A pull quote sources the **ExternalAccount** — the previous body pointed at the platform's internal account. Grid rejects `/execute` for such a quote (*"funds must be pushed … use POST /sandbox/send"*), so the quote is created and left pending, and `POST /sandbox/send` stands in for the push. Amounts here are USD cents, not USDB micro-units. - **Add from crypto**: Base / Solana / Ethereum, network → amount → the real Grid-provisioned deposit address for that chain. - Saved banks are seeded from `GET /customers/external-accounts` (filtered to USD/EUR, ACTIVE); selecting one quotes against that account instead of re-creating it — which also fixes the stale-recipient gap noted in #740's original follow-ups. ### Webhooks - **`X-Grid-Signature` parsing was rejecting every real delivery.** Grid sends `{"v":"1","s":"…"}` (or a bare base64 header); we required `{"version":1,"signature":"…"}`, so verification never ran and everything 401'd. The earlier tests signed using our own assumed shape, so they passed — they now assert the documented formats. - Verified deliveries **stream to the panel over SSE** (`/api/webhooks/stream`); nothing polls. The bus is pinned to `globalThis` because Next gives each route handler its own module instance — the publisher and subscriber were otherwise in different registries. - **An arrival, not a tap, is what says money landed**: `INCOMING_PAYMENT.COMPLETED` drives the toast and Activity row from its payload and re-reads the balance from the API. Confirming an add no longer claims anything. ### Panel, wallet, packaging - The panel shows only real requests: every synthesized call and response body is gone (including a fabricated `POST https://your-app.com/webhooks/grid` and ~330 lines of stubbed quote/transaction/session responses). Flows with no client call behind them log nothing. - Activity renders the real `GET /transactions` history (EXPIRED quotes filtered out); the balance hero shows **available** with the account **total** beneath, since sandbox book balance runs ahead of spendable. - Bank picker trimmed to the US + euro area (ACH + SEPA); APY 3%. - `Dockerfile` + `.dockerignore` on `output: 'standalone'`. **`NEXT_PUBLIC_GRID_SANDBOX` is a build arg** (NEXT_PUBLIC_* is inlined at build time) and gates the sandbox-only "simulate funding" affordance — a production image must be built without it. - `scripts/ensure-business-customer.mjs`: idempotent `BUSINESS` customer provisioning, writes `GRID_BUSINESS_CUSTOMER_ID` to `.env.local`. 60 vitest tests (was 38). ### Still open - A received webhook only drives the *add* path; `INCOMING_PAYMENT.PENDING` and `OUTGOING_PAYMENT.*` are logged but don't touch the wallet. - The sandbox funding stand-in for the *instructions* screen still runs the platform on-ramp (no quote exists there to `/sandbox/send`). - USDB-native funding over Spark works (`REALTIME_FUNDING` + `cryptoNetwork: SPARK`, rate 1.0, verified) but isn't offered in the UI — both current paths convert. - `docker build` unverified: the daemon wasn't running here. The standalone server itself was run and exercised (page, webhook, SSE, proxy → Grid all responding). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent aca44db commit 7d71357

67 files changed

Lines changed: 7546 additions & 2082 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Build context hygiene — anything here is either rebuilt in the image, secret,
2+
# or irrelevant to the server bundle.
3+
node_modules
4+
.next
5+
.git
6+
.cursor
7+
.playwright-mcp
8+
9+
# Secrets never belong in an image layer; pass them at `docker run` with
10+
# --env-file / -e instead.
11+
.env
12+
.env.local
13+
.env*.local
14+
15+
# Local scratch + verification artefacts.
16+
scripts/.tmp-*
17+
*.png
18+
tsconfig.tsbuildinfo
19+
npm-debug.log*
20+
.DS_Store
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Grid wallet production demo — Next 14 standalone server.
2+
#
3+
# docker build -t grid-wallet-prod .
4+
# docker run --rm -p 4001:4001 --env-file .env.local grid-wallet-prod
5+
#
6+
# Then http://localhost:4001, and the webhook receiver at /api/webhooks
7+
# (point ngrok or your platform's webhook config at that path).
8+
#
9+
# NOTE ON ENV: NEXT_PUBLIC_* is INLINED AT BUILD TIME, so the sandbox flag is a
10+
# build arg, not a runtime var — a production image must be built WITHOUT it, or
11+
# it will still offer the "simulate funding" affordance. Everything else
12+
# (GRID_CLIENT_ID / GRID_CLIENT_SECRET / GRID_API_BASE_URL / GRID_CUSTOMER_ID /
13+
# GRID_WEBHOOK_PUBKEY) is read per request and belongs at `docker run` time.
14+
15+
# ── deps ──────────────────────────────────────────────────────────────────────
16+
FROM node:22-alpine AS deps
17+
WORKDIR /app
18+
# Playwright is a devDependency used only by the verification scripts; its
19+
# postinstall would otherwise pull ~400MB of browsers into the build.
20+
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
21+
COPY package.json package-lock.json ./
22+
RUN npm ci
23+
24+
# ── build ─────────────────────────────────────────────────────────────────────
25+
FROM node:22-alpine AS builder
26+
WORKDIR /app
27+
ENV NEXT_TELEMETRY_DISABLED=1
28+
# Sandbox keys? Pass --build-arg NEXT_PUBLIC_GRID_SANDBOX=true to bake in the
29+
# simulated-funding button. Omit for a production build.
30+
ARG NEXT_PUBLIC_GRID_SANDBOX
31+
ENV NEXT_PUBLIC_GRID_SANDBOX=$NEXT_PUBLIC_GRID_SANDBOX
32+
COPY --from=deps /app/node_modules ./node_modules
33+
COPY . .
34+
RUN npm run build
35+
36+
# ── run ───────────────────────────────────────────────────────────────────────
37+
FROM node:22-alpine AS runner
38+
WORKDIR /app
39+
ENV NODE_ENV=production \
40+
NEXT_TELEMETRY_DISABLED=1 \
41+
PORT=4001 \
42+
HOSTNAME=0.0.0.0
43+
# Unprivileged: nothing here needs root.
44+
RUN addgroup -g 1001 -S nodejs && adduser -S -u 1001 -G nodejs nextjs
45+
# `standalone` traces in only the server files actually reached, and ships its
46+
# own minimal node_modules; static assets and public/ are copied alongside it.
47+
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
48+
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
49+
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
50+
USER nextjs
51+
EXPOSE 4001
52+
# The SSE webhook stream is long-lived; give the container a plain HTTP check
53+
# that doesn't hold a connection open.
54+
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
55+
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||4001)+'/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
56+
CMD ["node", "server.js"]

‎components/grid-wallet-prod/README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,10 @@ Differences from the demo:
1818
npm install --ignore-scripts # --ignore-scripts avoids a central-icons license postinstall
1919
npm run dev # http://localhost:4001 (grid-wallet-demo keeps 4000)
2020
```
21+
22+
Sandbox "Add money" runs the real platform on-ramp (fund the platform's USD account, then
23+
quote + execute platform -> your embedded wallet) — a direct sandbox fund of the wallet itself
24+
only mints book balance with no on-chain USDB. Any fee Grid's on-ramp charges comes straight out
25+
of the real quote (observed anywhere from $0 to ~$0.20 on a $5-$20 add across sandbox runs), so
26+
the balance credit can land at or slightly under the amount you added — that's real fee behavior,
27+
not a bug.

‎components/grid-wallet-prod/next.config.mjs‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
66

77
/** @type {import('next').NextConfig} */
88
const nextConfig = {
9+
// Self-contained server bundle (.next/standalone) — what the Docker runtime
10+
// stage copies, so the image carries no node_modules of its own.
11+
output: 'standalone',
912
transpilePackages: ['@lightsparkdev/origin'],
1013
typescript: {
1114
// Origin is source-linked without its own node_modules,
@@ -33,11 +36,15 @@ const nextConfig = {
3336
];
3437
},
3538
webpack: (config) => {
36-
// Ensure dependencies imported by the local Origin package
37-
// resolve from this project's node_modules
39+
// Ensure dependencies imported by the local Origin package resolve from
40+
// this project's node_modules. The relative 'node_modules' entry comes
41+
// FIRST so webpack's default nested walk-up still wins (e.g. @turnkey/crypto's
42+
// own node_modules/@noble/* pin, which differs from this project's top-level
43+
// @noble/* version and exports different subpaths) — the absolute path is
44+
// only a fallback for imports with no node_modules to walk up from.
3845
config.resolve.modules = [
39-
path.resolve(__dirname, 'node_modules'),
4046
'node_modules',
47+
path.resolve(__dirname, 'node_modules'),
4148
];
4249

4350
// Origin only exports its main barrel — narrow imports for tree-shaken components.

0 commit comments

Comments
 (0)