diff --git a/.verify-content-baseline b/.verify-content-baseline index d825567d1..5f6e60798 100644 --- a/.verify-content-baseline +++ b/.verify-content-baseline @@ -1 +1 @@ -726 +745 diff --git a/src/app/(mobile-ui)/card-recovery/page.tsx b/src/app/(mobile-ui)/card-recovery/page.tsx new file mode 100644 index 000000000..8cbbcb913 --- /dev/null +++ b/src/app/(mobile-ui)/card-recovery/page.tsx @@ -0,0 +1,215 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import type { Address, Hex } from 'viem' +import { Button } from '@/components/0_Bruddle/Button' +import { Card } from '@/components/0_Bruddle/Card' +import ErrorAlert from '@/components/Global/ErrorAlert' +import NavHeader from '@/components/Global/NavHeader' +import PeanutLoading from '@/components/Global/PeanutLoading' +import { useKernelClient } from '@/context/kernelClient.context' +import { + RAIN_WITHDRAW_EIP712_DOMAIN_NAME, + RAIN_WITHDRAW_EIP712_DOMAIN_VERSION, + rainWithdrawEip712Types, +} from '@/constants/rain.consts' +import { PEANUT_WALLET_CHAIN } from '@/constants/zerodev.consts' +import { rainApi, type RecoverFundsPreviewResponse } from '@/services/rain' +import { getExplorerUrl } from '@/utils/general.utils' + +type Step = 'preview' | 'confirm' | 'signing' | 'submitting' | 'done' + +/** + * Card collateral recovery flow. + * + * For the deleted-Rain-user case: a user's collateral USDC is sitting on-chain + * in the Rain coordinator's proxy, but Rain's balance endpoint won't return it + * because their Rain user record was deleted. The normal /withdraw flow can't + * see the balance, so we have a dedicated recovery endpoint pair on the + * backend that reads the on-chain balance directly and asks Rain for a + * signature for that exact amount, paid to the user's own smart wallet. + * + * This page wires that flow: preview → confirm → kernel-sign EIP-712 → + * submit. The destination address is decided by the backend and shown here + * for transparency; the FE cannot influence it. + * + * Not linked from anywhere in the main app — accessed by URL only. It's safe + * to share the URL with a user who needs to recover funds: the JWT cookie is + * the only auth, the recipient is server-locked, and the signing step still + * requires the user's passkey. + */ +export default function CardRecoveryPage() { + const router = useRouter() + const { getClientForChain } = useKernelClient() + + const [step, setStep] = useState('preview') + const [preview, setPreview] = useState(null) + const [error, setError] = useState(null) + const [txHash, setTxHash] = useState(null) + + useEffect(() => { + let cancelled = false + ;(async () => { + try { + const data = await rainApi.getRecoverFundsPreview() + if (!cancelled) setPreview(data) + } catch (e) { + if (!cancelled) setError((e as Error).message || 'Could not load recovery preview') + } + })() + return () => { + cancelled = true + } + }, []) + + const handleRecover = useCallback(async () => { + setError(null) + setStep('signing') + try { + // Prepare locks in the amount + recipient server-side. Even if the + // page were tampered with at runtime, the backend signs over the + // values it computed itself. + const prep = await rainApi.prepareRecoverFunds() + + const chainIdStr = String(PEANUT_WALLET_CHAIN.id) + const chainIdNum = Number(prep.chainId) + const kernelClient = getClientForChain(chainIdStr) + + const adminSignature = (await kernelClient.account!.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, + }, + 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), + }, + })) as Hex + + setStep('submitting') + const { txHash: hash } = await rainApi.submitWithdrawal({ + preparationId: prep.preparationId, + amount: prep.amount, + recipientAddress: prep.recipientAddress, + directTransfer: prep.directTransfer, + adminSalt: prep.adminSalt, + adminNonce: prep.adminNonce, + adminSignature, + executorSignature: prep.executorSignature, + executorSalt: prep.executorSalt, + expiresAt: prep.expiresAt, + }) + setTxHash(hash as Hex) + setStep('done') + } catch (e) { + setError((e as Error).message || 'Recovery failed — please try again') + setStep('preview') + } + }, [getClientForChain]) + + if (!preview && !error) return + + return ( +
+ router.push('/home')} /> +
+ {error && } + + {step === 'done' && txHash ? ( + +

Funds sent to your wallet.

+

+ ${formatCents(preview!.amountCents)} USDC has been returned to your peanut wallet. +

+ + View transaction + +
+ ) : ( + preview && ( + <> + +

+ {preview.hasRecoverableCard ? 'Recover your card collateral' : 'No card on file'} +

+

+ This pulls every USDC currently held in your card collateral contract back to your + peanut wallet. Auto-balance is turned off as part of recovery so the rebalancer + can't top up between now and the transfer. +

+ + + + {BigInt(preview.dustWei) > 0n && ( + + )} + +
+ + + + ) + )} +
+
+ ) +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ) +} + +// Render Rain cents (2 dp) as a fixed-precision USD amount with thousand +// separators. Cents are bigint-string from the wire — never Number() them +// directly; > 2^53 risks lossy display on whales. +function formatCents(centsStr: string): string { + const cents = BigInt(centsStr) + const dollars = cents / 100n + const remainder = (cents % 100n).toString().padStart(2, '0') + return `${dollars.toLocaleString('en-US')}.${remainder}` +} + +function shorten(addr: string): string { + if (addr.length <= 12) return addr + return `${addr.slice(0, 6)}…${addr.slice(-4)}` +} diff --git a/src/app/(mobile-ui)/card/page.tsx b/src/app/(mobile-ui)/card/page.tsx index 0e37a72b5..1c996087a 100644 --- a/src/app/(mobile-ui)/card/page.tsx +++ b/src/app/(mobile-ui)/card/page.tsx @@ -8,7 +8,7 @@ import { cardApi, type CardInfoResponse } from '@/services/card' import { useAuth } from '@/context/authContext' import { RAIN_CARD_OVERVIEW_QUERY_KEY, useRainCardOverview } from '@/hooks/useRainCardOverview' import { computeCardState, findActiveCard, type CardTopLevelState } from '@/components/Card/cardState.utils' -import { pollUntilApplyAdvances } from '@/components/Card/cardApply.utils' +import { pollUntilApplyAdvances, pollUntilReady } from '@/components/Card/cardApply.utils' import AddCardEntryScreen from '@/components/Card/AddCardEntryScreen' import ApplicationStatusScreen from '@/components/Card/ApplicationStatusScreen' import CardTermsScreen from '@/components/Card/CardTermsScreen' @@ -360,10 +360,36 @@ const CardPage: FC = () => { pollAbortRef.current = controller try { + // Two-stage poll. First wait for the webhook-stamped readiness flag + // (cheap, DB-only, safe at 1s cadence). Once Sumsub has reviewed + // rain-requirements GREEN, fall through to the existing apply poll. + // + // Previous behaviour: single-stage `pollUntilApplyAdvances` against + // POST /rain/cards — every iteration hit Sumsub for `moveToLevel` + + // `getApplicant` + `getQuestionnaireAnswers`. ~75 Sumsub round-trips + // per stuck user, AND the WebSDK got re-opened on every `incomplete` + // in the race window, showing the user "verification is taking + // longer than expected" (Barbara F-M's 2026-06-02 Crisp escalation). + const readyResult = await pollUntilReady({ + fetchReadiness: () => rainApi.getCardApplyReadiness(), + intervalMs: 1000, + timeoutMs: 30000, + signal: controller.signal, + }) + if (controller.signal.aborted) return + if (readyResult === false) { + setApplyError('Verification is taking longer than expected. Please try again.') + return + } + + // Sumsub is GREEN — single apply call should now advance past + // `incomplete`. Keep `pollUntilApplyAdvances` as a thin safety net + // for the rare case where the webhook flag landed but the + // applicant state hasn't fully propagated (e.g. read-replica lag). const res = await pollUntilApplyAdvances({ fetchApply: () => rainApi.applyForCard({ termsAccepted: false }), intervalMs: 1000, - timeoutMs: 15000, + timeoutMs: 5000, signal: controller.signal, }) if (controller.signal.aborted) return diff --git a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx index dd01b1493..bcc351fb7 100644 --- a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx +++ b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx @@ -129,7 +129,7 @@ jest.mock('@/hooks/useRainCardOverview', () => ({ })) jest.mock('@/utils/balance.utils', () => ({ - rainSpendingPowerToWei: jest.fn(() => 0n), + rainCentsToUsdcUnits: jest.fn(() => 0n), })) const mockUseTransactionDetailsDrawer = jest.fn() diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 447c09730..6c4e37740 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -21,7 +21,7 @@ import { useSignSpendBundle } from '@/hooks/wallet/useSignSpendBundle' import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle' import { rainCollateralErrorMessage } from '@/utils/friendly-error.utils' import { useRainCardOverview } from '@/hooks/useRainCardOverview' -import { rainSpendingPowerToWei } from '@/utils/balance.utils' +import { rainCentsToUsdcUnits } from '@/utils/balance.utils' import { isTxReverted, saveRedirectUrl, formatNumberForDisplay } from '@/utils/general.utils' import { getShakeClass, type ShakeIntensity } from '@/utils/perk.utils' import { @@ -600,7 +600,7 @@ export default function QRPayPage() { requiredUsdcAmount, recipient: MANTECA_DEPOSIT_ADDRESS, smartBalance: balance ?? 0n, - rainSpendingPower: rainSpendingPowerToWei(rainCardOverview?.balance?.spendingPower), + rainSpendingPower: rainCentsToUsdcUnits(rainCardOverview?.balance?.spendingPower), kind: 'QR_PAY', }) } catch (error) { diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index a9f59907a..55743b55f 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -4,7 +4,7 @@ import { useWallet } from '@/hooks/wallet/useWallet' import { useSignSpendBundle } from '@/hooks/wallet/useSignSpendBundle' import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle' import { rainCollateralErrorMessage } from '@/utils/friendly-error.utils' -import { rainSpendingPowerToWei } from '@/utils/balance.utils' +import { rainCentsToUsdcUnits } from '@/utils/balance.utils' import { useRainCardOverview } from '@/hooks/useRainCardOverview' import { useState, useMemo, useContext, useEffect, useCallback, useId } from 'react' import { useRouter, useSearchParams } from 'next/navigation' @@ -341,7 +341,7 @@ export default function MantecaWithdrawFlow() { requiredUsdcAmount, recipient: MANTECA_DEPOSIT_ADDRESS, smartBalance: smartBalance ?? 0n, - rainSpendingPower: rainSpendingPowerToWei(rainCardOverview?.balance?.spendingPower), + rainSpendingPower: rainCentsToUsdcUnits(rainCardOverview?.balance?.spendingPower), kind: 'FIAT_OFFRAMP', }) } catch (error) { diff --git a/src/components/Card/CancelCardModal.tsx b/src/components/Card/CancelCardModal.tsx index e04b3c0a9..e15e5c148 100644 --- a/src/components/Card/CancelCardModal.tsx +++ b/src/components/Card/CancelCardModal.tsx @@ -12,7 +12,7 @@ import { RAIN_CARD_OVERVIEW_QUERY_KEY, useRainCardOverview } from '@/hooks/useRa import { useSignSpendBundle } from '@/hooks/wallet/useSignSpendBundle' import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle' import { useWallet } from '@/hooks/wallet/useWallet' -import { rainSpendingPowerToWei } from '@/utils/balance.utils' +import { rainCentsToUsdcUnits } from '@/utils/balance.utils' type Phase = 'confirm' | 'canceling' | 'feedback' | 'submitting-feedback' | 'thanks' @@ -60,18 +60,18 @@ const CancelCardModal: FC = ({ cardId, isOpen, onClose }) => { // Cancel can be terminal on Rain's side (collateral contract may // become unreachable), so we MUST drain it BEFORE the cancel. // Backend enforces order — this just delivers the signed body. - const spendingPowerWei = rainSpendingPowerToWei(overview?.balance?.spendingPower) + const spendingPowerUnits = rainCentsToUsdcUnits(overview?.balance?.spendingPower) let verifiedWithdrawal: import('@/hooks/wallet/useSignSpendBundle').SignedRainWithdrawal | undefined - if (spendingPowerWei > 0n) { + if (spendingPowerUnits > 0n) { if (!smartWalletAddress) { throw new Error('Wallet not ready — please retry in a moment') } // Force collateral-only routing — same pattern as LockCardModal. const artifact = await signSpend({ - requiredUsdcAmount: spendingPowerWei, + requiredUsdcAmount: spendingPowerUnits, recipient: smartWalletAddress as `0x${string}`, smartBalance: 0n, - rainSpendingPower: spendingPowerWei, + rainSpendingPower: spendingPowerUnits, kind: 'CRYPTO_WITHDRAW', }) if (artifact.strategy !== 'collateral-only') { diff --git a/src/components/Card/LockCardModal.tsx b/src/components/Card/LockCardModal.tsx index e174adb22..c74f33c22 100644 --- a/src/components/Card/LockCardModal.tsx +++ b/src/components/Card/LockCardModal.tsx @@ -12,7 +12,7 @@ import { RAIN_CARD_OVERVIEW_QUERY_KEY, useRainCardOverview } from '@/hooks/useRa import { useSignSpendBundle } from '@/hooks/wallet/useSignSpendBundle' import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle' import { useWallet } from '@/hooks/wallet/useWallet' -import { rainSpendingPowerToWei } from '@/utils/balance.utils' +import { rainCentsToUsdcUnits } from '@/utils/balance.utils' type Mode = 'lock' | 'unlock' type Phase = 'prompt' | 'loading' | 'success' | 'error' @@ -67,9 +67,9 @@ const LockCardModal: FC = ({ cardId, mode, isOpen, onClose }) => { // smart wallet BEFORE locking so funds stay liquid. The // backend gates the lock on a successful withdrawal — order // is handled there. We only need to deliver the signed body. - const spendingPowerWei = rainSpendingPowerToWei(overview?.balance?.spendingPower) + const spendingPowerUnits = rainCentsToUsdcUnits(overview?.balance?.spendingPower) let verifiedWithdrawal: import('@/hooks/wallet/useSignSpendBundle').SignedRainWithdrawal | undefined - if (spendingPowerWei > 0n) { + if (spendingPowerUnits > 0n) { if (!smartWalletAddress) { throw new Error('Wallet not ready — please retry in a moment') } @@ -78,10 +78,10 @@ const LockCardModal: FC = ({ cardId, mode, isOpen, onClose }) => { // picks 'collateral-only' and signs a Rain withdrawal // straight to the user's smart wallet (1 passkey tap). const artifact = await signSpend({ - requiredUsdcAmount: spendingPowerWei, + requiredUsdcAmount: spendingPowerUnits, recipient: smartWalletAddress as `0x${string}`, smartBalance: 0n, - rainSpendingPower: spendingPowerWei, + rainSpendingPower: spendingPowerUnits, kind: 'CRYPTO_WITHDRAW', }) if (artifact.strategy !== 'collateral-only') { diff --git a/src/components/Card/cardApply.utils.ts b/src/components/Card/cardApply.utils.ts index 9787babe2..3ada472cc 100644 --- a/src/components/Card/cardApply.utils.ts +++ b/src/components/Card/cardApply.utils.ts @@ -35,3 +35,46 @@ export async function pollUntilApplyAdvances({ if (now() - start >= timeoutMs) return null } } + +/** + * Cheap poll-until-ready helper for the post-Sumsub-WebSDK-close window. + * + * Each `fetchReadiness` call reads a single webhook-stamped flag from our DB + * (no Sumsub round-trip), so it's safe to poll fast. Returns `true` when the + * backend reports `ready: true` (Sumsub finished reviewing rain-requirements + * GREEN), `false` on timeout, `null` on abort. + * + * Replaces the previous pattern of polling `POST /rain/cards` itself — each + * of those calls did `moveToLevel` + `getApplicant` + `getQuestionnaireAnswers` + * against Sumsub's API, costing ~5 round-trips per poll × 15 polls per stuck + * user. + */ +export async function pollUntilReady({ + fetchReadiness, + intervalMs, + timeoutMs, + signal, + sleep = (ms) => new Promise((r) => setTimeout(r, ms)), + now = () => Date.now(), +}: { + fetchReadiness: () => Promise<{ ready: boolean }> + intervalMs: number + timeoutMs: number + signal?: AbortSignal + sleep?: (ms: number) => Promise + now?: () => number +}): Promise { + const start = now() + while (true) { + if (signal?.aborted) return null + try { + const { ready } = await fetchReadiness() + if (ready) return true + } catch { + // Swallow transient fetch errors — the next poll iteration retries. + } + await sleep(intervalMs) + if (signal?.aborted) return null + if (now() - start >= timeoutMs) return false + } +} diff --git a/src/constants/routes.ts b/src/constants/routes.ts index 2f16d9b26..6ae52ee85 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -43,6 +43,7 @@ export const DEDICATED_ROUTES = [ 'limits', 'notifications', 'recover-funds', + 'card-recovery', // Public pages (existing) 'm', // merchant landing pages (/m/[slug]) — added on main; register so the catch-all never treats it as a recipient diff --git a/src/content b/src/content index 177594605..b816c97ad 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit 177594605b74ab42f90897da1d12a2abf13a925f +Subproject commit b816c97ad1537de32deba3ba6984a7e903ad0471 diff --git a/src/context/kernelClient.context.tsx b/src/context/kernelClient.context.tsx index 5fd0d65ba..1b2274fc3 100644 --- a/src/context/kernelClient.context.tsx +++ b/src/context/kernelClient.context.tsx @@ -395,6 +395,29 @@ export const KernelClientProvider = ({ children }: { children: ReactNode }) => { inFlightRef.current.delete(primaryChainId) } + // Guard: the restored WebAuthnKey must belong to the logged-in user. + // On a shared device with two Peanut accounts (two passkeys for the + // same RP), the restore path can pair this session with the OTHER + // account's credential. The derived kernel address then differs from + // the user's real smart-wallet address, and any server-bound ERC-1271 + // check (Rain card withdraw) rejects the signature as "invalid admin + // signature". Purge the poisoned key and force a clean re-auth rather + // than silently signing with the wrong credential. Skipped when the + // user has no smart-wallet account yet (mid-registration) and for + // pre-migration users (address is passed in, so it always matches). + const expectedAddress = user?.accounts.find((a) => a.type === AccountType.PEANUT_WALLET)?.identifier + const derivedAddress = kernelClient.account?.address + if (expectedAddress && derivedAddress && derivedAddress.toLowerCase() !== expectedAddress.toLowerCase()) { + console.error('[KernelClient] restored WebAuthnKey does not match the logged-in user — purging', { + userId: user?.user.userId, + derivedAddress, + expectedAddress, + }) + if (user?.user.userId) updateUserPreferences(user.user.userId, { webAuthnKey: undefined }) + logoutUser() + return + } + // Only update state after primary succeeds — avoids // registering→not→registering UI flicker between retries. if (isMounted) { diff --git a/src/hooks/wallet/__tests__/useSpendBundle.test.ts b/src/hooks/wallet/__tests__/useSpendBundle.test.ts index 0fa021a6f..885201a4a 100644 --- a/src/hooks/wallet/__tests__/useSpendBundle.test.ts +++ b/src/hooks/wallet/__tests__/useSpendBundle.test.ts @@ -5,7 +5,7 @@ * session-key grant flow — those paths are covered by integration + manual * testing on sandbox. These tests lock down the deterministic pieces: * - `computeSpendStrategy` routing (collateral → smart → mixed → insufficient) - * - `usdcWeiToRainCents` amount conversion at the Rain API boundary + * - `usdcUnitsToRainCents` amount conversion at the Rain API boundary */ // Mock the ZeroDev imports so Jest doesn't try to parse their ESM. diff --git a/src/hooks/wallet/useSendMoney.ts b/src/hooks/wallet/useSendMoney.ts index 5e8e3ad27..7222b07ce 100644 --- a/src/hooks/wallet/useSendMoney.ts +++ b/src/hooks/wallet/useSendMoney.ts @@ -6,7 +6,7 @@ import { TRANSACTIONS, BALANCE_DECREASE, SEND_MONEY } from '@/constants/query.co import { useToast } from '@/components/0_Bruddle/Toast' import { useBalance } from './useBalance' import { useRainCardOverview, RAIN_CARD_OVERVIEW_QUERY_KEY } from '../useRainCardOverview' -import { rainSpendingPowerToWei } from '@/utils/balance.utils' +import { rainCentsToUsdcUnits } from '@/utils/balance.utils' import type { RainCollateralKind } from '@/services/rain' import { InsufficientSpendableError, @@ -74,7 +74,7 @@ export const useSendMoney = ({ address }: UseSendMoneyOptions) => { requiredUsdcAmount: amountToSend, recipient: toAddress, smartBalance: smartBalance ?? 0n, - rainSpendingPower: rainSpendingPowerToWei(overview?.balance?.spendingPower), + rainSpendingPower: rainCentsToUsdcUnits(overview?.balance?.spendingPower), kind, chargeId, onStrategyDecided, diff --git a/src/hooks/wallet/useSignSpendBundle.ts b/src/hooks/wallet/useSignSpendBundle.ts index 6a76ad7f2..cecbd2c3d 100644 --- a/src/hooks/wallet/useSignSpendBundle.ts +++ b/src/hooks/wallet/useSignSpendBundle.ts @@ -23,7 +23,7 @@ import { SessionKeyGrantRequiredError, type SpendStrategy, } from './useSpendBundle' -import { usdcWeiToRainCents } from '@/utils/balance.utils' +import { usdcUnitsToRainCents } from '@/utils/balance.utils' /** * Wire payload for a Rain withdrawal that the backend will broadcast (via @@ -188,7 +188,7 @@ export const useSignSpendBundle = () => { // the user's session-key UserOp (1 tap total). if (strategy === 'collateral-only') { const prep = await rainApi.prepareWithdrawal({ - amount: usdcWeiToRainCents(requiredUsdcAmount).toString(), + amount: usdcUnitsToRainCents(requiredUsdcAmount).toString(), recipientAddress: recipient, directTransfer: true, kind, @@ -240,13 +240,13 @@ export const useSignSpendBundle = () => { const shortfall = requiredUsdcAmount - smartBalance const prep = await rainApi.prepareWithdrawal({ - amount: usdcWeiToRainCents(shortfall).toString(), + amount: usdcUnitsToRainCents(shortfall).toString(), // 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: usdcUnitsToRainCents(requiredUsdcAmount).toString(), }) const adminSignature = (await kernelAccount.signTypedData({ diff --git a/src/hooks/wallet/useSpendBundle.ts b/src/hooks/wallet/useSpendBundle.ts index 1493556d6..bee2c7f06 100644 --- a/src/hooks/wallet/useSpendBundle.ts +++ b/src/hooks/wallet/useSpendBundle.ts @@ -19,7 +19,7 @@ import { rainApi, type RainCollateralKind } from '@/services/rain' import { useZeroDev } from '@/hooks/useZeroDev' import { useRainCardOverview } from '@/hooks/useRainCardOverview' import { useGrantSessionKey, type GrantSessionKeyError } from './useGrantSessionKey' -import { usdcWeiToRainCents } from '@/utils/balance.utils' +import { usdcUnitsToRainCents } from '@/utils/balance.utils' import { useModalsContextOptional } from '@/context/ModalsContext' export type SpendStrategy = 'collateral-only' | 'smart-only' | 'mixed' | 'insufficient' @@ -95,11 +95,11 @@ export class SessionKeyGrantRequiredError extends Error { } } -// `usdcWeiToRainCents` lives in @/utils/balance.utils alongside its sibling -// `rainSpendingPowerToWei`. Rain's wire convention is asymmetric: cents (2dp) +// `usdcUnitsToRainCents` lives in @/utils/balance.utils alongside its sibling +// `rainCentsToUsdcUnits`. Rain's wire convention is asymmetric: cents (2dp) // on INPUT to /prepare, USDC wei (PEANUT_WALLET_TOKEN_DECIMALS) on OUTPUT in // the signed parameters (what the EIP-712 message + coordinator sign over). -// `usdcWeiToRainCents` is for the input side only — never call it on amounts +// `usdcUnitsToRainCents` is for the input side only — never call it on amounts // returned from Rain. /** @@ -200,7 +200,7 @@ export const useSpendBundle = () => { // ─── collateral-only ────────────────────────────────────────────── if (strategy === 'collateral-only') { const prep = await rainApi.prepareWithdrawal({ - amount: usdcWeiToRainCents(requiredUsdcAmount).toString(), + amount: usdcUnitsToRainCents(requiredUsdcAmount).toString(), recipientAddress: recipient!, directTransfer: true, kind, @@ -281,7 +281,7 @@ export const useSpendBundle = () => { const shortfall = requiredUsdcAmount - smartBalance const prep = await rainApi.prepareWithdrawal({ - amount: usdcWeiToRainCents(shortfall).toString(), + amount: usdcUnitsToRainCents(shortfall).toString(), // 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. @@ -290,7 +290,7 @@ export const useSpendBundle = () => { kind, // History shows the full user-initiated spend, not just the // shortfall Rain signed over. - totalAmountCents: usdcWeiToRainCents(requiredUsdcAmount).toString(), + totalAmountCents: usdcUnitsToRainCents(requiredUsdcAmount).toString(), }) const kernelClient = getClientForChain(chainIdStr) diff --git a/src/hooks/wallet/useWallet.ts b/src/hooks/wallet/useWallet.ts index 180e14f2c..7b5c7e067 100644 --- a/src/hooks/wallet/useWallet.ts +++ b/src/hooks/wallet/useWallet.ts @@ -14,7 +14,7 @@ import { useBalance } from './useBalance' import { useSendMoney as useSendMoneyMutation } from './useSendMoney' import { formatCurrency } from '@/utils/general.utils' import { useRainCardOverview, RAIN_CARD_OVERVIEW_QUERY_KEY } from '../useRainCardOverview' -import { rainSpendingPowerToWei } from '@/utils/balance.utils' +import { computeAvailableSpendable, computeDisplaySpendable, rainCentsToUsdcUnits } from '@/utils/balance.utils' import { useSpendBundle, type SpendStrategy } from './useSpendBundle' import type { RainCollateralKind } from '@/services/rain' @@ -147,7 +147,7 @@ export const useWallet = () => { // calls, route through useSpendBundle so Rain collateral can top up // the smart account within the same UserOp when the balance is short. if (options.requiredUsdcAmount !== undefined) { - const rainSpendingPower = rainSpendingPowerToWei(rainOverview?.balance?.spendingPower) + const rainSpendingPower = rainCentsToUsdcUnits(rainOverview?.balance?.spendingPower) const smartNow = balanceFromQuery ?? 0n const result = await spendBundle({ requiredUsdcAmount: options.requiredUsdcAmount, @@ -202,18 +202,27 @@ export const useWallet = () => { // consider balance as fetching until: address is validated and query has resolved const isBalanceLoading = !isAddressReady || isFetchingBalance - // Rain collateral (spendingPower) — added to the smart-account balance to produce - // the single "spendable" number the user sees on home. See docs §4.5 and §6 in - // peanut-api-ts/docs/rain-card-test-summary.md and the card design spec. - // `rainOverview` is declared above so `sendTransactions` can consult it too. - const rainSpendingPowerWei = useMemo( - () => rainSpendingPowerToWei(rainOverview?.balance?.spendingPower), - [rainOverview?.balance?.spendingPower] - ) + // Two flavours of "spendable", both summing the smart-account balance with + // Rain collateral. See docs §4.5 and §6 in peanut-api-ts/docs/rain-card-test-summary.md + // and the card design spec. `rainOverview` is declared above so `sendTransactions` + // can consult it too. + // • availableSpendableBalance — smart + LANDED collateral. What can actually + // be spent right now; backs the affordability gate + spend routing. + // • rawSpendableBalance (display) — also adds collateral top-ups still in + // transit, so the unified balance doesn't crater to 0 during the ~10–45s + // smart→collateral auto-balance handoff. + const availableSpendableBalance = useMemo(() => { + if (balance === undefined) return undefined + return computeAvailableSpendable(balance, rainOverview?.balance?.spendingPower) + }, [balance, rainOverview?.balance?.spendingPower]) const rawSpendableBalance = useMemo(() => { if (balance === undefined) return undefined - return balance + rainSpendingPowerWei - }, [balance, rainSpendingPowerWei]) + return computeDisplaySpendable( + balance, + rainOverview?.balance?.spendingPower, + rainOverview?.balance?.inTransitToCollateralCents + ) + }, [balance, rainOverview?.balance?.spendingPower, rainOverview?.balance?.inTransitToCollateralCents]) // The two inputs (smart-account + rain overview) refresh independently. // When both change at once (e.g. auto-balancer deposit: smart goes down, @@ -252,23 +261,29 @@ export const useWallet = () => { // they actually have and the "insufficient balance" gate trips even // though useSpendBundle would route through collateral just fine // (2026-05-08 jotest097 report TASK-19573). + // NOTE: derived from `spendableBalance`, which includes in-transit collateral + // top-ups, so during the ~10–45s smart→collateral handoff this can read + // higher than `hasSufficientSpendableBalance` allows (gate is on available-now, + // by design — those funds aren't routable until they land). Self-heals in seconds. const formattedSpendableBalance = useMemo(() => { if (spendableBalance === undefined) return '0.00' return formatCurrency(formatUnits(spendableBalance, PEANUT_WALLET_TOKEN_DECIMALS)) }, [spendableBalance]) - // Check if the user has enough spendable to cover a USD amount. - // Spendable = smart account + Rain collateral `spendingPower`. Use this - // anywhere a user-facing "can you afford X?" gate is needed. + // Check if the user has enough spendable to cover a USD amount. Gates on + // available-now (smart + LANDED collateral) — NOT the displayed total, which + // includes in-transit top-ups that can't be routed until they land. Using the + // real figure avoids green-lighting a send that would fail at execution during + // the brief top-up window. const hasSufficientSpendableBalance = useCallback( (amountUsd: string | number): boolean => { - if (spendableBalance === undefined) return false + if (availableSpendableBalance === undefined) return false const amount = typeof amountUsd === 'string' ? parseFloat(amountUsd) : amountUsd if (isNaN(amount) || amount < 0) return false - const amountInWei = BigInt(Math.floor(amount * 10 ** PEANUT_WALLET_TOKEN_DECIMALS)) - return spendableBalance >= amountInWei + const amountInBaseUnits = BigInt(Math.floor(amount * 10 ** PEANUT_WALLET_TOKEN_DECIMALS)) + return availableSpendableBalance >= amountInBaseUnits }, - [spendableBalance] + [availableSpendableBalance] ) return { diff --git a/src/services/rain.ts b/src/services/rain.ts index 0c7fe8d32..1bdef3bf9 100644 --- a/src/services/rain.ts +++ b/src/services/rain.ts @@ -31,6 +31,13 @@ export interface RainCardBalance { pendingCharges: number postedCharges: number balanceDue: number + /** + * Card collateral top-up funds debited from the smart account on-chain but + * not yet credited to Rain collateral (the ~10–45s smart→collateral + * handoff). Folded into the displayed balance so it doesn't crater to 0 + * mid-top-up. Optional for backward-compat with a pre-deploy backend. + */ + inTransitToCollateralCents?: number } export interface RainCardSummary { @@ -75,7 +82,7 @@ export type RainCollateralKind = export interface PrepareRainWithdrawalInput { /** Rain cents (2dp), as a decimal string. e.g. `"500"` for $5.00. - * Convert from USDC wei via `usdcWeiToRainCents` at the boundary. */ + * Convert from USDC wei via `usdcUnitsToRainCents` at the boundary. */ amount: string recipientAddress: string directTransfer: boolean @@ -129,6 +136,33 @@ export interface SubmitRainWithdrawalResponse { txHash: string } +// ─── Funds-recovery types ──────────────────────────────────────────────────── +// +// Recovery is for the deleted-Rain-user case: Rain's balance endpoint stops +// returning the collateral, but the on-chain USDC is still there and Rain's +// signature endpoint still works. The server determines amount + recipient; +// the FE just signs the admin EIP-712 over what the server gives it. See +// peanut-api-ts/src/routes/rain/recover-funds.ts for the contract. + +export interface RecoverFundsPreviewResponse { + collateralProxy: string + /** The user's own smart-wallet address — the only allowed recipient. */ + recipient: string + /** Full on-chain USDC balance in token smallest units (6 dp). */ + amountWei: string + /** Recoverable amount in Rain cents (2 dp). */ + amountCents: string + /** Wei below one cent — stays in the contract after recovery. */ + dustWei: string + autoBalanceEnabled: boolean + hasRecoverableCard: boolean +} + +export interface PrepareRecoverFundsResponse extends PrepareRainWithdrawalResponse { + amountCents: string + dustWei: string +} + // ─── Types for card management endpoints ──────────────────────────────────── export interface RainCardDetailsResponse { @@ -354,6 +388,37 @@ export const rainApi = { }) }, + /** + * Read-only preview of what would be recovered: on-chain USDC balance, + * the user's smart-wallet recipient, and the current autoBalanceEnabled + * flag. Backed by GET /rain/cards/recover-funds/preview — no side + * effects, so safe to call on page mount and on refresh. + */ + getRecoverFundsPreview: async (): Promise => { + return rainRequest({ + method: 'GET', + path: '/rain/cards/recover-funds/preview', + noStore: true, + }) + }, + + /** + * Side-effectful: flips autoBalanceEnabled to false, reads on-chain + * balance, fetches Rain's executor signature for the FULL cent-aligned + * amount payable to the user's smart wallet, creates a TransactionIntent. + * Returns the prepared payload the caller signs with their kernel and + * submits to /rain/cards/withdraw/submit (unchanged). + * + * Empty body on purpose — amount and recipient are server-locked. + */ + prepareRecoverFunds: async (): Promise => { + return rainRequest({ + method: 'POST', + path: '/rain/cards/recover-funds/prepare', + body: {}, + }) + }, + /** * Stamp a client-submitted mixed-strategy UserOp with its on-chain tx * hash so the Rain collateral webhook can reconcile against the right @@ -399,6 +464,27 @@ export const rainApi = { }) }, + /** + * Cheap polling endpoint for the post-Sumsub WebSDK-close window. + * + * `applyForCard` is a heavy call — each invocation does `moveToLevel` + + * `getApplicant` + `getQuestionnaireAnswers` against Sumsub's API. Polling + * it every second for 15s during the async-review race adds up to ~75 + * Sumsub round-trips per stuck user. This endpoint reads a single + * webhook-stamped flag from our DB instead, so it's safe to poll at high + * frequency without burning Sumsub rate budget. + */ + getCardApplyReadiness: async (): Promise<{ + ready: boolean + hasApplication: boolean + readyAt?: string + }> => { + return rainRequest<{ ready: boolean; hasApplication: boolean; readyAt?: string }>({ + method: 'GET', + path: '/rain/cards/readiness', + }) + }, + /** Activate a card (from locked or not-activated). Returns the new Rain status. */ activateCard: async (cardId: string): Promise => { const { status } = await rainRequest<{ status: string }>({ diff --git a/src/utils/__tests__/balance.utils.test.ts b/src/utils/__tests__/balance.utils.test.ts index f30ba0c92..06eaf2269 100644 --- a/src/utils/__tests__/balance.utils.test.ts +++ b/src/utils/__tests__/balance.utils.test.ts @@ -1,4 +1,9 @@ -import { printableUsdc, rainSpendingPowerToWei } from '../balance.utils' +import { + computeAvailableSpendable, + computeDisplaySpendable, + printableUsdc, + rainCentsToUsdcUnits, +} from '../balance.utils' describe('balance utils', () => { describe('printableUsdc', () => { @@ -27,34 +32,77 @@ describe('balance utils', () => { }) }) - describe('rainSpendingPowerToWei', () => { + describe('rainCentsToUsdcUnits', () => { it.each([ - // [cents input, expected USDC wei (6dp)] + // [cents input, expected USDC base units (6dp)] [0, 0n], - [1, 10_000n], // $0.01 → 10_000 wei - [100, 1_000_000n], // $1.00 → 1_000_000 wei - [4950, 49_500_000n], // $49.50 → 49_500_000 wei - [50_000, 500_000_000n], // $500.00 → 500_000_000 wei - ])('widens %i cents to %s wei', (cents, expected) => { - expect(rainSpendingPowerToWei(cents)).toBe(expected) + [1, 10_000n], // $0.01 → 10_000 base units + [100, 1_000_000n], // $1.00 → 1_000_000 base units + [4950, 49_500_000n], // $49.50 → 49_500_000 base units + [50_000, 500_000_000n], // $500.00 → 500_000_000 base units + ])('widens %i cents to %s base units', (cents, expected) => { + expect(rainCentsToUsdcUnits(cents)).toBe(expected) }) it.each([[null], [undefined], [-100], [Number.NaN], [Number.POSITIVE_INFINITY], [Number.NEGATIVE_INFINITY]])( 'returns 0n for invalid input (%s)', (input) => { - expect(rainSpendingPowerToWei(input)).toBe(0n) + expect(rainCentsToUsdcUnits(input)).toBe(0n) } ) - it('sums cleanly with a smart-account balance in wei', () => { + it('sums cleanly with a smart-account balance in base units', () => { const smartAccount = 150_000_000n // $150.00 USDC (6dp) const rainCents = 4950 // $49.50 - const total = smartAccount + rainSpendingPowerToWei(rainCents) + const total = smartAccount + rainCentsToUsdcUnits(rainCents) expect(printableUsdc(total)).toBe('199.50') }) it("floors fractional cents (shouldn't happen but is defensive)", () => { - expect(rainSpendingPowerToWei(99.9)).toBe(990_000n) // floors to 99 cents + expect(rainCentsToUsdcUnits(99.9)).toBe(990_000n) // floors to 99 cents }) }) + + describe('computeAvailableSpendable', () => { + it('sums smart-account balance with landed collateral', () => { + // $150 smart + $49.50 collateral = $199.50 + expect(printableUsdc(computeAvailableSpendable(150_000_000n, 4950))).toBe('199.50') + }) + + it.each([[null], [undefined], [0]])('returns smart-only when spendingPower is %s', (cents) => { + expect(computeAvailableSpendable(1_000_000n, cents)).toBe(1_000_000n) + }) + }) + + describe('computeDisplaySpendable', () => { + it('holds the displayed balance steady mid-top-up (funds left smart, collateral not yet landed)', () => { + // Auto-balancer moved the user's $500 from smart → collateral. The + // on-chain debit landed (smart now 0) but Rain hasn't credited the + // collateral yet, so spendingPower is still 0. + const smart = 0n + const spendingPowerCents = 0 + const inTransitCents = 50_000 // $500 mid-flight + // available-now craters to $0 ... + expect(computeAvailableSpendable(smart, spendingPowerCents)).toBe(0n) + // ... but the displayed total stays at $500 (no scary $0 flash). + expect(printableUsdc(computeDisplaySpendable(smart, spendingPowerCents, inTransitCents))).toBe('500.00') + }) + + it('keeps the displayed total conserved as the collateral lands', () => { + const preLanding = computeDisplaySpendable(0n, 0, 50_000) // smart 0, sp 0, in-transit $500 + const postLanding = computeDisplaySpendable(0n, 50_000, 0) // smart 0, sp $500, in-transit 0 + expect(preLanding).toBe(postLanding) + expect(printableUsdc(preLanding)).toBe('500.00') + }) + + it.each([[0], [null], [undefined]])( + 'equals available-now when nothing is in transit (%s)', + (inTransitCents) => { + const smart = 150_000_000n + expect(computeDisplaySpendable(smart, 4950, inTransitCents)).toBe( + computeAvailableSpendable(smart, 4950) + ) + } + ) + }) }) diff --git a/src/utils/balance.utils.ts b/src/utils/balance.utils.ts index 99ac67385..da3d4b280 100644 --- a/src/utils/balance.utils.ts +++ b/src/utils/balance.utils.ts @@ -18,28 +18,62 @@ export const printableUsdc = (balance: bigint): string => { * Returns 0n for null/undefined/negative/non-finite inputs so callers can * safely pass `overview?.balance?.spendingPower` without pre-guarding. */ -export const rainSpendingPowerToWei = (spendingPowerCents: number | null | undefined): bigint => { +export const rainCentsToUsdcUnits = (spendingPowerCents: number | null | undefined): bigint => { if (spendingPowerCents == null || !Number.isFinite(spendingPowerCents) || spendingPowerCents <= 0) { return 0n } - // cents (2dp) → USDC wei (PEANUT_WALLET_TOKEN_DECIMALS) — widen by 10^(decimals - 2) + // cents (2dp) → USDC base units (PEANUT_WALLET_TOKEN_DECIMALS) — widen by 10^(decimals - 2) const widenFactor = BigInt(10 ** (PEANUT_WALLET_TOKEN_DECIMALS - 2)) return BigInt(Math.floor(spendingPowerCents)) * widenFactor } /** - * Convert a USDC wei amount (PEANUT_WALLET_TOKEN_DECIMALS, typically 6dp) to - * cents (2dp), the unit Rain's `/signatures/withdrawals` API takes on its + * Available-now spendable balance, as a USDC base-unit bigint (6dp) — the + * smart-account balance plus landed Rain collateral `spendingPower`. This is + * what the user can actually spend right now (`useSpendBundle` routes through + * the smart account and landed collateral), so it backs the affordability gate + * and spend routing. + */ +export const computeAvailableSpendable = ( + smartBalance: bigint, + spendingPowerCents: number | null | undefined +): bigint => smartBalance + rainCentsToUsdcUnits(spendingPowerCents) + +/** + * Total spendable balance for DISPLAY, as a USDC base-unit bigint (6dp) — + * available-now plus card collateral top-ups still in transit. + * + * The auto-balancer debits the smart account on-chain ~10–45s before Rain + * credits the collateral; in that gap the funds are in neither bucket and the + * raw `smart + spendingPower` sum craters to 0. Adding the in-transit amount + * (from the backend's `inTransitToCollateralCents`) keeps the unified balance + * steady through the handoff. + * + * In-transit funds aren't spendable until they land, so they are deliberately + * EXCLUDED from `computeAvailableSpendable` (gate + routing). During the window + * the displayed total therefore exceeds spendable-now by the in-flight amount — + * by design — and reconciles within seconds once collateral lands. + */ +export const computeDisplaySpendable = ( + smartBalance: bigint, + spendingPowerCents: number | null | undefined, + inTransitToCollateralCents: number | null | undefined +): bigint => + computeAvailableSpendable(smartBalance, spendingPowerCents) + rainCentsToUsdcUnits(inTransitToCollateralCents) + +/** + * Convert a USDC base-unit amount (PEANUT_WALLET_TOKEN_DECIMALS, typically 6dp) + * to cents (2dp), the unit Rain's `/signatures/withdrawals` API takes on its * INPUT side. Rounds up so a sub-cent shortfall still withdraws at least one * cent — Rain rejects 0-amount withdrawals. * - * Asymmetry warning: Rain accepts cents on input but RETURNS the signed - * amount in USDC wei (it's what the EIP-712 message + on-chain coordinator - * sign over). The prepare → /submit roundtrip is cents-in / wei-out. Don't + * Asymmetry warning: Rain accepts cents on input but RETURNS the signed amount + * in USDC base units (it's what the EIP-712 message + on-chain coordinator sign + * over). The prepare → /submit roundtrip is cents-in / base-units-out. Don't * use this function on values returned from Rain. */ -export const usdcWeiToRainCents = (amountWei: bigint): bigint => { - if (amountWei <= 0n) return 0n +export const usdcUnitsToRainCents = (amountUnits: bigint): bigint => { + if (amountUnits <= 0n) return 0n const divisor = 10n ** BigInt(PEANUT_WALLET_TOKEN_DECIMALS - 2) - return (amountWei + divisor - 1n) / divisor + return (amountUnits + divisor - 1n) / divisor }