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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/components/Card/CardLimitEditModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -22,6 +23,7 @@ interface Props {

const CardLimitEditModal: FC<Props> = ({ cardId, frequency, label, initialAmountCents, isOpen, onClose }) => {
const queryClient = useQueryClient()
const { returnExcess } = useReturnExcessCollateral()
const [value, setValue] = useState<string>(initialAmountCents != null ? (initialAmountCents / 100).toFixed(2) : '')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
Expand Down Expand Up @@ -49,6 +51,33 @@ const CardLimitEditModal: FC<Props> = ({ 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] }),
Expand Down
4 changes: 4 additions & 0 deletions src/constants/analytics.consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
119 changes: 119 additions & 0 deletions src/hooks/wallet/__tests__/useReturnExcessCollateral.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
)

// `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()
})
})
150 changes: 150 additions & 0 deletions src/hooks/wallet/__tests__/useSignSpendBundle.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)

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<ReturnType<typeof result.current.signSpend>> | 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()
})
})
Loading
Loading