From a3c14978cc109eb4c76ce3530bf6b3dd9b47706f Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Wed, 29 Jul 2026 16:09:48 +0200 Subject: [PATCH] fix(card): force collateral-only routing on lock/cancel Lock/cancel sign a withdrawal whose purpose is draining Rain collateral back to the wallet, but since cb302d35a removed the smartBalance:0n input, routing fell through to live-balance strategy selection: any user whose wallet USDC covered their spending power routed smart-only, tripped the modals' own strategy check, and could neither lock nor cancel ('Unexpected withdrawal strategy', prod, 3 users affected). Also fail closed when the card overview hasn't loaded: undefined read as zero spending power, silently skipping the withdrawal and getting the action rejected server-side ('Withdrawal signature required'). Port of the dev-based #2570 (closed in favor of this main-based hotfix; that branch holds the i18n-ified variant for the dev back-merge). --- src/components/Card/CancelCardModal.tsx | 7 + src/components/Card/LockCardModal.tsx | 16 +- .../Card/__tests__/LockCardModal.test.tsx | 158 ++++++++++++++++++ 3 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 src/components/Card/__tests__/LockCardModal.test.tsx diff --git a/src/components/Card/CancelCardModal.tsx b/src/components/Card/CancelCardModal.tsx index 57602ce962..6b8a1ce4ed 100644 --- a/src/components/Card/CancelCardModal.tsx +++ b/src/components/Card/CancelCardModal.tsx @@ -57,6 +57,12 @@ const CancelCardModal: FC = ({ cardId, isOpen, onClose }) => { setPhase('canceling') setError(null) try { + // An unloaded overview reads as zero spending power below, which + // would skip the withdrawal and get the cancel rejected by the + // backend ("Withdrawal signature required"). Fail closed instead. + if (!overview) { + throw new Error('Card details still loading — please retry in a moment') + } // 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. @@ -72,6 +78,7 @@ const CancelCardModal: FC = ({ cardId, isOpen, onClose }) => { recipient: smartWalletAddress as `0x${string}`, rainSpendingPower: spendingPowerUnits, kind: 'CRYPTO_WITHDRAW', + forceStrategy: 'collateral-only', }) if (artifact.strategy !== 'collateral-only') { throw new Error('Unexpected withdrawal strategy — please contact support') diff --git a/src/components/Card/LockCardModal.tsx b/src/components/Card/LockCardModal.tsx index b7dda32168..4748f5eed5 100644 --- a/src/components/Card/LockCardModal.tsx +++ b/src/components/Card/LockCardModal.tsx @@ -63,6 +63,12 @@ const LockCardModal: FC = ({ cardId, mode, isOpen, onClose }) => { setError(null) try { if (mode === 'lock') { + // An unloaded overview reads as zero spending power below, which + // would skip the withdrawal and get the lock rejected by the + // backend ("Withdrawal signature required"). Fail closed instead. + if (!overview) { + throw new Error('Card details still loading — please retry in a moment') + } // If the user has spending power, return collateral to their // smart wallet BEFORE locking so funds stay liquid. The // backend gates the lock on a successful withdrawal — order @@ -73,15 +79,17 @@ const LockCardModal: FC = ({ cardId, mode, isOpen, onClose }) => { if (!smartWalletAddress) { throw new Error('Wallet not ready — please retry in a moment') } - // Force collateral-only routing: smart=0n eliminates the - // smart-only and mixed branches, so the strategy resolver - // picks 'collateral-only' and signs a Rain withdrawal - // straight to the user's smart wallet (1 passkey tap). + // Routing MUST NOT pick smart-only here: the point of this + // spend is to drain Rain collateral back to the wallet, so a + // smart-account transfer would be a self-transfer no-op that + // leaves the collateral behind. (The `smartBalance: 0n` that + // used to force this was removed in cb302d35a.) const artifact = await signSpend({ requiredUsdcAmount: spendingPowerUnits, recipient: smartWalletAddress as `0x${string}`, rainSpendingPower: spendingPowerUnits, kind: 'CRYPTO_WITHDRAW', + forceStrategy: 'collateral-only', }) if (artifact.strategy !== 'collateral-only') { throw new Error('Unexpected withdrawal strategy — please contact support') diff --git a/src/components/Card/__tests__/LockCardModal.test.tsx b/src/components/Card/__tests__/LockCardModal.test.tsx new file mode 100644 index 0000000000..baa13923e6 --- /dev/null +++ b/src/components/Card/__tests__/LockCardModal.test.tsx @@ -0,0 +1,158 @@ +/** + * Regression tests for the lock/cancel collateral withdrawal (prod incident + * 2026-07-24): both modals MUST force collateral-only routing when returning + * spending power. Without `forceStrategy`, live routing picks smart-only + * whenever the smart wallet covers the amount (spendPreflight), and the + * modals then reject their own artifact ("Unexpected withdrawal strategy"), + * so users with wallet balance ≥ card balance could neither lock nor cancel. + * + * Contracts locked down here, for BOTH modals: + * 1. spendingPower > 0 → signSpend is called WITH forceStrategy: + * 'collateral-only' (the assertion that catches the regression) and the + * signed withdrawal is delivered to the backend call, + * 2. an unloaded overview fails closed BEFORE signing — undefined reads as + * zero spending power, which would silently skip the withdrawal and get + * the action rejected server-side, + * 3. a loaded overview with zero spending power proceeds without signing + * (no passkey prompt when there is nothing to return). + */ +import React, { type ReactNode } from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import LockCardModal from '@/components/Card/LockCardModal' +import CancelCardModal from '@/components/Card/CancelCardModal' +import { useRainCardOverview } from '@/hooks/useRainCardOverview' +import { useWallet } from '@/hooks/wallet/useWallet' +import { useSignSpendBundle } from '@/hooks/wallet/useSignSpendBundle' +import { rainApi } from '@/services/rain' + +const WALLET = '0xafbea1a6a6036d7d827e08072cd4315248b77352' + +jest.mock('posthog-js', () => ({ __esModule: true, default: { capture: jest.fn() } })) +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: { + lockCard: jest.fn(), + activateCard: jest.fn(), + cancelCard: jest.fn(), + submitCancellationFeedback: jest.fn(), + }, +})) +// Modal chrome and the slide gesture are not under test — render passthroughs. +jest.mock('@/components/Global/Modal', () => ({ + __esModule: true, + default: ({ visible, children }: { visible: boolean; children: React.ReactNode }) => + visible ?
{children}
: null, +})) +jest.mock('@/components/Card/SlideToAction', () => ({ + __esModule: true, + default: ({ label, onComplete, disabled }: { label: string; onComplete: () => void; disabled?: boolean }) => ( + + ), +})) + +const mockOverview = useRainCardOverview as jest.Mock +const mockUseWallet = useWallet as jest.Mock +const mockUseSignSpendBundle = useSignSpendBundle as jest.Mock +const mockLockCard = rainApi.lockCard as jest.Mock +const mockCancelCard = rainApi.cancelCard as jest.Mock +const mockSignSpend = jest.fn() + +const RAIN_WITHDRAWAL = { preparationId: 'prep-1', amount: '10060000' } +// $10.06 spending power — the reporting user's exact state. +const OVERVIEW = { balance: { spendingPower: 1006 } } +const FORCED_SIGN_ARGS = { + requiredUsdcAmount: 10_060_000n, // 1006 cents → 6dp USDC units + recipient: WALLET, + rainSpendingPower: 10_060_000n, + kind: 'CRYPTO_WITHDRAW', + forceStrategy: 'collateral-only', +} + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +) + +const setup = (overview?: { balance: { spendingPower: number } }) => { + mockOverview.mockReturnValue({ overview }) + mockUseWallet.mockReturnValue({ address: WALLET }) + mockUseSignSpendBundle.mockReturnValue({ signSpend: mockSignSpend }) +} + +const renderLock = () => + render(, { wrapper: Wrapper }) +const renderCancel = () => render(, { wrapper: Wrapper }) + +beforeEach(() => { + jest.clearAllMocks() + mockSignSpend.mockResolvedValue({ strategy: 'collateral-only', rainWithdrawal: RAIN_WITHDRAWAL }) + mockLockCard.mockResolvedValue({}) + mockCancelCard.mockResolvedValue({}) +}) + +describe('LockCardModal — lock with spending power', () => { + it('forces collateral-only routing and delivers the withdrawal to the lock call', async () => { + setup(OVERVIEW) + renderLock() + fireEvent.click(screen.getByText('Slide to Lock')) + expect(await screen.findByText('Card locked')).toBeInTheDocument() + expect(mockSignSpend).toHaveBeenCalledWith(FORCED_SIGN_ARGS) + expect(mockLockCard).toHaveBeenCalledWith('card-1', RAIN_WITHDRAWAL) + }) + + it('fails closed before signing when the overview has not loaded', async () => { + setup(undefined) + renderLock() + fireEvent.click(screen.getByText('Slide to Lock')) + expect(await screen.findByText(/still loading/)).toBeInTheDocument() + expect(mockSignSpend).not.toHaveBeenCalled() + expect(mockLockCard).not.toHaveBeenCalled() + }) + + it('locks without signing when there is no spending power to return', async () => { + setup({ balance: { spendingPower: 0 } }) + renderLock() + fireEvent.click(screen.getByText('Slide to Lock')) + expect(await screen.findByText('Card locked')).toBeInTheDocument() + expect(mockSignSpend).not.toHaveBeenCalled() + expect(mockLockCard).toHaveBeenCalledWith('card-1', undefined) + }) +}) + +describe('CancelCardModal', () => { + it('forces collateral-only routing and delivers the withdrawal to the cancel call', async () => { + setup(OVERVIEW) + renderCancel() + fireEvent.click(screen.getByText('Slide to Cancel')) + expect(await screen.findByText('Card canceled')).toBeInTheDocument() + expect(mockSignSpend).toHaveBeenCalledWith(FORCED_SIGN_ARGS) + expect(mockCancelCard).toHaveBeenCalledWith('card-1', { verifiedWithdrawal: RAIN_WITHDRAWAL }) + }) + + it('fails closed before signing when the overview has not loaded', async () => { + setup(undefined) + renderCancel() + fireEvent.click(screen.getByText('Slide to Cancel')) + expect(await screen.findByText(/still loading/)).toBeInTheDocument() + expect(mockSignSpend).not.toHaveBeenCalled() + expect(mockCancelCard).not.toHaveBeenCalled() + }) + + it('cancels without signing when there is no spending power to return', async () => { + setup({ balance: { spendingPower: 0 } }) + renderCancel() + fireEvent.click(screen.getByText('Slide to Cancel')) + expect(await screen.findByText('Card canceled')).toBeInTheDocument() + expect(mockSignSpend).not.toHaveBeenCalled() + expect(mockCancelCard).toHaveBeenCalledWith('card-1', { verifiedWithdrawal: undefined }) + }) +})