diff --git a/samples/frontend/package-lock.json b/samples/frontend/package-lock.json index c89d0a4d..3efdf66b 100644 --- a/samples/frontend/package-lock.json +++ b/samples/frontend/package-lock.json @@ -8,6 +8,9 @@ "name": "grid-sample-frontend", "version": "0.0.0", "dependencies": { + "@hpke/core": "^1.9.0", + "@noble/curves": "^2.2.0", + "bs58check": "^4.0.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -746,6 +749,27 @@ "node": ">=18" } }, + "node_modules/@hpke/common": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@hpke/common/-/common-1.10.1.tgz", + "integrity": "sha512-moJwhmtLtuxiUzzNp1jpfBfx8yefKoO9D/RCR9dmwrnc7qjJqId1rEtQz+lSlU5cabX8daToMSx/7HayXOiaFw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@hpke/core": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@hpke/core/-/core-1.9.0.tgz", + "integrity": "sha512-pFxWl1nNJeQCSUFs7+GAblHvXBCjn9EPN65vdKlYQil2aURaRxfGMO6vBKGqm1YHTKwiAxJQNEI70PbSowMP9Q==", + "license": "MIT", + "dependencies": { + "@hpke/common": "^1.10.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -796,6 +820,45 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@playwright/test": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", @@ -1542,6 +1605,12 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/base-x": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", + "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", @@ -1586,6 +1655,25 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs58": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "license": "MIT", + "dependencies": { + "base-x": "^5.0.0" + } + }, + "node_modules/bs58check": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-4.0.0.tgz", + "integrity": "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bs58": "^6.0.0" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001769", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", diff --git a/samples/frontend/package.json b/samples/frontend/package.json index 548475f0..4a97564a 100644 --- a/samples/frontend/package.json +++ b/samples/frontend/package.json @@ -11,6 +11,9 @@ "test:e2e:headed": "playwright test --headed" }, "dependencies": { + "@hpke/core": "^1.9.0", + "@noble/curves": "^2.2.0", + "bs58check": "^4.0.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/samples/frontend/src/App.tsx b/samples/frontend/src/App.tsx index aa3d707e..1a836c2f 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: 'Global Account', + subtitle: 'Issue a self-custody dollar account and withdraw 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..1c19b4a1 --- /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: 'Global Account', + description: 'Issue a self-custody dollar account and withdraw 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..abb3710a --- /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('US') + + 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..4fdd8b52 100644 --- a/samples/frontend/src/lib/api.ts +++ b/samples/frontend/src/lib/api.ts @@ -1,9 +1,22 @@ -export async function apiPost(path: string, body?: unknown): Promise { +export async function apiPost( + path: string, + body?: unknown, + extraHeaders?: Record, +): Promise { const res = await fetch(path, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...extraHeaders }, 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/CreateExternalAccount.tsx b/samples/frontend/src/steps/CreateExternalAccount.tsx index 05305e1d..d985b3f3 100644 --- a/samples/frontend/src/steps/CreateExternalAccount.tsx +++ b/samples/frontend/src/steps/CreateExternalAccount.tsx @@ -117,6 +117,29 @@ const COUNTRY_CONFIGS: Record void + disabled: boolean +} + +// Toggle between sandbox and production paths. Sandbox accepts magic values +// for the WebAuthn assertion signature ("sandbox-valid-passkey-signature") +// and the wallet signature header ("sandbox-valid-signature"); production +// runs the real WebAuthn assertion + HPKE decrypt + ECDSA sign chain. +// +// Set VITE_SANDBOX_PASSKEY=0 in .env to exercise the production path against +// a sandbox that supports real signatures, or against production credentials. +const SANDBOX_MODE = (import.meta.env.VITE_SANDBOX_PASSKEY ?? '1') !== '0' + +const SANDBOX_PASSKEY_SIGNATURE = 'sandbox-valid-passkey-signature' +const SANDBOX_WALLET_SIGNATURE = 'sandbox-valid-signature' + +// Step 7: Authenticate with the registered passkey and sign payloadToSign. +// +// Production flow: +// 1. Generate ephemeral P-256 client key pair (HPKE recipient key). +// 2. POST /challenge with clientPublicKey β€” Grid bakes it into the +// session-creation payload and returns a Grid-issued WebAuthn challenge. +// 3. navigator.credentials.get(challenge) β†’ WebAuthn assertion. +// 4. POST /verify with the assertion (no clientPublicKey on /verify) β†’ +// Grid returns encryptedSessionSigningKey sealed to clientPublicKey. +// 5. HPKE-decrypt the session signing key with the client private key +// (DHKEM-P256 / HKDF-SHA256 / AES-256-GCM, base58check wire format). +// 6. ECDSA-sign payloadToSign bytes with the session key, DER-encode, +// base64-encode, and pass to step 8 as the Grid-Wallet-Signature header. +// +// Sandbox flow (this is what runs by default): +// - Step 3 still triggers the real OS biometric prompt. +// - Step 4's wire signature is the magic value sandbox-valid-passkey-signature. +// - Step 5 is skipped (the encryptedSessionSigningKey is a stub in sandbox). +// - Step 6 returns the magic value sandbox-valid-signature for step 8. +export default function AuthenticateAndSign({ + authMethodId, + payloadToSign, + onComplete, + disabled, +}: Props) { + 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. Ephemeral keypair. Private key is non-extractable; only the public + // key leaves the device. + const keyPair = (await crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, + false, + ['deriveBits'], + )) as CryptoKeyPair + const rawPublicKey = new Uint8Array( + await crypto.subtle.exportKey('raw', keyPair.publicKey), + ) + const clientPublicKey = bytesToHex(rawPublicKey) + + // 2. Ask Grid for an authentication challenge sealed to this public key. + const challenge = await apiPost<{ challenge: string; requestId: string }>( + `/api/auth/credentials/${encodeURIComponent(authMethodId)}/challenge`, + { clientPublicKey }, + ) + + // 3. WebAuthn assertion against the Grid-issued challenge. The OS shows + // its biometric prompt regardless of whether we send the real + // signature or the sandbox magic value below. + const assertion = (await navigator.credentials.get({ + publicKey: { + challenge: base64urlToBytes(challenge.challenge), + userVerification: 'required', + timeout: 60_000, + }, + })) as PublicKeyCredential | null + if (!assertion) throw new Error('No assertion returned from authenticator') + const ar = assertion.response as AuthenticatorAssertionResponse + + // 4. Verify with Grid. In sandbox, swap the real WebAuthn signature for + // the magic value Grid will accept. + const wireSignature = SANDBOX_MODE + ? SANDBOX_PASSKEY_SIGNATURE + : bytesToBase64url(new Uint8Array(ar.signature)) + + const verify = await apiPost<{ encryptedSessionSigningKey?: string }>( + `/api/auth/credentials/${encodeURIComponent(authMethodId)}/verify`, + { + assertion: { + credentialId: assertion.id, + clientDataJson: bytesToBase64url(new Uint8Array(ar.clientDataJSON)), + authenticatorData: bytesToBase64url(new Uint8Array(ar.authenticatorData)), + signature: wireSignature, + userHandle: ar.userHandle + ? bytesToBase64url(new Uint8Array(ar.userHandle)) + : undefined, + }, + }, + { 'Request-Id': challenge.requestId }, + ) + + // 5 + 6. Decrypt the session signing key and sign payloadToSign. In + // sandbox the encryptedSessionSigningKey is a stub, so we skip + // the crypto and use the magic wallet-signature header value. + let signature: string + if (SANDBOX_MODE) { + signature = SANDBOX_WALLET_SIGNATURE + } else { + const sessionKey = await decryptSessionSigningKey( + keyPair.privateKey, + verify.encryptedSessionSigningKey ?? '', + ) + signature = signPayload(sessionKey, payloadToSign) + } + + setResponse( + JSON.stringify( + { mode: SANDBOX_MODE ? 'sandbox' : 'production', signature, verify }, + null, + 2, + ), + ) + onComplete({ signature }) + } catch (e) { + setError((e as Error).message) + } finally { + setLoading(false) + } + } + + return ( +
+

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

+

+ Generates a P-256 keypair, sends the public key on /challenge, runs the + WebAuthn assertion, and verifies. In sandbox mode ({SANDBOX_MODE + ? 'on' + : 'off'}) the wire signatures use Grid's magic values; turn it off via{' '} + VITE_SANDBOX_PASSKEY=0 to exercise the real HPKE decrypt + ECDSA sign chain. +

+ + +
+ ) +} + +// HPKE-decrypt the encrypted session signing key returned by /verify. +// Wire format (base58check-decoded): 33-byte compressed encapsulated public +// key || ciphertext || 16-byte AES-256-GCM auth tag. +async function decryptSessionSigningKey( + recipientPrivateKey: CryptoKey, + encryptedSessionSigningKey: string, +): Promise { + if (!encryptedSessionSigningKey) throw new Error('No encrypted session signing key returned') + const payload = bs58check.decode(encryptedSessionSigningKey) + if (payload.length < 33 + 16) { + throw new Error(`encryptedSessionSigningKey too short: ${payload.length} bytes`) + } + const enc = payload.slice(0, 33) + const ciphertext = payload.slice(33) + const suite = new CipherSuite({ + kem: new DhkemP256HkdfSha256(), + kdf: new HkdfSha256(), + aead: new Aes256Gcm(), + }) + const recipient = await suite.createRecipientContext({ + recipientKey: recipientPrivateKey, + enc, + }) + const plaintext = await recipient.open(ciphertext) + return new Uint8Array(plaintext) +} + +// Sign payloadToSign bytes verbatim (Grid hashes them on its side as part of +// signature verification). DER-encoded ECDSA signature, base64-encoded. +function signPayload(sessionPrivateKey: Uint8Array, payloadToSign: string): string { + const msg = new TextEncoder().encode(payloadToSign) + const sig = p256.sign(msg, sessionPrivateKey, { format: 'der' }) + return bytesToBase64(sig as Uint8Array) +} + +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(/=+$/, '') +} + +function bytesToBase64(bytes: Uint8Array): string { + let bin = '' + for (const b of bytes) bin += String.fromCharCode(b) + return btoa(bin) +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') +} diff --git a/samples/frontend/src/steps/embeddedWallet/CreateWithdrawalQuote.tsx b/samples/frontend/src/steps/embeddedWallet/CreateWithdrawalQuote.tsx new file mode 100644 index 00000000..ffccb2b5 --- /dev/null +++ b/samples/frontend/src/steps/embeddedWallet/CreateWithdrawalQuote.tsx @@ -0,0 +1,100 @@ +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 +} + +// Creates a quote with the Global Account as the source. The quote response +// carries a payloadToSign β€” step 7 signs it, step 8 executes the quote with +// that signature in the Grid-Wallet-Signature header. +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: 'ACCOUNT', + accountId: walletAccountId ?? '', + customerId: customerId ?? '', + }, + destination: { + destinationType: 'ACCOUNT', + accountId: externalAccountId ?? '', + }, + lockedCurrencySide: 'SENDING', + // USDB has 6 decimals β€” 10_000_000 minor units = 10 USDB. + lockedCurrencyAmount: 10_000_000, + description: 'Withdrawal from Global Account', + }, null, 2)) + }, [customerId, walletAccountId, externalAccountId]) + + const submit = async () => { + setLoading(true) + setError(null) + setResponse(null) + try { + const data = await apiPost<{ + id?: string + quoteId?: string + paymentInstructions?: Array<{ + accountOrWalletInfo?: { accountType?: string; payloadToSign?: string } + payloadToSign?: string + }> + payloadToSign?: string + }>('/api/quotes', JSON.parse(body)) + setResponse(JSON.stringify(data, null, 2)) + // paymentInstructions is an array β€” for an embedded-wallet source, look + // for the instruction whose accountOrWalletInfo carries the payloadToSign. + const signingInstruction = data.paymentInstructions?.find( + (i) => i.accountOrWalletInfo?.payloadToSign || i.payloadToSign, + ) + const payloadToSign = + signingInstruction?.accountOrWalletInfo?.payloadToSign ?? + signingInstruction?.payloadToSign ?? + data.payloadToSign ?? + '' + onComplete({ + quoteId: (data.quoteId ?? data.id) as string, + 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..135e7d67 --- /dev/null +++ b/samples/frontend/src/steps/embeddedWallet/ExecuteSignedQuote.tsx @@ -0,0 +1,60 @@ +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 +} + +// Submits the signed withdrawal. Backend (POST /api/quotes/{quoteId}/execute) +// forwards Grid-Wallet-Signature and Idempotency-Key headers to Grid. +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, undefined, { + 'Grid-Wallet-Signature': signature, + 'Idempotency-Key': crypto.randomUUID(), + }) + 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..c23108cf --- /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 +} + +// Lists the customer's internal accounts and surfaces the auto-provisioned +// Global Account (type: EMBEDDED_WALLET) so subsequent steps can reference it. +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..f8c1ce8d --- /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 +} + +// Sandbox-only: deposits funds straight into the Global Account so the +// withdrawal flow has a balance to draw from. +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_000_000, // 100 USDB (6 decimals) + }, 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..a364ed38 --- /dev/null +++ b/samples/frontend/src/steps/embeddedWallet/RegisterPasskey.tsx @@ -0,0 +1,122 @@ +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 +} + +// 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, + 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 { + 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, + }) + + const credential = (await navigator.credentials.create({ + publicKey: { + challenge: base64urlToBytes(reg.challenge), + rp: reg.rp, + user: { + ...reg.user, + id: base64urlToBytes(reg.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 + const transports = + (att as AuthenticatorAttestationResponse & { getTransports?: () => string[] }) + .getTransports?.() ?? [] + + const data = await apiPost>('/api/auth/credentials', { + accountId: walletAccountId, + 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)) + 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 { + setLoading(false) + } + } + + return ( +
+

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

+

+ Your browser will prompt for a biometric. 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(/=+$/, '') +} diff --git a/samples/kotlin/build.gradle.kts b/samples/kotlin/build.gradle.kts index f5bf960b..56cb83ae 100644 --- a/samples/kotlin/build.gradle.kts +++ b/samples/kotlin/build.gradle.kts @@ -48,7 +48,7 @@ dependencies { implementation("io.ktor:ktor-server-config-yaml:3.1.3") // Grid Kotlin SDK - implementation("com.lightspark.grid:lightspark-grid-kotlin:1.6.0") + implementation("com.lightspark.grid:lightspark-grid-kotlin:1.7.1") // JSON implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.18.2") diff --git a/samples/kotlin/src/main/kotlin/com/grid/sample/Application.kt b/samples/kotlin/src/main/kotlin/com/grid/sample/Application.kt index 927b0f6c..25d64008 100644 --- a/samples/kotlin/src/main/kotlin/com/grid/sample/Application.kt +++ b/samples/kotlin/src/main/kotlin/com/grid/sample/Application.kt @@ -36,6 +36,8 @@ fun Application.module() { routing { customerRoutes() externalAccountRoutes() + internalAccountRoutes() + authCredentialRoutes() quoteRoutes() sandboxRoutes() webhookRoutes() diff --git a/samples/kotlin/src/main/kotlin/com/grid/sample/routes/AuthCredentials.kt b/samples/kotlin/src/main/kotlin/com/grid/sample/routes/AuthCredentials.kt new file mode 100644 index 00000000..82ac4b45 --- /dev/null +++ b/samples/kotlin/src/main/kotlin/com/grid/sample/routes/AuthCredentials.kt @@ -0,0 +1,238 @@ +package com.grid.sample.routes + +import com.lightspark.grid.models.auth.credentials.CredentialCreateParams.AuthCredentialCreateRequest.PasskeyCredentialCreateRequest +import com.lightspark.grid.models.auth.credentials.CredentialResendChallengeParams +import com.lightspark.grid.models.auth.credentials.CredentialVerifyParams +import com.lightspark.grid.models.auth.credentials.CredentialVerifyParams.AuthCredentialVerifyRequest.PasskeyCredentialVerifyRequest +import com.grid.sample.GridClientBuilder +import com.grid.sample.JsonUtils +import com.grid.sample.Log +import com.grid.sample.optText +import io.ktor.http.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import java.security.SecureRandom +import java.util.Base64 +import java.util.concurrent.ConcurrentHashMap + +// Backend-issued WebAuthn registration challenges. Production integrators would +// keep these in their session store; an in-memory map is sufficient for the sample. +private object RegistrationChallengeStore { + private val random = SecureRandom() + private val store = ConcurrentHashMap() + private const val TTL_MS = 5 * 60 * 1000L + + fun mint(): String { + val bytes = ByteArray(32).also(random::nextBytes) + val challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + store[challenge] = System.currentTimeMillis() + TTL_MS + return challenge + } + + fun consume(challenge: String): Boolean { + val expiresAt = store.remove(challenge) ?: return false + return System.currentTimeMillis() < expiresAt + } +} + +fun Route.authCredentialRoutes() { + route("/api/auth/credentials") { + // Mints a WebAuthn registration challenge plus the rp/user blocks the + // client needs to feed navigator.credentials.create(). Backend-only β€” + // not a Grid endpoint. + post("/registration-challenge") { + val body = call.receiveText() + Log.incoming("POST", "/api/auth/credentials/registration-challenge", body) + val json = JsonUtils.mapper.readTree(body) + // accountId is the Global Account id the credential will be bound + // to. user.id derives from it so the authenticator can recognize + // the same user across registration attempts on the same device. + val accountId = json.optText("accountId") + ?: return@post call.respondText( + """{"error": "accountId is required"}""", + ContentType.Application.Json, + HttpStatusCode.BadRequest + ) + val customerId = json.optText("customerId") ?: "" + // rpId must match the page's effective domain. The frontend sends + // window.location.hostname so the same backend works on any host. + val rpId = json.optText("rpId") ?: "localhost" + + val challenge = RegistrationChallengeStore.mint() + val userId = Base64.getUrlEncoder().withoutPadding() + .encodeToString(accountId.toByteArray()) + + val response = mapOf( + "challenge" to challenge, + "rp" to mapOf("id" to rpId, "name" to "Grid Sample"), + "user" to mapOf( + "id" to userId, + "name" to customerId.ifEmpty { "global-account-user" }, + "displayName" to "Grid Global Account", + ), + ) + call.respondText( + JsonUtils.prettyPrint(response), + ContentType.Application.Json, + HttpStatusCode.OK + ) + } + + // Registers a passkey with Grid. Expects the WebAuthn attestation from + // the client plus the challenge originally minted above. + post { + try { + val body = call.receiveText() + val json = JsonUtils.mapper.readTree(body) + Log.incoming("POST", "/api/auth/credentials", body) + + val challenge = json.optText("challenge") + ?: throw IllegalArgumentException("challenge is required") + if (!RegistrationChallengeStore.consume(challenge)) { + return@post call.respondText( + """{"error": "challenge is invalid or expired"}""", + ContentType.Application.Json, + HttpStatusCode.BadRequest + ) + } + + val attestationNode = json.get("attestation") + ?: throw IllegalArgumentException("attestation is required") + val attestation = PasskeyCredentialCreateRequest.Attestation.builder() + .credentialId(attestationNode.get("credentialId").asText()) + .clientDataJson(attestationNode.get("clientDataJson").asText()) + .attestationObject(attestationNode.get("attestationObject").asText()) + .build() + + val request = PasskeyCredentialCreateRequest.builder() + .type(PasskeyCredentialCreateRequest.Type.PASSKEY) + .accountId(json.get("accountId").asText()) + .challenge(challenge) + .attestation(attestation) + .apply { + json.optText("nickname")?.let { nickname(it) } + } + .build() + + Log.gridRequest("auth.credentials.create", JsonUtils.prettyPrint(request)) + val response = GridClientBuilder.client.auth().credentials().create(request) + val responseJson = JsonUtils.prettyPrint(response) + Log.gridResponse("auth.credentials.create", responseJson) + + call.respondText(responseJson, ContentType.Application.Json, HttpStatusCode.Created) + } catch (e: Exception) { + Log.gridError("auth.credentials.create", e) + call.respondText( + """{"error": "${e.message}"}""", + ContentType.Application.Json, + HttpStatusCode.InternalServerError + ) + } + } + + // Issues a fresh challenge for an already-registered credential, used + // before signing a sensitive action (withdrawal, revoke, export). + post("/{authMethodId}/challenge") { + try { + val authMethodId = call.parameters["authMethodId"] + ?: return@post call.respondText( + """{"error": "authMethodId is required"}""", + ContentType.Application.Json, + HttpStatusCode.BadRequest + ) + // Body is required for PASSKEY (carries clientPublicKey so Grid can + // seal the session signing key to the device). Empty body is fine + // for EMAIL_OTP and OAUTH. + val body = runCatching { call.receiveText() }.getOrDefault("") + val clientPublicKey = body.takeIf { it.isNotBlank() } + ?.let { JsonUtils.mapper.readTree(it).optText("clientPublicKey") } + Log.incoming("POST", "/api/auth/credentials/$authMethodId/challenge", body) + + val params = CredentialResendChallengeParams.builder() + .id(authMethodId) + .apply { clientPublicKey?.let { clientPublicKey(it) } } + .build() + + Log.gridRequest( + "auth.credentials.resendChallenge", + "id=$authMethodId clientPublicKey=${clientPublicKey != null}", + ) + val response = GridClientBuilder.client.auth().credentials().resendChallenge(params) + val responseJson = JsonUtils.prettyPrint(response) + Log.gridResponse("auth.credentials.resendChallenge", responseJson) + + call.respondText(responseJson, ContentType.Application.Json, HttpStatusCode.OK) + } catch (e: Exception) { + Log.gridError("auth.credentials.resendChallenge", e) + call.respondText( + """{"error": "${e.message}"}""", + ContentType.Application.Json, + HttpStatusCode.InternalServerError + ) + } + } + + // Verifies a passkey assertion and mints a session signing key. The client + // decrypts encryptedSessionSigningKey with the matching client private key + // and uses it to sign payloadToSign for the next sensitive action. + post("/{authMethodId}/verify") { + try { + val authMethodId = call.parameters["authMethodId"] + ?: return@post call.respondText( + """{"error": "authMethodId is required"}""", + ContentType.Application.Json, + HttpStatusCode.BadRequest + ) + val requestId = call.request.headers["Request-Id"] + ?: return@post call.respondText( + """{"error": "Request-Id header is required"}""", + ContentType.Application.Json, + HttpStatusCode.BadRequest + ) + val body = call.receiveText() + val json = JsonUtils.mapper.readTree(body) + Log.incoming("POST", "/api/auth/credentials/$authMethodId/verify", body) + + val assertionNode = json.get("assertion") + ?: throw IllegalArgumentException("assertion is required") + val assertion = PasskeyCredentialVerifyRequest.Assertion.builder() + .credentialId(assertionNode.get("credentialId").asText()) + .clientDataJson(assertionNode.get("clientDataJson").asText()) + .authenticatorData(assertionNode.get("authenticatorData").asText()) + .signature(assertionNode.get("signature").asText()) + .apply { + assertionNode.optText("userHandle")?.let { userHandle(it) } + } + .build() + + // clientPublicKey moved to /challenge in SDK 1.7.x; verify carries + // the assertion only. + val verifyRequest = PasskeyCredentialVerifyRequest.builder() + .type(PasskeyCredentialVerifyRequest.Type.PASSKEY) + .assertion(assertion) + .build() + + val params = CredentialVerifyParams.builder() + .id(authMethodId) + .requestId(requestId) + .authCredentialVerifyRequest(verifyRequest) + .build() + + Log.gridRequest("auth.credentials.verify", "id=$authMethodId requestId=$requestId") + val response = GridClientBuilder.client.auth().credentials().verify(params) + val responseJson = JsonUtils.prettyPrint(response) + Log.gridResponse("auth.credentials.verify", responseJson) + + call.respondText(responseJson, ContentType.Application.Json, HttpStatusCode.OK) + } catch (e: Exception) { + Log.gridError("auth.credentials.verify", e) + call.respondText( + """{"error": "${e.message}"}""", + ContentType.Application.Json, + HttpStatusCode.InternalServerError + ) + } + } + } +} diff --git a/samples/kotlin/src/main/kotlin/com/grid/sample/routes/InternalAccounts.kt b/samples/kotlin/src/main/kotlin/com/grid/sample/routes/InternalAccounts.kt new file mode 100644 index 00000000..d63a71b8 --- /dev/null +++ b/samples/kotlin/src/main/kotlin/com/grid/sample/routes/InternalAccounts.kt @@ -0,0 +1,57 @@ +package com.grid.sample.routes + +import com.lightspark.grid.models.customers.CustomerListInternalAccountsParams +import com.grid.sample.GridClientBuilder +import com.grid.sample.JsonUtils +import com.grid.sample.Log +import io.ktor.http.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +// Lists a customer's internal accounts. Used by the frontend to find the +// auto-provisioned Grid Global Account (filtered by type=EMBEDDED_WALLET). +fun Route.internalAccountRoutes() { + route("/api/internal-accounts") { + get { + try { + val customerId = call.request.queryParameters["customerId"] + ?: return@get call.respondText( + """{"error": "customerId is required"}""", + ContentType.Application.Json, + HttpStatusCode.BadRequest + ) + val typeParam = call.request.queryParameters["type"] + Log.incoming("GET", "/api/internal-accounts?customerId=$customerId&type=$typeParam") + + val params = CustomerListInternalAccountsParams.builder() + .customerId(customerId) + .apply { + typeParam?.let { type(CustomerListInternalAccountsParams.Type.of(it)) } + } + .build() + + Log.gridRequest("customers.listInternalAccounts", "customerId=$customerId type=$typeParam") + val page = GridClientBuilder.client.customers().listInternalAccounts(params) + // Page wraps a service reference Jackson can't serialize; flatten to its response shape. + val responsePayload = mapOf( + "data" to page.data(), + "hasMore" to page.hasMore(), + "nextCursor" to page.nextCursor(), + "totalCount" to page.totalCount(), + ) + val responseJson = JsonUtils.prettyPrint(responsePayload) + Log.gridResponse("customers.listInternalAccounts", responseJson) + + call.respondText(responseJson, ContentType.Application.Json, HttpStatusCode.OK) + } catch (e: Exception) { + Log.gridError("customers.listInternalAccounts", e) + call.respondText( + """{"error": "${e.message}"}""", + ContentType.Application.Json, + HttpStatusCode.InternalServerError + ) + } + } + } +} diff --git a/samples/kotlin/src/main/kotlin/com/grid/sample/routes/Quotes.kt b/samples/kotlin/src/main/kotlin/com/grid/sample/routes/Quotes.kt index 0900599a..71f525ff 100644 --- a/samples/kotlin/src/main/kotlin/com/grid/sample/routes/Quotes.kt +++ b/samples/kotlin/src/main/kotlin/com/grid/sample/routes/Quotes.kt @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode import com.lightspark.grid.models.quotes.QuoteCreateParams import com.lightspark.grid.models.quotes.QuoteCreateParams.LockedCurrencySide import com.lightspark.grid.models.quotes.QuoteCreateParams.PurposeOfPayment +import com.lightspark.grid.models.quotes.QuoteExecuteParams import com.lightspark.grid.models.quotes.QuoteSourceOneOf import com.lightspark.grid.models.quotes.QuoteSourceOneOf.AccountQuoteSource import com.lightspark.grid.models.quotes.QuoteSourceOneOf.RealtimeFundingQuoteSource @@ -74,9 +75,22 @@ fun Route.quoteRoutes() { HttpStatusCode.BadRequest ) + // Withdrawals from a Global Account require a client-supplied + // Grid-Wallet-Signature; for non-wallet quotes the header is absent. + val signature = call.request.headers["Grid-Wallet-Signature"] + val idempotencyKey = call.request.headers["Idempotency-Key"] Log.incoming("POST", "/api/quotes/$quoteId/execute") - Log.gridRequest("quotes.execute", "quoteId=$quoteId") - val quote = GridClientBuilder.client.quotes().execute(quoteId) + + val params = QuoteExecuteParams.builder() + .quoteId(quoteId) + .apply { + signature?.let { gridWalletSignature(it) } + idempotencyKey?.let { idempotencyKey(it) } + } + .build() + + Log.gridRequest("quotes.execute", "quoteId=$quoteId signed=${signature != null}") + val quote = GridClientBuilder.client.quotes().execute(params) val responseJson = JsonUtils.prettyPrint(quote) Log.gridResponse("quotes.execute", responseJson) diff --git a/samples/kotlin/src/main/kotlin/com/grid/sample/routes/Sandbox.kt b/samples/kotlin/src/main/kotlin/com/grid/sample/routes/Sandbox.kt index cadcd7ef..4ddacdb3 100644 --- a/samples/kotlin/src/main/kotlin/com/grid/sample/routes/Sandbox.kt +++ b/samples/kotlin/src/main/kotlin/com/grid/sample/routes/Sandbox.kt @@ -2,6 +2,7 @@ package com.grid.sample.routes import com.fasterxml.jackson.databind.JsonNode import com.lightspark.grid.models.sandbox.SandboxSendFundsParams +import com.lightspark.grid.models.sandbox.internalaccounts.InternalAccountFundParams import com.grid.sample.GridClientBuilder import com.grid.sample.JsonUtils import com.grid.sample.Log @@ -13,6 +14,43 @@ import io.ktor.server.routing.* fun Route.sandboxRoutes() { route("/api/sandbox") { + post("/internal-accounts/{accountId}/fund") { + try { + val accountId = call.parameters["accountId"] + ?: return@post call.respondText( + """{"error": "accountId is required"}""", + ContentType.Application.Json, + HttpStatusCode.BadRequest + ) + val body = call.receiveText() + val json = JsonUtils.mapper.readTree(body) + Log.incoming("POST", "/api/sandbox/internal-accounts/$accountId/fund", body) + + val amount = (json.get("amount") ?: json.get("currencyAmount")) + ?.takeIf { !it.isNull } + ?.asLong() + ?: throw IllegalArgumentException("amount (or currencyAmount) is required") + val params = InternalAccountFundParams.builder() + .accountId(accountId) + .amount(amount) + .build() + + Log.gridRequest("sandbox.internalAccounts.fund", body) + val response = GridClientBuilder.client.sandbox().internalAccounts().fund(params) + val responseJson = JsonUtils.prettyPrint(response) + Log.gridResponse("sandbox.internalAccounts.fund", responseJson) + + call.respondText(responseJson, ContentType.Application.Json, HttpStatusCode.OK) + } catch (e: Exception) { + Log.gridError("sandbox.internalAccounts.fund", e) + call.respondText( + """{"error": "${e.message}"}""", + ContentType.Application.Json, + HttpStatusCode.InternalServerError + ) + } + } + post("/send-funds") { try { val body = call.receiveText()