Skip to content

feat(rain): init→passkey→prepare flow for all collateral spend callers (TASK-19573)#2027

Open
jjramirezn wants to merge 3 commits into
devfrom
feat/rain-withdraw-init-flow
Open

feat(rain): init→passkey→prepare flow for all collateral spend callers (TASK-19573)#2027
jjramirezn wants to merge 3 commits into
devfrom
feat/rain-withdraw-init-flow

Conversation

@jjramirezn

@jjramirezn jjramirezn commented May 14, 2026

Copy link
Copy Markdown
Contributor

Pairs with peanut-api-ts #785deploy together.

Why

Rain locks a user out for up to ~7min when they cancel a passkey because the legacy `/prepare` already burned a Rain signature. The init flow defers the Rain call to AFTER the user commits, so passkey-cancel = zero Rain sig burn.

Coverage

Migrated all four collateral-touching callsites:

Hook Branch Passkey prompts Win
`useSpendBundle.spend()` collateral-only 1 (admin sig) Full — cancel burns nothing
`useSpendBundle.spend()` mixed 2 (admin sig + UserOp) ~half — first cancel saved, second still burns
`useSignSpendBundle.signSpend()` collateral-only 1 Full
`useSignSpendBundle.signSpend()` mixed 2 ~half

Mixed paths have two prompts because the kernel UserOp's calldata embeds Rain's executor signature — Rain must be called between the two prompts. We can't avoid burning on the second cancel without restructuring the on-chain coordinator interface (out of scope).

Affected user flows

  • Card-collateral send (peer/direct/request-pay)
  • Pot contribution
  • Cross-chain crypto withdraw via collateral
  • QR pay
  • Manteca offramp from collateral
  • Lock / cancel card (returns collateral via signSpend)

All ship in this one PR.

Tests

  • `useSpendBundle.test.ts` covers `computeSpendStrategy` (pure function) — unchanged behaviour, still passes.
  • No test changes needed for the new flow shape — happy paths exercise the same end-to-end behaviour.

QA

  1. Card-collateral pay/send/withdraw → succeeds, one passkey, end-to-end.
  2. Open a withdraw, cancel the passkey, immediately start a different withdraw → succeeds without "previous withdrawal active" 425 (no Rain sig was burned).
  3. Mixed-strategy spend (smart + collateral): cancel the admin-sig prompt → no Rain burn ✅. Cancel the UserOp prompt → still burns (expected).
  4. Manteca offramp, QR pay, lock/cancel card → all work; same admin-sig-cancel safety.
  5. Smart-only spends → unchanged (never touch Rain).

🤖 Generated with Claude Code

Pairs with peanut-api-ts feat/rain-withdraw-init-flow. Rain locks out
the user for ~7min when they cancel a passkey because /prepare already
burned a signature. Now we call POST /rain/cards/withdraw/init first
(no Rain call), pass the resulting EIP-712 fields to the passkey, then
call /prepare with the pre-signed admin sig. /prepare verifies the sig
BEFORE calling Rain. Cancelled passkey → zero Rain sigs burned.

Scoped to useSpendBundle's collateral-only branch (BE-broadcasted, the
most common path). Mixed-strategy + useSignSpendBundle (QR pay, manteca
offramp, lock/cancel card modals) stay on the legacy /prepare-first
flow — they need their own restructure since the FE broadcasts the
kernel UserOp itself. Follow-up.

TASK-19573
@notion-workspace

Copy link
Copy Markdown

@vercel

vercel Bot commented May 14, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview, Comment May 14, 2026 5:02pm

Request Review

@coderabbitai

coderabbitai Bot commented May 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds a Rain withdrawal init step and types, exposes rainApi.initWithdrawal, and refactors both useSignSpendBundle and useSpendBundle (collateral-only and mixed) to call init, derive admin EIP-712 signing inputs from init, then call prepare with initId and adminSignature before submission.

Changes

Rain withdrawal init-prepare flow

Layer / File(s) Summary
Init and prepare API contracts
src/services/rain.ts
InitRainWithdrawalInput and InitRainWithdrawalResponse are added; PrepareRainWithdrawalInput gains optional initId and adminSignature fields.
Init endpoint implementation
src/services/rain.ts
rainApi.initWithdrawal is added for POST /rain/cards/withdraw/init; prepareWithdrawal docs updated to describe consuming init-provided nonce/salt and verifying admin signature.
Sign hook: collateral-only flow
src/hooks/wallet/useSignSpendBundle.ts
Collateral-only signing now calls initWithdrawal, signs the admin EIP-712 Withdraw using fields from the init response, and passes initId + adminSignature to prepareWithdrawal.
Sign hook: mixed flow
src/hooks/wallet/useSignSpendBundle.ts
Mixed signing computes shortfallCents/totalCents, calls initWithdrawal (directTransfer=false), signs using init fields, then calls prepareWithdrawal with initId and adminSignature.
Spend hook: collateral-only flow
src/hooks/wallet/useSpendBundle.ts
Collateral-only spend now starts with initWithdrawal(amountCents, recipient, directTransfer=true), signs admin Withdraw from init fields, then calls prepareWithdrawal with initId + adminSignature and submits using the prepared payload.
Spend hook: mixed flow
src/hooks/wallet/useSpendBundle.ts
Mixed spend updates comments/control flow to init → admin-passkey → prepare → kernel UserOp → UserOp-passkey; computes shortfallCents and totalAmountCents, calls initWithdrawal, signs from init, and prepares with initId, adminSignature, amount: shortfallCents, and totalAmountCents.
Friendly error mapping
src/utils/friendly-error.utils.tsx
rainCollateralErrorMessage now recognizes init-session failures and returns “Withdrawal session expired — please try again.”.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • peanutprotocol/peanut-ui#1996: Both PRs touch the Rain withdrawal prepare path and extend PrepareRainWithdrawalInput consumed by rainApi.prepareWithdrawal.
  • peanutprotocol/peanut-ui#2014: Both PRs modify src/utils/friendly-error.utils.tsx, specifically Rain error handling and collateral/init-related user messages.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately captures the main change: introducing an init→passkey→prepare flow for collateral spend operations across all affected callers.
Description check ✅ Passed The description clearly explains the motivation (avoiding Rain signature burn on passkey cancellation), covers all four migrated callsites, documents affected user flows, and includes testing and QA strategies.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented May 14, 2026

Copy link
Copy Markdown
Contributor

Code-analysis diff

Painscore total: 5649.23 → 5610.88 (-38.35)
Findings: 0 net (+16 new, -16 resolved)

🆕 New findings (16)

  • critical complexity — src/utils/friendly-error.utils.tsx — CC 50, MI 45.82, SLOC 136
  • high complexity — src/services/rain.ts — CC 41, MI 61.12, SLOC 198
  • high method-complexity — src/utils/friendly-error.utils.tsx:52 — CC 35 SLOC 100
  • high complexity — src/hooks/wallet/useSignSpendBundle.ts — CC 13, MI 37.77, SLOC 194
  • medium high-mdd — src/hooks/wallet/useSpendBundle.ts:133 — useSpendBundle: MDD 64.4 (uses across many lines from declarations)
  • medium high-mdd — src/hooks/wallet/useSpendBundle.ts:141 — : MDD 62.0 (uses across many lines from declarations)
  • medium high-mdd — src/hooks/wallet/useSignSpendBundle.ts:101 — useSignSpendBundle: MDD 50.7 (uses across many lines from declarations)
  • medium high-mdd — src/hooks/wallet/useSignSpendBundle.ts:108 — : MDD 48.2 (uses across many lines from declarations)
  • medium structural-dup — src/hooks/wallet/useSignSpendBundle.ts:262 — 33 duplicate lines / 0 tokens with src/hooks/wallet/useSpendBundle.ts:321
  • medium structural-dup — src/hooks/wallet/useSignSpendBundle.ts:197 — 25 duplicate lines / 0 tokens with src/hooks/wallet/useSpendBundle.ts:215
  • medium structural-dup — src/hooks/wallet/useSpendBundle.ts:321 — 24 duplicate lines / 0 tokens with src/hooks/wallet/useSpendBundle.ts:215
  • medium structural-dup — src/hooks/wallet/useSignSpendBundle.ts:294 — 22 duplicate lines / 0 tokens with src/hooks/wallet/useSpendBundle.ts:355
  • low high-dlt — src/hooks/wallet/useSpendBundle.ts:133 — useSpendBundle: DLT 25 (calls 25 distinct functions — high context load)
  • low high-dlt — src/hooks/wallet/useSignSpendBundle.ts:101 — useSignSpendBundle: DLT 19 (calls 19 distinct functions — high context load)
  • low high-dlt — src/hooks/wallet/useSpendBundle.ts:141 — : DLT 19 (calls 19 distinct functions — high context load)
  • low high-mdd — src/services/rain.ts:246 — rainRequest: MDD 10.6 (uses across many lines from declarations)

✅ Resolved (16)

  • src/utils/friendly-error.utils.tsx — CC 47, MI 46.31, SLOC 131
  • src/services/rain.ts — CC 40, MI 61.15, SLOC 191
  • src/utils/friendly-error.utils.tsx:41 — CC 35 SLOC 100
  • src/hooks/wallet/useSignSpendBundle.ts — CC 13, MI 39.09, SLOC 174
  • src/hooks/wallet/useSpendBundle.ts:133 — useSpendBundle: MDD 60.9 (uses across many lines from declarations)
  • src/hooks/wallet/useSpendBundle.ts:141 — : MDD 58.5 (uses across many lines from declarations)
  • src/hooks/wallet/useSignSpendBundle.ts:101 — useSignSpendBundle: MDD 51.7 (uses across many lines from declarations)
  • src/hooks/wallet/useSignSpendBundle.ts:108 — : MDD 49.1 (uses across many lines from declarations)
  • src/hooks/wallet/useSpendBundle.ts:286 — 24 duplicate lines / 0 tokens with src/hooks/wallet/useSpendBundle.ts:202
  • src/hooks/wallet/useSignSpendBundle.ts:249 — 23 duplicate lines / 0 tokens with src/hooks/wallet/useSignSpendBundle.ts:194
  • src/hooks/wallet/useSignSpendBundle.ts:271 — 22 duplicate lines / 0 tokens with src/hooks/wallet/useSpendBundle.ts:311
  • src/hooks/wallet/useSignSpendBundle.ts:197 — 20 duplicate lines / 0 tokens with src/hooks/wallet/useSpendBundle.ts:206
  • src/hooks/wallet/useSpendBundle.ts:133 — useSpendBundle: DLT 24 (calls 24 distinct functions — high context load)
  • src/hooks/wallet/useSignSpendBundle.ts:101 — useSignSpendBundle: DLT 18 (calls 18 distinct functions — high context load)
  • src/hooks/wallet/useSpendBundle.ts:141 — : DLT 18 (calls 18 distinct functions — high context load)
  • src/services/rain.ts:214 — rainRequest: MDD 10.6 (uses across many lines from declarations)

📈 Painscore deltas (top movers)

File Before After Δ
src/hooks/wallet/useSpendBundle.ts 8.5 9.4 +1.0

@github-actions

github-actions Bot commented May 14, 2026

Copy link
Copy Markdown
Contributor

🧪 UI test report — ✅ all green

Suites

  • unit: 1073 ran, 0 failed, 0 skipped, 18.5s

📊 Coverage (unit)

metric %
statements 46.2%
branches 27.4%
functions 27.0%
lines 46.0%
⏱ 10 slowest test cases
time test
0.4s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in updateUserById
0.3s src/app/actions/__tests__/api-headers-extended.test.ts › should not include apiKey in updateUserById body
0.1s src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx › Manteca PIX form ready shows merchant card + amount input + pay button
0.1s src/app/actions/__tests__/api-headers.test.ts › should include Content-Type in createOnrampForGuest
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle valid 9-digit US account
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle valid US account with spaces 2
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle valid ETH address
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle valid ENS name
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle invalid ETH address (missing 0x prefix)
0.1s src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx › should handle too long for US account
📍 Inline annotations are in the **Unit test report** check above. Coverage artifact: `coverage-unit`. Generated by `.github/workflows/tests.yml`.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/hooks/wallet/useSpendBundle.ts (1)

209-227: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Sign against the chain returned by /init.

The init response is now the authoritative EIP-712 bundle, but this still signs with PEANUT_WALLET_CHAIN.id and looks up the client with chainIdStr. If frontend config ever drifts from the backend-issued init chain, the admin signature is invalid and every collateral-only withdrawal fails. Use init.chainId here, and ideally assert it matches the local constant before proceeding.

🔧 Suggested fix
-                    const kernelClient = getClientForChain(chainIdStr)
+                    const initChainId = Number(init.chainId)
+                    if (initChainId !== chainIdNum) {
+                        throw new Error(`useSpendBundle: init chain mismatch (${init.chainId})`)
+                    }
+                    const kernelClient = getClientForChain(init.chainId)
                     const adminSignature = (await kernelClient.account!.signTypedData({
                         domain: {
                             name: RAIN_WITHDRAW_EIP712_DOMAIN_NAME,
                             version: RAIN_WITHDRAW_EIP712_DOMAIN_VERSION,
-                            chainId: chainIdNum,
+                            chainId: initChainId,
                             verifyingContract: init.collateralProxy as Address,
                             salt: init.adminSalt as Hex,
                         },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/wallet/useSpendBundle.ts` around lines 209 - 227, The admin
signature is being produced using the local chain lookup (getClientForChain /
chainIdStr / PEANUT_WALLET_CHAIN.id) instead of the authoritative chain from the
init response; change the client lookup and signing to use init.chainId (e.g.,
call getClientForChain(String(init.chainId)) and use init.chainId for the
EIP-712 domain chainId) and add a runtime assertion that init.chainId matches
the expected PEANUT_WALLET_CHAIN.id constant before proceeding (throw or
log/abort if it does not) so adminSignature and kernelClient are derived from
the backend-provided chain.
🧹 Nitpick comments (1)
src/services/rain.ts (1)

91-96: ⚡ Quick win

Make the init-flow fields all-or-none in the request type.

PrepareRainWithdrawalInput currently allows initId without adminSignature (and vice versa), but the contract described here is “both or neither”. Encoding that as a union prevents future callers from compiling an invalid /prepare request and only discovering it via a runtime 4xx.

♻️ Proposed type shape
-export interface PrepareRainWithdrawalInput {
+type PrepareRainWithdrawalBase = {
     amount: string
     recipientAddress: string
     directTransfer: boolean
     kind: RainCollateralKind
     totalAmountCents?: string
     chargeId?: string
-    initId?: string
-    adminSignature?: string
-}
+}
+
+type PrepareRainWithdrawalLegacy = {
+    initId?: never
+    adminSignature?: never
+}
+
+type PrepareRainWithdrawalInitFlow = {
+    initId: string
+    adminSignature: string
+}
+
+export type PrepareRainWithdrawalInput =
+    PrepareRainWithdrawalBase & (PrepareRainWithdrawalLegacy | PrepareRainWithdrawalInitFlow)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/rain.ts` around lines 91 - 96, PrepareRainWithdrawalInput
currently permits initId and adminSignature independently but needs to be an
all-or-none pair; change the type definition for PrepareRainWithdrawalInput to a
union that enforces either both fields present (e.g., { initId: string;
adminSignature: string }) or neither present (e.g., { initId?: undefined;
adminSignature?: undefined }) so callers cannot compile with only one set.
Update any functions handling PrepareRainWithdrawalInput (references to
PrepareRainWithdrawalInput, prepareRainWithdrawal, validate/handler code) to
match the new shape (use type narrowing or runtime checks where necessary) so
code compiles and handles both-unset and both-set cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/hooks/wallet/useSpendBundle.ts`:
- Around line 209-227: The admin signature is being produced using the local
chain lookup (getClientForChain / chainIdStr / PEANUT_WALLET_CHAIN.id) instead
of the authoritative chain from the init response; change the client lookup and
signing to use init.chainId (e.g., call getClientForChain(String(init.chainId))
and use init.chainId for the EIP-712 domain chainId) and add a runtime assertion
that init.chainId matches the expected PEANUT_WALLET_CHAIN.id constant before
proceeding (throw or log/abort if it does not) so adminSignature and
kernelClient are derived from the backend-provided chain.

---

Nitpick comments:
In `@src/services/rain.ts`:
- Around line 91-96: PrepareRainWithdrawalInput currently permits initId and
adminSignature independently but needs to be an all-or-none pair; change the
type definition for PrepareRainWithdrawalInput to a union that enforces either
both fields present (e.g., { initId: string; adminSignature: string }) or
neither present (e.g., { initId?: undefined; adminSignature?: undefined }) so
callers cannot compile with only one set. Update any functions handling
PrepareRainWithdrawalInput (references to PrepareRainWithdrawalInput,
prepareRainWithdrawal, validate/handler code) to match the new shape (use type
narrowing or runtime checks where necessary) so code compiles and handles
both-unset and both-set cases.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0a0d1730-52ce-4c68-b955-40b96e9f1597

📥 Commits

Reviewing files that changed from the base of the PR and between 9b66edc and 6a16864.

📒 Files selected for processing (2)
  • src/hooks/wallet/useSpendBundle.ts
  • src/services/rain.ts

Same change as the collateral-only branch — call /init first (no Rain),
let the user sign the admin EIP-712, then call /prepare with the
pre-signed sig. /prepare verifies the sig before touching Rain.

Mixed and signSpend each have TWO passkey prompts (admin sig + kernel
UserOp). The init flow saves a Rain burn on the FIRST one; the second
still burns if cancelled because Rain has to be called between the two
prompts (its executor sig is part of the UserOp's calldata). ~half the
burn cases solved — same fix shape as collateral-only, no reason to
ship it later.

useSpendBundle.spend() mixed branch + useSignSpendBundle.signSpend()
collateral-only + mixed branches all migrated. No BE changes needed
(peanut-api-ts feat/rain-withdraw-init-flow already accepts the init
flow optionally).
@jjramirezn jjramirezn changed the title feat(rain): init→passkey→prepare flow on collateral-only spends (TASK-19573) feat(rain): init→passkey→prepare flow for all collateral spend callers (TASK-19573) May 14, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/hooks/wallet/useSpendBundle.ts (1)

209-227: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use one checked kernel account for both Rain signatures and the mixed-path admin address.

Lines 210 and 310 still dereference kernelClient.account!, while Lines 287-290 prepare the mixed withdrawal with user.accounts instead of the signer. If auth and kernel state drift, /init can be created for one address and the typed data signed by another, or either branch can throw before the passkey prompt. Hoisting a guarded kernelAccount once and reusing kernelAccount.address here keeps the init payload and signature aligned.

🛠️ Proposed fix
                 const chainIdNum = PEANUT_WALLET_CHAIN.id
                 const chainIdStr = chainIdNum.toString()
+                const kernelClient = getClientForChain(chainIdStr)
+                const kernelAccount = kernelClient.account
+                if (!kernelAccount) {
+                    throw new Error('useSpendBundle: kernel account not initialized')
+                }

                 // ─── collateral-only ──────────────────────────────────────────────
                 // init → passkey → prepare → submit. /init does no Rain call,
                 // so if the user cancels the passkey at step 2 we never burned
@@
-                    const kernelClient = getClientForChain(chainIdStr)
-                    const adminSignature = (await kernelClient.account!.signTypedData({
+                    const adminSignature = (await kernelAccount.signTypedData({
                         domain: {
                             name: RAIN_WITHDRAW_EIP712_DOMAIN_NAME,
                             version: RAIN_WITHDRAW_EIP712_DOMAIN_VERSION,
                             chainId: chainIdNum,
@@
-                const adminAddress = user?.accounts.find((a) => a.type === AccountType.PEANUT_WALLET)?.identifier as
-                    | Address
-                    | undefined
-                if (!adminAddress) throw new Error('useSpendBundle: missing peanut-wallet address for mixed spend')
+                const adminAddress = kernelAccount.address as Address
@@
-                const kernelClient = getClientForChain(chainIdStr)
-                const adminSignature = (await kernelClient.account!.signTypedData({
+                const adminSignature = (await kernelAccount.signTypedData({
                     domain: {
                         name: RAIN_WITHDRAW_EIP712_DOMAIN_NAME,
                         version: RAIN_WITHDRAW_EIP712_DOMAIN_VERSION,
                         chainId: chainIdNum,

Also applies to: 287-337

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/wallet/useSpendBundle.ts` around lines 209 - 227, Dereference and
validate the kernel account once and reuse it for both EIP-712 admin signing and
the mixed-path admin address assembly to avoid signer/state drift: call
getClientForChain(chainIdStr), assert kernelClient.account exists into a guarded
local (e.g., kernelAccount) and use kernelAccount.address wherever
init.adminAddress/mixed withdrawal user signer and signTypedData use
kernelClient.account! (affecting adminSignature creation and the mixed
withdrawal path around the code that builds the init payload and uses
user.accounts); ensure any early errors are thrown before user prompts so the
same checked kernelAccount is used for signing and for populating
init.adminAddress.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/hooks/wallet/useSpendBundle.ts`:
- Around line 209-227: Dereference and validate the kernel account once and
reuse it for both EIP-712 admin signing and the mixed-path admin address
assembly to avoid signer/state drift: call getClientForChain(chainIdStr), assert
kernelClient.account exists into a guarded local (e.g., kernelAccount) and use
kernelAccount.address wherever init.adminAddress/mixed withdrawal user signer
and signTypedData use kernelClient.account! (affecting adminSignature creation
and the mixed withdrawal path around the code that builds the init payload and
uses user.accounts); ensure any early errors are thrown before user prompts so
the same checked kernelAccount is used for signing and for populating
init.adminAddress.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2efb27c9-1444-4481-b142-d2a9eb441ec1

📥 Commits

Reviewing files that changed from the base of the PR and between 6a16864 and d2ef627.

📒 Files selected for processing (2)
  • src/hooks/wallet/useSignSpendBundle.ts
  • src/hooks/wallet/useSpendBundle.ts

@jjramirezn

Copy link
Copy Markdown
Contributor Author

@coderabbitai review in depth.

Evaluate:

  • risk of introducing new bugs and breaking existing functionality
  • performance impact and latency
  • code quality and DRY

Also, write a very concise testing guide for QA

@coderabbitai

coderabbitai Bot commented May 14, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== src/services/rain.ts ===" 
cat src/services/rain.ts

echo ""
echo "=== src/hooks/wallet/useSpendBundle.ts ==="
cat src/hooks/wallet/useSpendBundle.ts

echo ""
echo "=== src/hooks/wallet/useSignSpendBundle.ts ==="
cat src/hooks/wallet/useSignSpendBundle.ts

Length of output: 53939


@jjramirezn Here is the in-depth review:


Risk of Introducing New Bugs

🔴 kernelClient.account! missing null guard (useSpendBundle.ts)

useSignSpendBundle.ts correctly guards the kernel account before use:

const kernelAccount = kernelClient.account
if (!kernelAccount) throw new Error('useSignSpendBundle: kernel account not initialized')

But useSpendBundle.ts uses kernelClient.account!.signTypedData(...) in both the collateral-only and mixed branches with bare non-null assertions. If auth is still hydrating when a spend fires, this crashes silently rather than surfacing a meaningful error. The guard pattern from useSignSpendBundle should be mirrored here.

🟡 No user-friendly handling for init expiry (HTTP 410)

InitRainWithdrawalResponse.expiresAt is documented as "After this the init is gone server-side; /prepare 410s." If the passkey prompt stalls (slow device, user hesitation), /prepare returns a 410, which surfaces as a raw "Request failed: 410" message through the generic error handler. There's no branch to detect this and show a retry-friendly message (e.g. "Session expired — please try again"). This is a UX cliff especially on low-end devices.

🟡 Silent amount semantics mismatch risk

The Rain API is asymmetric: cents on input to /init and /prepare, USDC wei on output in init.amount and prep.amount. The code handles this correctly today — amountCents flows into the API calls, while BigInt(init.amount) (wei) flows into the EIP-712 message. However, this asymmetry is not enforced by types; a future caller who passes init.amount directly to /prepare would produce a silent 100× over-withdrawal. A type-level wrapper (RainCents branded string vs UsdcWei) or at minimum a comment co-located with the EIP-712 message block would prevent regression.

🟢 Existing functionality preserved

The smart-only branch is entirely unchanged. The legacy /prepare-first path is still supported by the backend (optional initId/adminSignature), so callers not yet migrated (lock/cancel card modals) continue to work.


Performance Impact & Latency

Path Before After Delta
collateral-only 2 API calls (prepare, submit) + 1 passkey 3 API calls (init, prepare, submit) + 1 passkey +1 RTT
mixed 2 API calls (prepare) + passkey + UserOp 3 API calls (init, prepare) + passkey + UserOp +1 RTT
smart-only unchanged unchanged 0

The +1 RTT is the explicit trade-off for never burning a Rain signature on cancellation. One minor sequencing inefficiency: getClientForChain is called after the await initWithdrawal(...) call in both useSpendBundle.ts paths. Since getClientForChain is synchronous, moving it before the init await would pipeline marginally better and also fail fast if the kernel client is unavailable before a network call is made.

// Move these lines above the initWithdrawal call:
const kernelClient = getClientForChain(chainIdStr)
if (!kernelClient.account) throw new Error('...')
const amountCents = ...
const init = await rainApi.initWithdrawal(...)

Code Quality & DRY

🔴 EIP-712 signing block duplicated 4×

The following block appears identically in 4 places (collateral-only + mixed in each of the two hooks):

const adminSignature = (await <account>.signTypedData({
    domain: {
        name: RAIN_WITHDRAW_EIP712_DOMAIN_NAME,
        version: RAIN_WITHDRAW_EIP712_DOMAIN_VERSION,
        chainId: chainIdNum,
        verifyingContract: init.collateralProxy as Address,
        salt: init.adminSalt as Hex,
    },
    types: rainWithdrawEip712Types,
    primaryType: 'Withdraw',
    message: {
        user: init.adminAddress as Address,
        asset: init.tokenAddress as Address,
        amount: BigInt(init.amount),
        recipient: init.recipientAddress as Address,
        nonce: BigInt(init.adminNonce),
    },
})) as Hex

This should be extracted into a shared utility — e.g. in utils/rain.utils.ts or co-located in constants/rain.consts.ts:

export async function signRainAdminWithdraw(
    account: KernelAccount,
    chainId: number,
    init: InitRainWithdrawalResponse
): Promise<Hex> {
    return (await account.signTypedData({
        domain: {
            name: RAIN_WITHDRAW_EIP712_DOMAIN_NAME,
            version: RAIN_WITHDRAW_EIP712_DOMAIN_VERSION,
            chainId,
            verifyingContract: init.collateralProxy as Address,
            salt: init.adminSalt as Hex,
        },
        types: rainWithdrawEip712Types,
        primaryType: 'Withdraw',
        message: {
            user: init.adminAddress as Address,
            asset: init.tokenAddress as Address,
            amount: BigInt(init.amount),
            recipient: init.recipientAddress as Address,
            nonce: BigInt(init.adminNonce),
        },
    })) as Hex
}

This also eliminates the kernelClient.account! vs kernelAccount discrepancy at the same time, since the guard would live at the call site once.

🟡 prepareWithdrawal call pattern repeated

The initWithdrawal → signTypedData → prepareWithdrawal trio for collateral-only is near-identical between useSpendBundle.ts and useSignSpendBundle.ts. The difference is only what happens to prep afterward (submit vs. return). A shared helper initAndPrepareCollateralWithdrawal(account, chainId, params) returning { prep, adminSignature } would cut duplication further and ensure both hooks stay in sync when the API contract evolves.


Concise QA Testing Guide

Prerequisites

  • A wallet with known Rain card in good standing
  • Test amounts: one < smart balance, one > smart balance but < smart + collateral, one requiring collateral only

Test Matrix

# Scenario Expected
1 Collateral-only, happy path — send USDC where Rain covers 100% Single passkey prompt → tx submitted → receipt shows collateral-only strategy
2 Collateral-only, passkey cancel — tap cancel at the passkey sheet No Rain signature burned; user can immediately retry; CARD_WITHDRAW_FAILED event fired
3 Collateral-only, cancel then re-attempt Second attempt succeeds normally (no 7-min lockout)
4 Mixed strategy — send amount requiring both smart + Rain Two prompts (admin EIP-712 then UserOp); funds arrive at recipient; intent stamped
5 Mixed, cancel admin passkey No Rain sig burned; retry succeeds
6 Mixed, cancel UserOp passkey Rain sig IS burned (known limitation per PR scope); confirm 7-min cooldown behavior
7 Smart-only — send amount covered entirely by smart balance Single UserOp passkey; no /init call in network tab
8 Session-key grant missing Extra grant passkey fires before the spend passkey; flow continues normally after
9 chargeId flow (collateral-only + charge) chargeId visible in /init request body; history shows correct category; caller must NOT call recordPayment
10 QR Pay / Manteca (useSignSpendBundle) — collateral-only initWithdrawal called with directTransfer: true; signed artifact returned; backend broadcasts
11 QR Pay / Manteca — mixed initWithdrawal called with directTransfer: false; signed UserOp returned
12 Slow passkey (>init TTL) — delay >init expiry between prompt and confirmation Graceful error, not a raw "Request failed: 410"

Network tab checks: confirm /rain/cards/withdraw/init is called before any passkey prompt for all collateral-touching flows, and that /rain/cards/withdraw/prepare carries initId + adminSignature in the request body.

- useSpendBundle (collateral-only + mixed): resolve kernel account + admin
  address synchronously BEFORE the init call. Mirrors useSignSpendBundle's
  pattern. Fails fast if auth is still hydrating (was silent crash via
  bare `kernelClient.account!.signTypedData` non-null assertion) and
  pipelines the sync setup with the async init call's start.
- friendly-error.utils: surface a retry-friendly 'Withdrawal session
  expired — please try again.' for /prepare's 410 (init TTL expiry, race
  with a parallel consume, or foreign initId). Otherwise the raw
  'Request failed: 410' / 'Unknown or expired init' leaked through the
  generic error handler — a UX cliff on low-end devices where the
  passkey prompt can stall past the 5min TTL.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/utils/friendly-error.utils.tsx (1)

40-44: ⚡ Quick win

Match init-expiry substrings against both text and message.

Line 40-44 currently checks only text; if the backend phrase lands in message instead, users can still see generic/raw errors. Checking both fields makes this mapper more reliable.

Proposed diff
 export const rainCollateralErrorMessage = (error: unknown): string | null => {
     const { text, message } = extractErrorParts(error)
+    const searchable = `${text} ${message ?? ''}`
     if (
         text.includes('A previous withdrawal is still active for this card') ||
         text.includes('A previous withdrawal signature is still active') ||
         text.includes('Insufficient collateral balance for this withdrawal')
     ) {
         return message ?? text
     }
@@
-    if (
-        text.includes('Unknown or expired init') ||
-        text.includes('Init was just consumed') ||
-        text.includes('Init does not belong to this user')
-    ) {
+    if (
+        searchable.includes('Unknown or expired init') ||
+        searchable.includes('Init was just consumed') ||
+        searchable.includes('Init does not belong to this user')
+    ) {
         return 'Withdrawal session expired — please try again.'
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/friendly-error.utils.tsx` around lines 40 - 44, Update the
init-expiry substring checks in friendly-error.utils.tsx to test both the
existing `text` variable and the `message` variable (where backend errors may
appear); inside the if-condition that currently inspects `text.includes('Unknown
or expired init') || text.includes('Init was just consumed') ||
text.includes('Init does not belong to this user')`, extend each check to also
OR `message.includes(...)` so the same three substrings are matched against both
`text` and `message`, ensuring the same friendly mapping logic in the function
that uses these variables.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/utils/friendly-error.utils.tsx`:
- Around line 40-44: Update the init-expiry substring checks in
friendly-error.utils.tsx to test both the existing `text` variable and the
`message` variable (where backend errors may appear); inside the if-condition
that currently inspects `text.includes('Unknown or expired init') ||
text.includes('Init was just consumed') || text.includes('Init does not belong
to this user')`, extend each check to also OR `message.includes(...)` so the
same three substrings are matched against both `text` and `message`, ensuring
the same friendly mapping logic in the function that uses these variables.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3cc6f389-7a5c-4582-8a2e-2a7a83737e82

📥 Commits

Reviewing files that changed from the base of the PR and between d2ef627 and 524e326.

📒 Files selected for processing (2)
  • src/hooks/wallet/useSpendBundle.ts
  • src/utils/friendly-error.utils.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/hooks/wallet/useSpendBundle.ts

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