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..4015dad02 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 { captureMessage } 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,46 +24,108 @@ 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 (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 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) + // 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 = overview?.cards?.[0] + const card = findActiveCard(overview) const shouldShow = card?.status === 'ACTIVE' && !card.hasWithdrawApproval && !!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 — + // 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 + // 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 && !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' + ) + captureMessage('card session-key grant succeeded but hasWithdrawApproval never flipped', { + level: 'error', + extra: { cardId: card.id }, + }) + } + }, [stuckAfterSuccess, card]) 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: () => { - void grant() + 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. + if (result.ok && result.overviewFresh) setGrantSucceededFor(grantedCardId) + }) }, }, ] - // 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 (errorForThisCard || stuckAfterSuccess) { ctas.push({ 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 ( , []>() 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 = [{ id: 'card-a', status: 'ACTIVE', hasWithdrawApproval: false }] }) describe('EnableAutoBalanceBanner', () => { @@ -69,4 +86,142 @@ 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 = [ + { id: 'card-dup', status: 'CANCELED', hasWithdrawApproval: false }, + { id: 'card-real', 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 = [ + { id: 'card-dup', status: 'CANCELED', hasWithdrawApproval: false }, + { id: 'card-real', 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, overviewFresh: 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() + // 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'), + expect.objectContaining({ level: 'error' }) + ) + 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, overviewFresh: 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() + }) + 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() + }) }) diff --git a/src/hooks/wallet/useGrantSessionKey.ts b/src/hooks/wallet/useGrantSessionKey.ts index a5eb330f2..987f0e5c3 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' @@ -66,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`). */ @@ -217,7 +222,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() @@ -230,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]) 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()