From e43179c7f0646872f1cc9fe42f1580c947787b74 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 2 Jun 2026 22:06:10 +0100 Subject: [PATCH 01/10] fix(card): keep unified balance steady during collateral top-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the peanut-api-ts change. The home balance is a unified smart-account + Rain collateral figure; during an auto-balance top-up the on-chain debit lands ~10-45s before Rain credits the collateral, so the sum counted the funds in neither bucket and flashed to $0 — alarming on every top-up. Consume the backend's inTransitToCollateralCents: fold it into the DISPLAY balance (computeDisplaySpendable) so it no longer craters, while the spend gate + routing stay on available-now funds (computeAvailableSpendable) so we never green-light a send that can't be routed until collateral lands. Math extracted to balance.utils + unit-tested. Field is optional, so the FE is safe against a pre-deploy backend. --- src/hooks/wallet/useWallet.ts | 47 +++++++++++++-------- src/services/rain.ts | 7 ++++ src/utils/__tests__/balance.utils.test.ts | 50 ++++++++++++++++++++++- src/utils/balance.utils.ts | 34 +++++++++++++++ 4 files changed, 119 insertions(+), 19 deletions(-) diff --git a/src/hooks/wallet/useWallet.ts b/src/hooks/wallet/useWallet.ts index 180e14f2c..46a4b6ca6 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, rainSpendingPowerToWei } from '@/utils/balance.utils' import { useSpendBundle, type SpendStrategy } from './useSpendBundle' import type { RainCollateralKind } from '@/services/rain' @@ -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, @@ -257,18 +266,20 @@ export const useWallet = () => { 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..c0be7a7be 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 { diff --git a/src/utils/__tests__/balance.utils.test.ts b/src/utils/__tests__/balance.utils.test.ts index f30ba0c92..3d27f862f 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, + rainSpendingPowerToWei, +} from '../balance.utils' describe('balance utils', () => { describe('printableUsdc', () => { @@ -57,4 +62,47 @@ describe('balance utils', () => { expect(rainSpendingPowerToWei(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..e0dc093f3 100644 --- a/src/utils/balance.utils.ts +++ b/src/utils/balance.utils.ts @@ -27,6 +27,40 @@ export const rainSpendingPowerToWei = (spendingPowerCents: number | null | undef return BigInt(Math.floor(spendingPowerCents)) * widenFactor } +/** + * 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 + rainSpendingPowerToWei(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) + rainSpendingPowerToWei(inTransitToCollateralCents) + /** * Convert a USDC wei amount (PEANUT_WALLET_TOKEN_DECIMALS, typically 6dp) to * cents (2dp), the unit Rain's `/signatures/withdrawals` API takes on its From fe493d4c415a34038f8fdeb3aecc518126aec06e Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 2 Jun 2026 22:16:46 +0100 Subject: [PATCH 02/10] doc: flag display-vs-gate split on formattedSpendableBalance /code-review (high): note that formattedSpendableBalance includes in-transit collateral and can read higher than the hasSufficientSpendableBalance gate during a top-up, so a future maintainer doesn't unify them and reintroduce the failed-send path. --- src/hooks/wallet/useWallet.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/hooks/wallet/useWallet.ts b/src/hooks/wallet/useWallet.ts index 46a4b6ca6..2847095a9 100644 --- a/src/hooks/wallet/useWallet.ts +++ b/src/hooks/wallet/useWallet.ts @@ -261,6 +261,10 @@ 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)) From a8f5959ae4208dae18be1cbac912db2b9b40883c Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 2 Jun 2026 22:57:02 +0100 Subject: [PATCH 03/10] refactor: rename wei-misnomer USDC helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These values are USDC in 6-decimal base units, not ETH's 18-decimal wei — the 'wei' name was misleading. Rename: rainSpendingPowerToWei → rainCentsToUsdcUnits usdcWeiToRainCents → usdcUnitsToRainCents plus the spendingPowerWei/amountWei locals and the 'USDC wei' comments. Mechanical; no behaviour change. --- .../qr-pay/__tests__/qr-pay-states.test.tsx | 2 +- src/app/(mobile-ui)/qr-pay/page.tsx | 4 +-- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 4 +-- src/components/Card/CancelCardModal.tsx | 10 +++---- src/components/Card/LockCardModal.tsx | 10 +++---- .../wallet/__tests__/useSpendBundle.test.ts | 2 +- src/hooks/wallet/useSendMoney.ts | 4 +-- src/hooks/wallet/useSignSpendBundle.ts | 8 +++--- src/hooks/wallet/useSpendBundle.ts | 14 +++++----- src/hooks/wallet/useWallet.ts | 4 +-- src/services/rain.ts | 2 +- src/utils/__tests__/balance.utils.test.ts | 26 +++++++++---------- src/utils/balance.utils.ts | 24 ++++++++--------- 13 files changed, 57 insertions(+), 57 deletions(-) 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 61f1210f1..f1b8d2684 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 { @@ -580,7 +580,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/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 2847095a9..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 { computeAvailableSpendable, computeDisplaySpendable, 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, diff --git a/src/services/rain.ts b/src/services/rain.ts index c0be7a7be..c2f6fba3a 100644 --- a/src/services/rain.ts +++ b/src/services/rain.ts @@ -82,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 diff --git a/src/utils/__tests__/balance.utils.test.ts b/src/utils/__tests__/balance.utils.test.ts index 3d27f862f..06eaf2269 100644 --- a/src/utils/__tests__/balance.utils.test.ts +++ b/src/utils/__tests__/balance.utils.test.ts @@ -2,7 +2,7 @@ import { computeAvailableSpendable, computeDisplaySpendable, printableUsdc, - rainSpendingPowerToWei, + rainCentsToUsdcUnits, } from '../balance.utils' describe('balance utils', () => { @@ -32,34 +32,34 @@ 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 }) }) diff --git a/src/utils/balance.utils.ts b/src/utils/balance.utils.ts index e0dc093f3..da3d4b280 100644 --- a/src/utils/balance.utils.ts +++ b/src/utils/balance.utils.ts @@ -18,11 +18,11 @@ 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 } @@ -37,7 +37,7 @@ export const rainSpendingPowerToWei = (spendingPowerCents: number | null | undef export const computeAvailableSpendable = ( smartBalance: bigint, spendingPowerCents: number | null | undefined -): bigint => smartBalance + rainSpendingPowerToWei(spendingPowerCents) +): bigint => smartBalance + rainCentsToUsdcUnits(spendingPowerCents) /** * Total spendable balance for DISPLAY, as a USDC base-unit bigint (6dp) — @@ -59,21 +59,21 @@ export const computeDisplaySpendable = ( spendingPowerCents: number | null | undefined, inTransitToCollateralCents: number | null | undefined ): bigint => - computeAvailableSpendable(smartBalance, spendingPowerCents) + rainSpendingPowerToWei(inTransitToCollateralCents) + computeAvailableSpendable(smartBalance, spendingPowerCents) + rainCentsToUsdcUnits(inTransitToCollateralCents) /** - * Convert a USDC wei amount (PEANUT_WALLET_TOKEN_DECIMALS, typically 6dp) to - * cents (2dp), the unit Rain's `/signatures/withdrawals` API takes on its + * 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 } From 80f45cb551d1295f996e487814f98dabc1813b23 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 2 Jun 2026 23:42:42 +0100 Subject: [PATCH 04/10] fix(seo): wire spei + faster-payments deposit rails; green the content-link check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fiat rails (SPEI/MX, Faster Payments/GB) were wired end-to-end via Bridge with MDX content authored, but never registered in DEPOSIT_RAILS — so the live /deposit/via-{spei,faster-payments} routes 404'd and every inbound content link to them failed the 'Validate sitemap, footer, and blog links' CI check. Register both; de-drift the validator's duplicate RAIL_SLUGS. Bumps the peanut-content submodule to the companion fix (peanut-content#45: compare/wise typo + dead MX corridor + landing/team polish override). validate-links now passes clean. --- .verify-content-baseline | 2 +- scripts/verify-content.ts | 2 ++ src/content | 2 +- src/data/seo/exchanges.ts | 17 +++++++---------- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/.verify-content-baseline b/.verify-content-baseline index d825567d1..bf2f48558 100644 --- a/.verify-content-baseline +++ b/.verify-content-baseline @@ -1 +1 @@ -726 +746 diff --git a/scripts/verify-content.ts b/scripts/verify-content.ts index 734bda315..996b38ab6 100644 --- a/scripts/verify-content.ts +++ b/scripts/verify-content.ts @@ -45,9 +45,11 @@ const RAIL_SLUGS = new Set([ 'avalanche', 'base', 'ethereum', + 'faster-payments', 'polygon', 'sepa', 'solana', + 'spei', 'tron', 'wire', ]) diff --git a/src/content b/src/content index 400925caa..371f66a06 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit 400925caae5c0a39191ebc70529401fbb2fc2ebc +Subproject commit 371f66a0622d414e0dedbd9fd34e4ae4f800ea8c diff --git a/src/data/seo/exchanges.ts b/src/data/seo/exchanges.ts index 566671193..1d8680a20 100644 --- a/src/data/seo/exchanges.ts +++ b/src/data/seo/exchanges.ts @@ -26,19 +26,16 @@ interface DepositFrontmatter { /** Crypto networks + fiat rails served at /deposit/via-{slug}. Hardcoded * because rails have no entity data — they're purely a content-page concept. - * - * TODO(reorg): two missing fiat keys need to be added during the next pass: - * 'faster-payments': 'Faster Payments', // UK — GBP via Bridge, live - * spei: 'SPEI Bank Transfer', // Mexico — MXN via Bridge, live - * MDX content already exists at mono/content/deposit/{spei,faster-payments}/ - * (pushed 2026-05-25). The pages 404 on the live site until the keys are - * registered here — generateStaticParams iterates Object.keys(DEPOSIT_RAILS). - * Both rails are wired end-to-end already: see src/utils/bridge.utils.ts - * getCurrencyConfig('MX' | 'GB', ...) — onramp + offramp via Bridge. - * Full context: mono/content/_system/ROADMAP.md (entry dated 2026-05-22). */ + * generateStaticParams iterates Object.keys(DEPOSIT_RAILS), so a rail 404s on + * the live site (and its inbound content links break) until it's listed here. + * faster-payments (GB) + spei (MX) are wired end-to-end via Bridge + * (getCurrencyConfig in src/utils/bridge.utils.ts); MDX lives in + * content/deposit/{faster-payments,spei}/. */ export const DEPOSIT_RAILS: Record = { ach: 'ACH Bank Transfer', sepa: 'SEPA Bank Transfer', + 'faster-payments': 'Faster Payments', + spei: 'SPEI Bank Transfer', wire: 'Wire Transfer', arbitrum: 'Arbitrum', avalanche: 'Avalanche', From 3561b16b9fa9247aae33c1c207626edd23ec7fdf Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 3 Jun 2026 12:43:57 +0100 Subject: [PATCH 05/10] chore(content): bump submodule for card-legal formatting fix (#2180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up peanut-content@658face3 (mirror of mono@bdede5f): structure-aware re-render of the 4 card legal pages — list items as bullets (not giant H2s), privacy FACTS form as prose sections + one sharing table. --- src/content | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content b/src/content index 177594605..658face32 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit 177594605b74ab42f90897da1d12a2abf13a925f +Subproject commit 658face320c6147347100588903d417994b131e7 From 0c750312def9a7637862fe5f86b5067ce29fc938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Wed, 3 Jun 2026 10:06:48 -0300 Subject: [PATCH 06/10] fix(seo): keep spei + faster-payments as deposit exchanges (from-), matching merged content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge pulled in peanut-content#45 (308f9fcdb), whose published pages link to /deposit/from-{spei,faster-payments} (14 links, 0 via-). But the branch's earlier seo commit had registered both in DEPOSIT_RAILS, which routes them as via-{slug} and excludes them from loadExchanges() — so every from- content link 404'd and validate-links failed with 15 broken links + a page-count drop. Adopt main's design: spei/faster-payments are exchanges (loadExchanges picks up content/deposit/{spei,faster-payments}, generateStaticParams emits from-{slug}). Drops them from DEPOSIT_RAILS and the validator's RAIL_SLUGS, and ratchets the content baseline to the merged count (745). validate-links now passes clean. --- .verify-content-baseline | 2 +- scripts/verify-content.ts | 2 -- src/data/seo/exchanges.ts | 17 ++++++++++------- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.verify-content-baseline b/.verify-content-baseline index bf2f48558..5f6e60798 100644 --- a/.verify-content-baseline +++ b/.verify-content-baseline @@ -1 +1 @@ -746 +745 diff --git a/scripts/verify-content.ts b/scripts/verify-content.ts index 57f0237c1..7d9ed1e85 100644 --- a/scripts/verify-content.ts +++ b/scripts/verify-content.ts @@ -45,11 +45,9 @@ const RAIL_SLUGS = new Set([ 'avalanche', 'base', 'ethereum', - 'faster-payments', 'polygon', 'sepa', 'solana', - 'spei', 'tron', 'wire', ]) diff --git a/src/data/seo/exchanges.ts b/src/data/seo/exchanges.ts index ea9165a8d..7f8863adc 100644 --- a/src/data/seo/exchanges.ts +++ b/src/data/seo/exchanges.ts @@ -26,16 +26,19 @@ interface DepositFrontmatter { /** Crypto networks + fiat rails served at /deposit/via-{slug}. Hardcoded * because rails have no entity data — they're purely a content-page concept. - * generateStaticParams iterates Object.keys(DEPOSIT_RAILS), so a rail 404s on - * the live site (and its inbound content links break) until it's listed here. - * faster-payments (GB) + spei (MX) are wired end-to-end via Bridge - * (getCurrencyConfig in src/utils/bridge.utils.ts); MDX lives in - * content/deposit/{faster-payments,spei}/. */ + * + * TODO(reorg): two missing fiat keys need to be added during the next pass: + * 'faster-payments': 'Faster Payments', // UK — GBP via Bridge, live + * spei: 'SPEI Bank Transfer', // Mexico — MXN via Bridge, live + * MDX content already exists at mono/content/deposit/{spei,faster-payments}/ + * (pushed 2026-05-25). The pages 404 on the live site until the keys are + * registered here — generateStaticParams iterates Object.keys(DEPOSIT_RAILS). + * Both rails are wired end-to-end already: see src/utils/bridge.utils.ts + * getCurrencyConfig('MX' | 'GB', ...) — onramp + offramp via Bridge. + * Full context: mono/content/_system/ROADMAP.md (entry dated 2026-05-22). */ export const DEPOSIT_RAILS: Record = { ach: 'ACH Bank Transfer', sepa: 'SEPA Bank Transfer', - 'faster-payments': 'Faster Payments', - spei: 'SPEI Bank Transfer', wire: 'Wire Transfer', arbitrum: 'Arbitrum', avalanche: 'Avalanche', From 2372fa96e2c4f444e2a1ab1b25d524882d97b178 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 3 Jun 2026 16:27:38 +0100 Subject: [PATCH 07/10] =?UTF-8?q?chore(content):=20bump=20submodule=20?= =?UTF-8?q?=E2=80=94=20non-custodial=20collateral=20language=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit peanut-content@b816c97 (mirror of mono@8b6cb85): international card terms no longer attribute collateral valuation/liquidation to Peanut (Rain-managed flow → the Issuer), keeping Peanut non-custodial. Plus term-consistency + a stray-bracket typo fix on the US page. --- src/content | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content b/src/content index 308f9fcdb..b816c97ad 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit 308f9fcdb66e31ea2fcc461c6326eae591dd1e0b +Subproject commit b816c97ad1537de32deba3ba6984a7e903ad0471 From 274439e9fa7a3f3feaebe6f8b3e4add9edaa8082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Wed, 3 Jun 2026 17:24:38 -0300 Subject: [PATCH 08/10] fix(card-apply): poll cheap readiness endpoint instead of POST /rain/cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with peanut-api-ts#976. After Sumsub's WebSDK closes, we now poll GET /rain/cards/readiness (single Prisma read, no Sumsub contact) at 1s/30s until the webhook-stamped `rainSubmissionReadyAt` lands, then fire a single POST /rain/cards. Old code polled POST /rain/cards itself at 1s/15s. Every iteration triggered `moveToLevel` + `getApplicant` + `getQuestionnaireAnswers` against Sumsub's API (~5 round trips per call × 15 polls = ~75 round trips per stuck user) AND surfaced `incomplete` to the FE, re-opening the WebSDK against an already-approved applicant — Barbara F-M's 2026-06-02 "Start Secure Verification" loop. `pollUntilApplyAdvances` stays as a 5s safety net for the rare case where readiness landed but the applicant state hasn't fully propagated through Sumsub's read replica. --- src/app/(mobile-ui)/card/page.tsx | 30 ++++++++++++++++-- src/components/Card/cardApply.utils.ts | 43 ++++++++++++++++++++++++++ src/services/rain.ts | 21 +++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) 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/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/services/rain.ts b/src/services/rain.ts index c2f6fba3a..b4998798d 100644 --- a/src/services/rain.ts +++ b/src/services/rain.ts @@ -406,6 +406,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 }>({ From af22841c9aa480840ce06a367ad2edb1383be3ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Thu, 4 Jun 2026 09:50:34 -0300 Subject: [PATCH 09/10] feat(card): /card-recovery page for deleted-Rain-user collateral recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the BE recover-funds endpoints (PR #977 on peanut-api-ts) into a dedicated page. Flow: 1. GET /rain/cards/recover-funds/preview on mount — show recoverable amount, destination (user's own smart wallet), dust left in the contract, current auto-balance flag. 2. Recover button: POST /rain/cards/recover-funds/prepare — backend disables auto-balance, reads on-chain balance, fetches Rain's executor sig for the full cent-aligned amount, returns the same payload as the regular /prepare. 3. Kernel-sign the EIP-712 with the user's passkey. 4. POST /rain/cards/withdraw/submit (existing) to broadcast. 5. Show the tx link. The destination address is decided by the backend and merely displayed here. The FE has no way to send recovery funds anywhere other than the authenticated user's own smart wallet — even if this page were tampered with at runtime. Not linked from anywhere in the main app; the URL is shareable to a user who needs to recover funds because the JWT cookie is the only auth, the recipient is server-locked, and the signing step still requires the user's passkey. CROSS-REPO: needs peanut-api-ts PR #977 to land first. --- src/app/(mobile-ui)/card-recovery/page.tsx | 215 +++++++++++++++++++++ src/constants/routes.ts | 1 + src/services/rain.ts | 58 ++++++ 3 files changed, 274 insertions(+) create mode 100644 src/app/(mobile-ui)/card-recovery/page.tsx 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/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/services/rain.ts b/src/services/rain.ts index c2f6fba3a..21b2f4a80 100644 --- a/src/services/rain.ts +++ b/src/services/rain.ts @@ -136,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 { @@ -361,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 From 52246c00c513c233545491a2b3bb3a7c3b7e19bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Thu, 4 Jun 2026 16:05:26 -0300 Subject: [PATCH 10/10] fix(kernel): reject a restored passkey that belongs to a different account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a shared device with two Peanut accounts (two passkeys for the same RP in Google Password Manager), the WebAuthnKey restore path can pair the current session with the OTHER account's credential — the per-user preference is empty/poisoned and the device-global cookie fallback (or a prior poisoned write) supplies the wrong key. Every flow except Rain card withdraw tolerated this silently: the kernel just signs for whatever wallet the key controls. The withdraw path is the only one that verifies the signature against the server-derived adminAddress, so it hard-failed with 'invalid admin signature' for one such user (her browser signed with her second account's passkey). After the primary kernel client builds, assert its derived address equals the user's smart-wallet account identifier; on mismatch, purge the key and force a clean re-auth instead of signing with the wrong credential. Skipped mid-registration (no wallet yet) and for pre-migration users (address is passed in, so it always matches). --- src/context/kernelClient.context.tsx | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) 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) {