From 0153eb50c646ae116b535c7f4c4c8f3745117cdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= <70615692+jjramirezn@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:20:59 -0300 Subject: [PATCH 1/3] fix(send-link): keep submit-time errors visible through balance polls The balance-gate effect shares one errorState slot with submit-time failures (Rain signature-cooldown 425, settling copy). Right after a collateral spend the ~30s-polled spendable balance oscillates around the typed amount, so the gate overwrote the submit error with the insufficient-balance copy on the dip and then cleared it on the recovery - the user's real error silently vanished while they still couldn't spend (PostHog session 019f8f8c-d4d5-775c-97ab-3e47c532a694). The gate now only claims the error slot when it is free or already showing its own message; submit-time errors persist until the user retries or edits. --- .../link/views/Initial.link.send.view.tsx | 11 +- .../__tests__/Initial.link.send.view.test.tsx | 167 ++++++++++++++++++ 2 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 src/components/Send/link/views/__tests__/Initial.link.send.view.test.tsx 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..ec55b5b095 100644 --- a/src/components/Send/link/views/Initial.link.send.view.tsx +++ b/src/components/Send/link/views/Initial.link.send.view.tsx @@ -150,7 +150,15 @@ const LinkSendInitialView = () => { // 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,6 +171,7 @@ const LinkSendInitialView = () => { setErrorState, hasPendingTransactions, isLoading, + errorState?.showError, errorState?.errorMessage, ]) 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..3ad7857ab1 --- /dev/null +++ b/src/components/Send/link/views/__tests__/Initial.link.send.view.test.tsx @@ -0,0 +1,167 @@ +/** + * 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', () => { + 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: (...args: any[]) => mockUseWallet(...args), +})) + +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: () =>
, +})) + +jest.mock('@/components/0_Bruddle/Button', () => ({ + Button: ({ children, onClick, disabled }: any) => ( + + ), +})) + +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) => ({ + fetchBalance: jest.fn(), + spendableBalance: usdc(spendableDollars), + formattedSpendableBalance: 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('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()) + }) +}) From 5933177a3d579093f2793b9aca8ad2466a8e44af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= <70615692+jjramirezn@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:24:03 -0300 Subject: [PATCH 2/3] chore(send-link): lint-clean the new test + drop barrel import in touched view New code must lint clean: type the Button mock and drop the any-spread in the useWallet mock. The touched view's '@/context' barrel import is swapped for the specific loadingStates.context file (same module instance, restricted-imports rule). --- .../Send/link/views/Initial.link.send.view.tsx | 2 +- .../__tests__/Initial.link.send.view.test.tsx | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) 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 ec55b5b095..a08b976495 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' 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 index 3ad7857ab1..a479c0d418 100644 --- 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 @@ -19,7 +19,7 @@ const COOLDOWN_MESSAGE = 'A previous withdrawal signature is still active. Try a // ---------- module mocks ---------- -jest.mock('@/context', () => { +jest.mock('@/context/loadingStates.context', () => { const ReactActual = jest.requireActual('react') return { loadingStateContext: ReactActual.createContext({ @@ -37,7 +37,7 @@ jest.mock('@/components/Create/useCreateLink', () => ({ const mockUseWallet = jest.fn() jest.mock('@/hooks/wallet/useWallet', () => ({ - useWallet: (...args: any[]) => mockUseWallet(...args), + useWallet: () => mockUseWallet(), })) jest.mock('@/hooks/wallet/usePendingTransactions', () => ({ @@ -73,7 +73,15 @@ jest.mock('@/components/Global/AmountInput', () => ({ })) jest.mock('@/components/0_Bruddle/Button', () => ({ - Button: ({ children, onClick, disabled }: any) => ( + Button: ({ + children, + onClick, + disabled, + }: { + children?: React.ReactNode + onClick?: () => void + disabled?: boolean + }) => ( From b1fb260c0ff613ee53a226ab643fc408ed03511e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= <70615692+jjramirezn@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:36:35 -0300 Subject: [PATCH 3/3] fix(send-link): complete error-ownership transitions (CodeRabbit) Two more transitions could still eat a submit-time error: a momentarily-unavailable balance cleared every error (now releases only the gate's own message; an emptied amount still clears all - that IS user input), and editing the amount left a stale failure on screen (now released back to the gate, which re-flags a genuine shortfall on the new amount). Regression tests for both. --- .../link/views/Initial.link.send.view.tsx | 24 +++++++++-- .../__tests__/Initial.link.send.view.test.tsx | 41 +++++++++++++++++-- 2 files changed, 58 insertions(+), 7 deletions(-) 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 a08b976495..ff1f582ec0 100644 --- a/src/components/Send/link/views/Initial.link.send.view.tsx +++ b/src/components/Send/link/views/Initial.link.send.view.tsx @@ -142,8 +142,13 @@ 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 @@ -175,13 +180,26 @@ const LinkSendInitialView = () => { 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 index a479c0d418..4fd6916436 100644 --- 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 @@ -69,7 +69,9 @@ jest.mock('@/components/Global/FileUploadInput', () => ({ jest.mock('@/components/Global/AmountInput', () => ({ __esModule: true, - default: () =>
, + default: ({ setPrimaryAmount }: { setPrimaryAmount: (value: string) => void }) => ( + setPrimaryAmount(e.target.value)} /> + ), })) jest.mock('@/components/0_Bruddle/Button', () => ({ @@ -99,10 +101,10 @@ import LinkSendInitialView from '../Initial.link.send.view' const usdc = (dollars: number) => BigInt(Math.round(dollars * 1e6)) -const walletState = (spendableDollars: number) => ({ +const walletState = (spendableDollars: number | undefined) => ({ fetchBalance: jest.fn(), - spendableBalance: usdc(spendableDollars), - formattedSpendableBalance: spendableDollars.toFixed(2), + 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). */ @@ -162,6 +164,37 @@ describe('LinkSendInitialView error ownership', () => { 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))