From 35b86548948a9e2cb4fe92279797af279d8e9db2 Mon Sep 17 00:00:00 2001 From: Peng Ying Date: Mon, 27 Apr 2026 11:39:39 -0700 Subject: [PATCH 1/4] feat(sample): scaffold embedded wallet flow with sidebar nav Restructure the frontend sample to host multiple flows. Extracts the existing payout wizard into PayoutFlow, adds a left Sidebar to switch between flows, and scaffolds an EmbeddedWalletFlow with eight steps (create customer, find embedded wallet, register passkey, sandbox fund, add external account, withdrawal quote, authenticate & sign, execute). The passkey signing step (P-256 keypair / HPKE decrypt / ECDSA sign) is left as a documented TODO. Backend Kotlin routes for the new endpoints (/api/internal-accounts, /api/auth/credentials*, /api/sandbox/internal-accounts/{id}/fund, /api/quotes/{id}/execute) are not yet implemented. Also docks the WebhookStream to the bottom as a collapsible panel so it sits below all flows and frees the main column for step UI. Co-Authored-By: Claude Opus 4.7 (1M context) --- samples/frontend/src/App.tsx | 116 +++----------- samples/frontend/src/components/Sidebar.tsx | 55 +++++++ .../frontend/src/components/WebhookStream.tsx | 91 +++++++---- .../frontend/src/flows/EmbeddedWalletFlow.tsx | 144 ++++++++++++++++++ samples/frontend/src/flows/PayoutFlow.tsx | 95 ++++++++++++ samples/frontend/src/lib/api.ts | 9 ++ .../embeddedWallet/AuthenticateAndSign.tsx | 82 ++++++++++ .../embeddedWallet/CreateWithdrawalQuote.tsx | 86 +++++++++++ .../embeddedWallet/ExecuteSignedQuote.tsx | 57 +++++++ .../embeddedWallet/FindEmbeddedWallet.tsx | 53 +++++++ .../embeddedWallet/FundEmbeddedWallet.tsx | 60 ++++++++ .../steps/embeddedWallet/RegisterPasskey.tsx | 116 ++++++++++++++ 12 files changed, 840 insertions(+), 124 deletions(-) create mode 100644 samples/frontend/src/components/Sidebar.tsx create mode 100644 samples/frontend/src/flows/EmbeddedWalletFlow.tsx create mode 100644 samples/frontend/src/flows/PayoutFlow.tsx create mode 100644 samples/frontend/src/steps/embeddedWallet/AuthenticateAndSign.tsx create mode 100644 samples/frontend/src/steps/embeddedWallet/CreateWithdrawalQuote.tsx create mode 100644 samples/frontend/src/steps/embeddedWallet/ExecuteSignedQuote.tsx create mode 100644 samples/frontend/src/steps/embeddedWallet/FindEmbeddedWallet.tsx create mode 100644 samples/frontend/src/steps/embeddedWallet/FundEmbeddedWallet.tsx create mode 100644 samples/frontend/src/steps/embeddedWallet/RegisterPasskey.tsx diff --git a/samples/frontend/src/App.tsx b/samples/frontend/src/App.tsx index aa3d707e..89b5c687 100644 --- a/samples/frontend/src/App.tsx +++ b/samples/frontend/src/App.tsx @@ -1,107 +1,39 @@ import { useState } from 'react' -import StepWizard from './components/StepWizard' +import Sidebar, { FlowKey } from './components/Sidebar' import WebhookStream from './components/WebhookStream' -import CreateCustomer from './steps/CreateCustomer' -import CreateExternalAccount from './steps/CreateExternalAccount' -import CreateQuote from './steps/CreateQuote' -import SandboxFund from './steps/SandboxFund' +import PayoutFlow from './flows/PayoutFlow' +import EmbeddedWalletFlow from './flows/EmbeddedWalletFlow' -export default function App() { - const [activeStep, setActiveStep] = useState(0) - const [customerId, setCustomerId] = useState(null) - const [externalAccountId, setExternalAccountId] = useState(null) - const [quoteId, setQuoteId] = useState(null) - const [selectedCountry, setSelectedCountry] = useState('MX') - - const advance = () => setActiveStep((s) => s + 1) - - const restartFromExternalAccount = () => { - setExternalAccountId(null) - setQuoteId(null) - setActiveStep(1) - } +const FLOW_META: Record = { + payout: { + title: 'Payout to Bank Account', + subtitle: 'Send a real time payment funded with USDC', + }, + 'embedded-wallet': { + title: 'Embedded Wallets', + subtitle: 'Create a wallet and move funds on behalf of a user', + }, +} - const steps = [ - { - title: '1. Create Customer', - summary: customerId ? `ID: ${customerId}` : null, - content: ( - { - setCustomerId(data.id as string) - advance() - }} - /> - ), - }, - { - title: '2. Create External Account', - summary: externalAccountId ? `ID: ${externalAccountId}` : null, - content: ( - { - setExternalAccountId(data.id as string) - advance() - }} - /> - ), - }, - { - title: '3. Create Quote', - summary: quoteId ? `ID: ${quoteId}` : null, - content: ( - { - setQuoteId((data.quoteId ?? data.id) as string) - advance() - }} - /> - ), - }, - { - title: '4. Simulate Funding (Sandbox Only)', - summary: activeStep > 3 ? 'Funded' : null, - content: ( - advance()} - /> - ), - }, - ] +export default function App() { + const [activeFlow, setActiveFlow] = useState('payout') + const meta = FLOW_META[activeFlow] return ( -
+

Grid API Sample

-

Send a real time payment funded with USDC

+

{meta.subtitle}

-
- - {activeStep >= 1 && ( - - )} + +
+

{meta.title}

+ {activeFlow === 'payout' && } + {activeFlow === 'embedded-wallet' && }
-
+
) } diff --git a/samples/frontend/src/components/Sidebar.tsx b/samples/frontend/src/components/Sidebar.tsx new file mode 100644 index 00000000..6e62a94c --- /dev/null +++ b/samples/frontend/src/components/Sidebar.tsx @@ -0,0 +1,55 @@ +export type FlowKey = 'payout' | 'embedded-wallet' + +interface FlowEntry { + key: FlowKey + label: string + description: string +} + +const FLOWS: FlowEntry[] = [ + { + key: 'payout', + label: 'Payout to Bank Account', + description: 'Send a real-time payment funded with USDC', + }, + { + key: 'embedded-wallet', + label: 'Embedded Wallets', + description: 'Create a wallet and move funds on behalf of a user', + }, +] + +interface SidebarProps { + activeFlow: FlowKey + onSelect: (flow: FlowKey) => void +} + +export default function Sidebar({ activeFlow, onSelect }: SidebarProps) { + return ( + + ) +} diff --git a/samples/frontend/src/components/WebhookStream.tsx b/samples/frontend/src/components/WebhookStream.tsx index 2da65c8e..53d1ed73 100644 --- a/samples/frontend/src/components/WebhookStream.tsx +++ b/samples/frontend/src/components/WebhookStream.tsx @@ -10,7 +10,11 @@ export default function WebhookStream() { const [events, setEvents] = useState([]) const [connected, setConnected] = useState(false) const [expandedIndex, setExpandedIndex] = useState(null) + const [open, setOpen] = useState(false) + const [unread, setUnread] = useState(0) const eventSourceRef = useRef(null) + const openRef = useRef(open) + openRef.current = open useEffect(() => { const connect = () => { @@ -34,6 +38,7 @@ export default function WebhookStream() { raw: event.data }, ...prev]) } + if (!openRef.current) setUnread((n) => n + 1) } es.onerror = () => { setConnected(false) @@ -46,43 +51,65 @@ export default function WebhookStream() { return () => eventSourceRef.current?.close() }, []) + const toggle = () => { + setOpen((prev) => { + if (!prev) setUnread(0) + return !prev + }) + } + return ( -
-
-

Webhooks

+
+
-
- {events.length === 0 && ( -

No webhook events received yet.

+ {unread > 0 && !open && ( + + {unread} new + )} - {events.map((evt, i) => ( -
-
-
- - {evt.type} - - - {new Date(evt.timestamp).toLocaleTimeString()} - + + {events.length} event{events.length === 1 ? '' : 's'} + + {open ? '▼' : '▲'} + + {open && ( +
+ {events.length === 0 ? ( +

No webhook events received yet.

+ ) : ( + events.map((evt, i) => ( +
+
+
+ + {evt.type} + + + {new Date(evt.timestamp).toLocaleTimeString()} + +
+ +
+ {expandedIndex === i && ( +
+                    {evt.raw}
+                  
+ )}
- -
- {expandedIndex === i && ( -
-                {evt.raw}
-              
- )} -
- ))} -
+ )) + )} +
+ )}
) } diff --git a/samples/frontend/src/flows/EmbeddedWalletFlow.tsx b/samples/frontend/src/flows/EmbeddedWalletFlow.tsx new file mode 100644 index 00000000..d3fe8cfd --- /dev/null +++ b/samples/frontend/src/flows/EmbeddedWalletFlow.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react' +import StepWizard from '../components/StepWizard' +import CreateCustomer from '../steps/CreateCustomer' +import CreateExternalAccount from '../steps/CreateExternalAccount' +import FindEmbeddedWallet from '../steps/embeddedWallet/FindEmbeddedWallet' +import RegisterPasskey from '../steps/embeddedWallet/RegisterPasskey' +import FundEmbeddedWallet from '../steps/embeddedWallet/FundEmbeddedWallet' +import CreateWithdrawalQuote from '../steps/embeddedWallet/CreateWithdrawalQuote' +import AuthenticateAndSign from '../steps/embeddedWallet/AuthenticateAndSign' +import ExecuteSignedQuote from '../steps/embeddedWallet/ExecuteSignedQuote' + +export default function EmbeddedWalletFlow() { + const [activeStep, setActiveStep] = useState(0) + const [customerId, setCustomerId] = useState(null) + const [walletAccountId, setWalletAccountId] = useState(null) + const [authMethodId, setAuthMethodId] = useState(null) + const [externalAccountId, setExternalAccountId] = useState(null) + const [quoteId, setQuoteId] = useState(null) + const [payloadToSign, setPayloadToSign] = useState(null) + const [signature, setSignature] = useState(null) + const [selectedCountry, setSelectedCountry] = useState('MX') + + const advance = () => setActiveStep((s) => s + 1) + + const steps = [ + { + title: '1. Create Customer', + summary: customerId ? `ID: ${customerId}` : null, + content: ( + { + setCustomerId(data.id as string) + advance() + }} + /> + ), + }, + { + title: '2. Find Embedded Wallet', + summary: walletAccountId ? `ID: ${walletAccountId}` : null, + content: ( + { + const accounts = (data.accounts ?? data.data ?? []) as Array> + const wallet = accounts[0] + setWalletAccountId((wallet?.id ?? null) as string | null) + advance() + }} + /> + ), + }, + { + title: '3. Register Passkey', + summary: authMethodId ? `Auth: ${authMethodId}` : null, + content: ( + { + setAuthMethodId(data.authMethodId) + advance() + }} + /> + ), + }, + { + title: '4. Fund the Wallet (Sandbox)', + summary: activeStep > 3 ? 'Funded' : null, + content: ( + advance()} + /> + ), + }, + { + title: '5. Add External Bank Account', + summary: externalAccountId ? `ID: ${externalAccountId}` : null, + content: ( + { + setExternalAccountId(data.id as string) + advance() + }} + /> + ), + }, + { + title: '6. Create Withdrawal Quote', + summary: quoteId ? `ID: ${quoteId}` : null, + content: ( + { + setQuoteId(quoteId) + setPayloadToSign(payloadToSign) + advance() + }} + /> + ), + }, + { + title: '7. Authenticate & Sign', + summary: signature ? 'Signed' : null, + content: ( + { + setSignature(data.signature) + advance() + }} + /> + ), + }, + { + title: '8. Execute Withdrawal', + summary: activeStep > 7 ? 'Submitted' : null, + content: ( + advance()} + /> + ), + }, + ] + + return +} diff --git a/samples/frontend/src/flows/PayoutFlow.tsx b/samples/frontend/src/flows/PayoutFlow.tsx new file mode 100644 index 00000000..de80ab27 --- /dev/null +++ b/samples/frontend/src/flows/PayoutFlow.tsx @@ -0,0 +1,95 @@ +import { useState } from 'react' +import StepWizard from '../components/StepWizard' +import CreateCustomer from '../steps/CreateCustomer' +import CreateExternalAccount from '../steps/CreateExternalAccount' +import CreateQuote from '../steps/CreateQuote' +import SandboxFund from '../steps/SandboxFund' + +export default function PayoutFlow() { + const [activeStep, setActiveStep] = useState(0) + const [customerId, setCustomerId] = useState(null) + const [externalAccountId, setExternalAccountId] = useState(null) + const [quoteId, setQuoteId] = useState(null) + const [selectedCountry, setSelectedCountry] = useState('MX') + + const advance = () => setActiveStep((s) => s + 1) + + const restartFromExternalAccount = () => { + setExternalAccountId(null) + setQuoteId(null) + setActiveStep(1) + } + + const steps = [ + { + title: '1. Create Customer', + summary: customerId ? `ID: ${customerId}` : null, + content: ( + { + setCustomerId(data.id as string) + advance() + }} + /> + ), + }, + { + title: '2. Create External Account', + summary: externalAccountId ? `ID: ${externalAccountId}` : null, + content: ( + { + setExternalAccountId(data.id as string) + advance() + }} + /> + ), + }, + { + title: '3. Create Quote', + summary: quoteId ? `ID: ${quoteId}` : null, + content: ( + { + setQuoteId((data.quoteId ?? data.id) as string) + advance() + }} + /> + ), + }, + { + title: '4. Simulate Funding (Sandbox Only)', + summary: activeStep > 3 ? 'Funded' : null, + content: ( + advance()} + /> + ), + }, + ] + + return ( + <> + + {activeStep >= 1 && ( + + )} + + ) +} diff --git a/samples/frontend/src/lib/api.ts b/samples/frontend/src/lib/api.ts index 0d0d2fef..34987c2d 100644 --- a/samples/frontend/src/lib/api.ts +++ b/samples/frontend/src/lib/api.ts @@ -4,6 +4,15 @@ export async function apiPost(path: string, body?: unknown): Promis headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, }) + return parseResponse(res) +} + +export async function apiGet(path: string): Promise { + const res = await fetch(path) + return parseResponse(res) +} + +async function parseResponse(res: Response): Promise { const text = await res.text() let data: T try { diff --git a/samples/frontend/src/steps/embeddedWallet/AuthenticateAndSign.tsx b/samples/frontend/src/steps/embeddedWallet/AuthenticateAndSign.tsx new file mode 100644 index 00000000..6f43dc10 --- /dev/null +++ b/samples/frontend/src/steps/embeddedWallet/AuthenticateAndSign.tsx @@ -0,0 +1,82 @@ +import { useState } from 'react' +import ResponsePanel from '../../components/ResponsePanel' +import { apiPost } from '../../lib/api' + +interface Props { + authMethodId: string | null + payloadToSign: string | null + onComplete: (response: { signature: string }) => void + disabled: boolean +} + +// Backend routes TODO: +// POST /api/auth/credentials/{authMethodId}/challenge → Grid: same path +// POST /api/auth/credentials/{authMethodId}/verify → Grid: same path +// Client-side crypto required (NOT yet implemented in this scaffold): +// 1. Generate ephemeral P-256 key pair +// 2. navigator.credentials.get() with the challenge +// 3. Send WebAuthn assertion + client public key to /verify +// 4. HPKE-decrypt the returned session signing key with the client private key +// 5. ECDSA-sign the quote's payloadToSign bytes (verbatim) with the session key +// See https://grid.lightspark.com/payouts-and-b2b/embedded-wallets/client-keys +export default function AuthenticateAndSign({ + authMethodId, + payloadToSign, + onComplete, + disabled, +}: Props) { + void onComplete // wired up once passkey signing is implemented below + const [response, setResponse] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const submit = async () => { + if (!authMethodId || !payloadToSign) return + setLoading(true) + setError(null) + setResponse(null) + try { + // 1. Request a fresh challenge. + const challenge = await apiPost<{ challenge: string; requestId: string }>( + `/api/auth/credentials/${encodeURIComponent(authMethodId)}/challenge`, + {}, + ) + + // 2. Run navigator.credentials.get(), build assertion + client public key, + // POST to /verify, HPKE-decrypt session key, ECDSA-sign payloadToSign. + // Stub for now — see client-keys docs for the full implementation. + void challenge + throw new Error( + 'Passkey signing not yet implemented in this sample. See client-keys docs.', + ) + + // const data = await apiPost<{ signature: string }>(...) + // setResponse(JSON.stringify(data, null, 2)) + // onComplete(data) + } catch (e) { + setError((e as Error).message) + } finally { + setLoading(false) + } + } + + return ( +
+

+ Authenticate with the registered passkey and sign the withdrawal payload. +

+

+ This step requires P-256 keypair generation, HPKE decryption, and ECDSA signing — left as + a TODO in this scaffold. +

+ + +
+ ) +} diff --git a/samples/frontend/src/steps/embeddedWallet/CreateWithdrawalQuote.tsx b/samples/frontend/src/steps/embeddedWallet/CreateWithdrawalQuote.tsx new file mode 100644 index 00000000..4043e17f --- /dev/null +++ b/samples/frontend/src/steps/embeddedWallet/CreateWithdrawalQuote.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from 'react' +import JsonEditor from '../../components/JsonEditor' +import ResponsePanel from '../../components/ResponsePanel' +import { apiPost } from '../../lib/api' + +interface Props { + customerId: string | null + walletAccountId: string | null + externalAccountId: string | null + onComplete: (response: { quoteId: string; payloadToSign: string }) => void + disabled: boolean +} + +// Backend route: POST /api/quotes (existing) +// Source for embedded wallet withdrawals is the wallet's internal account. +export default function CreateWithdrawalQuote({ + customerId, + walletAccountId, + externalAccountId, + onComplete, + disabled, +}: Props) { + const [body, setBody] = useState('') + const [response, setResponse] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + useEffect(() => { + setBody(JSON.stringify({ + source: { + sourceType: 'INTERNAL_ACCOUNT', + accountId: walletAccountId ?? '', + currency: 'USDB', + }, + destination: { + destinationType: 'EXTERNAL_ACCOUNT', + accountId: externalAccountId ?? '', + }, + lockedSide: 'SOURCE', + sourceAmount: 25_00, + customerId: customerId ?? '', + }, null, 2)) + }, [customerId, walletAccountId, externalAccountId]) + + const submit = async () => { + setLoading(true) + setError(null) + setResponse(null) + try { + const data = await apiPost<{ + id?: string + quoteId?: string + paymentInstructions?: { payloadToSign?: string } + payloadToSign?: string + }>('/api/quotes', JSON.parse(body)) + setResponse(JSON.stringify(data, null, 2)) + onComplete({ + quoteId: (data.quoteId ?? data.id) as string, + payloadToSign: + data.paymentInstructions?.payloadToSign ?? data.payloadToSign ?? '', + }) + } catch (e) { + setError((e as Error).message) + } finally { + setLoading(false) + } + } + + return ( +
+

+ Generate a withdrawal quote. The response includes a payloadToSign the + customer must sign with their passkey before execution. +

+ + + +
+ ) +} diff --git a/samples/frontend/src/steps/embeddedWallet/ExecuteSignedQuote.tsx b/samples/frontend/src/steps/embeddedWallet/ExecuteSignedQuote.tsx new file mode 100644 index 00000000..0fdd59f0 --- /dev/null +++ b/samples/frontend/src/steps/embeddedWallet/ExecuteSignedQuote.tsx @@ -0,0 +1,57 @@ +import { useState } from 'react' +import ResponsePanel from '../../components/ResponsePanel' +import { apiPost } from '../../lib/api' + +interface Props { + quoteId: string | null + signature: string | null + onComplete: (response: Record) => void + disabled: boolean +} + +// Backend route TODO: POST /api/quotes/{quoteId}/execute +// Backend forwards to Grid with the `Grid-Wallet-Signature` header set to the base64 signature. +export default function ExecuteSignedQuote({ + quoteId, + signature, + onComplete, + disabled, +}: Props) { + const [response, setResponse] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const submit = async () => { + if (!quoteId || !signature) return + setLoading(true) + setError(null) + setResponse(null) + try { + const path = `/api/quotes/${encodeURIComponent(quoteId)}/execute` + const data = await apiPost>(path, { signature }) + setResponse(JSON.stringify(data, null, 2)) + onComplete(data) + } catch (e) { + setError((e as Error).message) + } finally { + setLoading(false) + } + } + + return ( +
+

+ Submit the signed withdrawal to Grid. The backend forwards the signature in the{' '} + Grid-Wallet-Signature header. +

+ + +
+ ) +} diff --git a/samples/frontend/src/steps/embeddedWallet/FindEmbeddedWallet.tsx b/samples/frontend/src/steps/embeddedWallet/FindEmbeddedWallet.tsx new file mode 100644 index 00000000..24f8547c --- /dev/null +++ b/samples/frontend/src/steps/embeddedWallet/FindEmbeddedWallet.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react' +import ResponsePanel from '../../components/ResponsePanel' +import { apiGet } from '../../lib/api' + +interface Props { + customerId: string | null + onComplete: (response: Record) => void + disabled: boolean +} + +// Backend route TODO: GET /api/internal-accounts?customerId=...&type=EMBEDDED_WALLET +// → Grid: GET /internal-accounts (filter to EMBEDDED_WALLET) +export default function FindEmbeddedWallet({ customerId, onComplete, disabled }: Props) { + const [response, setResponse] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const submit = async () => { + if (!customerId) return + setLoading(true) + setError(null) + setResponse(null) + try { + const url = `/api/internal-accounts?customerId=${encodeURIComponent(customerId)}&type=EMBEDDED_WALLET` + const data = await apiGet>(url) + setResponse(JSON.stringify(data, null, 2)) + onComplete(data) + } catch (e) { + setError((e as Error).message) + } finally { + setLoading(false) + } + } + + return ( +
+

+ Locate the embedded wallet auto-provisioned for this customer. +

+
+        GET /internal-accounts?customerId={customerId ?? ''}&type=EMBEDDED_WALLET
+      
+ + +
+ ) +} diff --git a/samples/frontend/src/steps/embeddedWallet/FundEmbeddedWallet.tsx b/samples/frontend/src/steps/embeddedWallet/FundEmbeddedWallet.tsx new file mode 100644 index 00000000..bdf7a594 --- /dev/null +++ b/samples/frontend/src/steps/embeddedWallet/FundEmbeddedWallet.tsx @@ -0,0 +1,60 @@ +import { useEffect, useState } from 'react' +import JsonEditor from '../../components/JsonEditor' +import ResponsePanel from '../../components/ResponsePanel' +import { apiPost } from '../../lib/api' + +interface Props { + walletAccountId: string | null + onComplete: (response: Record) => void + disabled: boolean +} + +// Backend route TODO: POST /api/sandbox/internal-accounts/{id}/fund +// → Grid: POST /sandbox/internal-accounts/{id}/fund +export default function FundEmbeddedWallet({ walletAccountId, onComplete, disabled }: Props) { + const [body, setBody] = useState('') + const [response, setResponse] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + useEffect(() => { + setBody(JSON.stringify({ + currencyCode: 'USDB', + currencyAmount: 100_00, // 100.00 USDB in minor units + }, null, 2)) + }, [walletAccountId]) + + const submit = async () => { + if (!walletAccountId) return + setLoading(true) + setError(null) + setResponse(null) + try { + const path = `/api/sandbox/internal-accounts/${encodeURIComponent(walletAccountId)}/fund` + const data = await apiPost>(path, JSON.parse(body)) + setResponse(JSON.stringify(data, null, 2)) + onComplete(data) + } catch (e) { + setError((e as Error).message) + } finally { + setLoading(false) + } + } + + return ( +
+

+ Sandbox-only: deposit funds into the embedded wallet. +

+ + + +
+ ) +} diff --git a/samples/frontend/src/steps/embeddedWallet/RegisterPasskey.tsx b/samples/frontend/src/steps/embeddedWallet/RegisterPasskey.tsx new file mode 100644 index 00000000..48d36636 --- /dev/null +++ b/samples/frontend/src/steps/embeddedWallet/RegisterPasskey.tsx @@ -0,0 +1,116 @@ +import { useState } from 'react' +import ResponsePanel from '../../components/ResponsePanel' +import { apiPost } from '../../lib/api' + +interface Props { + walletAccountId: string | null + customerId: string | null + onComplete: (response: { authMethodId: string }) => void + disabled: boolean +} + +// Backend route TODO: POST /api/auth/credentials +// Two-phase: client gets a registration challenge, runs navigator.credentials.create(), +// then posts the WebAuthn attestation to the backend, which forwards to Grid. +// See https://grid.lightspark.com/payouts-and-b2b/embedded-wallets/authentication +export default function RegisterPasskey({ + walletAccountId, + customerId, + onComplete, + disabled, +}: Props) { + const [response, setResponse] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const submit = async () => { + if (!walletAccountId || !customerId) return + setLoading(true) + setError(null) + setResponse(null) + try { + // Step 1: ask backend for a registration challenge from Grid. + const challenge = await apiPost<{ + challenge: string + rp: PublicKeyCredentialRpEntity + user: { id: string; name: string; displayName: string } + }>('/api/auth/credentials/registration-challenge', { + accountId: walletAccountId, + customerId, + }) + + // Step 2: invoke the platform authenticator. + const credential = (await navigator.credentials.create({ + publicKey: { + challenge: base64urlToBytes(challenge.challenge), + rp: challenge.rp, + user: { + ...challenge.user, + id: base64urlToBytes(challenge.user.id), + }, + pubKeyCredParams: [ + { type: 'public-key', alg: -7 }, // ES256 + { type: 'public-key', alg: -257 }, // RS256 + ], + authenticatorSelection: { userVerification: 'required' }, + timeout: 60_000, + }, + })) as PublicKeyCredential | null + + if (!credential) throw new Error('No credential returned from authenticator') + + const att = credential.response as AuthenticatorAttestationResponse + + // Step 3: send the attestation to the backend → Grid. + const data = await apiPost<{ authMethodId: string }>('/api/auth/credentials', { + accountId: walletAccountId, + credentialId: credential.id, + clientDataJSON: bytesToBase64url(new Uint8Array(att.clientDataJSON)), + attestationObject: bytesToBase64url(new Uint8Array(att.attestationObject)), + }) + + setResponse(JSON.stringify(data, null, 2)) + onComplete(data) + } catch (e) { + setError((e as Error).message) + } finally { + setLoading(false) + } + } + + return ( +
+

+ Register a passkey on this device to authorize future withdrawals. +

+

+ Requires a backend that proxies POST /auth/credentials and a registration + challenge endpoint. WebAuthn requires HTTPS or localhost. +

+ + +
+ ) +} + +function base64urlToBytes(s: string): ArrayBuffer { + const pad = '='.repeat((4 - (s.length % 4)) % 4) + const b64 = (s + pad).replace(/-/g, '+').replace(/_/g, '/') + const bin = atob(b64) + const buf = new ArrayBuffer(bin.length) + const view = new Uint8Array(buf) + for (let i = 0; i < bin.length; i++) view[i] = bin.charCodeAt(i) + return buf +} + +function bytesToBase64url(bytes: Uint8Array): string { + let bin = '' + for (const b of bytes) bin += String.fromCharCode(b) + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} From 2b94022487e9cf25c841d17ac767f0bf96ffa7b4 Mon Sep 17 00:00:00 2001 From: Peng Ying Date: Mon, 27 Apr 2026 14:16:03 -0700 Subject: [PATCH 2/4] feat(sample): wire embedded wallet backend on Kotlin SDK 1.7.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the Kotlin sample to lightspark-grid-kotlin 1.7.0 and adds the routes the frontend scaffold needs: - GET /api/internal-accounts → customers().listInternalAccounts(...) (used to find the auto-provisioned Grid Global Account) - POST /api/auth/credentials/registration-challenge → backend-only; mints a 32-byte WebAuthn challenge with rp + user blocks for navigator.credentials.create() - POST /api/auth/credentials → auth().credentials().create(...) with passkey attestation - POST /api/auth/credentials/{id}/challenge → resendChallenge(...) - POST /api/auth/credentials/{id}/verify → credentials().verify(...) - POST /api/sandbox/internal-accounts/{id}/fund → sandbox per-account funding via sandbox().internalAccounts().fund(...) - /api/quotes/{id}/execute now reads Grid-Wallet-Signature and Idempotency-Key headers and forwards them via QuoteExecuteParams Frontend RegisterPasskey now sends the rpId from window.location and posts the nested {challenge, attestation: {credentialId, clientDataJson, attestationObject, transports}} shape that matches Grid's create endpoint. Live-tested against sandbox: customer create, listInternalAccounts, challenge mint, sandbox fund, and resendChallenge all reach Grid correctly. Passkey registration is wired end-to-end including the WebAuthn ceremony, but Grid sandbox currently returns 501 NOT_IMPLEMENTED for PASSKEY creation — works once that lands. API contract preserved verbatim: EMBEDDED_WALLET enum, Grid-Wallet-Signature header, EmbeddedWallet:/InternalAccount: id prefixes, and SDK type names are unchanged. User-facing prose uses "Grid Global Account". Co-Authored-By: Claude Opus 4.7 (1M context) --- .../steps/embeddedWallet/RegisterPasskey.tsx | 46 ++-- samples/kotlin/build.gradle.kts | 2 +- .../kotlin/com/grid/sample/Application.kt | 2 + .../com/grid/sample/routes/AuthCredentials.kt | 222 ++++++++++++++++++ .../grid/sample/routes/InternalAccounts.kt | 57 +++++ .../kotlin/com/grid/sample/routes/Quotes.kt | 18 +- .../kotlin/com/grid/sample/routes/Sandbox.kt | 34 +++ 7 files changed, 358 insertions(+), 23 deletions(-) create mode 100644 samples/kotlin/src/main/kotlin/com/grid/sample/routes/AuthCredentials.kt create mode 100644 samples/kotlin/src/main/kotlin/com/grid/sample/routes/InternalAccounts.kt diff --git a/samples/frontend/src/steps/embeddedWallet/RegisterPasskey.tsx b/samples/frontend/src/steps/embeddedWallet/RegisterPasskey.tsx index 48d36636..a364ed38 100644 --- a/samples/frontend/src/steps/embeddedWallet/RegisterPasskey.tsx +++ b/samples/frontend/src/steps/embeddedWallet/RegisterPasskey.tsx @@ -9,10 +9,9 @@ interface Props { disabled: boolean } -// Backend route TODO: POST /api/auth/credentials -// Two-phase: client gets a registration challenge, runs navigator.credentials.create(), -// then posts the WebAuthn attestation to the backend, which forwards to Grid. -// See https://grid.lightspark.com/payouts-and-b2b/embedded-wallets/authentication +// Two-phase: backend mints a WebAuthn challenge, client runs +// navigator.credentials.create(), client posts the attestation back, backend +// forwards to Grid as POST /auth/credentials. export default function RegisterPasskey({ walletAccountId, customerId, @@ -29,24 +28,23 @@ export default function RegisterPasskey({ setError(null) setResponse(null) try { - // Step 1: ask backend for a registration challenge from Grid. - const challenge = await apiPost<{ + const reg = await apiPost<{ challenge: string rp: PublicKeyCredentialRpEntity user: { id: string; name: string; displayName: string } }>('/api/auth/credentials/registration-challenge', { accountId: walletAccountId, customerId, + rpId: window.location.hostname, }) - // Step 2: invoke the platform authenticator. const credential = (await navigator.credentials.create({ publicKey: { - challenge: base64urlToBytes(challenge.challenge), - rp: challenge.rp, + challenge: base64urlToBytes(reg.challenge), + rp: reg.rp, user: { - ...challenge.user, - id: base64urlToBytes(challenge.user.id), + ...reg.user, + id: base64urlToBytes(reg.user.id), }, pubKeyCredParams: [ { type: 'public-key', alg: -7 }, // ES256 @@ -60,17 +58,26 @@ export default function RegisterPasskey({ if (!credential) throw new Error('No credential returned from authenticator') const att = credential.response as AuthenticatorAttestationResponse + const transports = + (att as AuthenticatorAttestationResponse & { getTransports?: () => string[] }) + .getTransports?.() ?? [] - // Step 3: send the attestation to the backend → Grid. - const data = await apiPost<{ authMethodId: string }>('/api/auth/credentials', { + const data = await apiPost>('/api/auth/credentials', { accountId: walletAccountId, - credentialId: credential.id, - clientDataJSON: bytesToBase64url(new Uint8Array(att.clientDataJSON)), - attestationObject: bytesToBase64url(new Uint8Array(att.attestationObject)), + challenge: reg.challenge, + nickname: 'Grid Global Account passkey', + attestation: { + credentialId: credential.id, + clientDataJson: bytesToBase64url(new Uint8Array(att.clientDataJSON)), + attestationObject: bytesToBase64url(new Uint8Array(att.attestationObject)), + transports, + }, }) setResponse(JSON.stringify(data, null, 2)) - onComplete(data) + const authMethodId = (data.id ?? data.authMethodId) as string | undefined + if (!authMethodId) throw new Error('No auth method id in response') + onComplete({ authMethodId }) } catch (e) { setError((e as Error).message) } finally { @@ -81,11 +88,10 @@ export default function RegisterPasskey({ return (

- Register a passkey on this device to authorize future withdrawals. + Register a passkey on this device to authorize future Grid Global Account actions.

- Requires a backend that proxies POST /auth/credentials and a registration - challenge endpoint. WebAuthn requires HTTPS or localhost. + Your browser will prompt for a biometric. WebAuthn requires HTTPS or localhost.