Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/app/(mobile-ui)/dev/card-session-approve/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -18,7 +19,7 @@ export default function CardSessionApprovePage() {
const { grant, isGranting } = useGrantSessionKey()
const [status, setStatus] = useState<string>('')

const card = overview?.cards?.[0]
const card = findActiveCard(overview)

const handleClick = async () => {
setStatus('Waiting for passkey tap…')
Expand Down
86 changes: 75 additions & 11 deletions src/components/Home/EnableAutoBalanceBanner.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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<string | null>(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<string | null>(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<string | null>(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<Set<string>>(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 (
<ActionModal
visible={shouldShow && !dismissed}
Expand All @@ -72,7 +136,7 @@ export default function EnableAutoBalanceBanner() {
iconContainerClassName="bg-yellow-1"
title="Finish setting up your card"
description={
hardError
hardError || stuckAfterSuccess
? "We couldn't finish setting up your card. Please try again, or skip for now — we'll prompt you again on your first card payment."
: 'One passkey tap to start using your card.'
}
Expand Down
171 changes: 163 additions & 8 deletions src/components/Home/__tests__/EnableAutoBalanceBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -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<Promise<{ ok: boolean; overviewFresh?: boolean }>, []>()
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 }[] }) =>
Expand All @@ -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', () => {
Expand All @@ -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(<EnableAutoBalanceBanner />)
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(<EnableAutoBalanceBanner />)
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(<EnableAutoBalanceBanner />)
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(<EnableAutoBalanceBanner />)

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(<EnableAutoBalanceBanner />)

// 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(<EnableAutoBalanceBanner />)
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(<EnableAutoBalanceBanner />)

// Grant succeeds for card A and the flag flips — modal hides, all good.
await act(async () => {
fireEvent.click(screen.getByText('Continue'))
})
rerender(<EnableAutoBalanceBanner />)
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(<EnableAutoBalanceBanner />)
// 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(<EnableAutoBalanceBanner />)
await act(async () => {
fireEvent.click(screen.getByText('Continue'))
})
mockLastError = { kind: 'unexpected', message: 'boom' }
rerender(<EnableAutoBalanceBanner />)
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(<EnableAutoBalanceBanner />)
expect(screen.getByText('Continue')).toBeInTheDocument()
expect(screen.queryByText('Try again')).not.toBeInTheDocument()
expect(screen.queryByText('Skip for now')).not.toBeInTheDocument()
})
})
Loading
Loading