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
37 changes: 32 additions & 5 deletions src/components/Send/link/views/Initial.link.send.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import ErrorAlert from '@/components/Global/ErrorAlert'
import PeanutActionCard from '@/components/Global/PeanutActionCard'
import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts'
import { TRANSACTIONS } from '@/constants/query.consts'
import { loadingStateContext } from '@/context'
import { loadingStateContext } from '@/context/loadingStates.context'
import { useLinkSendFlow } from '@/context/LinkSendFlowContext'
import { useWallet } from '@/hooks/wallet/useWallet'
import { sendLinksApi } from '@/services/sendLinks'
Expand Down Expand Up @@ -142,15 +142,28 @@ const LinkSendInitialView = () => {
}

if (!peanutWalletBalance || !tokenValue) {
// clear error state when no balance or token value
setErrorState({ showError: false, errorMessage: '' })
// An emptied amount is user input — clear everything (a Retry with no
// amount would be a dead button). A momentarily-unavailable balance is
// NOT a user action: release only the gate's own error, never a
// submit-time failure the user hasn't acted on yet.
if (!tokenValue || errorState?.errorMessage === INSUFFICIENT_BALANCE_MESSAGE) {
setErrorState({ showError: false, errorMessage: '' })
}
return
}
// Gate on the displayed total: block only a true shortfall. An in-transit
// amount passes and fails late (settling message + refetch) — the FE balance
// is ~30s-polled, so blocking it here would over-reject routable funds.
if (!isAmountWithinBalance(tokenValue, balance)) {
setErrorState({ showError: true, errorMessage: INSUFFICIENT_BALANCE_MESSAGE })
// Claim the error slot only when it's free or already ours. A submit-time
// failure (cooldown / settling copy) must stay until the user retries or
// edits — right after a collateral spend the polled balance oscillates
// around the amount boundary, and overwriting here let the recovery
// branch below clear the swapped-in message, silently swallowing the
// real error while the user still couldn't spend.
if (!errorState?.showError || errorState.errorMessage === INSUFFICIENT_BALANCE_MESSAGE) {
setErrorState({ showError: true, errorMessage: INSUFFICIENT_BALANCE_MESSAGE })
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else if (errorState?.errorMessage === INSUFFICIENT_BALANCE_MESSAGE) {
// only clear OUR balance-gate error — never wipe a submit-time failure
// message (e.g. the settling copy) that handleOnNext set on a late failure.
Expand All @@ -163,16 +176,30 @@ const LinkSendInitialView = () => {
setErrorState,
hasPendingTransactions,
isLoading,
errorState?.showError,
errorState?.errorMessage,
])

// A changed amount means the previous failure no longer describes what the
// user is about to submit — hand the error slot back to the balance gate
// (which immediately re-flags a shortfall on the new amount if there is one).
const handleAmountChange = useCallback(
(value: string) => {
if (value !== tokenValue && errorState?.showError) {
setErrorState({ showError: false, errorMessage: '' })
}
setTokenValue(value)
},
[tokenValue, errorState?.showError, setErrorState, setTokenValue]
)

return (
<div className="w-full space-y-4">
<PeanutActionCard type="send" />

<AmountInput
initialAmount={tokenValue}
setPrimaryAmount={setTokenValue}
setPrimaryAmount={handleAmountChange}
onSubmit={handleOnNext}
walletBalance={peanutWalletBalance}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* LinkSendInitialView — error-state ownership tests
*
* The balance-gate useEffect and submit-time failures (Rain cooldown 425,
* settling copy) share one errorState slot. Regression suite for the
* "error message disappears" bug: right after a collateral spend the polled
* spendable balance oscillates around the typed amount, and the gate was
* overwriting the submit-time error with INSUFFICIENT_BALANCE_MESSAGE on the
* dip, then clearing it on the recovery — silently swallowing the real error.
* (PostHog session 019f8f8c-d4d5-775c-97ab-3e47c532a694, 2026-07-23.)
*/
import React from 'react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { LinkSendFlowProvider, useLinkSendFlow } from '@/context/LinkSendFlowContext'
import { INSUFFICIENT_BALANCE_MESSAGE } from '@/utils/balance.utils'

const COOLDOWN_MESSAGE = 'A previous withdrawal signature is still active. Try again in about 2 min.'

// ---------- module mocks ----------

jest.mock('@/context/loadingStates.context', () => {
const ReactActual = jest.requireActual('react')
return {
loadingStateContext: ReactActual.createContext({
loadingState: 'Idle',
setLoadingState: jest.fn(),
isLoading: false,
}),
}
})

const mockCreateLink = jest.fn()
jest.mock('@/components/Create/useCreateLink', () => ({
useCreateLink: () => ({ createLink: mockCreateLink }),
}))

const mockUseWallet = jest.fn()
jest.mock('@/hooks/wallet/useWallet', () => ({
useWallet: () => mockUseWallet(),
}))

jest.mock('@/hooks/wallet/usePendingTransactions', () => ({
usePendingTransactions: () => ({ hasPendingTransactions: false, pendingCount: 0 }),
}))

jest.mock('@/services/sendLinks', () => ({
sendLinksApi: { create: jest.fn().mockResolvedValue({}) },
}))

jest.mock('posthog-js', () => ({
__esModule: true,
default: { capture: jest.fn() },
}))

jest.mock('@sentry/nextjs', () => ({
captureException: jest.fn(),
}))

jest.mock('@/components/Global/PeanutActionCard', () => ({
__esModule: true,
default: () => <div data-testid="action-card" />,
}))

jest.mock('@/components/Global/FileUploadInput', () => ({
__esModule: true,
default: () => <div data-testid="file-upload" />,
}))

jest.mock('@/components/Global/AmountInput', () => ({
__esModule: true,
default: ({ setPrimaryAmount }: { setPrimaryAmount: (value: string) => void }) => (
<input data-testid="amount-input" onChange={(e) => setPrimaryAmount(e.target.value)} />
),
}))

jest.mock('@/components/0_Bruddle/Button', () => ({
Button: ({
children,
onClick,
disabled,
}: {
children?: React.ReactNode
onClick?: () => void
disabled?: boolean
}) => (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
),
}))

jest.mock('@/components/Global/ErrorAlert', () => ({
__esModule: true,
default: ({ description }: { description: string }) => <div data-testid="error-alert">{description}</div>,
}))

import LinkSendInitialView from '../Initial.link.send.view'

// ---------- helpers ----------

const usdc = (dollars: number) => BigInt(Math.round(dollars * 1e6))

const walletState = (spendableDollars: number | undefined) => ({
fetchBalance: jest.fn(),
spendableBalance: spendableDollars === undefined ? undefined : usdc(spendableDollars),
formattedSpendableBalance: spendableDollars === undefined ? '0.00' : spendableDollars.toFixed(2),
})

/** Drives the flow context from inside the provider (AmountInput is mocked out). */
const SetAmount = ({ amount }: { amount: string }) => {
const { setTokenValue } = useLinkSendFlow()
return (
<button data-testid="set-amount" onClick={() => setTokenValue(amount)}>
set
</button>
)
}

const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })

const viewTree = (amount: string) => (
<QueryClientProvider client={queryClient}>
<LinkSendFlowProvider>
<SetAmount amount={amount} />
<LinkSendInitialView />
</LinkSendFlowProvider>
</QueryClientProvider>
)

const renderView = (amount: string) => {
const utils = render(viewTree(amount))
fireEvent.click(screen.getByTestId('set-amount'))
return utils
}

const rerenderView = (utils: ReturnType<typeof render>, amount: string) => {
utils.rerender(viewTree(amount))
}

beforeEach(() => {
jest.clearAllMocks()
})

// ---------- tests ----------

describe('LinkSendInitialView error ownership', () => {
test('submit-time error survives a balance dip below the amount (gate must not overwrite it)', async () => {
mockUseWallet.mockReturnValue(walletState(100))
mockCreateLink.mockRejectedValue(new Error(COOLDOWN_MESSAGE))

const utils = renderView('20')
fireEvent.click(screen.getByText('Create link'))
await waitFor(() => expect(screen.getByTestId('error-alert')).toHaveTextContent(COOLDOWN_MESSAGE))

// balance poll settles below the amount — the cooldown copy must stay
mockUseWallet.mockReturnValue(walletState(10))
rerenderView(utils, '20')
expect(screen.getByTestId('error-alert')).toHaveTextContent(COOLDOWN_MESSAGE)

// next poll recovers above the amount — still must not be cleared
mockUseWallet.mockReturnValue(walletState(100))
rerenderView(utils, '20')
expect(screen.getByTestId('error-alert')).toHaveTextContent(COOLDOWN_MESSAGE)
})

test('submit-time error survives the balance briefly reading as unavailable', async () => {
mockUseWallet.mockReturnValue(walletState(100))
mockCreateLink.mockRejectedValue(new Error(COOLDOWN_MESSAGE))

const utils = renderView('20')
fireEvent.click(screen.getByText('Create link'))
await waitFor(() => expect(screen.getByTestId('error-alert')).toHaveTextContent(COOLDOWN_MESSAGE))

// balance query momentarily has no data — not a user action, must not clear
mockUseWallet.mockReturnValue(walletState(undefined))
rerenderView(utils, '20')
expect(screen.getByTestId('error-alert')).toHaveTextContent(COOLDOWN_MESSAGE)
})

test('editing the amount releases a submit-time error back to the balance gate', async () => {
mockUseWallet.mockReturnValue(walletState(100))
mockCreateLink.mockRejectedValue(new Error(COOLDOWN_MESSAGE))

renderView('20')
fireEvent.click(screen.getByText('Create link'))
await waitFor(() => expect(screen.getByTestId('error-alert')).toHaveTextContent(COOLDOWN_MESSAGE))

// user types a new amount — the stale failure clears...
fireEvent.change(screen.getByTestId('amount-input'), { target: { value: '30' } })
await waitFor(() => expect(screen.queryByTestId('error-alert')).not.toBeInTheDocument())

// ...and the gate immediately re-flags a genuine shortfall on the new amount
fireEvent.change(screen.getByTestId('amount-input'), { target: { value: '200' } })
await waitFor(() => expect(screen.getByTestId('error-alert')).toHaveTextContent(INSUFFICIENT_BALANCE_MESSAGE))
})

test('balance-gate error appears on shortfall and clears on recovery when no submit error is showing', async () => {
mockUseWallet.mockReturnValue(walletState(10))

const utils = renderView('20')
await waitFor(() => expect(screen.getByTestId('error-alert')).toHaveTextContent(INSUFFICIENT_BALANCE_MESSAGE))

mockUseWallet.mockReturnValue(walletState(100))
rerenderView(utils, '20')
await waitFor(() => expect(screen.queryByTestId('error-alert')).not.toBeInTheDocument())
})
})
Loading