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
7 changes: 7 additions & 0 deletions src/components/Card/CancelCardModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ const CancelCardModal: FC<Props> = ({ 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.
Expand All @@ -72,6 +78,7 @@ const CancelCardModal: FC<Props> = ({ 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')
Expand Down
16 changes: 12 additions & 4 deletions src/components/Card/LockCardModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ const LockCardModal: FC<Props> = ({ 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
Expand All @@ -73,15 +79,17 @@ const LockCardModal: FC<Props> = ({ 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')
Expand Down
158 changes: 158 additions & 0 deletions src/components/Card/__tests__/LockCardModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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 ? <div>{children}</div> : null,
}))
jest.mock('@/components/Card/SlideToAction', () => ({
__esModule: true,
default: ({ label, onComplete, disabled }: { label: string; onComplete: () => void; disabled?: boolean }) => (
<button onClick={onComplete} disabled={disabled}>
{label}
</button>
),
}))

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 }) => (
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
{children}
</QueryClientProvider>
)

const setup = (overview?: { balance: { spendingPower: number } }) => {
mockOverview.mockReturnValue({ overview })
mockUseWallet.mockReturnValue({ address: WALLET })
mockUseSignSpendBundle.mockReturnValue({ signSpend: mockSignSpend })
}

const renderLock = () =>
render(<LockCardModal cardId="card-1" mode="lock" isOpen onClose={jest.fn()} />, { wrapper: Wrapper })
const renderCancel = () => render(<CancelCardModal cardId="card-1" isOpen onClose={jest.fn()} />, { 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 })
})
})
Loading