From 6a168641009163c1088ba16774b81a309ae3134f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Thu, 14 May 2026 08:14:12 -0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(rain):=20init=E2=86=92passkey=E2=86=92?= =?UTF-8?q?prepare=20flow=20on=20collateral-only=20spends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/hooks/wallet/useSpendBundle.ts | 36 ++++++++++++++------- src/services/rain.ts | 51 ++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src/hooks/wallet/useSpendBundle.ts b/src/hooks/wallet/useSpendBundle.ts index 3d7c2d44a..1055f51a5 100644 --- a/src/hooks/wallet/useSpendBundle.ts +++ b/src/hooks/wallet/useSpendBundle.ts @@ -191,14 +191,18 @@ export const useSpendBundle = () => { } // ─── collateral-only ────────────────────────────────────────────── + // init → passkey → prepare → submit. /init does no Rain call, + // so if the user cancels the passkey at step 2 we never burned + // a Rain signature. /prepare verifies the admin sig BEFORE + // calling Rain. (See peanut-api-ts/src/routes/rain/withdraw.ts + // for the rationale.) if (strategy === 'collateral-only') { - const prep = await rainApi.prepareWithdrawal({ - amount: usdcWeiToRainCents(requiredUsdcAmount).toString(), + const amountCents = usdcWeiToRainCents(requiredUsdcAmount).toString() + const init = await rainApi.initWithdrawal({ + amount: amountCents, recipientAddress: recipient!, directTransfer: true, kind, - // When set, the backend completes the charge directly on - // confirm — caller must skip recordPayment for this strategy. chargeId, }) @@ -208,20 +212,30 @@ export const useSpendBundle = () => { name: RAIN_WITHDRAW_EIP712_DOMAIN_NAME, version: RAIN_WITHDRAW_EIP712_DOMAIN_VERSION, chainId: chainIdNum, - verifyingContract: prep.collateralProxy as Address, - salt: prep.adminSalt as Hex, + verifyingContract: init.collateralProxy as Address, + salt: init.adminSalt as Hex, }, types: rainWithdrawEip712Types, primaryType: 'Withdraw', message: { - user: prep.adminAddress as Address, - asset: prep.tokenAddress as Address, - amount: BigInt(prep.amount), - recipient: prep.recipientAddress as Address, - nonce: BigInt(prep.adminNonce), + 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 + const prep = await rainApi.prepareWithdrawal({ + amount: amountCents, + recipientAddress: recipient!, + directTransfer: true, + kind, + chargeId, + initId: init.initId, + adminSignature, + }) + const { txHash } = await rainApi.submitWithdrawal({ preparationId: prep.preparationId, amount: prep.amount, diff --git a/src/services/rain.ts b/src/services/rain.ts index 3b0bf4c9b..47c290c16 100644 --- a/src/services/rain.ts +++ b/src/services/rain.ts @@ -88,6 +88,38 @@ export interface PrepareRainWithdrawalInput { * The backend then uses the charge intent itself as the prep and marks it * COMPLETED on confirm — so the FE must NOT also call `recordPayment`. */ chargeId?: string + /** init→passkey→prepare flow: pre-signed admin EIP-712 + the matching init + * id. When both are set, /prepare verifies the sig BEFORE calling Rain so + * a cancelled passkey doesn't burn a Rain signature. Legacy callers can + * omit both and get the old fresh-salt-on-/prepare behaviour. */ + initId?: string + adminSignature?: string +} + +export interface InitRainWithdrawalInput { + amount: string + recipientAddress: string + directTransfer: boolean + kind: RainCollateralKind + totalAmountCents?: string + chargeId?: string +} + +export interface InitRainWithdrawalResponse { + initId: string + adminAddress: string + collateralProxy: string + tokenAddress: string + chainId: string + /** USDC wei — exactly what /prepare will require Rain to echo. Use this as + * the `amount` field of the admin EIP-712 message you're about to sign. */ + amount: string + recipientAddress: string + directTransfer: boolean + adminSalt: string + adminNonce: string + /** Unix seconds. After this the init is gone server-side; /prepare 410s. */ + expiresAt: number } export interface PrepareRainWithdrawalResponse { @@ -275,10 +307,29 @@ export const rainApi = { }) }, + /** + * Step 1 of init→passkey→prepare. Backend reads adminNonce on-chain, + * mints an adminSalt, and caches the bundle by initId. NO Rain call yet — + * so a cancelled passkey at step 2 never burns a Rain signature (Rain + * enforces a 5min validity + 2min cooldown per-user gate). + */ + initWithdrawal: async (input: InitRainWithdrawalInput): Promise => { + return rainRequest({ + method: 'POST', + path: '/rain/cards/withdraw/init', + body: input, + }) + }, + /** * Stage a Rain V2 withdrawal: backend fetches Rain's executor signature, * reads the current adminNonce from the collateral proxy, and persists a * short-lived prep record. Caller then signs the admin EIP-712 payload. + * + * Init flow: pass `{ initId, adminSignature }` alongside the params from + * the init response. /prepare consumes the init, verifies the admin sig + * BEFORE calling Rain, and uses the init's adminSalt/adminNonce — so a + * passkey-cancelled flow never burned a Rain sig. */ prepareWithdrawal: async (input: PrepareRainWithdrawalInput): Promise => { return rainRequest({ From d2ef6272176fbb3e54d61d35fca5c5a5a4a01dea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Thu, 14 May 2026 09:25:23 -0300 Subject: [PATCH 2/3] =?UTF-8?q?feat(rain):=20init=E2=86=92passkey=E2=86=92?= =?UTF-8?q?prepare=20on=20mixed=20and=20signSpend=20callers=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/hooks/wallet/useSignSpendBundle.ts | 75 +++++++++++++++++--------- src/hooks/wallet/useSpendBundle.ts | 44 ++++++++++----- 2 files changed, 79 insertions(+), 40 deletions(-) diff --git a/src/hooks/wallet/useSignSpendBundle.ts b/src/hooks/wallet/useSignSpendBundle.ts index 6a76ad7f2..b8be523ba 100644 --- a/src/hooks/wallet/useSignSpendBundle.ts +++ b/src/hooks/wallet/useSignSpendBundle.ts @@ -184,11 +184,14 @@ export const useSignSpendBundle = () => { } // ─── collateral-only ──────────────────────────────────────────── - // Only sign the admin EIP-712 — backend submits the withdrawal via - // the user's session-key UserOp (1 tap total). + // init → admin-passkey → prepare. Only the admin EIP-712 sig is + // needed here — backend submits via the user's session-key UserOp + // downstream. The init flow means a cancelled passkey burns zero + // Rain sigs. if (strategy === 'collateral-only') { - const prep = await rainApi.prepareWithdrawal({ - amount: usdcWeiToRainCents(requiredUsdcAmount).toString(), + const amountCents = usdcWeiToRainCents(requiredUsdcAmount).toString() + const init = await rainApi.initWithdrawal({ + amount: amountCents, recipientAddress: recipient, directTransfer: true, kind, @@ -199,20 +202,29 @@ export const useSignSpendBundle = () => { name: RAIN_WITHDRAW_EIP712_DOMAIN_NAME, version: RAIN_WITHDRAW_EIP712_DOMAIN_VERSION, chainId: chainIdNum, - verifyingContract: prep.collateralProxy as Address, - salt: prep.adminSalt as Hex, + verifyingContract: init.collateralProxy as Address, + salt: init.adminSalt as Hex, }, types: rainWithdrawEip712Types, primaryType: 'Withdraw', message: { - user: prep.adminAddress as Address, - asset: prep.tokenAddress as Address, - amount: BigInt(prep.amount), - recipient: prep.recipientAddress as Address, - nonce: BigInt(prep.adminNonce), + 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 + const prep = await rainApi.prepareWithdrawal({ + amount: amountCents, + recipientAddress: recipient, + directTransfer: true, + kind, + initId: init.initId, + adminSignature, + }) + return { strategy, rainWithdrawal: { @@ -231,22 +243,23 @@ export const useSignSpendBundle = () => { } // ─── mixed ────────────────────────────────────────────────────── - // Pull the shortfall from collateral into the smart account, then - // forward the full amount to the recipient — one atomic UserOp, - // signed without broadcasting. Two passkey taps (admin sig + UserOp). - // Use the kernel account's own address as the admin recipient (the - // address we sign FROM) instead of re-deriving from useAuth. + // init → admin-passkey → prepare → build kernel UserOp. The kernel + // UserOp's own passkey prompt happens downstream (caller broadcasts). + // First passkey-cancel saves a Rain sig burn; second still burns. const adminAddress = kernelAccount.address as Address const shortfall = requiredUsdcAmount - smartBalance - const prep = await rainApi.prepareWithdrawal({ - amount: usdcWeiToRainCents(shortfall).toString(), + const shortfallCents = usdcWeiToRainCents(shortfall).toString() + const totalCents = usdcWeiToRainCents(requiredUsdcAmount).toString() + + const init = await rainApi.initWithdrawal({ + amount: shortfallCents, // directTransfer=false sends tokens to the admin (kernel). Same // semantics as broadcasting useSpendBundle.spend's mixed path. recipientAddress: adminAddress, directTransfer: false, kind, - totalAmountCents: usdcWeiToRainCents(requiredUsdcAmount).toString(), + totalAmountCents: totalCents, }) const adminSignature = (await kernelAccount.signTypedData({ @@ -254,20 +267,30 @@ export const useSignSpendBundle = () => { name: RAIN_WITHDRAW_EIP712_DOMAIN_NAME, version: RAIN_WITHDRAW_EIP712_DOMAIN_VERSION, chainId: chainIdNum, - verifyingContract: prep.collateralProxy as Address, - salt: prep.adminSalt as Hex, + verifyingContract: init.collateralProxy as Address, + salt: init.adminSalt as Hex, }, types: rainWithdrawEip712Types, primaryType: 'Withdraw', message: { - user: prep.adminAddress as Address, - asset: prep.tokenAddress as Address, - amount: BigInt(prep.amount), - recipient: prep.recipientAddress as Address, - nonce: BigInt(prep.adminNonce), + 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 + const prep = await rainApi.prepareWithdrawal({ + amount: shortfallCents, + recipientAddress: adminAddress, + directTransfer: false, + kind, + totalAmountCents: totalCents, + initId: init.initId, + adminSignature, + }) + const withdrawCall = { to: prep.coordinatorAddress as Hex, value: 0n, diff --git a/src/hooks/wallet/useSpendBundle.ts b/src/hooks/wallet/useSpendBundle.ts index 1055f51a5..ae498e034 100644 --- a/src/hooks/wallet/useSpendBundle.ts +++ b/src/hooks/wallet/useSpendBundle.ts @@ -276,10 +276,13 @@ export const useSpendBundle = () => { } // ─── mixed ─────────────────────────────────────────────────────── - // Pull the shortfall from collateral into the smart account, then - // the kernel fires the USDC transfer + any subsequent calls — all in - // one atomic UserOp. Two passkey taps total (admin sig, UserOp). - + // init → admin-passkey → prepare → build kernel UserOp → UserOp-passkey. + // The init flow saves a Rain sig burn when the admin-passkey is + // cancelled. The kernel UserOp passkey still happens AFTER /prepare + // (which is where Rain is called), so a cancel at the second prompt + // will still burn — there's no way around that without restructuring + // the on-chain coordinator interface. ~half the burn cases solved. + // // Admin address = user's peanut-wallet address (kernel smart account). const adminAddress = user?.accounts.find((a) => a.type === AccountType.PEANUT_WALLET)?.identifier as | Address @@ -287,8 +290,11 @@ export const useSpendBundle = () => { if (!adminAddress) throw new Error('useSpendBundle: missing peanut-wallet address for mixed spend') const shortfall = requiredUsdcAmount - smartBalance - const prep = await rainApi.prepareWithdrawal({ - amount: usdcWeiToRainCents(shortfall).toString(), + const shortfallCents = usdcWeiToRainCents(shortfall).toString() + const totalCents = usdcWeiToRainCents(requiredUsdcAmount).toString() + + const init = await rainApi.initWithdrawal({ + amount: shortfallCents, // directTransfer=false sends tokens to the admin (kernel). We still pass // the admin address here; the backend + coordinator treat it as the // withdraw beneficiary, which equals msg.sender-to-be in the follow-up UserOp. @@ -297,7 +303,7 @@ export const useSpendBundle = () => { kind, // History shows the full user-initiated spend, not just the // shortfall Rain signed over. - totalAmountCents: usdcWeiToRainCents(requiredUsdcAmount).toString(), + totalAmountCents: totalCents, }) const kernelClient = getClientForChain(chainIdStr) @@ -306,20 +312,30 @@ export const useSpendBundle = () => { name: RAIN_WITHDRAW_EIP712_DOMAIN_NAME, version: RAIN_WITHDRAW_EIP712_DOMAIN_VERSION, chainId: chainIdNum, - verifyingContract: prep.collateralProxy as Address, - salt: prep.adminSalt as Hex, + verifyingContract: init.collateralProxy as Address, + salt: init.adminSalt as Hex, }, types: rainWithdrawEip712Types, primaryType: 'Withdraw', message: { - user: prep.adminAddress as Address, - asset: prep.tokenAddress as Address, - amount: BigInt(prep.amount), - recipient: prep.recipientAddress as Address, - nonce: BigInt(prep.adminNonce), + 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 + const prep = await rainApi.prepareWithdrawal({ + amount: shortfallCents, + recipientAddress: adminAddress, + directTransfer: false, + kind, + totalAmountCents: totalCents, + initId: init.initId, + adminSignature, + }) + // Backend `/prepare` normalizes the executor salt (bytes32) and signature // to 0x-hex regardless of what Rain returned — trust the wire shape here. const withdrawCall: UserOpEncodedParams = { From 524e326146cf4330100f3fa73758c9e4b322173d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Thu, 14 May 2026 13:57:54 -0300 Subject: [PATCH 3/3] fix(rain/init): kernel-account null guard + friendly init-expiry copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src/hooks/wallet/useSpendBundle.ts | 24 +++++++++++++++++++----- src/utils/friendly-error.utils.tsx | 11 +++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/hooks/wallet/useSpendBundle.ts b/src/hooks/wallet/useSpendBundle.ts index ae498e034..9f3df09dc 100644 --- a/src/hooks/wallet/useSpendBundle.ts +++ b/src/hooks/wallet/useSpendBundle.ts @@ -197,6 +197,15 @@ export const useSpendBundle = () => { // calling Rain. (See peanut-api-ts/src/routes/rain/withdraw.ts // for the rationale.) if (strategy === 'collateral-only') { + // Resolve the kernel account BEFORE the init call so we fail + // fast if auth is still hydrating, and pipeline the sync + // setup with the async network call's start. + const kernelClient = getClientForChain(chainIdStr) + const kernelAccount = kernelClient.account + if (!kernelAccount) { + throw new Error('useSpendBundle: kernel account not initialized') + } + const amountCents = usdcWeiToRainCents(requiredUsdcAmount).toString() const init = await rainApi.initWithdrawal({ amount: amountCents, @@ -206,8 +215,7 @@ export const useSpendBundle = () => { chargeId, }) - 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, @@ -283,7 +291,14 @@ export const useSpendBundle = () => { // will still burn — there's no way around that without restructuring // the on-chain coordinator interface. ~half the burn cases solved. // - // Admin address = user's peanut-wallet address (kernel smart account). + // Resolve the kernel account + admin address synchronously up + // front so auth-still-hydrating fails fast (before any network + // call), and pipeline the sync setup with the init call. + const kernelClient = getClientForChain(chainIdStr) + const kernelAccount = kernelClient.account + if (!kernelAccount) { + throw new Error('useSpendBundle: kernel account not initialized') + } const adminAddress = user?.accounts.find((a) => a.type === AccountType.PEANUT_WALLET)?.identifier as | Address | undefined @@ -306,8 +321,7 @@ export const useSpendBundle = () => { totalAmountCents: totalCents, }) - 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, diff --git a/src/utils/friendly-error.utils.tsx b/src/utils/friendly-error.utils.tsx index 32659fbb6..9509a2506 100644 --- a/src/utils/friendly-error.utils.tsx +++ b/src/utils/friendly-error.utils.tsx @@ -33,6 +33,17 @@ export const rainCollateralErrorMessage = (error: unknown): string | null => { ) { return message ?? text } + // /rain/cards/withdraw/init has a 5-min TTL; if the passkey prompt stalls + // (slow device, hesitation), /prepare returns 410. The raw BE copy starts + // with "Unknown or expired init" / "Init was just consumed" — friendly-ify + // both into a retry-friendly message so the user knows what to do. + if ( + text.includes('Unknown or expired init') || + text.includes('Init was just consumed') || + text.includes('Init does not belong to this user') + ) { + return 'Withdrawal session expired — please try again.' + } return null }