Repository navigation
Commit 7d71357
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
File tree
- components/grid-wallet-prod
- scripts
- src
- apps
- aurora
- wallet
- app
- api
- grid
- [...path]
- webhooks
- stream
- components
- ApiPanel
- AppPanel
- DemoPhone
- data
- hooks
- lib
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
9 | 12 | | |
10 | 13 | | |
11 | 14 | | |
| |||
33 | 36 | | |
34 | 37 | | |
35 | 38 | | |
36 | | - | |
37 | | - | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
38 | 45 | | |
39 | | - | |
40 | 46 | | |
| 47 | + | |
41 | 48 | | |
42 | 49 | | |
43 | 50 | | |
| |||
0 commit comments