diff --git a/src/components/Card/CardLimitEditModal.tsx b/src/components/Card/CardLimitEditModal.tsx index 173584a80a..94049809d7 100644 --- a/src/components/Card/CardLimitEditModal.tsx +++ b/src/components/Card/CardLimitEditModal.tsx @@ -8,6 +8,7 @@ import { Button } from '@/components/0_Bruddle/Button' import { Icon } from '@/components/Global/Icons/Icon' import { rainApi, type RainCardLimit, type RainLimitFrequency } from '@/services/rain' import { RAIN_CARD_OVERVIEW_QUERY_KEY } from '@/hooks/useRainCardOverview' +import { useReturnExcessCollateral } from '@/hooks/wallet/useReturnExcessCollateral' export const CARD_LIMITS_QUERY_KEY = 'rain-card-limits' @@ -22,6 +23,7 @@ interface Props { const CardLimitEditModal: FC = ({ cardId, frequency, label, initialAmountCents, isOpen, onClose }) => { const queryClient = useQueryClient() + const { returnExcess } = useReturnExcessCollateral() const [value, setValue] = useState(initialAmountCents != null ? (initialAmountCents / 100).toFixed(2) : '') const [saving, setSaving] = useState(false) const [error, setError] = useState(null) @@ -49,6 +51,33 @@ const CardLimitEditModal: FC = ({ cardId, frequency, label, initialAmount try { const payload: RainCardLimit[] = [{ amount: amountCents, frequency }] await rainApi.updateCardLimits(cardId, payload) + // The card's backing tracks the per-transaction limit. If it now + // holds more than the new limit, return the difference to the + // user's wallet — surfaced only as a passkey prompt. Ordering + // matters: the PATCH above must land first so the auto-balancer's + // target is already lowered and can't race the withdrawal by + // topping the collateral back up. + // Non-fatal: the limit change itself succeeded, the unified + // displayed balance is identical either way, and re-saving the + // limit retries the return — so a cancelled passkey or withdrawal + // cooldown never blocks the modal. + if (frequency === 'perAuthorization') { + try { + const returnedCents = await returnExcess(amountCents) + if (returnedCents > 0) { + posthog.capture(ANALYTICS_EVENTS.CARD_LIMIT_EXCESS_RETURNED, { + returned_cents: returnedCents, + new_limit_cents: amountCents, + }) + } + } catch (excessError) { + posthog.capture(ANALYTICS_EVENTS.CARD_LIMIT_EXCESS_RETURN_FAILED, { + new_limit_cents: amountCents, + error_kind: (excessError as Error)?.name ?? 'unknown', + error_message: (excessError as Error)?.message, + }) + } + } await Promise.all([ queryClient.invalidateQueries({ queryKey: [CARD_LIMITS_QUERY_KEY, cardId] }), queryClient.invalidateQueries({ queryKey: [RAIN_CARD_OVERVIEW_QUERY_KEY] }), diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index a002e0f067..e7dba2d3cc 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -231,6 +231,10 @@ export const ANALYTICS_EVENTS = { CARD_LIMIT_CHANGE_OPENED: 'card_limit_change_opened', CARD_LIMIT_CHANGED: 'card_limit_changed', CARD_LIMIT_CHANGE_FAILED: 'card_limit_change_failed', + // Excess collateral returned to the smart wallet after a limit decrease + // (useReturnExcessCollateral). FAILED is non-fatal — limit change stuck. + CARD_LIMIT_EXCESS_RETURNED: 'card_limit_excess_returned', + CARD_LIMIT_EXCESS_RETURN_FAILED: 'card_limit_excess_return_failed', CARD_LOCK_OPENED: 'card_lock_opened', CARD_LOCKED: 'card_locked', CARD_UNLOCKED: 'card_unlocked', diff --git a/src/hooks/wallet/__tests__/useReturnExcessCollateral.test.tsx b/src/hooks/wallet/__tests__/useReturnExcessCollateral.test.tsx new file mode 100644 index 0000000000..83bbec6cd3 --- /dev/null +++ b/src/hooks/wallet/__tests__/useReturnExcessCollateral.test.tsx @@ -0,0 +1,119 @@ +/** + * Contract tests for useReturnExcessCollateral — the hook that, after a card + * limit decrease, returns the collateral held above the new limit to the + * user's smart wallet. + * + * The contracts locked down here: + * 1. below-threshold / no-excess cases return 0 WITHOUT any signing or + * submission (the user must not see a passkey prompt), + * 2. an excess is signed via the FORCED collateral-only strategy (routing + * would pick smart-only — a self-transfer no-op — whenever the smart + * wallet covers the amount) and submitted for exactly the excess, + * 3. a missing wallet address fails closed before any signing. + */ +import { renderHook, act } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import { useReturnExcessCollateral } from '../useReturnExcessCollateral' +import { useRainCardOverview } from '@/hooks/useRainCardOverview' +import { useWallet } from '@/hooks/wallet/useWallet' +import { useSignSpendBundle } from '@/hooks/wallet/useSignSpendBundle' +import { rainApi } from '@/services/rain' + +const WALLET = '0xc97fffbf8768ca90cd62fae2e313b084fe13e553' + +jest.mock('@/hooks/useRainCardOverview', () => ({ + useRainCardOverview: jest.fn(), + RAIN_CARD_OVERVIEW_QUERY_KEY: 'rain-card-overview', +})) +jest.mock('@/hooks/wallet/useWallet', () => ({ useWallet: jest.fn() })) +jest.mock('@/hooks/wallet/useSignSpendBundle', () => ({ useSignSpendBundle: jest.fn() })) +jest.mock('@/services/rain', () => ({ rainApi: { submitWithdrawal: jest.fn() } })) + +const mockOverview = useRainCardOverview as jest.Mock +const mockUseWallet = useWallet as jest.Mock +const mockUseSignSpendBundle = useSignSpendBundle as jest.Mock +const mockSubmitWithdrawal = rainApi.submitWithdrawal as jest.Mock + +const RAIN_WITHDRAWAL = { preparationId: 'prep-1', amount: '150000000' } +const mockSignSpend = jest.fn() + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +) + +// `null` sentinels model the not-loaded states (a destructuring default would +// silently swallow an explicit `undefined`). +const setup = ({ + spendingPower = 20_000, + address = WALLET, +}: { + spendingPower?: number | null + address?: string | null +} = {}) => { + mockOverview.mockReturnValue({ + overview: spendingPower === null ? undefined : { balance: { spendingPower } }, + }) + mockUseWallet.mockReturnValue({ address: address === null ? undefined : address }) + mockUseSignSpendBundle.mockReturnValue({ signSpend: mockSignSpend }) + return renderHook(() => useReturnExcessCollateral(), { wrapper }) +} + +beforeEach(() => { + jest.clearAllMocks() + mockSignSpend.mockResolvedValue({ strategy: 'collateral-only', rainWithdrawal: RAIN_WITHDRAWAL }) + mockSubmitWithdrawal.mockResolvedValue({ txHash: '0xhash' }) +}) + +describe('useReturnExcessCollateral', () => { + it('skips (no prompt, no submit) when the collateral does not exceed the new limit', async () => { + const { result } = setup({ spendingPower: 5_000 }) + await act(async () => { + expect(await result.current.returnExcess(20_000)).toBe(0) + }) + expect(mockSignSpend).not.toHaveBeenCalled() + expect(mockSubmitWithdrawal).not.toHaveBeenCalled() + }) + + it('skips when the overview has not loaded (no spending power)', async () => { + const { result } = setup({ spendingPower: null }) + await act(async () => { + expect(await result.current.returnExcess(5_000)).toBe(0) + }) + expect(mockSignSpend).not.toHaveBeenCalled() + }) + + it('signs a FORCED collateral-only withdrawal of exactly the excess, to the smart wallet, then submits', async () => { + const { result } = setup({ spendingPower: 20_000 }) + await act(async () => { + // $200 backing, limit lowered to $50 → $150 excess + expect(await result.current.returnExcess(5_000)).toBe(15_000) + }) + expect(mockSignSpend).toHaveBeenCalledWith({ + requiredUsdcAmount: 150_000_000n, // 15000 cents → 6dp USDC units + recipient: WALLET, + rainSpendingPower: 200_000_000n, + kind: 'AUTO_REBALANCE', + forceStrategy: 'collateral-only', + }) + expect(mockSubmitWithdrawal).toHaveBeenCalledWith(RAIN_WITHDRAWAL) + }) + + it('fails closed before signing when the wallet address is not ready', async () => { + const { result } = setup({ address: null }) + await act(async () => { + await expect(result.current.returnExcess(5_000)).rejects.toThrow('Wallet not ready') + }) + expect(mockSignSpend).not.toHaveBeenCalled() + expect(mockSubmitWithdrawal).not.toHaveBeenCalled() + }) + + it('does not submit when signing fails (passkey cancelled)', async () => { + mockSignSpend.mockRejectedValue(new Error('user cancelled')) + const { result } = setup() + await act(async () => { + await expect(result.current.returnExcess(5_000)).rejects.toThrow('user cancelled') + }) + expect(mockSubmitWithdrawal).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/wallet/__tests__/useSignSpendBundle.test.tsx b/src/hooks/wallet/__tests__/useSignSpendBundle.test.tsx new file mode 100644 index 0000000000..c265d1d910 --- /dev/null +++ b/src/hooks/wallet/__tests__/useSignSpendBundle.test.tsx @@ -0,0 +1,150 @@ +/** + * Direct tests for useSignSpendBundle's FORCED collateral-only branch + * (`forceStrategy: 'collateral-only'`) — used by flows whose purpose is + * moving collateral itself (excess return after a limit decrease), where + * live-balance routing would wrongly pick smart-only. + * + * Contracts: + * 1. sufficient collateral → signs and returns a collateral-only artifact + * WITHOUT consulting resolveSpendStrategy (no live-balance read), + * 2. insufficient collateral → captures the CARD_WITHDRAW_FAILED funnel + * event, refreshes the overview, and throws InsufficientSpendableError + * — same handling as resolveSpendStrategy's insufficient branch. + */ +import { renderHook, act } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import posthog from 'posthog-js' +import { useSignSpendBundle } from '../useSignSpendBundle' +import { InsufficientSpendableError, resolveSpendStrategy, runCollateralSpendPreflight } from '../spendPreflight' +import { rainApi } from '@/services/rain' + +const ACCOUNT = '0xc97fffbf8768ca90cd62fae2e313b084fe13e553' +const RECIPIENT = '0x4e5b89fd498f333ed7f2a59c5f23d5b5dc41b3de' + +jest.mock('posthog-js', () => ({ __esModule: true, default: { capture: jest.fn() } })) +jest.mock('@/constants/zerodev.consts', () => ({ + PEANUT_WALLET_CHAIN: { id: 42161 }, + PEANUT_WALLET_TOKEN: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + PEANUT_WALLET_TOKEN_DECIMALS: 6, +})) +jest.mock('@/constants/rain.consts', () => ({ + rainCoordinatorAbi: [ + { type: 'function', name: 'withdrawAsset', inputs: [], outputs: [], stateMutability: 'nonpayable' }, + ], +})) +const mockSignTypedData = jest.fn() +jest.mock('@/context/kernelClient.context', () => ({ + useKernelClient: () => ({ + getClientForChain: () => ({ account: { address: ACCOUNT, signTypedData: mockSignTypedData } }), + rebuildClientForChain: jest.fn(), + }), +})) +jest.mock('@/hooks/useZeroDev', () => ({ useZeroDev: () => ({ handleSendUserOpEncoded: jest.fn() }) })) +jest.mock('@/context/ModalsContext', () => ({ useModalsContextOptional: () => undefined })) +jest.mock('@/hooks/useRainCardOverview', () => ({ + useRainCardOverview: () => ({ overview: { cards: [] } }), + RAIN_CARD_OVERVIEW_QUERY_KEY: 'rain-card-overview', +})) +jest.mock('../useGrantSessionKey', () => ({ useGrantSessionKey: () => ({ grant: jest.fn() }) })) +jest.mock('../useSignUserOp', () => ({ useSignUserOp: () => ({ signCallsUserOp: jest.fn() }) })) +jest.mock('@/utils/rainWithdraw.utils', () => ({ buildRainWithdrawTypedData: jest.fn(() => ({})) })) +jest.mock('@/services/rain', () => ({ rainApi: { prepareWithdrawal: jest.fn() } })) +// Keep the real InsufficientSpendableError class (instanceof must hold); +// mock only the two engine entry points. +jest.mock('../spendPreflight', () => ({ + ...jest.requireActual('../spendPreflight'), + resolveSpendStrategy: jest.fn(), + runCollateralSpendPreflight: jest.fn(), +})) + +const mockResolveSpendStrategy = resolveSpendStrategy as jest.Mock +const mockPreflight = runCollateralSpendPreflight as jest.Mock +const mockPrepareWithdrawal = rainApi.prepareWithdrawal as jest.Mock +const mockCapture = posthog.capture as jest.Mock + +const PREP = { + preparationId: 'prep-1', + coordinatorAddress: '0xc0d5bd6307ec8c8da03e7502a00b8cba24eefc06', + collateralProxy: '0x1111111111111111111111111111111111111111', + adminAddress: ACCOUNT, + chainId: '42161', + tokenAddress: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + amount: '150000000', + recipientAddress: RECIPIENT, + directTransfer: true, + adminSalt: '0xsalt', + adminNonce: '1', + executorSignature: '0xexecsig', + executorSalt: '0xexecsalt', + expiresAt: 1234567890, +} + +let queryClient: QueryClient +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +) + +beforeEach(() => { + jest.clearAllMocks() + queryClient = new QueryClient() + mockPreflight.mockImplementation(async ({ kernelClient }) => kernelClient) + mockPrepareWithdrawal.mockResolvedValue(PREP) + mockSignTypedData.mockResolvedValue('0xadminsig') +}) + +describe('useSignSpendBundle — forceStrategy: collateral-only', () => { + it('signs a collateral-only withdrawal without consulting live-balance routing', async () => { + const { result } = renderHook(() => useSignSpendBundle(), { wrapper }) + let artifact: Awaited> | undefined + await act(async () => { + artifact = await result.current.signSpend({ + requiredUsdcAmount: 150_000_000n, // $150 + recipient: RECIPIENT, + rainSpendingPower: 200_000_000n, // $200 — sufficient + kind: 'AUTO_REBALANCE', + forceStrategy: 'collateral-only', + }) + }) + expect(mockResolveSpendStrategy).not.toHaveBeenCalled() + expect(mockPrepareWithdrawal).toHaveBeenCalledWith({ + amount: '15000', // USDC units → Rain cents + recipientAddress: RECIPIENT, + directTransfer: true, + kind: 'AUTO_REBALANCE', + }) + expect(artifact).toEqual({ + strategy: 'collateral-only', + rainWithdrawal: expect.objectContaining({ + preparationId: 'prep-1', + amount: PREP.amount, + adminSignature: '0xadminsig', + directTransfer: true, + }), + }) + }) + + it('insufficient collateral: captures the funnel event, refreshes the overview, throws', async () => { + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries') + const { result } = renderHook(() => useSignSpendBundle(), { wrapper }) + await act(async () => { + await expect( + result.current.signSpend({ + requiredUsdcAmount: 200_000_000n, + recipient: RECIPIENT, + rainSpendingPower: 150_000_000n, // short + kind: 'AUTO_REBALANCE', + forceStrategy: 'collateral-only', + }) + ).rejects.toBeInstanceOf(InsufficientSpendableError) + }) + expect(mockCapture).toHaveBeenCalledWith('card_withdraw_failed', { + strategy: 'insufficient', + error_kind: 'insufficient', + flow: 'sign-only', + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['rain-card-overview'] }) + expect(mockResolveSpendStrategy).not.toHaveBeenCalled() + expect(mockPrepareWithdrawal).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/wallet/useReturnExcessCollateral.ts b/src/hooks/wallet/useReturnExcessCollateral.ts new file mode 100644 index 0000000000..3d26c8f39a --- /dev/null +++ b/src/hooks/wallet/useReturnExcessCollateral.ts @@ -0,0 +1,68 @@ +'use client' + +import { useCallback } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import type { Address } from 'viem' +import { rainApi } from '@/services/rain' +import { RAIN_CARD_OVERVIEW_QUERY_KEY, useRainCardOverview } from '@/hooks/useRainCardOverview' +import { useWallet } from '@/hooks/wallet/useWallet' +import { useSignSpendBundle } from '@/hooks/wallet/useSignSpendBundle' +import { computeExcessCollateralCents, rainCentsToUsdcUnits } from '@/utils/balance.utils' + +/** + * After a card-limit decrease, returns the collateral held above the new + * limit to the user's smart wallet so the card's backing always matches the + * limit. The user sees exactly one thing: the passkey prompt for the admin + * EIP-712 signature — the backend broadcasts via the stored session key, the + * movement is collateral↔wallet (kind AUTO_REBALANCE → INTERNAL_TRANSFER, + * hidden from history), and the unified displayed balance never changes. + * + * MUST run only AFTER the limit PATCH has succeeded: the auto-balancer + * targets the DB cardLimit, so withdrawing first would race it topping the + * collateral straight back up. + * + * Returns the cents actually returned (0 = nothing above the threshold, no + * prompt shown). + */ +export const useReturnExcessCollateral = () => { + const { overview } = useRainCardOverview() + const { address: smartWalletAddress } = useWallet() + const { signSpend } = useSignSpendBundle() + const queryClient = useQueryClient() + + const returnExcess = useCallback( + async (newLimitCents: number): Promise => { + const spendingPowerCents = overview?.balance?.spendingPower + const excessCents = computeExcessCollateralCents(spendingPowerCents, newLimitCents) + if (excessCents <= 0) return 0 + if (!smartWalletAddress) { + throw new Error('Wallet not ready — please retry in a moment') + } + + // Force collateral-only: routing would pick smart-only (a + // self-transfer no-op) whenever the smart wallet covers the amount. + const artifact = await signSpend({ + requiredUsdcAmount: rainCentsToUsdcUnits(excessCents), + recipient: smartWalletAddress as Address, + rainSpendingPower: rainCentsToUsdcUnits(spendingPowerCents), + kind: 'AUTO_REBALANCE', + forceStrategy: 'collateral-only', + }) + if (artifact.strategy !== 'collateral-only') { + throw new Error('Unexpected withdrawal strategy') + } + await rainApi.submitWithdrawal(artifact.rainWithdrawal) + + // Funds moved collateral → smart wallet; refresh both buckets so the + // unified balance doesn't transiently double-count or crater. + await Promise.all([ + queryClient.invalidateQueries({ queryKey: [RAIN_CARD_OVERVIEW_QUERY_KEY] }), + queryClient.invalidateQueries({ queryKey: ['balance'] }), + ]) + return excessCents + }, + [overview, smartWalletAddress, signSpend, queryClient] + ) + + return { returnExcess } +} diff --git a/src/hooks/wallet/useSignSpendBundle.ts b/src/hooks/wallet/useSignSpendBundle.ts index 6d525006b3..34cc1db47f 100644 --- a/src/hooks/wallet/useSignSpendBundle.ts +++ b/src/hooks/wallet/useSignSpendBundle.ts @@ -13,10 +13,15 @@ import { buildRainWithdrawTypedData } from '@/utils/rainWithdraw.utils' import { useZeroDev } from '@/hooks/useZeroDev' import { useModalsContextOptional } from '@/context/ModalsContext' import { rainApi, type RainCollateralKind } from '@/services/rain' -import { useRainCardOverview } from '@/hooks/useRainCardOverview' +import { RAIN_CARD_OVERVIEW_QUERY_KEY, useRainCardOverview } from '@/hooks/useRainCardOverview' import { useGrantSessionKey } from './useGrantSessionKey' import { useSignUserOp, type SignedUserOpData } from './useSignUserOp' -import { resolveSpendStrategy, runCollateralSpendPreflight, type SpendStrategy } from './spendPreflight' +import { + InsufficientSpendableError, + resolveSpendStrategy, + runCollateralSpendPreflight, + type SpendStrategy, +} from './spendPreflight' import { usdcUnitsToRainCents } from '@/utils/balance.utils' /** @@ -68,6 +73,12 @@ export interface SignSpendBundleInput { /** User-semantic category of this spend (QR_PAY, FIAT_OFFRAMP, …). * Persisted on the `TransactionIntent` the backend creates in /prepare. */ kind: RainCollateralKind + /** Skip live-balance routing and force this strategy. For flows whose + * PURPOSE is moving collateral itself (e.g. returning excess to the + * wallet after a limit decrease) — routing would pick smart-only + * whenever the smart account covers the amount, a self-transfer no-op. + * Affordability is still enforced against `rainSpendingPower`. */ + forceStrategy?: 'collateral-only' /** Fires once routing is picked, before any signing. */ onStrategyDecided?: (strategy: Exclude) => void /** Fires right before the one-time session-key grant prompt appears. */ @@ -101,7 +112,15 @@ export const useSignSpendBundle = () => { const signSpend = useCallback( async (input: SignSpendBundleInput): Promise => { - const { requiredUsdcAmount, recipient, rainSpendingPower, kind, onStrategyDecided, onGrantRequired } = input + const { + requiredUsdcAmount, + recipient, + rainSpendingPower, + kind, + forceStrategy, + onStrategyDecided, + onGrantRequired, + } = input const chainIdNum = PEANUT_WALLET_CHAIN.id const chainIdStr = chainIdNum.toString() @@ -122,14 +141,33 @@ export const useSignSpendBundle = () => { // and reverts on-chain (incident #2230). // Manteca-style flows always have a single recipient and no // subsequent kernel calls — collateral-only is always eligible. - const { strategy, smartBalance } = await resolveSpendStrategy({ - queryClient, - accountAddress: kernelAccount.address, - requiredUsdcAmount, - rainSpendingPower, - collateralOnlyAllowed: true, - flow: 'sign-only', - }) + let strategy: Exclude + let smartBalance = 0n + if (forceStrategy === 'collateral-only') { + // Forced path: the caller is deliberately draining collateral, so + // only the collateral bucket can fund it. Same insufficient + // handling as resolveSpendStrategy (funnel capture, refresh, + // fail closed). + if (rainSpendingPower < requiredUsdcAmount) { + posthog.capture(ANALYTICS_EVENTS.CARD_WITHDRAW_FAILED, { + strategy: 'insufficient', + error_kind: 'insufficient', + flow: 'sign-only', + }) + queryClient.invalidateQueries({ queryKey: [RAIN_CARD_OVERVIEW_QUERY_KEY] }) + throw new InsufficientSpendableError() + } + strategy = 'collateral-only' + } else { + ;({ strategy, smartBalance } = await resolveSpendStrategy({ + queryClient, + accountAddress: kernelAccount.address, + requiredUsdcAmount, + rainSpendingPower, + collateralOnlyAllowed: true, + flow: 'sign-only', + })) + } onStrategyDecided?.(strategy) posthog.capture(ANALYTICS_EVENTS.CARD_WITHDRAW_ATTEMPTED, { strategy, kind, flow: 'sign-only' }) diff --git a/src/utils/__tests__/balance.utils.test.ts b/src/utils/__tests__/balance.utils.test.ts index 1ac08289b0..fc8a7cfb15 100644 --- a/src/utils/__tests__/balance.utils.test.ts +++ b/src/utils/__tests__/balance.utils.test.ts @@ -1,6 +1,8 @@ import { computeAvailableSpendable, computeDisplaySpendable, + computeExcessCollateralCents, + EXCESS_COLLATERAL_MIN_CENTS, isAmountWithinBalance, printableUsdc, rainCentsToUsdcUnits, @@ -156,4 +158,41 @@ describe('balance utils', () => { expect(computeAvailableSpendable(0n, 0)).toBe(0n) }) }) + + describe('computeExcessCollateralCents', () => { + it('returns the delta when the collateral exceeds the new limit', () => { + // $200 backing, limit lowered to $50 → $150 back to the wallet + expect(computeExcessCollateralCents(20_000, 5_000)).toBe(15_000) + }) + + it('returns 0 on a limit increase or exact match', () => { + expect(computeExcessCollateralCents(5_000, 20_000)).toBe(0) + expect(computeExcessCollateralCents(5_000, 5_000)).toBe(0) + }) + + it('leaves sub-threshold deltas in place (no passkey tap for cents)', () => { + expect(computeExcessCollateralCents(5_000 + EXCESS_COLLATERAL_MIN_CENTS - 1, 5_000)).toBe(0) + expect(computeExcessCollateralCents(5_000 + EXCESS_COLLATERAL_MIN_CENTS, 5_000)).toBe( + EXCESS_COLLATERAL_MIN_CENTS + ) + }) + + it('fails closed on missing/invalid spending power', () => { + expect(computeExcessCollateralCents(undefined, 5_000)).toBe(0) + expect(computeExcessCollateralCents(null, 5_000)).toBe(0) + expect(computeExcessCollateralCents(NaN, 5_000)).toBe(0) + expect(computeExcessCollateralCents(-100, 5_000)).toBe(0) + expect(computeExcessCollateralCents(20_000, NaN)).toBe(0) + expect(computeExcessCollateralCents(20_000, -1)).toBe(0) + }) + + it('floors fractional cents so we never sign for more than the collateral holds', () => { + // spendingPower 20000.9 → floor 20000; limit 5000 → 15000, not 15000.9 + expect(computeExcessCollateralCents(20_000.9, 5_000)).toBe(15_000) + }) + + it('a zero limit returns the whole backing', () => { + expect(computeExcessCollateralCents(20_000, 0)).toBe(20_000) + }) + }) }) diff --git a/src/utils/balance.utils.ts b/src/utils/balance.utils.ts index 7aa9be3edc..c2ab94cd0b 100644 --- a/src/utils/balance.utils.ts +++ b/src/utils/balance.utils.ts @@ -120,6 +120,33 @@ export const computeDisplaySpendable = ( ): bigint => computeAvailableSpendable(smartBalance, spendingPowerCents) + rainCentsToUsdcUnits(inTransitToCollateralCents) +/** + * Minimum excess worth returning after a card-limit decrease, in Rain cents. + * Mirrors the auto-balancer's $1 REBALANCE_THRESHOLD (peanut-api-ts + * rebalance-decision.ts) so a cent-sized delta never costs the user a passkey + * tap — or Rain's per-user withdrawal-signature cooldown. + */ +export const EXCESS_COLLATERAL_MIN_CENTS = 100 + +/** + * Collateral held above the card limit after a limit change, in whole Rain + * cents. This is the amount to return to the user's smart wallet so the + * card's backing matches the new limit. Returns 0 (nothing to return) for + * missing/invalid spending power, a limit increase, or a delta under + * EXCESS_COLLATERAL_MIN_CENTS — callers can branch on `> 0` alone. + */ +export const computeExcessCollateralCents = ( + spendingPowerCents: number | null | undefined, + newLimitCents: number +): number => { + if (spendingPowerCents == null || !Number.isFinite(spendingPowerCents) || spendingPowerCents <= 0) return 0 + if (!Number.isFinite(newLimitCents) || newLimitCents < 0) return 0 + // Floor the (possibly fractional) spending power so we never sign for more + // than the collateral can cover — sub-cent dust stays put. + const excess = Math.floor(spendingPowerCents) - Math.ceil(newLimitCents) + return excess >= EXCESS_COLLATERAL_MIN_CENTS ? excess : 0 +} + /** * 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