From 7fd3fb7a939f6b42a03a8b04c2d51e8bc8bc4030 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 2 Jul 2026 10:37:59 -0700 Subject: [PATCH 1/4] fix(card): key card flows off findActiveCard, never cards[0] + un-trappable setup modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-07-02 dup-card incident: the setup modal read cards[0] (newest) while the backend stored the session-key grant on a different card — every tap 'succeeded', the modal never dismissed, and the error-gated Skip never rendered. All card flows now use the existing findActiveCard helper, and a grant that succeeds without flipping hasWithdrawApproval reveals the escape hatch and pages Sentry (console.warn + captureMessage). --- .../dev/card-session-approve/page.tsx | 3 +- .../Home/EnableAutoBalanceBanner.tsx | 45 ++++++++++-- .../EnableAutoBalanceBanner.test.tsx | 72 ++++++++++++++++--- src/hooks/wallet/useGrantSessionKey.ts | 3 +- src/hooks/wallet/useSignSpendBundle.ts | 3 +- src/hooks/wallet/useSpendBundle.ts | 3 +- 6 files changed, 111 insertions(+), 18 deletions(-) diff --git a/src/app/(mobile-ui)/dev/card-session-approve/page.tsx b/src/app/(mobile-ui)/dev/card-session-approve/page.tsx index 34306f2a6..922031122 100644 --- a/src/app/(mobile-ui)/dev/card-session-approve/page.tsx +++ b/src/app/(mobile-ui)/dev/card-session-approve/page.tsx @@ -9,6 +9,7 @@ */ import { useState } from 'react' +import { findActiveCard } from '@/components/Card/cardState.utils' import { useRainCardOverview } from '@/hooks/useRainCardOverview' import { useGrantSessionKey } from '@/hooks/wallet/useGrantSessionKey' import { Button } from '@/components/0_Bruddle/Button' @@ -18,7 +19,7 @@ export default function CardSessionApprovePage() { const { grant, isGranting } = useGrantSessionKey() const [status, setStatus] = useState('') - const card = overview?.cards?.[0] + const card = findActiveCard(overview) const handleClick = async () => { setStatus('Waiting for passkey tap…') diff --git a/src/components/Home/EnableAutoBalanceBanner.tsx b/src/components/Home/EnableAutoBalanceBanner.tsx index 9498342ab..2425d7d8a 100644 --- a/src/components/Home/EnableAutoBalanceBanner.tsx +++ b/src/components/Home/EnableAutoBalanceBanner.tsx @@ -1,7 +1,9 @@ 'use client' -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' +import * as Sentry from '@sentry/nextjs' import ActionModal, { type ActionModalButtonProps } from '@/components/Global/ActionModal' +import { findActiveCard } from '@/components/Card/cardState.utils' import { useRainCardOverview } from '@/hooks/useRainCardOverview' import { useGrantSessionKey } from '@/hooks/wallet/useGrantSessionKey' @@ -22,13 +24,22 @@ import { useGrantSessionKey } from '@/hooks/wallet/useGrantSessionKey' * cancelled (very common on iOS / 1Password), and a non-dismissible modal * whose CTA silently does nothing is a hard lockout. Skipping is safe: the * grant is re-prompted on the first card spend regardless. + * + * The card this modal keys off MUST be `findActiveCard(overview)`, never + * `cards[0]`: in the 2026-07-02 duplicate-card incident `cards[0]` was a + * bare duplicate while the grant landed on the other card — every tap + * "succeeded" and the modal never dismissed. As a second belt: if a grant + * reports success but the flag still hasn't flipped, we surface the escape + * anyway and page Sentry — a non-dismissible modal must never depend on a + * distributed flag flipping. */ export default function EnableAutoBalanceBanner() { const { overview } = useRainCardOverview() const { grant, isGranting, lastError } = useGrantSessionKey() const [dismissed, setDismissed] = useState(false) + const [grantSucceeded, setGrantSucceeded] = useState(false) - const card = overview?.cards?.[0] + const card = findActiveCard(overview) const shouldShow = card?.status === 'ACTIVE' && !card.hasWithdrawApproval && @@ -40,6 +51,25 @@ export default function EnableAutoBalanceBanner() { // recoverable message. const hardError = !!lastError && lastError.kind !== 'user-cancelled' + // Loop signal: grant() resolved ok (which includes the overview refetch), + // yet the active card still lacks the approval. That is the dup-card + // lockout shape — warn once and treat it like a failure so the escape + // hatch renders. + const stuckAfterSuccess = grantSucceeded && shouldShow + const warnedRef = useRef(false) + useEffect(() => { + if (stuckAfterSuccess && !warnedRef.current) { + warnedRef.current = true + console.warn( + '[EnableAutoBalanceBanner] grant succeeded but the active card still lacks hasWithdrawApproval — duplicate-card lockout shape' + ) + Sentry.captureMessage('card session-key grant succeeded but hasWithdrawApproval never flipped', { + level: 'error', + extra: { cardId: card?.id }, + }) + } + }, [stuckAfterSuccess, card?.id]) + const ctas: ActionModalButtonProps[] = [ { text: isGranting ? 'Working…' : hardError ? 'Try again' : 'Continue', @@ -47,13 +77,16 @@ export default function EnableAutoBalanceBanner() { shadowSize: '4', disabled: isGranting, onClick: () => { - void grant() + void grant().then((result) => { + if (result.ok) setGrantSucceeded(true) + }) }, }, ] - // Escape hatch, shown only once a grant has failed, so the user is never - // trapped behind this non-dismissible modal. - if (lastError) { + // Escape hatch, shown once a grant has failed — or "succeeded" without + // clearing the modal — so the user is never trapped behind this + // non-dismissible modal. + if (lastError || stuckAfterSuccess) { ctas.push({ text: 'Skip for now', variant: 'stroke', diff --git a/src/components/Home/__tests__/EnableAutoBalanceBanner.test.tsx b/src/components/Home/__tests__/EnableAutoBalanceBanner.test.tsx index 5cd0e0d6d..7fa078d91 100644 --- a/src/components/Home/__tests__/EnableAutoBalanceBanner.test.tsx +++ b/src/components/Home/__tests__/EnableAutoBalanceBanner.test.tsx @@ -1,30 +1,45 @@ /** - * EnableAutoBalanceBanner — escape-hatch regression + * EnableAutoBalanceBanner — escape-hatch + duplicate-card regressions * - * The modal is preventClose + hideModalCloseButton. Before this fix `void grant()` - * discarded the result, so a cancelled/failed passkey left the user trapped with a - * button that appeared to do nothing. The fix surfaces the error and adds a "Skip - * for now" escape once a grant has failed. + * The modal is preventClose + hideModalCloseButton. Two ways it trapped users: + * + * 1. `void grant()` discarded the result, so a cancelled/failed passkey left + * the user with a button that appeared to do nothing → error surfaces + + * "Skip for now" escape once a grant has failed. + * + * 2. 2026-07-02 duplicate-card incident: the modal keyed off `cards[0]` + * (newest) while the backend stored the grant on a different card — every + * tap "succeeded" and the modal never dismissed, and with no error the + * escape never rendered. Regressions below pin (a) findActiveCard-based + * selection (a CANCELED newest row must not drive the modal) and (b) the + * stuck-after-success escape + Sentry/console signal. */ import React from 'react' -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen, fireEvent, act } from '@testing-library/react' import type { GrantSessionKeyError } from '@/hooks/wallet/useGrantSessionKey' -const mockGrant = jest.fn() +const mockGrant = jest.fn, []>() let mockLastError: GrantSessionKeyError | null = null jest.mock('@/hooks/wallet/useGrantSessionKey', () => ({ useGrantSessionKey: () => ({ grant: mockGrant, isGranting: false, lastError: mockLastError }), })) +type MockCard = { id?: string; status: string; hasWithdrawApproval: boolean } +let mockCards: MockCard[] = [] jest.mock('@/hooks/useRainCardOverview', () => ({ useRainCardOverview: () => ({ overview: { - cards: [{ status: 'ACTIVE', hasWithdrawApproval: false }], + cards: mockCards, status: { contractAddress: '0xabc', coordinatorAddress: '0xdef' }, }, }), })) +jest.mock('@sentry/nextjs', () => ({ + captureMessage: jest.fn(), +})) +import * as Sentry from '@sentry/nextjs' + jest.mock('@/components/Global/ActionModal', () => ({ __esModule: true, default: (props: { visible: boolean; description?: string; ctas?: { text: string; onClick: () => void }[] }) => @@ -45,6 +60,8 @@ import EnableAutoBalanceBanner from '../EnableAutoBalanceBanner' beforeEach(() => { jest.clearAllMocks() mockLastError = null + mockGrant.mockResolvedValue({ ok: false }) + mockCards = [{ status: 'ACTIVE', hasWithdrawApproval: false }] }) describe('EnableAutoBalanceBanner', () => { @@ -69,4 +86,43 @@ describe('EnableAutoBalanceBanner', () => { expect(screen.getByText(/couldn't finish setting up your card/i)).toBeInTheDocument() expect(screen.getByText('Try again')).toBeInTheDocument() }) + + it('keys off the ACTIVE card, not cards[0] — a CANCELED newest row with a granted older card hides the modal', () => { + // The post-remediation nicnode shape: duplicate canceled, real card granted. + mockCards = [ + { status: 'CANCELED', hasWithdrawApproval: false }, + { status: 'ACTIVE', hasWithdrawApproval: true }, + ] + render() + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + }) + + it('still prompts when the ACTIVE card lacks the grant even behind a CANCELED newest row', () => { + mockCards = [ + { status: 'CANCELED', hasWithdrawApproval: false }, + { status: 'ACTIVE', hasWithdrawApproval: false }, + ] + render() + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + + it('a grant that "succeeds" without clearing the modal reveals the escape and pages Sentry (dup-card lockout shape)', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + mockGrant.mockResolvedValue({ ok: true }) + // Overview never flips hasWithdrawApproval — the lockout shape. + render() + expect(screen.queryByText('Skip for now')).not.toBeInTheDocument() + + await act(async () => { + fireEvent.click(screen.getByText('Continue')) + }) + + expect(screen.getByText('Skip for now')).toBeInTheDocument() + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('duplicate-card lockout shape')) + expect(Sentry.captureMessage).toHaveBeenCalledWith( + expect.stringContaining('hasWithdrawApproval never flipped'), + expect.objectContaining({ level: 'error' }) + ) + warnSpy.mockRestore() + }) }) diff --git a/src/hooks/wallet/useGrantSessionKey.ts b/src/hooks/wallet/useGrantSessionKey.ts index a5eb330f2..17b9c40cd 100644 --- a/src/hooks/wallet/useGrantSessionKey.ts +++ b/src/hooks/wallet/useGrantSessionKey.ts @@ -6,6 +6,7 @@ import { pad, parseAbi, toFunctionSelector } from 'viem' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { useKernelClient } from '@/context/kernelClient.context' +import { findActiveCard } from '@/components/Card/cardState.utils' import { useRainCardOverview, RAIN_CARD_OVERVIEW_QUERY_KEY } from '@/hooks/useRainCardOverview' import { useQueryClient } from '@tanstack/react-query' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts' @@ -217,7 +218,7 @@ export const useGrantSessionKey = (): GrantSessionKeyResult => { const grant = useCallback(async () => { const result = await wrap(async () => { - const card = overview?.cards?.[0] + const card = findActiveCard(overview) if (!card) return { ok: false, error: { kind: 'no-card' } as const } const r = await runSerialize() diff --git a/src/hooks/wallet/useSignSpendBundle.ts b/src/hooks/wallet/useSignSpendBundle.ts index a6206642a..77cd1cf0d 100644 --- a/src/hooks/wallet/useSignSpendBundle.ts +++ b/src/hooks/wallet/useSignSpendBundle.ts @@ -15,6 +15,7 @@ import { rainWithdrawEip712Types, } from '@/constants/rain.consts' import { rainApi, type RainCollateralKind } from '@/services/rain' +import { findActiveCard } from '@/components/Card/cardState.utils' import { useRainCardOverview, RAIN_CARD_OVERVIEW_QUERY_KEY } from '@/hooks/useRainCardOverview' import { useGrantSessionKey, type GrantSessionKeyError } from './useGrantSessionKey' import { useSignUserOp, type SignedUserOpData } from './useSignUserOp' @@ -162,7 +163,7 @@ export const useSignSpendBundle = () => { if (!overview) { throw new SessionKeyGrantRequiredError({ kind: 'unexpected' } as GrantSessionKeyError) } - const card = overview.cards?.[0] + const card = findActiveCard(overview) if (card && !card.hasWithdrawApproval) { onGrantRequired?.() const grantResult = await grant() diff --git a/src/hooks/wallet/useSpendBundle.ts b/src/hooks/wallet/useSpendBundle.ts index ef12a6d5c..998ab179c 100644 --- a/src/hooks/wallet/useSpendBundle.ts +++ b/src/hooks/wallet/useSpendBundle.ts @@ -18,6 +18,7 @@ import { } from '@/constants/rain.consts' import { rainApi, type RainCollateralKind } from '@/services/rain' import { useZeroDev } from '@/hooks/useZeroDev' +import { findActiveCard } from '@/components/Card/cardState.utils' import { useRainCardOverview, RAIN_CARD_OVERVIEW_QUERY_KEY } from '@/hooks/useRainCardOverview' import { useGrantSessionKey, type GrantSessionKeyError } from './useGrantSessionKey' import { usdcUnitsToRainCents } from '@/utils/balance.utils' @@ -217,7 +218,7 @@ export const useSpendBundle = () => { // the one-time session-key grant. If missing, run the inline grant // flow now (one extra passkey tap the FIRST time, zero after). const touchesCollateral = strategy === 'collateral-only' || strategy === 'mixed' - const card = overview?.cards?.[0] + const card = findActiveCard(overview) if (touchesCollateral && card && !card.hasWithdrawApproval) { onGrantRequired?.() const grantResult = await grant() From c9c4da5f606ddc3de6344a05811700e5af508769 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 2 Jul 2026 11:43:14 -0700 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20key=20the=20stuck-after-success=20si?= =?UTF-8?q?gnal=20to=20the=20card=20identity=20(CodeRabbit)=20=E2=80=94=20?= =?UTF-8?q?a=20re-issued=20card=20must=20not=20inherit=20an=20old=20card's?= =?UTF-8?q?=20grant=20success?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/EnableAutoBalanceBanner.tsx | 26 +++++++----- .../EnableAutoBalanceBanner.test.tsx | 42 ++++++++++++++++--- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/src/components/Home/EnableAutoBalanceBanner.tsx b/src/components/Home/EnableAutoBalanceBanner.tsx index 2425d7d8a..6bb503497 100644 --- a/src/components/Home/EnableAutoBalanceBanner.tsx +++ b/src/components/Home/EnableAutoBalanceBanner.tsx @@ -37,7 +37,10 @@ export default function EnableAutoBalanceBanner() { const { overview } = useRainCardOverview() const { grant, isGranting, lastError } = useGrantSessionKey() const [dismissed, setDismissed] = useState(false) - const [grantSucceeded, setGrantSucceeded] = useState(false) + // Card id the last SUCCESSFUL grant was tapped for — keyed by identity so + // a later re-issued card (new id, legitimately needing its own setup pass) + // never inherits the stuck signal from an old card's grant. + const [grantSucceededFor, setGrantSucceededFor] = useState(null) const card = findActiveCard(overview) const shouldShow = @@ -52,23 +55,23 @@ export default function EnableAutoBalanceBanner() { const hardError = !!lastError && lastError.kind !== 'user-cancelled' // Loop signal: grant() resolved ok (which includes the overview refetch), - // yet the active card still lacks the approval. That is the dup-card - // lockout shape — warn once and treat it like a failure so the escape - // hatch renders. - const stuckAfterSuccess = grantSucceeded && shouldShow - const warnedRef = useRef(false) + // yet the SAME card still lacks the approval. That is the dup-card + // lockout shape — warn once per card and treat it like a failure so the + // escape hatch renders. + const stuckAfterSuccess = !!card && grantSucceededFor !== null && grantSucceededFor === card.id && shouldShow + const warnedForCardRef = useRef(null) useEffect(() => { - if (stuckAfterSuccess && !warnedRef.current) { - warnedRef.current = true + if (stuckAfterSuccess && card && warnedForCardRef.current !== card.id) { + warnedForCardRef.current = card.id console.warn( '[EnableAutoBalanceBanner] grant succeeded but the active card still lacks hasWithdrawApproval — duplicate-card lockout shape' ) Sentry.captureMessage('card session-key grant succeeded but hasWithdrawApproval never flipped', { level: 'error', - extra: { cardId: card?.id }, + extra: { cardId: card.id }, }) } - }, [stuckAfterSuccess, card?.id]) + }, [stuckAfterSuccess, card]) const ctas: ActionModalButtonProps[] = [ { @@ -77,8 +80,9 @@ export default function EnableAutoBalanceBanner() { shadowSize: '4', disabled: isGranting, onClick: () => { + const grantedCardId = card?.id ?? null void grant().then((result) => { - if (result.ok) setGrantSucceeded(true) + if (result.ok) setGrantSucceededFor(grantedCardId) }) }, }, diff --git a/src/components/Home/__tests__/EnableAutoBalanceBanner.test.tsx b/src/components/Home/__tests__/EnableAutoBalanceBanner.test.tsx index 7fa078d91..c60c3b739 100644 --- a/src/components/Home/__tests__/EnableAutoBalanceBanner.test.tsx +++ b/src/components/Home/__tests__/EnableAutoBalanceBanner.test.tsx @@ -61,7 +61,7 @@ beforeEach(() => { jest.clearAllMocks() mockLastError = null mockGrant.mockResolvedValue({ ok: false }) - mockCards = [{ status: 'ACTIVE', hasWithdrawApproval: false }] + mockCards = [{ id: 'card-a', status: 'ACTIVE', hasWithdrawApproval: false }] }) describe('EnableAutoBalanceBanner', () => { @@ -90,8 +90,8 @@ describe('EnableAutoBalanceBanner', () => { it('keys off the ACTIVE card, not cards[0] — a CANCELED newest row with a granted older card hides the modal', () => { // The post-remediation nicnode shape: duplicate canceled, real card granted. mockCards = [ - { status: 'CANCELED', hasWithdrawApproval: false }, - { status: 'ACTIVE', hasWithdrawApproval: true }, + { id: 'card-dup', status: 'CANCELED', hasWithdrawApproval: false }, + { id: 'card-real', status: 'ACTIVE', hasWithdrawApproval: true }, ] render() expect(screen.queryByTestId('modal')).not.toBeInTheDocument() @@ -99,8 +99,8 @@ describe('EnableAutoBalanceBanner', () => { it('still prompts when the ACTIVE card lacks the grant even behind a CANCELED newest row', () => { mockCards = [ - { status: 'CANCELED', hasWithdrawApproval: false }, - { status: 'ACTIVE', hasWithdrawApproval: false }, + { id: 'card-dup', status: 'CANCELED', hasWithdrawApproval: false }, + { id: 'card-real', status: 'ACTIVE', hasWithdrawApproval: false }, ] render() expect(screen.getByTestId('modal')).toBeInTheDocument() @@ -125,4 +125,36 @@ describe('EnableAutoBalanceBanner', () => { ) warnSpy.mockRestore() }) + + it('a re-issued card does NOT inherit the stuck signal from an old card grant (no premature escape, no false Sentry page)', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + // Model the REAL happy path: grant() refetches the overview before + // resolving, so by the time it returns ok the flag is already flipped. + mockGrant.mockImplementation(async () => { + mockCards = [{ id: 'card-a', status: 'ACTIVE', hasWithdrawApproval: true }] + return { ok: true } + }) + mockCards = [{ id: 'card-a', status: 'ACTIVE', hasWithdrawApproval: false }] + const { rerender } = render() + + // Grant succeeds for card A and the flag flips — modal hides, all good. + await act(async () => { + fireEvent.click(screen.getByText('Continue')) + }) + rerender() + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + + // Card A is replaced by card B, which legitimately needs its own setup: + // fresh prompt, NO escape, NO Sentry noise. + mockCards = [ + { id: 'card-a', status: 'CANCELED', hasWithdrawApproval: true }, + { id: 'card-b', status: 'ACTIVE', hasWithdrawApproval: false }, + ] + rerender() + // findActiveCard skips CANCELED → card-b drives the modal + expect(screen.getByTestId('modal')).toBeInTheDocument() + expect(screen.queryByText('Skip for now')).not.toBeInTheDocument() + expect(Sentry.captureMessage).not.toHaveBeenCalled() + warnSpy.mockRestore() + }) }) From 8dd1f9462ec5e744fe5e07838fae41f4ee60174a Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 2 Jul 2026 12:20:44 -0700 Subject: [PATCH 3/4] fix: address /code-review findings on the setup-modal escape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - overviewFresh: a grant whose overview refetch failed is STALE, not stuck — don't false-page Sentry on flaky connections (grant() now reports it) - per-card Skip dismissal: skipping stuck card A must not suppress card B's legitimate prompt in the same session - stuck state now explains itself (error copy + Try again CTA) instead of happy-path text with an unexplained Skip - Sentry warn dedupe uses a Set — alternating active cards (A→B→A) no longer re-page for an already-warned card --- .../Home/EnableAutoBalanceBanner.tsx | 51 ++++++++++++------- .../EnableAutoBalanceBanner.test.tsx | 49 ++++++++++++++++-- src/hooks/wallet/useGrantSessionKey.ts | 17 +++++-- 3 files changed, 91 insertions(+), 26 deletions(-) diff --git a/src/components/Home/EnableAutoBalanceBanner.tsx b/src/components/Home/EnableAutoBalanceBanner.tsx index 6bb503497..0d6c1712d 100644 --- a/src/components/Home/EnableAutoBalanceBanner.tsx +++ b/src/components/Home/EnableAutoBalanceBanner.tsx @@ -29,17 +29,24 @@ import { useGrantSessionKey } from '@/hooks/wallet/useGrantSessionKey' * `cards[0]`: in the 2026-07-02 duplicate-card incident `cards[0]` was a * bare duplicate while the grant landed on the other card — every tap * "succeeded" and the modal never dismissed. As a second belt: if a grant - * reports success but the flag still hasn't flipped, we surface the escape - * anyway and page Sentry — a non-dismissible modal must never depend on a - * distributed flag flipping. + * reports success (with a FRESH overview — a failed refetch is just a stale + * flag, not a lockout) and the flag still hasn't flipped for the SAME card, + * we surface the escape anyway, explain the failure, and page Sentry — a + * non-dismissible modal must never depend on a distributed flag flipping. + * All the "have we been here" state (grant success, skip dismissal, Sentry + * dedupe) is keyed by card id, so a later re-issued card always gets its + * own clean setup pass. */ export default function EnableAutoBalanceBanner() { const { overview } = useRainCardOverview() const { grant, isGranting, lastError } = useGrantSessionKey() - const [dismissed, setDismissed] = useState(false) - // Card id the last SUCCESSFUL grant was tapped for — keyed by identity so - // a later re-issued card (new id, legitimately needing its own setup pass) - // never inherits the stuck signal from an old card's grant. + // Card id the user chose "Skip for now" for — per card, so skipping a + // stuck card A never suppresses the prompt for a different card B that + // legitimately needs its own setup later in the same session. + const [dismissedFor, setDismissedFor] = useState(null) + // Card id the last SUCCESSFUL grant (with a fresh overview) was tapped + // for — keyed by identity so a later re-issued card never inherits the + // stuck signal from an old card's grant. const [grantSucceededFor, setGrantSucceededFor] = useState(null) const card = findActiveCard(overview) @@ -54,15 +61,18 @@ export default function EnableAutoBalanceBanner() { // recoverable message. const hardError = !!lastError && lastError.kind !== 'user-cancelled' - // Loop signal: grant() resolved ok (which includes the overview refetch), - // yet the SAME card still lacks the approval. That is the dup-card - // lockout shape — warn once per card and treat it like a failure so the - // escape hatch renders. + // Loop signal: grant() resolved ok with a FRESH overview, yet the SAME + // card still lacks the approval. That is the dup-card lockout shape — + // warn once per card and treat it like a failure so the escape hatch + // renders with an explanation. const stuckAfterSuccess = !!card && grantSucceededFor !== null && grantSucceededFor === card.id && shouldShow - const warnedForCardRef = useRef(null) + // Set of card ids already warned for — a plain "last id" ref would + // re-page Sentry when the active card alternates (A → B → A) during + // remediation. + const warnedCardsRef = useRef>(new Set()) useEffect(() => { - if (stuckAfterSuccess && card && warnedForCardRef.current !== card.id) { - warnedForCardRef.current = card.id + if (stuckAfterSuccess && card && !warnedCardsRef.current.has(card.id)) { + warnedCardsRef.current.add(card.id) console.warn( '[EnableAutoBalanceBanner] grant succeeded but the active card still lacks hasWithdrawApproval — duplicate-card lockout shape' ) @@ -75,14 +85,17 @@ export default function EnableAutoBalanceBanner() { const ctas: ActionModalButtonProps[] = [ { - text: isGranting ? 'Working…' : hardError ? 'Try again' : 'Continue', + text: isGranting ? 'Working…' : hardError || stuckAfterSuccess ? 'Try again' : 'Continue', variant: 'purple', shadowSize: '4', disabled: isGranting, onClick: () => { const grantedCardId = card?.id ?? null void grant().then((result) => { - if (result.ok) setGrantSucceededFor(grantedCardId) + // A failed refetch means the flag is merely STALE, not + // stuck — treating it as success would fire a false + // Sentry page on any flaky connection. + if (result.ok && result.overviewFresh) setGrantSucceededFor(grantedCardId) }) }, }, @@ -95,10 +108,12 @@ export default function EnableAutoBalanceBanner() { text: 'Skip for now', variant: 'stroke', disabled: isGranting, - onClick: () => setDismissed(true), + onClick: () => setDismissedFor(card?.id ?? null), }) } + const dismissed = dismissedFor !== null && dismissedFor === (card?.id ?? null) + return ( , []>() +const mockGrant = jest.fn, []>() let mockLastError: GrantSessionKeyError | null = null jest.mock('@/hooks/wallet/useGrantSessionKey', () => ({ useGrantSessionKey: () => ({ grant: mockGrant, isGranting: false, lastError: mockLastError }), @@ -108,7 +108,7 @@ describe('EnableAutoBalanceBanner', () => { it('a grant that "succeeds" without clearing the modal reveals the escape and pages Sentry (dup-card lockout shape)', async () => { const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) - mockGrant.mockResolvedValue({ ok: true }) + mockGrant.mockResolvedValue({ ok: true, overviewFresh: true }) // Overview never flips hasWithdrawApproval — the lockout shape. render() expect(screen.queryByText('Skip for now')).not.toBeInTheDocument() @@ -118,6 +118,10 @@ describe('EnableAutoBalanceBanner', () => { }) expect(screen.getByText('Skip for now')).toBeInTheDocument() + // The stuck state must EXPLAIN itself — happy-path copy with an + // unexplained Skip button just makes users re-tap Continue forever. + expect(screen.getByText(/couldn't finish setting up your card/i)).toBeInTheDocument() + expect(screen.getByText('Try again')).toBeInTheDocument() expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('duplicate-card lockout shape')) expect(Sentry.captureMessage).toHaveBeenCalledWith( expect.stringContaining('hasWithdrawApproval never flipped'), @@ -126,13 +130,52 @@ describe('EnableAutoBalanceBanner', () => { warnSpy.mockRestore() }) + it('a grant whose overview refetch FAILED is stale, not stuck — no escape, no Sentry page', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + // Grant succeeded but the follow-up refetch died (flaky network): + // the cached flag is stale; treating it as the lockout would false-page. + mockGrant.mockResolvedValue({ ok: true, overviewFresh: false }) + render() + + await act(async () => { + fireEvent.click(screen.getByText('Continue')) + }) + + expect(screen.queryByText('Skip for now')).not.toBeInTheDocument() + expect(Sentry.captureMessage).not.toHaveBeenCalled() + warnSpy.mockRestore() + }) + + it('skipping a stuck card does NOT suppress the prompt for a different card later in the session', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + mockGrant.mockResolvedValue({ ok: true, overviewFresh: true }) + mockCards = [{ id: 'card-a', status: 'ACTIVE', hasWithdrawApproval: false }] + const { rerender } = render() + + // Card A gets stuck; user escapes via Skip. + await act(async () => { + fireEvent.click(screen.getByText('Continue')) + }) + fireEvent.click(screen.getByText('Skip for now')) + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + + // Support cancels A; ungranted card B becomes active — it must prompt. + mockCards = [ + { id: 'card-a', status: 'CANCELED', hasWithdrawApproval: false }, + { id: 'card-b', status: 'ACTIVE', hasWithdrawApproval: false }, + ] + rerender() + expect(screen.getByTestId('modal')).toBeInTheDocument() + warnSpy.mockRestore() + }) + it('a re-issued card does NOT inherit the stuck signal from an old card grant (no premature escape, no false Sentry page)', async () => { const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) // Model the REAL happy path: grant() refetches the overview before // resolving, so by the time it returns ok the flag is already flipped. mockGrant.mockImplementation(async () => { mockCards = [{ id: 'card-a', status: 'ACTIVE', hasWithdrawApproval: true }] - return { ok: true } + return { ok: true, overviewFresh: true } }) mockCards = [{ id: 'card-a', status: 'ACTIVE', hasWithdrawApproval: false }] const { rerender } = render() diff --git a/src/hooks/wallet/useGrantSessionKey.ts b/src/hooks/wallet/useGrantSessionKey.ts index 17b9c40cd..987f0e5c3 100644 --- a/src/hooks/wallet/useGrantSessionKey.ts +++ b/src/hooks/wallet/useGrantSessionKey.ts @@ -67,8 +67,12 @@ export type GrantSessionKeyError = export interface GrantSessionKeyResult { /** Full grant: passkey tap + POST to `/session-approve`. Requires an - * active card; use for the lazy "first collateral spend" flow. */ - grant: () => Promise<{ ok: true } | { ok: false; error: GrantSessionKeyError }> + * active card; use for the lazy "first collateral spend" flow. + * `overviewFresh` is false when the grant itself succeeded but the + * follow-up overview refetch failed (react-query refetch resolves with + * an error state instead of throwing) — consumers must NOT read the + * still-stale `hasWithdrawApproval` as a lockout signal in that case. */ + grant: () => Promise<{ ok: true; overviewFresh: boolean } | { ok: false; error: GrantSessionKeyError }> /** Passkey tap only — returns the serialized approval string without * submitting it. Use when the card doesn't exist yet (issuance) and * another endpoint stores the string (e.g. `POST /rain/cards`). */ @@ -231,11 +235,14 @@ export const useGrantSessionKey = (): GrantSessionKeyResult => { } // Flip the `hasWithdrawApproval` flag in UI by refetching overview. - await refetch() + // refetch() resolves (never throws) with an error state on network + // failure — surface that so the caller can tell "flag is stale" + // apart from "flag genuinely didn't flip". + const refetchResult = await refetch() queryClient.invalidateQueries({ queryKey: [RAIN_CARD_OVERVIEW_QUERY_KEY] }) - return { ok: true as const } + return { ok: true as const, value: refetchResult.isSuccess } }) - if (result.ok) return { ok: true } + if (result.ok) return { ok: true, overviewFresh: result.value === true } return result }, [wrap, runSerialize, overview, refetch, queryClient]) From c2fa8e51292d43aa24dbfe4e60dd488d1502d33b Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 2 Jul 2026 12:25:05 -0700 Subject: [PATCH 4/4] fix: key lastError to the attempted card + named Sentry import (/code-review findings 4+5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hard failure on card A no longer leaks 'Try again' copy and the skip escape into a re-issued card B's first prompt — same stale-signal class as the grantSucceededFor fix, applied to the hook's un-scoped error. --- .../Home/EnableAutoBalanceBanner.tsx | 20 ++++++++++++---- .../EnableAutoBalanceBanner.test.tsx | 24 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/components/Home/EnableAutoBalanceBanner.tsx b/src/components/Home/EnableAutoBalanceBanner.tsx index 0d6c1712d..4015dad02 100644 --- a/src/components/Home/EnableAutoBalanceBanner.tsx +++ b/src/components/Home/EnableAutoBalanceBanner.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useRef, useState } from 'react' -import * as Sentry from '@sentry/nextjs' +import { captureMessage } from '@sentry/nextjs' import ActionModal, { type ActionModalButtonProps } from '@/components/Global/ActionModal' import { findActiveCard } from '@/components/Card/cardState.utils' import { useRainCardOverview } from '@/hooks/useRainCardOverview' @@ -48,6 +48,11 @@ export default function EnableAutoBalanceBanner() { // for — keyed by identity so a later re-issued card never inherits the // stuck signal from an old card's grant. const [grantSucceededFor, setGrantSucceededFor] = useState(null) + // Card id of the last grant attempt that RESOLVED (ok or failed). Gates + // `lastError` below: the hook's error state isn't card-scoped, so without + // this a failure on card A would leak "Try again" copy and the escape + // hatch into a re-issued card B's first-ever prompt. + const [lastAttemptFor, setLastAttemptFor] = useState(null) const card = findActiveCard(overview) const shouldShow = @@ -56,10 +61,16 @@ export default function EnableAutoBalanceBanner() { !!overview?.status?.contractAddress && !!overview?.status?.coordinatorAddress + // Only honor the hook's error if the attempt it came from was for THIS + // card. `lastAttemptFor === null` (error with no recorded attempt) can't + // occur in real flows but defaults to honoring the error — an unearned + // escape beats an unearned trap. + const errorForThisCard = !!lastError && (lastAttemptFor === null || lastAttemptFor === card?.id) + // `user-cancelled` just means the passkey sheet was dismissed — not a real // error, the user simply taps Continue again. Any other failure gets a // recoverable message. - const hardError = !!lastError && lastError.kind !== 'user-cancelled' + const hardError = errorForThisCard && lastError!.kind !== 'user-cancelled' // Loop signal: grant() resolved ok with a FRESH overview, yet the SAME // card still lacks the approval. That is the dup-card lockout shape — @@ -76,7 +87,7 @@ export default function EnableAutoBalanceBanner() { console.warn( '[EnableAutoBalanceBanner] grant succeeded but the active card still lacks hasWithdrawApproval — duplicate-card lockout shape' ) - Sentry.captureMessage('card session-key grant succeeded but hasWithdrawApproval never flipped', { + captureMessage('card session-key grant succeeded but hasWithdrawApproval never flipped', { level: 'error', extra: { cardId: card.id }, }) @@ -92,6 +103,7 @@ export default function EnableAutoBalanceBanner() { onClick: () => { const grantedCardId = card?.id ?? null void grant().then((result) => { + setLastAttemptFor(grantedCardId) // A failed refetch means the flag is merely STALE, not // stuck — treating it as success would fire a false // Sentry page on any flaky connection. @@ -103,7 +115,7 @@ export default function EnableAutoBalanceBanner() { // Escape hatch, shown once a grant has failed — or "succeeded" without // clearing the modal — so the user is never trapped behind this // non-dismissible modal. - if (lastError || stuckAfterSuccess) { + if (errorForThisCard || stuckAfterSuccess) { ctas.push({ text: 'Skip for now', variant: 'stroke', diff --git a/src/components/Home/__tests__/EnableAutoBalanceBanner.test.tsx b/src/components/Home/__tests__/EnableAutoBalanceBanner.test.tsx index f1e5fbf46..29dd0e95d 100644 --- a/src/components/Home/__tests__/EnableAutoBalanceBanner.test.tsx +++ b/src/components/Home/__tests__/EnableAutoBalanceBanner.test.tsx @@ -200,4 +200,28 @@ describe('EnableAutoBalanceBanner', () => { expect(Sentry.captureMessage).not.toHaveBeenCalled() warnSpy.mockRestore() }) + it("card A's failure does not leak error copy or the escape into a re-issued card B's first prompt", async () => { + // Grant fails hard for card A → error copy + escape for A. + mockGrant.mockResolvedValue({ ok: false }) + mockCards = [{ id: 'card-a', status: 'ACTIVE', hasWithdrawApproval: false }] + const { rerender } = render() + await act(async () => { + fireEvent.click(screen.getByText('Continue')) + }) + mockLastError = { kind: 'unexpected', message: 'boom' } + rerender() + expect(screen.getByText('Try again')).toBeInTheDocument() + expect(screen.getByText('Skip for now')).toBeInTheDocument() + + // A is canceled, B issued — the hook's lastError is still set, but B + // has never been attempted: fresh Continue, no error copy, no escape. + mockCards = [ + { id: 'card-a', status: 'CANCELED', hasWithdrawApproval: false }, + { id: 'card-b', status: 'ACTIVE', hasWithdrawApproval: false }, + ] + rerender() + expect(screen.getByText('Continue')).toBeInTheDocument() + expect(screen.queryByText('Try again')).not.toBeInTheDocument() + expect(screen.queryByText('Skip for now')).not.toBeInTheDocument() + }) })