Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
47 changes: 47 additions & 0 deletions components/grid-wallet-prod/.cursor/rules/wallet-skins.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
description: Wallet demo skin architecture — share the brain, not the face
globs: **/src/apps/**/*.{ts,tsx,scss}
alwaysApply: false
---

# Wallet demo: share the brain, not the face

`components/grid-wallet-demo` shares app logic once; each persona ("skin") owns its
entire UI. Full guide: `src/apps/SKINS.md` — read it before building or restyling a skin.

## The one rule

A per-skin file is **presentational only**. It calls the shared "brain" —
`useWalletHome()` and `useMoneySheet()` in `apps/shared/wallet` — reads shared data, and
renders. Never re-implement balance math, FX/fees, API calls, card issuance, tap-to-pay,
or bank/address data inside a skin.

## Restyle by forking the face — never by adding props

To give a skin its own look, **copy the face** into `apps/<skin>/wallet/` and edit freely.
Do NOT add props / tokens / variants to a shared component to reskin it.

## iOS system sheets are shared mechanics, not faces

OS chrome (the passkey sheet, `apps/shared/PasskeySheet`) looks identical in every app, so
it lives in `apps/shared/` with colors pinned to canonical iOS and the app name passed as
`appName` (from the registry `label`). Don't fork it per skin. Branded auth UI like
`AuthSheet` (OTP) is the opposite — it stays a per-skin face.

## Don't edit these to change one skin

`apps/shared/**`, `apps/aurora/**` (shipped — treat as untouchable), `data/`, `lib/`,
`hooks/` are shared by every skin. Edit the skin's own folder instead.

## Naming: code = category, brand = data

- Name skin code by its stable category: folder `apps/creator/`, components
`CreatorWalletScreen`, `id: 'creator'`, token `[data-app='creator']`.
- The brand name is a data value: `export const BRAND = '...'` in the skin's `config.ts`,
read by every face + the registry `label`. Re-branding is a one-line edit; never bake a
brand name into a filename or identifier. (Aurora keeps its `aurora` id historically.)

## Verify before done

- `npx tsc --noEmit` — ignore the 3 baseline errors (`cornerShape`×2, `superellipse`); add none.
- After any shared change, confirm Aurora (Financial app) looks identical AND your skin works.
20 changes: 20 additions & 0 deletions components/grid-wallet-prod/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Build context hygiene — anything here is either rebuilt in the image, secret,
# or irrelevant to the server bundle.
node_modules
.next
.git
.cursor
.playwright-mcp

# Secrets never belong in an image layer; pass them at `docker run` with
# --env-file / -e instead.
.env
.env.local
.env*.local

# Local scratch + verification artefacts.
scripts/.tmp-*
*.png
tsconfig.tsbuildinfo
npm-debug.log*
.DS_Store
18 changes: 18 additions & 0 deletions components/grid-wallet-prod/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Grid API credentials — from https://app.lightspark.com (Developers → API keys).
GRID_CLIENT_ID=
GRID_CLIENT_SECRET=

# Grid API base URL (same host for sandbox and production; the KEY decides which).
GRID_API_BASE_URL=https://api.lightspark.com/grid/2025-10-13

# Webhook signature verification: the P-256 public key from the dashboard's
# webhook settings. Deliveries to /api/webhooks 401 without a matching key.
GRID_WEBHOOK_PUBKEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n"

# The customer whose Global Account this demo drives.
GRID_CUSTOMER_ID=

# Sandbox keys? Gates demo-only affordances that must never run against real
# money (the simulated inbound transfer). NEXT_PUBLIC_* is inlined at BUILD time,
# so a production build must omit this.
NEXT_PUBLIC_GRID_SANDBOX=true
10 changes: 10 additions & 0 deletions components/grid-wallet-prod/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
node_modules/
.next/
out/
.vercel/
*.tsbuildinfo
next-env.d.ts
.env*.local
.DS_Store
certs/
.vercel
56 changes: 56 additions & 0 deletions components/grid-wallet-prod/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Grid wallet production demo — Next 14 standalone server.
#
# docker build -t grid-wallet-prod .
# docker run --rm -p 4001:4001 --env-file .env.local grid-wallet-prod
#
# Then http://localhost:4001, and the webhook receiver at /api/webhooks
# (point ngrok or your platform's webhook config at that path).
#
# NOTE ON ENV: NEXT_PUBLIC_* is INLINED AT BUILD TIME, so the sandbox flag is a
# build arg, not a runtime var — a production image must be built WITHOUT it, or
# it will still offer the "simulate funding" affordance. Everything else
# (GRID_CLIENT_ID / GRID_CLIENT_SECRET / GRID_API_BASE_URL / GRID_CUSTOMER_ID /
# GRID_WEBHOOK_PUBKEY) is read per request and belongs at `docker run` time.

# ── deps ──────────────────────────────────────────────────────────────────────
FROM node:22-alpine AS deps
WORKDIR /app
# Playwright is a devDependency used only by the verification scripts; its
# postinstall would otherwise pull ~400MB of browsers into the build.
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
COPY package.json package-lock.json ./
RUN npm ci

# ── build ─────────────────────────────────────────────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
# Sandbox keys? Pass --build-arg NEXT_PUBLIC_GRID_SANDBOX=true to bake in the
# simulated-funding button. Omit for a production build.
ARG NEXT_PUBLIC_GRID_SANDBOX
ENV NEXT_PUBLIC_GRID_SANDBOX=$NEXT_PUBLIC_GRID_SANDBOX
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# ── run ───────────────────────────────────────────────────────────────────────
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=4001 \
HOSTNAME=0.0.0.0
# Unprivileged: nothing here needs root.
RUN addgroup -g 1001 -S nodejs && adduser -S -u 1001 -G nodejs nextjs
# `standalone` traces in only the server files actually reached, and ships its
# own minimal node_modules; static assets and public/ are copied alongside it.
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 4001
# The SSE webhook stream is long-lived; give the container a plain HTTP check
# that doesn't hold a connection open.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
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))"
CMD ["node", "server.js"]
95 changes: 95 additions & 0 deletions components/grid-wallet-prod/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Grid Wallet Prod

Live demo of the **Grid Global Accounts** wallet lifecycle, running against the real Grid API,
forked from [`components/grid-wallet-demo`](../grid-wallet-demo) (the scripted variant embedded in
the Grid docs). Same stack and design system: Next.js 14 + React 18 + TypeScript, SCSS modules,
`@lightsparkdev/origin` tokens + text-style mixins, `@central-icons-react` icons, `motion`.

Differences from the demo:

- No config sidebar — the layout is the phone plus the API panel.
- The use case is pinned to **Fintech**, and the bank corridors to the **US + euro area**
(ACH/wire/RTP and SEPA — the two rails this wallet settles).
- Calls are **real**. The panel shows only traffic that actually happened: the request and response
of every call to Grid, plus webhooks Grid delivered. A flow with no client call behind it (card
issuance, tap to pay) logs nothing rather than inventing a request.

## Auth

Sign-in leads with the credential a Global Account is actually born with — `EMAIL_OTP`, with the
entry step prefilled from that credential's own `nickname`. A passkey is added **later**, from the
wallet, as its own signed action (one device ceremony, authorized by the live session). Once this
device has registered a passkey the sign-in button says so, and the passkey carries subsequent
sign-ins.

Passkeys are device-bound: one listed on the account but registered on another device is ignored
here, because `navigator.credentials.get` in this browser could never satisfy it.

A session lasts 15 minutes. Inside it, no re-authentication — each signed action (a cash-out)
stamps the quote's `payloadToSign` with the session key. Past it, the next signed action
re-authenticates in place.

## Money in

Two shapes, and the API only supports each one way:

- **Push** — a country's deposit instructions, read off the customer's own fiat account
(`GET /customers/internal-accounts`): account number, routing number, the rails they settle on,
and the reference that credits the deposit. The EUR block is a marked placeholder
(`src/data/placeholderDeposit.ts`) until the customer has an EUR internal account; the read
already returns one section per fiat account, so a real one appears with no code change.
- **Pull** — "add an account", which quotes from the registered `ExternalAccount`. Grid rejects
`POST /quotes/{id}/execute` for such a quote ("funds must be pushed to Lightspark from the source
account"), so the quote is created and left pending until the payment lands. Amounts here are in
USD cents, not the wallet's USDB micro-units.
- **Crypto** — USDC on Base / Solana / Ethereum: pick a network, enter an amount, and the screen
shows the real Grid-provisioned deposit address for that chain.

Arrival is reported by **webhook**, not by tapping: `INCOMING_PAYMENT.COMPLETED` drives the toast
and the Activity row, and the balance is re-read from the API.

## Webhooks

`POST /api/webhooks` verifies `X-Grid-Signature` against `GRID_WEBHOOK_PUBKEY` (SHA256/ECDSA over
the raw body, P-256 SPKI PEM). Grid sends `{"v":"1","s":"<base64>"}`; a bare base64 header is also
accepted, per the docs. Verified deliveries are **pushed** to the panel over SSE
(`GET /api/webhooks/stream`) — nothing polls.

To receive real deliveries locally, tunnel the port and register the URL with your platform:

```bash
ngrok http 4001 # then register https://<id>.ngrok-free.app/api/webhooks
```

## Develop

```bash
cp .env.example .env.local # then fill in the values (see below)
npm install --ignore-scripts # --ignore-scripts avoids a central-icons license postinstall
npm run dev # http://localhost:4001 (grid-wallet-demo keeps 4000)
npm test # vitest
```

`NEXT_PUBLIC_GRID_SANDBOX=true` gates the sandbox-only "simulate funding" button in the API panel.
`NEXT_PUBLIC_*` is inlined at **build** time, so a production build must omit it.

There's also a `Dockerfile` (`output: 'standalone'`, port 4001):

```bash
docker build -t grid-wallet-prod --build-arg NEXT_PUBLIC_GRID_SANDBOX=true .
docker run --rm -p 4001:4001 --env-file .env.local grid-wallet-prod
```

## Sandbox notes

- The magic OTP is `000000`. Sandbox **skips email delivery** — the code the phone "receives" is
simulated; the HPKE encryption and signature verification around it are real.
- `POST /sandbox/send` stands in for a customer pushing funds to a pending quote. That's what the
panel's "Simulate funding" button calls when a pull quote is waiting.
- A direct sandbox fund of the wallet itself (`POST /sandbox/internal-accounts/{id}/fund`) only
mints **book** balance with no on-chain USDB, which is why the balance hero shows available with
the account total beneath — the two diverge in sandbox. The instructions-screen stand-in funds the
platform's USD account and on-ramps from it instead.
- Any fee Grid's on-ramp charges comes straight out of the real quote (observed anywhere from $0 to
~$0.20 on a $5–$20 add across sandbox runs), so the balance credit can land at or slightly under
the amount you added — that's real fee behavior, not a bug.
67 changes: 67 additions & 0 deletions components/grid-wallet-prod/next.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import * as sass from 'sass';
import path from 'path';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

/** @type {import('next').NextConfig} */
const nextConfig = {
// Self-contained server bundle (.next/standalone) — what the Docker runtime
// stage copies, so the image carries no node_modules of its own.
output: 'standalone',
transpilePackages: ['@lightsparkdev/origin'],
typescript: {
// Origin is source-linked without its own node_modules,
// so its transitive type imports can't resolve from ../origin
ignoreBuildErrors: true,
},
sassOptions: {
implementation: sass,
importers: [new sass.NodePackageImporter(__dirname)],
includePaths: [
path.resolve(__dirname, 'node_modules'),
],
},
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Content-Security-Policy',
value: "frame-ancestors 'self' https://docs.lightspark.com https://*.lightspark.com https://*.mintlify.app https://*.mintlify.dev http://localhost:*",
},
],
},
];
},
webpack: (config) => {
// Ensure dependencies imported by the local Origin package resolve from
// this project's node_modules. The relative 'node_modules' entry comes
// FIRST so webpack's default nested walk-up still wins (e.g. @turnkey/crypto's
// own node_modules/@noble/* pin, which differs from this project's top-level
// @noble/* version and exports different subpaths) — the absolute path is
// only a fallback for imports with no node_modules to walk up from.
config.resolve.modules = [
'node_modules',
path.resolve(__dirname, 'node_modules'),
];

// Origin only exports its main barrel — narrow imports for tree-shaken components.
config.resolve.alias = {
...config.resolve.alias,
'@lightsparkdev/origin/checkbox': path.resolve(
__dirname,
'node_modules/@lightsparkdev/origin/src/components/Checkbox/index.ts',
),
'@lightsparkdev/origin/badge': path.resolve(
__dirname,
'node_modules/@lightsparkdev/origin/src/components/Badge/index.ts',
),
};

return config;
},
};

export default nextConfig;
Loading
Loading