Skip to content

feat(sample): scaffold embedded wallet flow with sidebar nav#402

Open
pengying wants to merge 2 commits intomainfrom
04-27-feat_sample_scaffold_embedded_wallet_flow_with_sidebar_nav
Open

feat(sample): scaffold embedded wallet flow with sidebar nav#402
pengying wants to merge 2 commits intomainfrom
04-27-feat_sample_scaffold_embedded_wallet_flow_with_sidebar_nav

Conversation

@pengying
Copy link
Copy Markdown
Contributor

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:

  • 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

pengying and others added 2 commits April 27, 2026 11:39
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>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
grid-flow-builder Ready Ready Preview, Comment Apr 27, 2026 9:16pm

Request Review

Copy link
Copy Markdown
Contributor Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@pengying pengying marked this pull request as ready for review April 27, 2026 21:30
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 27, 2026

Greptile Summary

This 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:

  • Signature not forwarded: ExecuteSignedQuote.tsx posts { signature } in the request body, but Quotes.kt reads the wallet signature from the Grid-Wallet-Signature header — Grid will never see the signature.
  • Fund field mismatch: The frontend sends currencyAmount but Sandbox.kt calls json.get(\"amount\").asLong(), throwing a NPE (→ 500) on every sandbox fund request.

Confidence Score: 3/5

Two 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.

Important Files Changed

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
Loading

Comments Outside Diff (4)

  1. samples/frontend/src/steps/embeddedWallet/ExecuteSignedQuote.tsx, line 807 (link)

    P1 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:

    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.

    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.

    Fix in Claude Code

  2. samples/kotlin/src/main/kotlin/com/grid/sample/routes/Sandbox.kt, line 1471-1472 (link)

    P1 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.

    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.

    Fix in Claude Code

  3. samples/kotlin/src/main/kotlin/com/grid/sample/routes/AuthCredentials.kt, line 1240-1243 (link)

    P2 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:

    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.

    Fix in Claude Code

  4. samples/kotlin/src/main/kotlin/com/grid/sample/routes/AuthCredentials.kt, line 1139-1155 (link)

    P2 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.

    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.

    Fix in Claude Code

Fix All in Claude Code

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant