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: () =>