feat(sample): scaffold embedded wallet flow with sidebar nav#402
feat(sample): scaffold embedded wallet flow with sidebar nav#402
Conversation
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR scaffolds an embedded wallet flow (8 steps) alongside the existing payout wizard, wires the new Kotlin backend routes against SDK 1.7.0, and docks the webhook stream as a collapsible bottom bar. Two integration bugs will prevent the embedded wallet from working end-to-end once the passkey signing TODO is implemented:
Confidence Score: 3/5Two P1 bugs will silently break the embedded wallet execute and fund steps once passkey signing is complete; safe to merge for scaffolding review but not as a working integration. Two independent P1 defects on the new critical path (signature header/body mismatch and field name mismatch causing NPE) pull the score below the P1 ceiling of 4. ExecuteSignedQuote.tsx and Sandbox.kt need fixes before the embedded wallet flow can work end-to-end.
|
| Filename | Overview |
|---|---|
| samples/frontend/src/steps/embeddedWallet/ExecuteSignedQuote.tsx | Sends wallet signature in JSON body, but Kotlin backend reads it from Grid-Wallet-Signature header — signature is never forwarded to Grid (P1) |
| samples/kotlin/src/main/kotlin/com/grid/sample/routes/Sandbox.kt | Reads json.get("amount") but frontend sends currencyAmount — NPE on every sandbox fund request (P1) |
| samples/kotlin/src/main/kotlin/com/grid/sample/routes/AuthCredentials.kt | New WebAuthn registration/verify/challenge routes; challenge store never evicts expired entries; error messages not JSON-escaped |
| samples/kotlin/src/main/kotlin/com/grid/sample/routes/Quotes.kt | Extends execute endpoint to read Grid-Wallet-Signature and Idempotency-Key headers and forward via QuoteExecuteParams; looks correct on the backend side |
| samples/frontend/src/steps/embeddedWallet/RegisterPasskey.tsx | Implements WebAuthn ceremony end-to-end with proper base64url helpers; looks correct |
| samples/frontend/src/flows/EmbeddedWalletFlow.tsx | Scaffolds 8-step embedded wallet flow; step state management and prop threading look correct |
| samples/frontend/src/lib/api.ts | Extracts shared parseResponse helper and adds apiGet — clean refactor, no issues |
| samples/frontend/src/components/WebhookStream.tsx | Converts side panel to collapsible bottom bar with unread badge; openRef pattern correctly avoids stale closure in the SSE handler |
Sequence Diagram
sequenceDiagram
participant B as Browser
participant K as Kotlin Backend
participant G as Grid API
B->>K: POST /api/auth/credentials/registration-challenge
K-->>B: {challenge, rp, user}
B->>B: navigator.credentials.create()
B->>K: POST /api/auth/credentials {accountId, challenge, attestation}
K->>G: auth.credentials.create(PasskeyCredentialCreateRequest)
G-->>K: {id / authMethodId}
K-->>B: {authMethodId}
B->>K: POST /api/sandbox/internal-accounts/{id}/fund
Note over K: ⚠️ reads json.get("amount") but body has "currencyAmount"
K->>G: sandbox.internalAccounts.fund(params)
G-->>K: result
K-->>B: funded
B->>K: POST /api/quotes (embedded wallet source)
K->>G: quotes.create(params)
G-->>K: {quoteId, payloadToSign}
K-->>B: quote
B->>K: POST /api/auth/credentials/{authMethodId}/challenge
K->>G: credentials.resendChallenge(params)
G-->>K: challenge
K-->>B: challenge
Note over B: TODO: navigator.credentials.get() + HPKE + ECDSA sign
B->>K: POST /api/quotes/{quoteId}/execute {signature in body}
Note over K: ⚠️ reads Grid-Wallet-Signature header (not body)
K->>G: quotes.execute(QuoteExecuteParams, no signature)
G-->>K: result
K-->>B: result
Comments Outside Diff (4)
-
samples/frontend/src/steps/embeddedWallet/ExecuteSignedQuote.tsx, line 807 (link)Signature sent in body, but backend reads it from header
The frontend posts
{ signature }as JSON body, butQuotes.ktreads the signature exclusively from theGrid-Wallet-Signaturerequest header. The backend will always receivenullfor that header and Grid will never see the wallet signature, causing every embedded wallet execution to fail silently (the quote executes as if unsigned).The frontend needs to pass the signature as a header instead of a body field:
const data = await apiPost<Record<string, unknown>>(path, {}, { 'Grid-Wallet-Signature': signature, })
(or
apiPostneeds a headers argument), OR the Kotlin backend needs to read the signature from the body.Prompt To Fix With AI
This is a comment left during a code review. Path: samples/frontend/src/steps/embeddedWallet/ExecuteSignedQuote.tsx Line: 807 Comment: **Signature sent in body, but backend reads it from header** The frontend posts `{ signature }` as JSON body, but `Quotes.kt` reads the signature exclusively from the `Grid-Wallet-Signature` *request header*. The backend will always receive `null` for that header and Grid will never see the wallet signature, causing every embedded wallet execution to fail silently (the quote executes as if unsigned). The frontend needs to pass the signature as a header instead of a body field: ```ts const data = await apiPost<Record<string, unknown>>(path, {}, { 'Grid-Wallet-Signature': signature, }) ``` (or `apiPost` needs a headers argument), OR the Kotlin backend needs to read the signature from the body. How can I resolve this? If you propose a fix, please make it concise.
-
samples/kotlin/src/main/kotlin/com/grid/sample/routes/Sandbox.kt, line 1471-1472 (link)Field name mismatch causes NPE on every fund request
FundEmbeddedWallet.tsxsends{ currencyCode, currencyAmount }but the backend callsjson.get("amount").asLong(). In Jackson,get("amount")returnsnullwhen the key is absent, so.asLong()throws aNullPointerException. Every call toPOST /api/sandbox/internal-accounts/{id}/fundwill return a 500. Additionally,currencyCodeis never forwarded toInternalAccountFundParamsat all.val params = InternalAccountFundParams.builder() .accountId(accountId) .amount(json.get("currencyAmount").asLong()) // matches frontend field .apply { json.optText("currencyCode")?.let { currencyCode(it) } // if SDK supports it } .build()
Prompt To Fix With AI
This is a comment left during a code review. Path: samples/kotlin/src/main/kotlin/com/grid/sample/routes/Sandbox.kt Line: 1471-1472 Comment: **Field name mismatch causes NPE on every fund request** `FundEmbeddedWallet.tsx` sends `{ currencyCode, currencyAmount }` but the backend calls `json.get("amount").asLong()`. In Jackson, `get("amount")` returns `null` when the key is absent, so `.asLong()` throws a `NullPointerException`. Every call to `POST /api/sandbox/internal-accounts/{id}/fund` will return a 500. Additionally, `currencyCode` is never forwarded to `InternalAccountFundParams` at all. ```kotlin val params = InternalAccountFundParams.builder() .accountId(accountId) .amount(json.get("currencyAmount").asLong()) // matches frontend field .apply { json.optText("currencyCode")?.let { currencyCode(it) } // if SDK supports it } .build() ``` How can I resolve this? If you propose a fix, please make it concise.
-
samples/kotlin/src/main/kotlin/com/grid/sample/routes/AuthCredentials.kt, line 1240-1243 (link)Unescaped error messages produce invalid JSON
Multiple handlers in this file (and in
InternalAccounts.kt,Sandbox.kt,Quotes.kt) interpolatee.messagedirectly into a hand-crafted JSON string. If the message contains",\, or a newline the response becomes malformed JSON, causing the frontend'sJSON.parseto throw and masking the real error. Use Jackson to serialize the error map instead:call.respondText( JsonUtils.prettyPrint(mapOf("error" to e.message)), ContentType.Application.Json, HttpStatusCode.InternalServerError )The same pattern occurs in every route's catch block across this PR.
Prompt To Fix With AI
This is a comment left during a code review. Path: samples/kotlin/src/main/kotlin/com/grid/sample/routes/AuthCredentials.kt Line: 1240-1243 Comment: **Unescaped error messages produce invalid JSON** Multiple handlers in this file (and in `InternalAccounts.kt`, `Sandbox.kt`, `Quotes.kt`) interpolate `e.message` directly into a hand-crafted JSON string. If the message contains `"`, `\`, or a newline the response becomes malformed JSON, causing the frontend's `JSON.parse` to throw and masking the real error. Use Jackson to serialize the error map instead: ```kotlin call.respondText( JsonUtils.prettyPrint(mapOf("error" to e.message)), ContentType.Application.Json, HttpStatusCode.InternalServerError ) ``` The same pattern occurs in every route's catch block across this PR. How can I resolve this? If you propose a fix, please make it concise.
-
samples/kotlin/src/main/kotlin/com/grid/sample/routes/AuthCredentials.kt, line 1139-1155 (link)RegistrationChallengeStorenever evicts expired entriesstoreis aConcurrentHashMapthat only removes an entry whenconsume()is called. Abandoned registration flows (user closes the browser mid-ceremony) leave entries permanently until the JVM restarts. For a long-lived sample server this is a bounded memory leak. Consider a scheduled cleanup or a lightweightLinkedHashMapwith a max-size eviction policy.Prompt To Fix With AI
This is a comment left during a code review. Path: samples/kotlin/src/main/kotlin/com/grid/sample/routes/AuthCredentials.kt Line: 1139-1155 Comment: **`RegistrationChallengeStore` never evicts expired entries** `store` is a `ConcurrentHashMap` that only removes an entry when `consume()` is called. Abandoned registration flows (user closes the browser mid-ceremony) leave entries permanently until the JVM restarts. For a long-lived sample server this is a bounded memory leak. Consider a scheduled cleanup or a lightweight `LinkedHashMap` with a max-size eviction policy. How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: samples/frontend/src/steps/embeddedWallet/ExecuteSignedQuote.tsx
Line: 807
Comment:
**Signature sent in body, but backend reads it from header**
The frontend posts `{ signature }` as JSON body, but `Quotes.kt` reads the signature exclusively from the `Grid-Wallet-Signature` *request header*. The backend will always receive `null` for that header and Grid will never see the wallet signature, causing every embedded wallet execution to fail silently (the quote executes as if unsigned).
The frontend needs to pass the signature as a header instead of a body field:
```ts
const data = await apiPost<Record<string, unknown>>(path, {}, {
'Grid-Wallet-Signature': signature,
})
```
(or `apiPost` needs a headers argument), OR the Kotlin backend needs to read the signature from the body.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: samples/kotlin/src/main/kotlin/com/grid/sample/routes/Sandbox.kt
Line: 1471-1472
Comment:
**Field name mismatch causes NPE on every fund request**
`FundEmbeddedWallet.tsx` sends `{ currencyCode, currencyAmount }` but the backend calls `json.get("amount").asLong()`. In Jackson, `get("amount")` returns `null` when the key is absent, so `.asLong()` throws a `NullPointerException`. Every call to `POST /api/sandbox/internal-accounts/{id}/fund` will return a 500. Additionally, `currencyCode` is never forwarded to `InternalAccountFundParams` at all.
```kotlin
val params = InternalAccountFundParams.builder()
.accountId(accountId)
.amount(json.get("currencyAmount").asLong()) // matches frontend field
.apply {
json.optText("currencyCode")?.let { currencyCode(it) } // if SDK supports it
}
.build()
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: samples/kotlin/src/main/kotlin/com/grid/sample/routes/AuthCredentials.kt
Line: 1240-1243
Comment:
**Unescaped error messages produce invalid JSON**
Multiple handlers in this file (and in `InternalAccounts.kt`, `Sandbox.kt`, `Quotes.kt`) interpolate `e.message` directly into a hand-crafted JSON string. If the message contains `"`, `\`, or a newline the response becomes malformed JSON, causing the frontend's `JSON.parse` to throw and masking the real error. Use Jackson to serialize the error map instead:
```kotlin
call.respondText(
JsonUtils.prettyPrint(mapOf("error" to e.message)),
ContentType.Application.Json,
HttpStatusCode.InternalServerError
)
```
The same pattern occurs in every route's catch block across this PR.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: samples/kotlin/src/main/kotlin/com/grid/sample/routes/AuthCredentials.kt
Line: 1139-1155
Comment:
**`RegistrationChallengeStore` never evicts expired entries**
`store` is a `ConcurrentHashMap` that only removes an entry when `consume()` is called. Abandoned registration flows (user closes the browser mid-ceremony) leave entries permanently until the JVM restarts. For a long-lived sample server this is a bounded memory leak. Consider a scheduled cleanup or a lightweight `LinkedHashMap` with a max-size eviction policy.
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "feat(sample): wire embedded wallet backe..." | Re-trigger Greptile

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) noreply@anthropic.com
feat(sample): wire embedded wallet backend on Kotlin SDK 1.7.0
Bumps the Kotlin sample to lightspark-grid-kotlin 1.7.0 and adds
the routes the frontend scaffold needs:
(used to find the auto-provisioned Grid Global Account)
mints a 32-byte WebAuthn challenge with rp + user blocks for
navigator.credentials.create()
with passkey attestation
funding via sandbox().internalAccounts().fund(...)
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) noreply@anthropic.com