diff --git a/src/components/Send/link/views/Initial.link.send.view.tsx b/src/components/Send/link/views/Initial.link.send.view.tsx index 2e9bf40d19..ff1f582ec0 100644 --- a/src/components/Send/link/views/Initial.link.send.view.tsx +++ b/src/components/Send/link/views/Initial.link.send.view.tsx @@ -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' @@ -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 }) + } } 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. @@ -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 (
diff --git a/src/components/Send/link/views/__tests__/Initial.link.send.view.test.tsx b/src/components/Send/link/views/__tests__/Initial.link.send.view.test.tsx new file mode 100644 index 0000000000..4fd6916436 --- /dev/null +++ b/src/components/Send/link/views/__tests__/Initial.link.send.view.test.tsx @@ -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: () =>
, +})) + +jest.mock('@/components/Global/FileUploadInput', () => ({ + __esModule: true, + default: () =>
, +})) + +jest.mock('@/components/Global/AmountInput', () => ({ + __esModule: true, + default: ({ setPrimaryAmount }: { setPrimaryAmount: (value: string) => void }) => ( + setPrimaryAmount(e.target.value)} /> + ), +})) + +jest.mock('@/components/0_Bruddle/Button', () => ({ + Button: ({ + children, + onClick, + disabled, + }: { + children?: React.ReactNode + onClick?: () => void + disabled?: boolean + }) => ( + + ), +})) + +jest.mock('@/components/Global/ErrorAlert', () => ({ + __esModule: true, + default: ({ description }: { description: string }) =>
{description}
, +})) + +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 ( + + ) +} + +const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + +const viewTree = (amount: string) => ( + + + + + + +) + +const renderView = (amount: string) => { + const utils = render(viewTree(amount)) + fireEvent.click(screen.getByTestId('set-amount')) + return utils +} + +const rerenderView = (utils: ReturnType, 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()) + }) +})