Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 49 additions & 26 deletions src/hooks/wallet/useSignSpendBundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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: {
Expand All @@ -231,43 +243,54 @@ 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({
domain: {
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,
Expand Down
104 changes: 74 additions & 30 deletions src/hooks/wallet/useSpendBundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,37 +191,59 @@ 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(),
// 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,
recipientAddress: recipient!,
directTransfer: true,
kind,
// When set, the backend completes the charge directly on
// confirm — caller must skip recordPayment for this strategy.
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,
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,
Expand Down Expand Up @@ -262,19 +284,32 @@ 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).

// Admin address = user's peanut-wallet address (kernel smart account).
// 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.
//
// 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
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.
Expand All @@ -283,29 +318,38 @@ 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)
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,
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 = {
Expand Down
51 changes: 51 additions & 0 deletions src/services/rain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<InitRainWithdrawalResponse> => {
return rainRequest<InitRainWithdrawalResponse>({
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<PrepareRainWithdrawalResponse> => {
return rainRequest<PrepareRainWithdrawalResponse>({
Expand Down
11 changes: 11 additions & 0 deletions src/utils/friendly-error.utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading