From e65bf16484c9697defc890eb6a778103d1475ad6 Mon Sep 17 00:00:00 2001
From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com>
Date: Thu, 2 Jul 2026 16:27:45 +0530
Subject: [PATCH 1/8] fix: surface bridge deposit errors instead of failing
silently
EEA-uplift users whose docs are under Bridge review were retrying deposits
with zero feedback: the onramp hook threw a hardcoded message without
reading the backend's { error } body, the page's catch read the hook's
error state before it flushed (always null on first attempt), and the
waiting-on-provider gate dead-ended the Continue button with no message.
Parse the error body, show the caught message directly, and tell
under-review users their verification is being processed.
---
.../add-money/[country]/bank/page.tsx | 39 ++++++----
.../__tests__/add-money-states.test.tsx | 61 ++++++++++++++-
src/hooks/__tests__/useCreateOnramp.test.ts | 78 +++++++++++++++++++
src/hooks/useCreateOnramp.ts | 6 +-
4 files changed, 166 insertions(+), 18 deletions(-)
create mode 100644 src/hooks/__tests__/useCreateOnramp.test.ts
diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
index 63c669149..01f4cb31d 100644
--- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
@@ -73,7 +73,7 @@ export default function OnrampBankPage() {
const { balance } = useWallet()
const { user, fetchUser } = useAuth()
- const { createOnramp, isLoading: isCreatingOnramp, error: onrampError } = useCreateOnramp()
+ const { createOnramp, isLoading: isCreatingOnramp } = useCreateOnramp()
// inline sumsub kyc flow for bridge bank onramp
// regionIntent is NOT passed here to avoid creating a backend record on mount.
@@ -254,11 +254,20 @@ export default function OnrampBankPage() {
if (!validateAmount(rawTokenAmount)) return
if (gate.kind !== 'ready') {
- // capabilities still loading OR provider doing internal review —
- // silently no-op instead of flashing a misleading needs_kyc modal.
- // `waiting-on-provider` means the user has nothing to do; opening
- // a KYC modal would imply otherwise.
- if (gate.kind === 'loading' || gate.kind === 'waiting-on-provider') return
+ // capabilities still loading — silently no-op instead of flashing
+ // a misleading needs_kyc modal.
+ if (gate.kind === 'loading') return
+ // `waiting-on-provider` means the user has nothing to do (e.g. bridge
+ // reviewing eea-uplift docs) — tell them to wait instead of a dead
+ // button or a doomed transfer attempt with no feedback.
+ if (gate.kind === 'waiting-on-provider') {
+ setError({
+ showError: true,
+ errorMessage:
+ 'Your verification details are being reviewed. This usually takes a few minutes — please try again soon.',
+ })
+ return
+ }
if (gate.kind === 'accept-tos') {
guardWithTos()
} else {
@@ -314,17 +323,21 @@ export default function OnrampBankPage() {
}
} catch (error) {
setShowWarningModal(false)
- const errorMessage = error instanceof Error ? error.message : 'Unknown error'
+ const errorMessage =
+ error instanceof Error
+ ? error.message
+ : 'Failed to create bank transfer. Please try again or contact support.'
posthog.capture(ANALYTICS_EVENTS.DEPOSIT_FAILED, {
method_type: 'bank',
error_message: errorMessage,
})
- if (onrampError) {
- setError({
- showError: true,
- errorMessage: onrampError,
- })
- }
+ // show the caught message directly — the hook's error state hasn't
+ // flushed yet in this synchronous catch, so reading it here always
+ // sees the previous render's value (null on first attempt).
+ setError({
+ showError: true,
+ errorMessage,
+ })
}
}
diff --git a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx
index affddb371..8cac5a91b 100644
--- a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx
+++ b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx
@@ -746,7 +746,14 @@ function setParams(params: Record) {
// then push it onto the useCapabilities mock. The page reads `gateFor(...)`,
// so the mock returns a stub gateFor closing over the desired state; it also
// exposes `bankRails()` for the few sites that read it directly.
-type Gate = 'ready' | 'accept-tos' | 'fixable-rejection' | 'blocked-rejection' | 'needs-identity' | 'needs-enrollment'
+type Gate =
+ | 'ready'
+ | 'accept-tos'
+ | 'fixable-rejection'
+ | 'blocked-rejection'
+ | 'needs-identity'
+ | 'needs-enrollment'
+ | 'waiting-on-provider'
function setGate(kind: Gate) {
let rails: any[] = []
@@ -833,6 +840,22 @@ function setGate(kind: Gate) {
rails = []
gateState = { kind: 'needs-identity' }
break
+ case 'waiting-on-provider':
+ // provider reviewing submitted info (e.g. eea-uplift docs) — user
+ // has nothing to do but wait
+ rails = [
+ {
+ id: 'bridge.ach_us',
+ provider: 'bridge',
+ method: 'ACH_US',
+ country: 'US',
+ currency: 'USD',
+ status: 'requires-info',
+ blockingActions: ['wait:bridge'],
+ },
+ ]
+ gateState = { kind: 'waiting-on-provider', reason: { code: 'bridge_processing' } }
+ break
}
mockUseCapabilities.mockReturnValue({
@@ -1308,10 +1331,13 @@ describe('GROUP 5: Bridge Bank Onramp', () => {
test('onramp error displays ErrorAlert', async () => {
const mockCreateOnramp = jest.fn().mockRejectedValue(new Error('Service unavailable'))
+ // error: null reproduces the first-attempt reality — the hook's error
+ // state hasn't flushed when the page's catch runs, so the page must
+ // surface the caught message itself, not read the hook state.
mockUseCreateOnramp.mockReturnValue({
createOnramp: mockCreateOnramp,
isLoading: false,
- error: 'Service unavailable',
+ error: null,
})
resetQueryState({ step: 'inputAmount', amount: '100' })
@@ -1327,8 +1353,35 @@ describe('GROUP 5: Bridge Bank Onramp', () => {
fireEvent.click(screen.getByTestId('confirm-onramp'))
})
- // After error, the setError should have been called
- expect(mockOnrampFlow.setError).toHaveBeenCalled()
+ // the caught message must be shown to the user
+ expect(mockOnrampFlow.setError).toHaveBeenCalledWith({
+ showError: true,
+ errorMessage: 'Service unavailable',
+ })
+ })
+
+ test('waiting-on-provider gate shows under-review message instead of silent no-op', async () => {
+ setGate('waiting-on-provider')
+ const mockCreateOnramp = jest.fn()
+ mockUseCreateOnramp.mockReturnValue({
+ createOnramp: mockCreateOnramp,
+ isLoading: false,
+ error: null,
+ })
+ resetQueryState({ step: 'inputAmount', amount: '100' })
+
+ renderWithProviders()
+
+ await act(async () => {
+ fireEvent.click(screen.getByText('Continue'))
+ })
+
+ // no transfer attempt, but the user is told why
+ expect(mockCreateOnramp).not.toHaveBeenCalled()
+ expect(mockOnrampFlow.setError).toHaveBeenCalledWith({
+ showError: true,
+ errorMessage: expect.stringContaining('being reviewed'),
+ })
})
test('limits blocking disables Continue and shows LimitsWarningCard', () => {
diff --git a/src/hooks/__tests__/useCreateOnramp.test.ts b/src/hooks/__tests__/useCreateOnramp.test.ts
new file mode 100644
index 000000000..18b8ba05c
--- /dev/null
+++ b/src/hooks/__tests__/useCreateOnramp.test.ts
@@ -0,0 +1,78 @@
+import { renderHook, act } from '@testing-library/react'
+import { useCreateOnramp } from '@/hooks/useCreateOnramp'
+import { type CountryData } from '@/components/AddMoney/consts'
+
+// regression test for the eea-uplift silent-failure incident: the backend
+// returns { error: '...' } on 400 (e.g. "Could not create transfer: customer
+// under review") but the hook used to throw a hardcoded generic message
+// without reading the body — users retried blind with no reason shown.
+const apiFetchMock = jest.fn()
+jest.mock('@/utils/api-fetch', () => ({
+ apiFetch: (...args: unknown[]) => apiFetchMock(...args),
+}))
+
+jest.mock('@/utils/bridge.utils', () => ({
+ getCurrencyConfig: () => ({ currency: 'eur', paymentRail: 'sepa' }),
+}))
+
+jest.mock('@/app/actions/currency', () => ({
+ getCurrencyPrice: jest.fn(),
+}))
+
+const country = { id: 'DE', type: 'country', path: 'germany' } as unknown as CountryData
+
+describe('useCreateOnramp', () => {
+ beforeEach(() => {
+ apiFetchMock.mockReset()
+ jest.spyOn(console, 'error').mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ jest.restoreAllMocks()
+ })
+
+ it('surfaces the backend error body on a non-ok response', async () => {
+ apiFetchMock.mockResolvedValue({
+ ok: false,
+ json: async () => ({ error: 'Could not create transfer: customer is under review' }),
+ })
+ const { result } = renderHook(() => useCreateOnramp())
+
+ await act(async () => {
+ await expect(result.current.createOnramp({ amount: '100', country })).rejects.toThrow(
+ 'Could not create transfer: customer is under review'
+ )
+ })
+ expect(result.current.error).toBe('Could not create transfer: customer is under review')
+ })
+
+ it('falls back to the generic message when the error body is unparseable', async () => {
+ apiFetchMock.mockResolvedValue({
+ ok: false,
+ json: async () => {
+ throw new Error('not json')
+ },
+ })
+ const { result } = renderHook(() => useCreateOnramp())
+
+ await act(async () => {
+ await expect(result.current.createOnramp({ amount: '100', country })).rejects.toThrow(
+ 'Failed to create bank transfer. Please try again or contact support.'
+ )
+ })
+ expect(result.current.error).toBe('Failed to create bank transfer. Please try again or contact support.')
+ })
+
+ it('returns the onramp data on success', async () => {
+ const data = { transferId: 't-1', depositInstructions: { amount: '100', currency: 'eur' } }
+ apiFetchMock.mockResolvedValue({ ok: true, json: async () => data })
+ const { result } = renderHook(() => useCreateOnramp())
+
+ let response: unknown
+ await act(async () => {
+ response = await result.current.createOnramp({ amount: '100', country })
+ })
+ expect(response).toEqual(data)
+ expect(result.current.error).toBeNull()
+ })
+})
diff --git a/src/hooks/useCreateOnramp.ts b/src/hooks/useCreateOnramp.ts
index ef3836f1b..fc70c9e36 100644
--- a/src/hooks/useCreateOnramp.ts
+++ b/src/hooks/useCreateOnramp.ts
@@ -60,7 +60,11 @@ export const useCreateOnramp = (): UseCreateOnrampReturn => {
if (!response.ok) {
// parse error body from backend to get specific message
- let errorMessage = 'Failed to create bank transfer. Please try again or contact support.'
+ const body = await response.json().catch(() => null)
+ const errorMessage =
+ body?.error ||
+ body?.message ||
+ 'Failed to create bank transfer. Please try again or contact support.'
setError(errorMessage)
throw new Error(errorMessage)
}
From 88a5117b45416a0913f2796af1d2c761018373bd Mon Sep 17 00:00:00 2001
From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com>
Date: Thu, 2 Jul 2026 17:24:52 +0530
Subject: [PATCH 2/8] fix: gate error-body surfacing to 4xx, drop dead hook
error state
Addresses code-review findings on the silent-failure fix:
- only surface the backend body for 4xx (client errors); 5xx bodies carry
raw internal messages the global handler doesn't sanitize, so those fall
back to the generic string (no internal-detail leak).
- make useCreateOnramp throw-only: its React error state had no reader after
the page stopped destructuring it, and was the same stale-closure trap the
page fix works around. The thrown Error carries the reason.
- reuse gate.userMessage for the waiting-on-provider copy instead of
hardcoding, matching every other gate consumer.
- extract GENERIC_ONRAMP_ERROR so the fallback copy lives in one place.
- keep the distinct 'Unknown error' analytics label for non-Error throws.
---
.../add-money/[country]/bank/page.tsx | 19 +++++-----
.../__tests__/add-money-states.test.tsx | 9 ++---
src/hooks/__tests__/useCreateOnramp.test.ts | 31 ++++++++++------
src/hooks/useCreateOnramp.ts | 35 ++++++++++---------
4 files changed, 52 insertions(+), 42 deletions(-)
diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
index 01f4cb31d..d5e4cec20 100644
--- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
@@ -13,7 +13,7 @@ import { useAuth } from '@/context/authContext'
import { useCapabilities } from '@/hooks/useCapabilities'
import { getKycModalVariant, getGateUserMessage } from '@/utils/capability-gate'
import { useModalsContext } from '@/context/ModalsContext'
-import { useCreateOnramp } from '@/hooks/useCreateOnramp'
+import { useCreateOnramp, GENERIC_ONRAMP_ERROR } from '@/hooks/useCreateOnramp'
import { useParams, useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useMemo, useState } from 'react'
import countryCurrencyMappings, { isNonEuroSepaCountry, isUKCountry } from '@/constants/countryCurrencyMapping'
@@ -261,9 +261,12 @@ export default function OnrampBankPage() {
// reviewing eea-uplift docs) — tell them to wait instead of a dead
// button or a doomed transfer attempt with no feedback.
if (gate.kind === 'waiting-on-provider') {
+ // prefer the backend's specific review copy (via the gate reason)
+ // and fall back to generic wait text.
setError({
showError: true,
errorMessage:
+ gate.userMessage ??
'Your verification details are being reviewed. This usually takes a few minutes — please try again soon.',
})
return
@@ -323,17 +326,15 @@ export default function OnrampBankPage() {
}
} catch (error) {
setShowWarningModal(false)
- const errorMessage =
- error instanceof Error
- ? error.message
- : 'Failed to create bank transfer. Please try again or contact support.'
+ const isError = error instanceof Error
+ const errorMessage = isError ? error.message : GENERIC_ONRAMP_ERROR
posthog.capture(ANALYTICS_EVENTS.DEPOSIT_FAILED, {
method_type: 'bank',
- error_message: errorMessage,
+ // keep the distinct label for truly-unexpected non-Error throws
+ error_message: isError ? errorMessage : 'Unknown error',
})
- // show the caught message directly — the hook's error state hasn't
- // flushed yet in this synchronous catch, so reading it here always
- // sees the previous render's value (null on first attempt).
+ // show the caught message directly — createOnramp carries the specific
+ // reason on the thrown Error, so we don't read any hook state here.
setError({
showError: true,
errorMessage,
diff --git a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx
index 8cac5a91b..195105e38 100644
--- a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx
+++ b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx
@@ -921,7 +921,6 @@ function applyDefaults() {
mockUseCreateOnramp.mockReturnValue({
createOnramp: jest.fn(),
isLoading: false,
- error: null,
})
mockUseLimitsValidation.mockReturnValue({
@@ -1309,7 +1308,6 @@ describe('GROUP 5: Bridge Bank Onramp', () => {
mockUseCreateOnramp.mockReturnValue({
createOnramp: mockCreateOnramp,
isLoading: false,
- error: null,
})
resetQueryState({ step: 'inputAmount', amount: '100' })
@@ -1331,13 +1329,11 @@ describe('GROUP 5: Bridge Bank Onramp', () => {
test('onramp error displays ErrorAlert', async () => {
const mockCreateOnramp = jest.fn().mockRejectedValue(new Error('Service unavailable'))
- // error: null reproduces the first-attempt reality — the hook's error
- // state hasn't flushed when the page's catch runs, so the page must
- // surface the caught message itself, not read the hook state.
+ // the page must surface the caught error's message directly — the hook
+ // exposes no error state to read (that channel was the stale-closure trap).
mockUseCreateOnramp.mockReturnValue({
createOnramp: mockCreateOnramp,
isLoading: false,
- error: null,
})
resetQueryState({ step: 'inputAmount', amount: '100' })
@@ -1366,7 +1362,6 @@ describe('GROUP 5: Bridge Bank Onramp', () => {
mockUseCreateOnramp.mockReturnValue({
createOnramp: mockCreateOnramp,
isLoading: false,
- error: null,
})
resetQueryState({ step: 'inputAmount', amount: '100' })
diff --git a/src/hooks/__tests__/useCreateOnramp.test.ts b/src/hooks/__tests__/useCreateOnramp.test.ts
index 18b8ba05c..f9b3a46a2 100644
--- a/src/hooks/__tests__/useCreateOnramp.test.ts
+++ b/src/hooks/__tests__/useCreateOnramp.test.ts
@@ -1,5 +1,5 @@
import { renderHook, act } from '@testing-library/react'
-import { useCreateOnramp } from '@/hooks/useCreateOnramp'
+import { useCreateOnramp, GENERIC_ONRAMP_ERROR } from '@/hooks/useCreateOnramp'
import { type CountryData } from '@/components/AddMoney/consts'
// regression test for the eea-uplift silent-failure incident: the backend
@@ -31,9 +31,10 @@ describe('useCreateOnramp', () => {
jest.restoreAllMocks()
})
- it('surfaces the backend error body on a non-ok response', async () => {
+ it('throws the backend error body on a 4xx response', async () => {
apiFetchMock.mockResolvedValue({
ok: false,
+ status: 400,
json: async () => ({ error: 'Could not create transfer: customer is under review' }),
})
const { result } = renderHook(() => useCreateOnramp())
@@ -43,12 +44,26 @@ describe('useCreateOnramp', () => {
'Could not create transfer: customer is under review'
)
})
- expect(result.current.error).toBe('Could not create transfer: customer is under review')
})
- it('falls back to the generic message when the error body is unparseable', async () => {
+ // the leak guard: a 500's body carries raw internal text (the BE global
+ // handler only sanitizes Prisma), so it must NOT be surfaced — the user
+ // gets the generic string and the body is never even read.
+ it('does not surface a 5xx error body, uses the generic message', async () => {
+ const jsonSpy = jest.fn(async () => ({ error: 'Request failed with status code 401 at /internal/bridge' }))
+ apiFetchMock.mockResolvedValue({ ok: false, status: 500, json: jsonSpy })
+ const { result } = renderHook(() => useCreateOnramp())
+
+ await act(async () => {
+ await expect(result.current.createOnramp({ amount: '100', country })).rejects.toThrow(GENERIC_ONRAMP_ERROR)
+ })
+ expect(jsonSpy).not.toHaveBeenCalled()
+ })
+
+ it('falls back to the generic message when a 4xx body is unparseable', async () => {
apiFetchMock.mockResolvedValue({
ok: false,
+ status: 400,
json: async () => {
throw new Error('not json')
},
@@ -56,16 +71,13 @@ describe('useCreateOnramp', () => {
const { result } = renderHook(() => useCreateOnramp())
await act(async () => {
- await expect(result.current.createOnramp({ amount: '100', country })).rejects.toThrow(
- 'Failed to create bank transfer. Please try again or contact support.'
- )
+ await expect(result.current.createOnramp({ amount: '100', country })).rejects.toThrow(GENERIC_ONRAMP_ERROR)
})
- expect(result.current.error).toBe('Failed to create bank transfer. Please try again or contact support.')
})
it('returns the onramp data on success', async () => {
const data = { transferId: 't-1', depositInstructions: { amount: '100', currency: 'eur' } }
- apiFetchMock.mockResolvedValue({ ok: true, json: async () => data })
+ apiFetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => data })
const { result } = renderHook(() => useCreateOnramp())
let response: unknown
@@ -73,6 +85,5 @@ describe('useCreateOnramp', () => {
response = await result.current.createOnramp({ amount: '100', country })
})
expect(response).toEqual(data)
- expect(result.current.error).toBeNull()
})
})
diff --git a/src/hooks/useCreateOnramp.ts b/src/hooks/useCreateOnramp.ts
index fc70c9e36..f41dfa809 100644
--- a/src/hooks/useCreateOnramp.ts
+++ b/src/hooks/useCreateOnramp.ts
@@ -20,23 +20,28 @@ export type CreateOnrampParams = {
}
)
+// shared so the hook, the page catch, and their tests can't drift apart.
+export const GENERIC_ONRAMP_ERROR = 'Failed to create bank transfer. Please try again or contact support.'
+
export interface UseCreateOnrampReturn {
createOnramp: (params: CreateOnrampParams) => Promise
isLoading: boolean
- error: string | null
}
/**
- * Custom hook for creating onramp transactions
+ * Custom hook for creating onramp transactions.
+ *
+ * The specific failure reason travels via the thrown Error's message — the
+ * caller shows `error.message` directly. We deliberately do NOT expose a React
+ * `error` state: reading it in a synchronous catch sees the pre-flush (stale)
+ * value, which is the silent-failure bug this hook was fixed for.
*/
export const useCreateOnramp = (): UseCreateOnrampReturn => {
const [isLoading, setIsLoading] = useState(false)
- const [error, setError] = useState(null)
const createOnramp = useCallback(
async ({ amount, country, chargeId, recipientAddress, usdAmount }: CreateOnrampParams) => {
setIsLoading(true)
- setError(null)
try {
const { currency, paymentRail } = getCurrencyConfig(country.id, 'onramp')
@@ -59,23 +64,22 @@ export const useCreateOnramp = (): UseCreateOnrampReturn => {
})
if (!response.ok) {
- // parse error body from backend to get specific message
- const body = await response.json().catch(() => null)
- const errorMessage =
- body?.error ||
- body?.message ||
- 'Failed to create bank transfer. Please try again or contact support.'
- setError(errorMessage)
- throw new Error(errorMessage)
+ // Only trust the backend's error copy for client errors (4xx) —
+ // those are deliberate, user-actionable messages from the route
+ // (ToS-not-accepted, KYC/endorsement needed, Bridge decline).
+ // 5xx bodies carry raw internal messages (the global handler only
+ // sanitizes Prisma), so surfacing them would leak internals — fall
+ // back to the generic string instead.
+ const isClientError = response.status >= 400 && response.status < 500
+ const body = isClientError ? await response.json().catch(() => null) : null
+ throw new Error(body?.error || body?.message || GENERIC_ONRAMP_ERROR)
}
const onrampData = await response.json()
return onrampData
} catch (err) {
console.error('Error creating onramp:', err)
- // only set generic fallback if no specific error was already set by the !response.ok block
- setError((prev) => prev ?? 'Failed to create bank transfer. Please try again or contact support.')
- throw err
+ throw err instanceof Error ? err : new Error(GENERIC_ONRAMP_ERROR)
} finally {
setIsLoading(false)
}
@@ -86,6 +90,5 @@ export const useCreateOnramp = (): UseCreateOnrampReturn => {
return {
createOnramp,
isLoading,
- error,
}
}
From 3c6a36e3d44c354920de2a60ed44aacf2f7e91ad Mon Sep 17 00:00:00 2001
From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com>
Date: Thu, 2 Jul 2026 19:46:09 +0530
Subject: [PATCH 3/8] feat(analytics): fire eea_uplift funnel on modal open,
cover all remediation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The eea_uplift_started event fired only on the advisory modal's 'Complete
now' CTA, so it missed (a) users who open the uplift modal and abandon, and
(b) the entire urgent post-cliff cohort, whose remediation is now blocking
(fixable-rejection → KYC modal) rather than a future-dated advisory.
Fire at modal-open instead, across both remediation paths and both channels
(deposit + withdraw):
- blocking/urgent: gate.reason.code === 'eea_uplift*' (BE sets it to the
questionnaire cluster).
- advisory/upcoming: requirementKey in the eea_uplift set (sof_individual_
primary_purpose, has_foreign_tax_registration, place_of_birth_missing,
nationalities) — derived from prod remediation data.
Event now carries source + urgent + effective_date so urgent vs upcoming
(dob-type) split in PostHog. PoA / gov-id-expired co-occur on the cliff but
are excluded (separate remediation) to avoid over-firing on generic doc
rejections for non-EEA users.
---
.../add-money/[country]/bank/page.tsx | 20 ++++-
.../withdraw/[country]/bank/page.tsx | 24 +++++-
src/hooks/useEeaUpliftFunnel.test.ts | 51 ++++++++----
src/hooks/useEeaUpliftFunnel.ts | 77 +++++++++++--------
src/utils/eea-uplift.utils.test.ts | 53 +++++++++++++
src/utils/eea-uplift.utils.ts | 39 ++++++++++
6 files changed, 214 insertions(+), 50 deletions(-)
create mode 100644 src/utils/eea-uplift.utils.test.ts
create mode 100644 src/utils/eea-uplift.utils.ts
diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
index d5e4cec20..f03e09563 100644
--- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
@@ -37,6 +37,7 @@ import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal'
import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt'
import { useEeaUpliftFunnel } from '@/hooks/useEeaUpliftFunnel'
+import { eeaUpliftReasonCode, isEeaUpliftAdvisory } from '@/utils/eea-uplift.utils'
import posthog from 'posthog-js'
import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import { addMoneyCountryUrl } from '@/utils/native-routes'
@@ -141,9 +142,10 @@ export default function OnrampBankPage() {
// Route through the self-heal resubmit path (reheal-tagged action) so the
// completed submission round-trips to Bridge. start-action mints a plain
// token whose webhook completion has no Bridge relay → answers are dropped.
+ // NB: eea_uplift_started is fired at modal-open (handleAmountContinue),
+ // not here, so abandoners are captured too.
onCompleteNow: () => {
if (!advisory) return Promise.resolve()
- trackUpliftStarted(advisory)
return sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey)
},
})
@@ -274,6 +276,12 @@ export default function OnrampBankPage() {
if (gate.kind === 'accept-tos') {
guardWithTos()
} else {
+ // urgent (post-cliff) EEA uplift lands here as a fixable-rejection —
+ // fire the funnel event as this KYC modal opens.
+ const upliftCode = eeaUpliftReasonCode(gate)
+ if (upliftCode) {
+ trackUpliftStarted({ requirementKey: upliftCode, source: 'blocking' })
+ }
setShowKycModal(true)
}
return
@@ -283,6 +291,16 @@ export default function OnrampBankPage() {
// (record the amount-entered event, open the confirmation modal) only
// runs once there's no pending requirement; while one exists the modal
// blocks and this never fires, so the event can't double-count.
+ // Upcoming (future-dated) EEA uplift opens the advisory modal here — fire
+ // the funnel event as it opens.
+ if (isEeaUpliftAdvisory(advisory)) {
+ trackUpliftStarted({
+ requirementKey: advisory?.requirementKey,
+ actionKey: advisory?.actionKey,
+ effectiveDate: advisory?.effectiveDate,
+ source: 'advisory',
+ })
+ }
advisoryIntercept(() => {
posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, {
amount_usd: usdEquivalent,
diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
index 060ddb02c..b20df3b2d 100644
--- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
@@ -33,6 +33,7 @@ import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal'
import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt'
import { useEeaUpliftFunnel } from '@/hooks/useEeaUpliftFunnel'
+import { eeaUpliftReasonCode, isEeaUpliftAdvisory } from '@/utils/eea-uplift.utils'
import { useCapabilities } from '@/hooks/useCapabilities'
import { getKycModalVariant, getGateUserMessage } from '@/utils/capability-gate'
import { useModalsContext } from '@/context/ModalsContext'
@@ -117,9 +118,10 @@ export default function WithdrawBankPage() {
// Route through the self-heal resubmit path (reheal-tagged action) so the
// completed submission round-trips to Bridge. start-action mints a plain
// token whose webhook completion has no Bridge relay → answers are dropped.
+ // NB: eea_uplift_started is fired at modal-open (the handlers below), not
+ // here, so abandoners are captured too.
onCompleteNow: () => {
if (!advisory) return Promise.resolve()
- trackUpliftStarted(advisory)
return sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey)
},
})
@@ -217,6 +219,12 @@ export default function WithdrawBankPage() {
if (gate.kind === 'accept-tos') {
guardWithTos()
} else {
+ // urgent (post-cliff) EEA uplift lands here as a fixable-rejection —
+ // fire the funnel event as this KYC modal opens.
+ const upliftCode = eeaUpliftReasonCode(gate)
+ if (upliftCode) {
+ trackUpliftStarted({ requirementKey: upliftCode, source: 'blocking' })
+ }
setShowKycModal(true)
}
return
@@ -346,7 +354,19 @@ export default function WithdrawBankPage() {
// Enforce the mandatory verification pre-empt, then run the offramp. When the
// gate isn't `ready` (or there's no pending requirement) this is a no-op and
// proceedWithOfframp runs straight away (it handles the not-ready cases).
- const handleCreateAndInitiateOfframp = () => advisoryIntercept(() => void proceedWithOfframp())
+ // Upcoming (future-dated) EEA uplift opens the advisory modal here — fire the
+ // funnel event as it opens.
+ const handleCreateAndInitiateOfframp = () => {
+ if (isEeaUpliftAdvisory(advisory)) {
+ trackUpliftStarted({
+ requirementKey: advisory?.requirementKey,
+ actionKey: advisory?.actionKey,
+ effectiveDate: advisory?.effectiveDate,
+ source: 'advisory',
+ })
+ }
+ advisoryIntercept(() => void proceedWithOfframp())
+ }
const countryCodeForFlag = () => {
if (!bankAccount?.details?.countryCode) return ''
diff --git a/src/hooks/useEeaUpliftFunnel.test.ts b/src/hooks/useEeaUpliftFunnel.test.ts
index f6699601e..081387f2a 100644
--- a/src/hooks/useEeaUpliftFunnel.test.ts
+++ b/src/hooks/useEeaUpliftFunnel.test.ts
@@ -1,46 +1,67 @@
import { act, renderHook } from '@testing-library/react'
import posthog from 'posthog-js'
-import { useEeaUpliftFunnel } from './useEeaUpliftFunnel'
+import { useEeaUpliftFunnel, type UpliftStartTrigger } from './useEeaUpliftFunnel'
import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
-import type { GateAdvisory } from '@/utils/capability-gate'
jest.mock('posthog-js', () => ({ capture: jest.fn() }))
const capture = posthog.capture as jest.Mock
-const advisory: GateAdvisory = {
- effectiveDate: '2026-06-29',
+// future-dated advisory (upcoming) trigger
+const advisoryTrigger: UpliftStartTrigger = {
+ effectiveDate: '2026-12-31',
actionKey: 'sumsub:eea_uplift',
- requirementKey: 'sof_individual_primary_purpose',
+ requirementKey: 'has_foreign_tax_registration',
+ source: 'advisory',
+}
+
+// post-cliff blocking trigger — no effective date, reason code as the key
+const blockingTrigger: UpliftStartTrigger = {
+ requirementKey: 'eea_uplift',
+ source: 'blocking',
}
beforeEach(() => capture.mockClear())
describe('useEeaUpliftFunnel', () => {
- test('trackStarted fires eea_uplift_started with channel + advisory props', () => {
+ test('trackStarted fires eea_uplift_started with channel + trigger props (advisory = not urgent)', () => {
const { result } = renderHook(() => useEeaUpliftFunnel('deposit'))
- act(() => result.current.trackStarted(advisory))
+ act(() => result.current.trackStarted(advisoryTrigger))
expect(capture).toHaveBeenCalledTimes(1)
expect(capture).toHaveBeenCalledWith(ANALYTICS_EVENTS.EEA_UPLIFT_STARTED, {
channel: 'deposit',
- requirement_key: 'sof_individual_primary_purpose',
+ requirement_key: 'has_foreign_tax_registration',
action_key: 'sumsub:eea_uplift',
- effective_date: '2026-06-29',
+ effective_date: '2026-12-31',
+ source: 'advisory',
+ urgent: false,
})
})
- test('trackCompleted fires eea_uplift_completed only after a start', () => {
+ test('blocking trigger is flagged urgent', () => {
+ const { result } = renderHook(() => useEeaUpliftFunnel('deposit'))
+ act(() => result.current.trackStarted(blockingTrigger))
+
+ expect(capture).toHaveBeenCalledWith(
+ ANALYTICS_EVENTS.EEA_UPLIFT_STARTED,
+ expect.objectContaining({ source: 'blocking', urgent: true, requirement_key: 'eea_uplift' })
+ )
+ })
+
+ test('trackCompleted fires eea_uplift_completed only after a start, echoing the trigger', () => {
const { result } = renderHook(() => useEeaUpliftFunnel('withdraw'))
- act(() => result.current.trackStarted(advisory))
+ act(() => result.current.trackStarted(advisoryTrigger))
capture.mockClear()
act(() => result.current.trackCompleted())
expect(capture).toHaveBeenCalledTimes(1)
expect(capture).toHaveBeenCalledWith(ANALYTICS_EVENTS.EEA_UPLIFT_COMPLETED, {
channel: 'withdraw',
- requirement_key: 'sof_individual_primary_purpose',
+ requirement_key: 'has_foreign_tax_registration',
action_key: 'sumsub:eea_uplift',
- effective_date: '2026-06-29',
+ effective_date: '2026-12-31',
+ source: 'advisory',
+ urgent: false,
})
})
@@ -52,7 +73,7 @@ describe('useEeaUpliftFunnel', () => {
test('trackCompleted only fires once per start (ref cleared)', () => {
const { result } = renderHook(() => useEeaUpliftFunnel('deposit'))
- act(() => result.current.trackStarted(advisory))
+ act(() => result.current.trackStarted(advisoryTrigger))
capture.mockClear()
act(() => result.current.trackCompleted())
@@ -62,7 +83,7 @@ describe('useEeaUpliftFunnel', () => {
test('reset clears a pending start so a later success cannot mis-fire completed', () => {
const { result } = renderHook(() => useEeaUpliftFunnel('deposit'))
- act(() => result.current.trackStarted(advisory))
+ act(() => result.current.trackStarted(advisoryTrigger))
capture.mockClear()
// user abandoned the flow → reset, then an unrelated KYC success fires
diff --git a/src/hooks/useEeaUpliftFunnel.ts b/src/hooks/useEeaUpliftFunnel.ts
index c6eb11fe7..82f7bd52f 100644
--- a/src/hooks/useEeaUpliftFunnel.ts
+++ b/src/hooks/useEeaUpliftFunnel.ts
@@ -1,55 +1,68 @@
import { useCallback, useRef } from 'react'
import posthog from 'posthog-js'
import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
-import type { GateAdvisory } from '@/utils/capability-gate'
type UpliftChannel = 'deposit' | 'withdraw'
+/**
+ * Describes the uplift attempt being started. `source` distinguishes the two
+ * remediation paths (and, since blocking = the effective date has already
+ * passed, doubles as the urgency signal): `blocking` is the urgent post-cliff
+ * cohort, `advisory` is the future-dated ("upcoming") one.
+ */
+export type UpliftStartTrigger = {
+ requirementKey?: string
+ actionKey?: string
+ effectiveDate?: string
+ source: 'advisory' | 'blocking'
+}
+
/**
* Fires the EEA-uplift funnel events for PostHog so the flow can be filtered
- * directly: `eea_uplift_started` when the user launches the verification, and
- * `eea_uplift_completed` on KYC success.
+ * directly (and session recordings tagged): `eea_uplift_started` when the uplift
+ * modal OPENS, and `eea_uplift_completed` on KYC success.
*
- * `trackCompleted` only emits if a start was recorded in this session — the KYC
- * success callback on the bank pages is shared with non-uplift KYC, so the
- * `startedRef` guard stops a generic success from mis-firing the completed
- * event. The advisory snapshot is captured at start time because the gate's
- * `advisory` clears once the requirement resolves, so it's gone by completion.
+ * Firing on modal-open (not on the modal's CTA) means abandoners are captured
+ * too — the whole point is to watch who attempts the uplift and whether they
+ * finish. `trackCompleted` only emits if a start was recorded in this session:
+ * the KYC success callback on the bank pages is shared with non-uplift KYC, so
+ * the `startedRef` latch stops a generic success from mis-firing `completed`.
+ * The trigger snapshot is captured at start because the gate's advisory/reason
+ * clears once the requirement resolves, so it's gone by completion.
*
* `reset` clears the pending start on abandonment (KYC modal closed without
- * success). Without it the latch would survive an abandoned attempt, and a later
- * unrelated KYC success on the same mounted page could mis-fire `completed`.
+ * success), so a later unrelated KYC success on the same page can't mis-fire.
*/
export function useEeaUpliftFunnel(channel: UpliftChannel) {
- const startedRef = useRef(null)
+ const startedRef = useRef(null)
+
+ const eventProps = useCallback(
+ (trigger: UpliftStartTrigger) => ({
+ channel,
+ requirement_key: trigger.requirementKey,
+ action_key: trigger.actionKey,
+ effective_date: trigger.effectiveDate,
+ source: trigger.source,
+ // blocking = the cliff date has passed → already gating → urgent.
+ urgent: trigger.source === 'blocking',
+ }),
+ [channel]
+ )
const trackStarted = useCallback(
- // `advisory` is required: callers gate on it before launching, and the
- // funnel contract needs requirement_key / action_key / effective_date
- // always present on the event.
- (advisory: GateAdvisory) => {
- startedRef.current = advisory
- posthog.capture(ANALYTICS_EVENTS.EEA_UPLIFT_STARTED, {
- channel,
- requirement_key: advisory.requirementKey,
- action_key: advisory.actionKey,
- effective_date: advisory.effectiveDate,
- })
+ (trigger: UpliftStartTrigger) => {
+ startedRef.current = trigger
+ posthog.capture(ANALYTICS_EVENTS.EEA_UPLIFT_STARTED, eventProps(trigger))
},
- [channel]
+ [eventProps]
)
const trackCompleted = useCallback(() => {
- const advisory = startedRef.current
- if (!advisory) return
+ const trigger = startedRef.current
+ if (!trigger) return
startedRef.current = null
- posthog.capture(ANALYTICS_EVENTS.EEA_UPLIFT_COMPLETED, {
- channel,
- requirement_key: advisory.requirementKey,
- action_key: advisory.actionKey,
- effective_date: advisory.effectiveDate,
- })
- }, [channel])
+ posthog.capture(ANALYTICS_EVENTS.EEA_UPLIFT_COMPLETED, eventProps(trigger))
+ }, [eventProps])
const reset = useCallback(() => {
startedRef.current = null
diff --git a/src/utils/eea-uplift.utils.test.ts b/src/utils/eea-uplift.utils.test.ts
new file mode 100644
index 000000000..e3fc03702
--- /dev/null
+++ b/src/utils/eea-uplift.utils.test.ts
@@ -0,0 +1,53 @@
+import { eeaUpliftReasonCode, isEeaUpliftAdvisory, EEA_UPLIFT_REQUIREMENT_KEYS } from './eea-uplift.utils'
+import type { GateState, GateAdvisory } from '@/utils/capability-gate'
+
+describe('eeaUpliftReasonCode (blocking path)', () => {
+ test('returns the code for an eea_uplift fixable-rejection', () => {
+ const gate = { kind: 'fixable-rejection', userMessage: 'x', reason: { code: 'eea_uplift', userMessage: 'x' } }
+ expect(eeaUpliftReasonCode(gate as GateState)).toBe('eea_uplift')
+ })
+
+ test('matches the eea_uplift_with_tin variant', () => {
+ const gate = {
+ kind: 'fixable-rejection',
+ userMessage: 'x',
+ reason: { code: 'eea_uplift_with_tin', userMessage: 'x' },
+ }
+ expect(eeaUpliftReasonCode(gate as GateState)).toBe('eea_uplift_with_tin')
+ })
+
+ test('returns undefined for a non-uplift rejection', () => {
+ const gate = {
+ kind: 'fixable-rejection',
+ userMessage: 'x',
+ reason: { code: 'document_rejected', userMessage: 'x' },
+ }
+ expect(eeaUpliftReasonCode(gate as GateState)).toBeUndefined()
+ })
+
+ test('returns undefined for a gate variant without a reason', () => {
+ expect(eeaUpliftReasonCode({ kind: 'ready' } as GateState)).toBeUndefined()
+ expect(eeaUpliftReasonCode({ kind: 'needs-identity' } as GateState)).toBeUndefined()
+ })
+})
+
+describe('isEeaUpliftAdvisory (advisory path)', () => {
+ test.each([...EEA_UPLIFT_REQUIREMENT_KEYS])('matches uplift requirement key: %s', (requirementKey) => {
+ const advisory: GateAdvisory = { effectiveDate: '2026-12-31', actionKey: 'k', requirementKey }
+ expect(isEeaUpliftAdvisory(advisory)).toBe(true)
+ })
+
+ test('does not match a co-occurring non-uplift key (proof_of_address / gov-id)', () => {
+ const advisory: GateAdvisory = {
+ effectiveDate: '2026-06-29',
+ actionKey: 'k',
+ requirementKey: 'proof_of_address_document',
+ }
+ expect(isEeaUpliftAdvisory(advisory)).toBe(false)
+ })
+
+ test('is safe for undefined / keyless advisory', () => {
+ expect(isEeaUpliftAdvisory(undefined)).toBe(false)
+ expect(isEeaUpliftAdvisory({ effectiveDate: '2026-12-31', actionKey: 'k' })).toBe(false)
+ })
+})
diff --git a/src/utils/eea-uplift.utils.ts b/src/utils/eea-uplift.utils.ts
new file mode 100644
index 000000000..bcc72b11d
--- /dev/null
+++ b/src/utils/eea-uplift.utils.ts
@@ -0,0 +1,39 @@
+import type { GateAdvisory, GateState } from '@/utils/capability-gate'
+
+/**
+ * Bridge remediation identified as an EEA-uplift questionnaire, split by how it
+ * reaches the FE:
+ *
+ * - BLOCKING (post-cliff, effective date passed → gate `fixable-rejection`): the
+ * BE sets `reason.code` to the questionnaire cluster — `eea_uplift` /
+ * `eea_uplift_with_tin` (peanut-api-ts resolver). So a code prefix match is
+ * the clean signal.
+ * - ADVISORY (future-dated → gate `ready` + advisory): the BE injects only
+ * `effectiveDate` + `requirementKey` onto the NextAction, NOT the cluster, so
+ * here we match the raw requirement keys whose cluster is `eea_uplift`.
+ *
+ * Key set derived from prod remediation data (2026-07). PoA / gov-id-expired
+ * co-occur on the same cliff but are separate remediation (cluster `null`), so
+ * they're deliberately excluded — keying PoA→eea would over-fire on ordinary
+ * document rejections for non-EEA users.
+ */
+export const EEA_UPLIFT_REQUIREMENT_KEYS = new Set([
+ 'sof_individual_primary_purpose',
+ 'has_foreign_tax_registration',
+ 'place_of_birth_missing',
+ 'nationalities',
+])
+
+/**
+ * Blocking path: the gate's reason code IS the `eea_uplift*` cluster. Returns
+ * the code when it's an uplift remediation (truthy → track it), else undefined.
+ */
+export function eeaUpliftReasonCode(gate: GateState): string | undefined {
+ const code = (gate as { reason?: { code?: string } }).reason?.code
+ return code?.startsWith('eea_uplift') ? code : undefined
+}
+
+/** Advisory path: the future-dated requirement key belongs to the uplift set. */
+export function isEeaUpliftAdvisory(advisory: GateAdvisory | undefined): boolean {
+ return !!advisory?.requirementKey && EEA_UPLIFT_REQUIREMENT_KEYS.has(advisory.requirementKey)
+}
From 904efd18b1505bc903000215285359df7222a3b8 Mon Sep 17 00:00:00 2001
From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com>
Date: Thu, 2 Jul 2026 21:24:32 +0530
Subject: [PATCH 4/8] fix(analytics): reset uplift latch on kyc-modal dismiss,
dedup started
Addresses code-review findings on the eea_uplift funnel:
- phantom completions: the blocking path arms the started latch, but the
InitiateKycModal onClose/onContactSupport did not reset it (only the
advisory/Sumsub path did). A later unrelated KYC approval would fire a
false eea_uplift_completed. Reset the latch on modal dismiss.
- re-fire: trackStarted is now idempotent per armed attempt, so repeat
Continue clicks in one attempt don't inflate the started count.
- DRY + convention: collapse the four duplicated trigger-construction sites
into upliftTriggerFrom{Gate,Advisory} helpers and move UpliftStartTrigger
out of the hook into eea-uplift.utils (per the no-types-in-hooks rule).
---
.../add-money/[country]/bank/page.tsx | 32 ++++-----
.../withdraw/[country]/bank/page.tsx | 34 +++++-----
src/hooks/useEeaUpliftFunnel.test.ts | 22 +++++-
src/hooks/useEeaUpliftFunnel.ts | 68 +++++++++----------
src/utils/eea-uplift.utils.test.ts | 39 ++++++-----
src/utils/eea-uplift.utils.ts | 41 +++++++++--
6 files changed, 141 insertions(+), 95 deletions(-)
diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
index f03e09563..db9bb2af4 100644
--- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
@@ -37,7 +37,7 @@ import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal'
import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt'
import { useEeaUpliftFunnel } from '@/hooks/useEeaUpliftFunnel'
-import { eeaUpliftReasonCode, isEeaUpliftAdvisory } from '@/utils/eea-uplift.utils'
+import { upliftTriggerFromGate, upliftTriggerFromAdvisory } from '@/utils/eea-uplift.utils'
import posthog from 'posthog-js'
import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import { addMoneyCountryUrl } from '@/utils/native-routes'
@@ -142,7 +142,7 @@ export default function OnrampBankPage() {
// Route through the self-heal resubmit path (reheal-tagged action) so the
// completed submission round-trips to Bridge. start-action mints a plain
// token whose webhook completion has no Bridge relay → answers are dropped.
- // NB: eea_uplift_started is fired at modal-open (handleAmountContinue),
+ // note: eea_uplift_started is fired at modal-open (handleAmountContinue),
// not here, so abandoners are captured too.
onCompleteNow: () => {
if (!advisory) return Promise.resolve()
@@ -276,12 +276,10 @@ export default function OnrampBankPage() {
if (gate.kind === 'accept-tos') {
guardWithTos()
} else {
- // urgent (post-cliff) EEA uplift lands here as a fixable-rejection —
+ // urgent (post-cliff) eea uplift lands here as a fixable-rejection —
// fire the funnel event as this KYC modal opens.
- const upliftCode = eeaUpliftReasonCode(gate)
- if (upliftCode) {
- trackUpliftStarted({ requirementKey: upliftCode, source: 'blocking' })
- }
+ const upliftTrigger = upliftTriggerFromGate(gate)
+ if (upliftTrigger) trackUpliftStarted(upliftTrigger)
setShowKycModal(true)
}
return
@@ -291,16 +289,10 @@ export default function OnrampBankPage() {
// (record the amount-entered event, open the confirmation modal) only
// runs once there's no pending requirement; while one exists the modal
// blocks and this never fires, so the event can't double-count.
- // Upcoming (future-dated) EEA uplift opens the advisory modal here — fire
+ // upcoming (future-dated) eea uplift opens the advisory modal here — fire
// the funnel event as it opens.
- if (isEeaUpliftAdvisory(advisory)) {
- trackUpliftStarted({
- requirementKey: advisory?.requirementKey,
- actionKey: advisory?.actionKey,
- effectiveDate: advisory?.effectiveDate,
- source: 'advisory',
- })
- }
+ const advisoryTrigger = upliftTriggerFromAdvisory(advisory)
+ if (advisoryTrigger) trackUpliftStarted(advisoryTrigger)
advisoryIntercept(() => {
posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, {
amount_usd: usdEquivalent,
@@ -486,7 +478,12 @@ export default function OnrampBankPage() {
setShowKycModal(false)}
+ onClose={() => {
+ // dismiss = abandon: clear the uplift latch so a later
+ // unrelated KYC success can't mis-fire eea_uplift_completed.
+ setShowKycModal(false)
+ resetUpliftFunnel()
+ }}
onVerify={async () => {
if (gate.kind === 'restart-identity') {
await sumsubFlow.handleRestartIdentity()
@@ -503,6 +500,7 @@ export default function OnrampBankPage() {
}}
onContactSupport={() => {
setShowKycModal(false)
+ resetUpliftFunnel()
setIsSupportModalOpen(true)
}}
isLoading={sumsubFlow.isLoading}
diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
index b20df3b2d..21895bf5e 100644
--- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
@@ -33,7 +33,7 @@ import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal'
import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt'
import { useEeaUpliftFunnel } from '@/hooks/useEeaUpliftFunnel'
-import { eeaUpliftReasonCode, isEeaUpliftAdvisory } from '@/utils/eea-uplift.utils'
+import { upliftTriggerFromGate, upliftTriggerFromAdvisory } from '@/utils/eea-uplift.utils'
import { useCapabilities } from '@/hooks/useCapabilities'
import { getKycModalVariant, getGateUserMessage } from '@/utils/capability-gate'
import { useModalsContext } from '@/context/ModalsContext'
@@ -118,8 +118,8 @@ export default function WithdrawBankPage() {
// Route through the self-heal resubmit path (reheal-tagged action) so the
// completed submission round-trips to Bridge. start-action mints a plain
// token whose webhook completion has no Bridge relay → answers are dropped.
- // NB: eea_uplift_started is fired at modal-open (the handlers below), not
- // here, so abandoners are captured too.
+ // note: eea_uplift_started is fired at modal-open (the handlers below),
+ // not here, so abandoners are captured too.
onCompleteNow: () => {
if (!advisory) return Promise.resolve()
return sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey)
@@ -219,12 +219,10 @@ export default function WithdrawBankPage() {
if (gate.kind === 'accept-tos') {
guardWithTos()
} else {
- // urgent (post-cliff) EEA uplift lands here as a fixable-rejection —
+ // urgent (post-cliff) eea uplift lands here as a fixable-rejection —
// fire the funnel event as this KYC modal opens.
- const upliftCode = eeaUpliftReasonCode(gate)
- if (upliftCode) {
- trackUpliftStarted({ requirementKey: upliftCode, source: 'blocking' })
- }
+ const upliftTrigger = upliftTriggerFromGate(gate)
+ if (upliftTrigger) trackUpliftStarted(upliftTrigger)
setShowKycModal(true)
}
return
@@ -354,17 +352,11 @@ export default function WithdrawBankPage() {
// Enforce the mandatory verification pre-empt, then run the offramp. When the
// gate isn't `ready` (or there's no pending requirement) this is a no-op and
// proceedWithOfframp runs straight away (it handles the not-ready cases).
- // Upcoming (future-dated) EEA uplift opens the advisory modal here — fire the
+ // upcoming (future-dated) eea uplift opens the advisory modal here — fire the
// funnel event as it opens.
const handleCreateAndInitiateOfframp = () => {
- if (isEeaUpliftAdvisory(advisory)) {
- trackUpliftStarted({
- requirementKey: advisory?.requirementKey,
- actionKey: advisory?.actionKey,
- effectiveDate: advisory?.effectiveDate,
- source: 'advisory',
- })
- }
+ const advisoryTrigger = upliftTriggerFromAdvisory(advisory)
+ if (advisoryTrigger) trackUpliftStarted(advisoryTrigger)
advisoryIntercept(() => void proceedWithOfframp())
}
@@ -563,7 +555,12 @@ export default function WithdrawBankPage() {
setShowKycModal(false)}
+ onClose={() => {
+ // dismiss = abandon: clear the uplift latch so a later
+ // unrelated KYC success can't mis-fire eea_uplift_completed.
+ setShowKycModal(false)
+ resetUpliftFunnel()
+ }}
onVerify={async () => {
if (gate.kind === 'restart-identity') {
await sumsubFlow.handleRestartIdentity()
@@ -580,6 +577,7 @@ export default function WithdrawBankPage() {
}}
onContactSupport={() => {
setShowKycModal(false)
+ resetUpliftFunnel()
setIsSupportModalOpen(true)
}}
isLoading={sumsubFlow.isLoading}
diff --git a/src/hooks/useEeaUpliftFunnel.test.ts b/src/hooks/useEeaUpliftFunnel.test.ts
index 081387f2a..5c9edff91 100644
--- a/src/hooks/useEeaUpliftFunnel.test.ts
+++ b/src/hooks/useEeaUpliftFunnel.test.ts
@@ -1,6 +1,7 @@
import { act, renderHook } from '@testing-library/react'
import posthog from 'posthog-js'
-import { useEeaUpliftFunnel, type UpliftStartTrigger } from './useEeaUpliftFunnel'
+import { useEeaUpliftFunnel } from './useEeaUpliftFunnel'
+import type { UpliftStartTrigger } from '@/utils/eea-uplift.utils'
import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
jest.mock('posthog-js', () => ({ capture: jest.fn() }))
@@ -48,6 +49,25 @@ describe('useEeaUpliftFunnel', () => {
)
})
+ test('trackStarted is idempotent per armed attempt (repeat clicks do not re-emit)', () => {
+ const { result } = renderHook(() => useEeaUpliftFunnel('deposit'))
+ act(() => result.current.trackStarted(blockingTrigger))
+ act(() => result.current.trackStarted(blockingTrigger))
+ expect(capture).toHaveBeenCalledTimes(1)
+
+ // a genuinely different requirement re-arms and fires again
+ act(() => result.current.trackStarted(advisoryTrigger))
+ expect(capture).toHaveBeenCalledTimes(2)
+ })
+
+ test('after reset, the same trigger fires again (a new attempt)', () => {
+ const { result } = renderHook(() => useEeaUpliftFunnel('deposit'))
+ act(() => result.current.trackStarted(blockingTrigger))
+ act(() => result.current.reset())
+ act(() => result.current.trackStarted(blockingTrigger))
+ expect(capture).toHaveBeenCalledTimes(2)
+ })
+
test('trackCompleted fires eea_uplift_completed only after a start, echoing the trigger', () => {
const { result } = renderHook(() => useEeaUpliftFunnel('withdraw'))
act(() => result.current.trackStarted(advisoryTrigger))
diff --git a/src/hooks/useEeaUpliftFunnel.ts b/src/hooks/useEeaUpliftFunnel.ts
index 82f7bd52f..ef35b9f1a 100644
--- a/src/hooks/useEeaUpliftFunnel.ts
+++ b/src/hooks/useEeaUpliftFunnel.ts
@@ -1,20 +1,24 @@
import { useCallback, useRef } from 'react'
import posthog from 'posthog-js'
import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
+import type { UpliftStartTrigger } from '@/utils/eea-uplift.utils'
type UpliftChannel = 'deposit' | 'withdraw'
-/**
- * Describes the uplift attempt being started. `source` distinguishes the two
- * remediation paths (and, since blocking = the effective date has already
- * passed, doubles as the urgency signal): `blocking` is the urgent post-cliff
- * cohort, `advisory` is the future-dated ("upcoming") one.
- */
-export type UpliftStartTrigger = {
- requirementKey?: string
- actionKey?: string
- effectiveDate?: string
- source: 'advisory' | 'blocking'
+function upliftEventProps(channel: UpliftChannel, trigger: UpliftStartTrigger) {
+ return {
+ channel,
+ requirement_key: trigger.requirementKey,
+ action_key: trigger.actionKey,
+ effective_date: trigger.effectiveDate,
+ source: trigger.source,
+ // blocking = the cliff date has passed → already gating → urgent.
+ urgent: trigger.source === 'blocking',
+ }
+}
+
+function sameTrigger(a: UpliftStartTrigger | null, b: UpliftStartTrigger): boolean {
+ return !!a && a.requirementKey === b.requirementKey && a.source === b.source
}
/**
@@ -24,45 +28,39 @@ export type UpliftStartTrigger = {
*
* Firing on modal-open (not on the modal's CTA) means abandoners are captured
* too — the whole point is to watch who attempts the uplift and whether they
- * finish. `trackCompleted` only emits if a start was recorded in this session:
- * the KYC success callback on the bank pages is shared with non-uplift KYC, so
- * the `startedRef` latch stops a generic success from mis-firing `completed`.
- * The trigger snapshot is captured at start because the gate's advisory/reason
- * clears once the requirement resolves, so it's gone by completion.
+ * finish. `trackStarted` is idempotent per armed attempt: re-clicking Continue
+ * while the same requirement is already latched does not re-emit `started`
+ * (until `reset`/`trackCompleted` clears the latch, at which point a genuine new
+ * attempt fires again).
+ *
+ * `trackCompleted` only emits if a start was recorded in this session: the KYC
+ * success callback on the bank pages is shared with non-uplift KYC, so the
+ * latch stops a generic success from mis-firing `completed`. The trigger
+ * snapshot is captured at start because the gate's advisory/reason clears once
+ * the requirement resolves, so it's gone by completion.
*
- * `reset` clears the pending start on abandonment (KYC modal closed without
- * success), so a later unrelated KYC success on the same page can't mis-fire.
+ * `reset` clears the pending start on abandonment (uplift modal dismissed
+ * without success), so a later unrelated KYC success can't mis-fire.
*/
export function useEeaUpliftFunnel(channel: UpliftChannel) {
const startedRef = useRef(null)
- const eventProps = useCallback(
- (trigger: UpliftStartTrigger) => ({
- channel,
- requirement_key: trigger.requirementKey,
- action_key: trigger.actionKey,
- effective_date: trigger.effectiveDate,
- source: trigger.source,
- // blocking = the cliff date has passed → already gating → urgent.
- urgent: trigger.source === 'blocking',
- }),
- [channel]
- )
-
const trackStarted = useCallback(
(trigger: UpliftStartTrigger) => {
+ // idempotent per armed attempt — don't re-emit on repeat clicks.
+ if (sameTrigger(startedRef.current, trigger)) return
startedRef.current = trigger
- posthog.capture(ANALYTICS_EVENTS.EEA_UPLIFT_STARTED, eventProps(trigger))
+ posthog.capture(ANALYTICS_EVENTS.EEA_UPLIFT_STARTED, upliftEventProps(channel, trigger))
},
- [eventProps]
+ [channel]
)
const trackCompleted = useCallback(() => {
const trigger = startedRef.current
if (!trigger) return
startedRef.current = null
- posthog.capture(ANALYTICS_EVENTS.EEA_UPLIFT_COMPLETED, eventProps(trigger))
- }, [eventProps])
+ posthog.capture(ANALYTICS_EVENTS.EEA_UPLIFT_COMPLETED, upliftEventProps(channel, trigger))
+ }, [channel])
const reset = useCallback(() => {
startedRef.current = null
diff --git a/src/utils/eea-uplift.utils.test.ts b/src/utils/eea-uplift.utils.test.ts
index e3fc03702..79adfdace 100644
--- a/src/utils/eea-uplift.utils.test.ts
+++ b/src/utils/eea-uplift.utils.test.ts
@@ -1,10 +1,10 @@
-import { eeaUpliftReasonCode, isEeaUpliftAdvisory, EEA_UPLIFT_REQUIREMENT_KEYS } from './eea-uplift.utils'
+import { upliftTriggerFromGate, upliftTriggerFromAdvisory, EEA_UPLIFT_REQUIREMENT_KEYS } from './eea-uplift.utils'
import type { GateState, GateAdvisory } from '@/utils/capability-gate'
-describe('eeaUpliftReasonCode (blocking path)', () => {
- test('returns the code for an eea_uplift fixable-rejection', () => {
+describe('upliftTriggerFromGate (blocking path)', () => {
+ test('returns a blocking trigger for an eea_uplift fixable-rejection', () => {
const gate = { kind: 'fixable-rejection', userMessage: 'x', reason: { code: 'eea_uplift', userMessage: 'x' } }
- expect(eeaUpliftReasonCode(gate as GateState)).toBe('eea_uplift')
+ expect(upliftTriggerFromGate(gate as GateState)).toEqual({ requirementKey: 'eea_uplift', source: 'blocking' })
})
test('matches the eea_uplift_with_tin variant', () => {
@@ -13,41 +13,46 @@ describe('eeaUpliftReasonCode (blocking path)', () => {
userMessage: 'x',
reason: { code: 'eea_uplift_with_tin', userMessage: 'x' },
}
- expect(eeaUpliftReasonCode(gate as GateState)).toBe('eea_uplift_with_tin')
+ expect(upliftTriggerFromGate(gate as GateState)?.requirementKey).toBe('eea_uplift_with_tin')
})
- test('returns undefined for a non-uplift rejection', () => {
+ test('returns null for a non-uplift rejection', () => {
const gate = {
kind: 'fixable-rejection',
userMessage: 'x',
reason: { code: 'document_rejected', userMessage: 'x' },
}
- expect(eeaUpliftReasonCode(gate as GateState)).toBeUndefined()
+ expect(upliftTriggerFromGate(gate as GateState)).toBeNull()
})
- test('returns undefined for a gate variant without a reason', () => {
- expect(eeaUpliftReasonCode({ kind: 'ready' } as GateState)).toBeUndefined()
- expect(eeaUpliftReasonCode({ kind: 'needs-identity' } as GateState)).toBeUndefined()
+ test('returns null for a gate variant without a reason', () => {
+ expect(upliftTriggerFromGate({ kind: 'ready' } as GateState)).toBeNull()
+ expect(upliftTriggerFromGate({ kind: 'needs-identity' } as GateState)).toBeNull()
})
})
-describe('isEeaUpliftAdvisory (advisory path)', () => {
- test.each([...EEA_UPLIFT_REQUIREMENT_KEYS])('matches uplift requirement key: %s', (requirementKey) => {
+describe('upliftTriggerFromAdvisory (advisory path)', () => {
+ test.each([...EEA_UPLIFT_REQUIREMENT_KEYS])('builds an advisory trigger for uplift key: %s', (requirementKey) => {
const advisory: GateAdvisory = { effectiveDate: '2026-12-31', actionKey: 'k', requirementKey }
- expect(isEeaUpliftAdvisory(advisory)).toBe(true)
+ expect(upliftTriggerFromAdvisory(advisory)).toEqual({
+ requirementKey,
+ actionKey: 'k',
+ effectiveDate: '2026-12-31',
+ source: 'advisory',
+ })
})
- test('does not match a co-occurring non-uplift key (proof_of_address / gov-id)', () => {
+ test('returns null for a co-occurring non-uplift key (proof_of_address / gov-id)', () => {
const advisory: GateAdvisory = {
effectiveDate: '2026-06-29',
actionKey: 'k',
requirementKey: 'proof_of_address_document',
}
- expect(isEeaUpliftAdvisory(advisory)).toBe(false)
+ expect(upliftTriggerFromAdvisory(advisory)).toBeNull()
})
test('is safe for undefined / keyless advisory', () => {
- expect(isEeaUpliftAdvisory(undefined)).toBe(false)
- expect(isEeaUpliftAdvisory({ effectiveDate: '2026-12-31', actionKey: 'k' })).toBe(false)
+ expect(upliftTriggerFromAdvisory(undefined)).toBeNull()
+ expect(upliftTriggerFromAdvisory({ effectiveDate: '2026-12-31', actionKey: 'k' })).toBeNull()
})
})
diff --git a/src/utils/eea-uplift.utils.ts b/src/utils/eea-uplift.utils.ts
index bcc72b11d..c0281c7c6 100644
--- a/src/utils/eea-uplift.utils.ts
+++ b/src/utils/eea-uplift.utils.ts
@@ -16,6 +16,11 @@ import type { GateAdvisory, GateState } from '@/utils/capability-gate'
* co-occur on the same cliff but are separate remediation (cluster `null`), so
* they're deliberately excluded — keying PoA→eea would over-fire on ordinary
* document rejections for non-EEA users.
+ *
+ * KNOWN LIMITATION: both signals mirror BE-owned strings (the resolver's cluster
+ * codes and requirement keys). If the BE adds/renames an uplift key or reason
+ * code, that segment silently drops out of the funnel until this file follows —
+ * accepted tradeoff for keeping this FE-only.
*/
export const EEA_UPLIFT_REQUIREMENT_KEYS = new Set([
'sof_individual_primary_purpose',
@@ -25,15 +30,37 @@ export const EEA_UPLIFT_REQUIREMENT_KEYS = new Set([
])
/**
- * Blocking path: the gate's reason code IS the `eea_uplift*` cluster. Returns
- * the code when it's an uplift remediation (truthy → track it), else undefined.
+ * The uplift attempt being started. `source` distinguishes the two remediation
+ * paths and — since blocking = the effective date has already passed — doubles
+ * as the urgency signal: `blocking` = urgent post-cliff, `advisory` = upcoming.
*/
-export function eeaUpliftReasonCode(gate: GateState): string | undefined {
+export type UpliftStartTrigger = {
+ requirementKey?: string
+ actionKey?: string
+ effectiveDate?: string
+ source: 'advisory' | 'blocking'
+}
+
+/**
+ * Blocking path: the gate's reason code IS the `eea_uplift*` cluster. Returns a
+ * ready-to-fire trigger when it's an uplift remediation, else null.
+ */
+export function upliftTriggerFromGate(gate: GateState): UpliftStartTrigger | null {
const code = (gate as { reason?: { code?: string } }).reason?.code
- return code?.startsWith('eea_uplift') ? code : undefined
+ if (!code?.startsWith('eea_uplift')) return null
+ return { requirementKey: code, source: 'blocking' }
}
-/** Advisory path: the future-dated requirement key belongs to the uplift set. */
-export function isEeaUpliftAdvisory(advisory: GateAdvisory | undefined): boolean {
- return !!advisory?.requirementKey && EEA_UPLIFT_REQUIREMENT_KEYS.has(advisory.requirementKey)
+/**
+ * Advisory path: the future-dated requirement key belongs to the uplift set.
+ * Returns a ready-to-fire trigger, else null.
+ */
+export function upliftTriggerFromAdvisory(advisory: GateAdvisory | undefined): UpliftStartTrigger | null {
+ if (!advisory?.requirementKey || !EEA_UPLIFT_REQUIREMENT_KEYS.has(advisory.requirementKey)) return null
+ return {
+ requirementKey: advisory.requirementKey,
+ actionKey: advisory.actionKey,
+ effectiveDate: advisory.effectiveDate,
+ source: 'advisory',
+ }
}
From 94d684ad2474c8f37ecda41108b520e2c4598904 Mon Sep 17 00:00:00 2001
From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com>
Date: Fri, 3 Jul 2026 11:23:30 +0530
Subject: [PATCH 5/8] feat: show bridge re-verification pending modal on
on/offramp attempt
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When a user completes an EEA uplift and then tries to on/offramp via Bridge
while Bridge is still re-reviewing, the deposit page showed only an inline
message and the withdraw page silently did nothing. Show a dedicated
pending/in-progress modal instead, driven by the waiting-on-provider gate.
To reflect proper live status from Bridge, re-arm the existing submission-
window poller (markSubmitted) when the modal opens — the 4s user refetch
picks up Bridge's latest verification status and the modal auto-dismisses
the moment the gate leaves waiting-on-provider. No new backend endpoint: the
capability pipeline already mirrors Bridge status.
Also closes the withdraw-side silent no-op noted in the deposit fix.
---
.../add-money/[country]/bank/page.tsx | 28 ++++++-----
.../__tests__/add-money-states.test.tsx | 9 ++--
.../withdraw/[country]/bank/page.tsx | 24 +++++++--
.../Kyc/KycReverificationPendingModal.tsx | 49 +++++++++++++++++++
4 files changed, 90 insertions(+), 20 deletions(-)
create mode 100644 src/components/Kyc/KycReverificationPendingModal.tsx
diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
index db9bb2af4..b5e8e0c35 100644
--- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
@@ -33,6 +33,8 @@ import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow'
import { useTosGuard } from '@/hooks/useTosGuard'
import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep'
import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals'
+import { KycReverificationPendingModal } from '@/components/Kyc/KycReverificationPendingModal'
+import { markSubmitted } from '@/hooks/useSubmissionWindow'
import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal'
import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt'
@@ -69,6 +71,7 @@ export default function OnrampBankPage() {
// Local UI state (not URL-appropriate - transient)
const [showWarningModal, setShowWarningModal] = useState(false)
const [showKycModal, setShowKycModal] = useState(false)
+ const [showPendingModal, setShowPendingModal] = useState(false)
const [isRiskAccepted, setIsRiskAccepted] = useState(false)
const { setError, error, setOnrampData, onrampData } = useOnrampFlow()
@@ -259,18 +262,14 @@ export default function OnrampBankPage() {
// capabilities still loading — silently no-op instead of flashing
// a misleading needs_kyc modal.
if (gate.kind === 'loading') return
- // `waiting-on-provider` means the user has nothing to do (e.g. bridge
- // reviewing eea-uplift docs) — tell them to wait instead of a dead
- // button or a doomed transfer attempt with no feedback.
+ // `waiting-on-provider` means bridge is re-reviewing submitted info
+ // (e.g. right after an eea uplift) — the user has nothing to do but
+ // wait. Show the pending modal instead of a dead button, and re-arm
+ // the capability poller so we pick up bridge's latest status live and
+ // the modal auto-dismisses the moment the gate clears.
if (gate.kind === 'waiting-on-provider') {
- // prefer the backend's specific review copy (via the gate reason)
- // and fall back to generic wait text.
- setError({
- showError: true,
- errorMessage:
- gate.userMessage ??
- 'Your verification details are being reviewed. This usually takes a few minutes — please try again soon.',
- })
+ markSubmitted()
+ setShowPendingModal(true)
return
}
if (gate.kind === 'accept-tos') {
@@ -512,6 +511,13 @@ export default function OnrampBankPage() {
+ setShowPendingModal(false)}
+ />
+
{
})
})
- test('waiting-on-provider gate shows under-review message instead of silent no-op', async () => {
+ test('waiting-on-provider gate shows the reverification pending modal instead of a dead button', async () => {
setGate('waiting-on-provider')
const mockCreateOnramp = jest.fn()
mockUseCreateOnramp.mockReturnValue({
@@ -1371,12 +1371,9 @@ describe('GROUP 5: Bridge Bank Onramp', () => {
fireEvent.click(screen.getByText('Continue'))
})
- // no transfer attempt, but the user is told why
+ // no doomed transfer attempt; the user sees the bridge-review pending modal
expect(mockCreateOnramp).not.toHaveBeenCalled()
- expect(mockOnrampFlow.setError).toHaveBeenCalledWith({
- showError: true,
- errorMessage: expect.stringContaining('being reviewed'),
- })
+ expect(await screen.findByText(/reviewing your details/i)).toBeInTheDocument()
})
test('limits blocking disables Continue and shows LimitsWarningCard', () => {
diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
index 21895bf5e..7bd40c8a0 100644
--- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
@@ -29,6 +29,8 @@ import { useTosGuard } from '@/hooks/useTosGuard'
import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep'
import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow'
import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals'
+import { KycReverificationPendingModal } from '@/components/Kyc/KycReverificationPendingModal'
+import { markSubmitted } from '@/hooks/useSubmissionWindow'
import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal'
import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt'
@@ -126,6 +128,7 @@ export default function WithdrawBankPage() {
},
})
const [showKycModal, setShowKycModal] = useState(false)
+ const [showPendingModal, setShowPendingModal] = useState(false)
const { setIsSupportModalOpen } = useModalsContext()
// close kyc modal when sumsub sdk opens
@@ -213,9 +216,17 @@ export default function WithdrawBankPage() {
const proceedWithOfframp = async () => {
if (gate.kind !== 'ready') {
- // Loading and waiting-on-provider both mean "user has no action to
- // take" — silently no-op instead of bouncing them through Sumsub.
- if (gate.kind === 'loading' || gate.kind === 'waiting-on-provider') return
+ // capabilities still loading — silently no-op.
+ if (gate.kind === 'loading') return
+ // `waiting-on-provider` means bridge is re-reviewing submitted info
+ // (e.g. right after an eea uplift) — show the pending modal instead of
+ // a dead button, and re-arm the capability poller so we pick up
+ // bridge's latest status live and the modal auto-dismisses on clear.
+ if (gate.kind === 'waiting-on-provider') {
+ markSubmitted()
+ setShowPendingModal(true)
+ return
+ }
if (gate.kind === 'accept-tos') {
guardWithTos()
} else {
@@ -587,6 +598,13 @@ export default function WithdrawBankPage() {
regionName={getCountryFromPath(country)?.title}
/>
+
+ setShowPendingModal(false)}
+ />
)
diff --git a/src/components/Kyc/KycReverificationPendingModal.tsx b/src/components/Kyc/KycReverificationPendingModal.tsx
new file mode 100644
index 000000000..288776144
--- /dev/null
+++ b/src/components/Kyc/KycReverificationPendingModal.tsx
@@ -0,0 +1,49 @@
+import { useRouter } from 'next/navigation'
+import ActionModal from '@/components/Global/ActionModal'
+import { type IconName } from '@/components/Global/Icons/Icon'
+
+interface KycReverificationPendingModalProps {
+ isOpen: boolean
+ onClose: () => void
+}
+
+/**
+ * Shown when the user tries to on/offramp via Bridge while Bridge is still
+ * re-reviewing their submitted info (e.g. right after an EEA uplift). This maps
+ * to the `waiting-on-provider` gate — the user has no action to take but wait.
+ *
+ * The parent ties `isOpen` to the gate, so the modal auto-dismisses the moment
+ * Bridge finishes (the capability poller — re-armed via markSubmitted when this
+ * opens — flips the gate away from `waiting-on-provider`).
+ */
+export const KycReverificationPendingModal = ({ isOpen, onClose }: KycReverificationPendingModalProps) => {
+ const router = useRouter()
+
+ return (
+
+ Your verification is being reviewed — this usually takes a few minutes. We'll let you know as
+ soon as you're ready to continue. You can wait here or head home.
+
+ }
+ ctas={[
+ {
+ text: 'Go to Home',
+ onClick: () => {
+ onClose()
+ router.push('/home')
+ },
+ variant: 'purple',
+ className: 'w-full',
+ shadowSize: '4',
+ },
+ ]}
+ />
+ )
+}
From 4e72a2d64a8053b7d14b4234ef429370446cae50 Mon Sep 17 00:00:00 2001
From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com>
Date: Fri, 3 Jul 2026 11:56:44 +0530
Subject: [PATCH 6/8] fix: keep pending modal's status poll alive + self-clear
(review)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Addresses code-review on the reverification pending modal:
- FEATURE-BREAKING: a single 30s submission window lapsed mid-review
(waiting-on-provider rails are requires-info, not pending, so the poller
self-terminated) — the modal never auto-dismissed even after Bridge
approved. Extract useWaitingOnProviderModal, which re-arms the window on a
20s interval while open so the ~4s user poll runs for the whole wait.
- stale-flag reopen: drop the request flag when the gate resolves, so a
transient re-flip to waiting-on-provider can't reopen the modal on its own.
- surface gate.userMessage (bridge-specific review copy) in the modal instead
of always the generic text.
- DRY: the shared hook collapses the duplicated deposit/withdraw wiring.
- drop the redundant 'clock' as IconName cast.
---
.../add-money/[country]/bank/page.tsx | 16 ++--
.../withdraw/[country]/bank/page.tsx | 16 ++--
.../Kyc/KycReverificationPendingModal.tsx | 11 +--
.../useWaitingOnProviderModal.test.ts | 79 +++++++++++++++++++
src/hooks/useWaitingOnProviderModal.ts | 50 ++++++++++++
5 files changed, 151 insertions(+), 21 deletions(-)
create mode 100644 src/hooks/__tests__/useWaitingOnProviderModal.test.ts
create mode 100644 src/hooks/useWaitingOnProviderModal.ts
diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
index b5e8e0c35..25400d075 100644
--- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
@@ -34,7 +34,7 @@ import { useTosGuard } from '@/hooks/useTosGuard'
import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep'
import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals'
import { KycReverificationPendingModal } from '@/components/Kyc/KycReverificationPendingModal'
-import { markSubmitted } from '@/hooks/useSubmissionWindow'
+import { useWaitingOnProviderModal } from '@/hooks/useWaitingOnProviderModal'
import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal'
import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt'
@@ -71,7 +71,6 @@ export default function OnrampBankPage() {
// Local UI state (not URL-appropriate - transient)
const [showWarningModal, setShowWarningModal] = useState(false)
const [showKycModal, setShowKycModal] = useState(false)
- const [showPendingModal, setShowPendingModal] = useState(false)
const [isRiskAccepted, setIsRiskAccepted] = useState(false)
const { setError, error, setOnrampData, onrampData } = useOnrampFlow()
@@ -135,6 +134,9 @@ export default function OnrampBankPage() {
const { gateFor } = useCapabilities()
const bankCountry = useMemo(() => railJurisdictionForBank(selectedCountry?.id), [selectedCountry?.id])
const gate = useMemo(() => gateFor('deposit', { channel: 'bank', country: bankCountry }), [gateFor, bankCountry])
+ // bridge re-verification ("we're reviewing your details") modal for the
+ // waiting-on-provider gate — keeps the status poll alive + auto-dismisses.
+ const pendingModal = useWaitingOnProviderModal(gate)
// A ready bank rail can still carry a pending Bridge requirement (the gate's
// `advisory`). Enforce it as a mandatory, non-skippable pre-empt at the
// proceed step — the deposit cannot continue until it's completed.
@@ -268,8 +270,7 @@ export default function OnrampBankPage() {
// the capability poller so we pick up bridge's latest status live and
// the modal auto-dismisses the moment the gate clears.
if (gate.kind === 'waiting-on-provider') {
- markSubmitted()
- setShowPendingModal(true)
+ pendingModal.open()
return
}
if (gate.kind === 'accept-tos') {
@@ -512,10 +513,9 @@ export default function OnrampBankPage() {
setShowPendingModal(false)}
+ isOpen={pendingModal.isOpen}
+ onClose={pendingModal.close}
+ message={pendingModal.message}
/>
diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
index 7bd40c8a0..8dde7460d 100644
--- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
@@ -30,7 +30,7 @@ import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep'
import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow'
import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals'
import { KycReverificationPendingModal } from '@/components/Kyc/KycReverificationPendingModal'
-import { markSubmitted } from '@/hooks/useSubmissionWindow'
+import { useWaitingOnProviderModal } from '@/hooks/useWaitingOnProviderModal'
import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal'
import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt'
@@ -93,6 +93,9 @@ export default function WithdrawBankPage() {
const { gateFor } = useCapabilities()
const bankCountry = useMemo(() => railJurisdictionForBank(getCountryFromPath(country)?.id), [country])
const gate = useMemo(() => gateFor('withdraw', { channel: 'bank', country: bankCountry }), [gateFor, bankCountry])
+ // bridge re-verification ("we're reviewing your details") modal for the
+ // waiting-on-provider gate — keeps the status poll alive + auto-dismisses.
+ const pendingModal = useWaitingOnProviderModal(gate)
// EEA-uplift funnel events (PostHog): started on launch, completed on KYC
// success. trackCompleted no-ops unless an uplift was started this session.
const {
@@ -128,7 +131,6 @@ export default function WithdrawBankPage() {
},
})
const [showKycModal, setShowKycModal] = useState(false)
- const [showPendingModal, setShowPendingModal] = useState(false)
const { setIsSupportModalOpen } = useModalsContext()
// close kyc modal when sumsub sdk opens
@@ -223,8 +225,7 @@ export default function WithdrawBankPage() {
// a dead button, and re-arm the capability poller so we pick up
// bridge's latest status live and the modal auto-dismisses on clear.
if (gate.kind === 'waiting-on-provider') {
- markSubmitted()
- setShowPendingModal(true)
+ pendingModal.open()
return
}
if (gate.kind === 'accept-tos') {
@@ -600,10 +601,9 @@ export default function WithdrawBankPage() {
setShowPendingModal(false)}
+ isOpen={pendingModal.isOpen}
+ onClose={pendingModal.close}
+ message={pendingModal.message}
/>
diff --git a/src/components/Kyc/KycReverificationPendingModal.tsx b/src/components/Kyc/KycReverificationPendingModal.tsx
index 288776144..a9b0e42f2 100644
--- a/src/components/Kyc/KycReverificationPendingModal.tsx
+++ b/src/components/Kyc/KycReverificationPendingModal.tsx
@@ -1,10 +1,11 @@
import { useRouter } from 'next/navigation'
import ActionModal from '@/components/Global/ActionModal'
-import { type IconName } from '@/components/Global/Icons/Icon'
interface KycReverificationPendingModalProps {
isOpen: boolean
onClose: () => void
+ // backend-specific review copy (gate.userMessage); falls back to generic text.
+ message?: string
}
/**
@@ -16,20 +17,20 @@ interface KycReverificationPendingModalProps {
* Bridge finishes (the capability poller — re-armed via markSubmitted when this
* opens — flips the gate away from `waiting-on-provider`).
*/
-export const KycReverificationPendingModal = ({ isOpen, onClose }: KycReverificationPendingModalProps) => {
+export const KycReverificationPendingModal = ({ isOpen, onClose, message }: KycReverificationPendingModalProps) => {
const router = useRouter()
return (
- Your verification is being reviewed — this usually takes a few minutes. We'll let you know as
- soon as you're ready to continue. You can wait here or head home.
+ {message ??
+ "Your verification is being reviewed — this usually takes a few minutes. We'll let you know as soon as you're ready to continue. You can wait here or head home."}
}
ctas={[
diff --git a/src/hooks/__tests__/useWaitingOnProviderModal.test.ts b/src/hooks/__tests__/useWaitingOnProviderModal.test.ts
new file mode 100644
index 000000000..88b58ff36
--- /dev/null
+++ b/src/hooks/__tests__/useWaitingOnProviderModal.test.ts
@@ -0,0 +1,79 @@
+import { act, renderHook } from '@testing-library/react'
+import { useWaitingOnProviderModal } from '@/hooks/useWaitingOnProviderModal'
+import type { GateState } from '@/utils/capability-gate'
+
+const markSubmittedMock = jest.fn()
+jest.mock('@/hooks/useSubmissionWindow', () => ({
+ markSubmitted: () => markSubmittedMock(),
+}))
+
+const waiting: GateState = { kind: 'waiting-on-provider', userMessage: 'Reviewing your proof of address' }
+const ready: GateState = { kind: 'ready' }
+
+beforeEach(() => {
+ jest.useFakeTimers()
+ markSubmittedMock.mockClear()
+})
+afterEach(() => {
+ jest.runOnlyPendingTimers()
+ jest.useRealTimers()
+})
+
+describe('useWaitingOnProviderModal', () => {
+ test('closed until open() is called, even while waiting', () => {
+ const { result } = renderHook(({ gate }) => useWaitingOnProviderModal(gate), {
+ initialProps: { gate: waiting },
+ })
+ expect(result.current.isOpen).toBe(false)
+
+ act(() => result.current.open())
+ expect(result.current.isOpen).toBe(true)
+ expect(markSubmittedMock).toHaveBeenCalledTimes(1)
+ })
+
+ test('surfaces the gate userMessage while open', () => {
+ const { result } = renderHook(({ gate }) => useWaitingOnProviderModal(gate), {
+ initialProps: { gate: waiting },
+ })
+ act(() => result.current.open())
+ expect(result.current.message).toBe('Reviewing your proof of address')
+ })
+
+ test('keeps re-arming the poller on an interval while open', () => {
+ const { result } = renderHook(({ gate }) => useWaitingOnProviderModal(gate), {
+ initialProps: { gate: waiting },
+ })
+ act(() => result.current.open())
+ markSubmittedMock.mockClear()
+
+ act(() => jest.advanceTimersByTime(60_000))
+ // ~20s cadence over 60s → at least a couple more re-arms (poll never lapses)
+ expect(markSubmittedMock.mock.calls.length).toBeGreaterThanOrEqual(2)
+ })
+
+ test('auto-dismisses and drops the flag when the gate resolves', () => {
+ const { result, rerender } = renderHook, { gate: GateState }>(
+ ({ gate }) => useWaitingOnProviderModal(gate),
+ { initialProps: { gate: waiting } }
+ )
+ act(() => result.current.open())
+ expect(result.current.isOpen).toBe(true)
+
+ // bridge finished → gate clears
+ rerender({ gate: ready })
+ expect(result.current.isOpen).toBe(false)
+
+ // a transient re-flip must NOT reopen it (stale-flag guard)
+ rerender({ gate: waiting })
+ expect(result.current.isOpen).toBe(false)
+ })
+
+ test('close() hides the modal', () => {
+ const { result } = renderHook(({ gate }) => useWaitingOnProviderModal(gate), {
+ initialProps: { gate: waiting },
+ })
+ act(() => result.current.open())
+ act(() => result.current.close())
+ expect(result.current.isOpen).toBe(false)
+ })
+})
diff --git a/src/hooks/useWaitingOnProviderModal.ts b/src/hooks/useWaitingOnProviderModal.ts
new file mode 100644
index 000000000..a9cc183be
--- /dev/null
+++ b/src/hooks/useWaitingOnProviderModal.ts
@@ -0,0 +1,50 @@
+import { useCallback, useEffect, useState } from 'react'
+import { markSubmitted } from '@/hooks/useSubmissionWindow'
+import type { GateState } from '@/utils/capability-gate'
+
+// Re-arm cadence: comfortably under SUBMISSION_WINDOW_MS (30s) so the singleton
+// user-poller never lapses while the user waits on the modal.
+const REARM_INTERVAL_MS = 20_000
+
+/**
+ * Drives the "Bridge is re-reviewing, please wait" modal for the
+ * `waiting-on-provider` gate (e.g. right after an EEA uplift, when the user
+ * tries to on/offramp while Bridge re-verifies).
+ *
+ * `waiting-on-provider` rails sit at `requires-info` — NOT `pending` — so the
+ * auto-refresh poller ({@link useUserAutoRefresh}) isn't self-sustaining here:
+ * a single markSubmitted() window (30s) would lapse mid-review and freeze the
+ * modal open forever. So while the modal is open we re-arm the submission
+ * window on an interval, keeping the user query refetching (~4s) until Bridge's
+ * decision flips the gate. `isOpen` is gated on the LIVE gate kind, so the modal
+ * auto-dismisses the moment the wait clears; we also drop the request flag then,
+ * so a later transient re-flip to `waiting-on-provider` can't reopen it on its own.
+ */
+export function useWaitingOnProviderModal(gate: GateState) {
+ const [requested, setRequested] = useState(false)
+ const isWaiting = gate.kind === 'waiting-on-provider'
+ const isOpen = requested && isWaiting
+ const message = isOpen ? ((gate as { userMessage?: string | null }).userMessage ?? undefined) : undefined
+
+ const open = useCallback(() => {
+ markSubmitted() // arm the poller immediately
+ setRequested(true)
+ }, [])
+
+ const close = useCallback(() => setRequested(false), [])
+
+ // drop the flag once the gate resolves, so it can't spuriously reopen.
+ useEffect(() => {
+ if (requested && !isWaiting) setRequested(false)
+ }, [requested, isWaiting])
+
+ // keep the poller alive for the whole wait (the 30s window would otherwise
+ // lapse mid-review, and the modal's auto-dismiss would never fire).
+ useEffect(() => {
+ if (!isOpen) return
+ const id = setInterval(() => markSubmitted(), REARM_INTERVAL_MS)
+ return () => clearInterval(id)
+ }, [isOpen])
+
+ return { isOpen, open, close, message }
+}
From 4bb67af0e3795213b5896897cd444d2604c73223 Mon Sep 17 00:00:00 2001
From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com>
Date: Fri, 3 Jul 2026 12:01:41 +0530
Subject: [PATCH 7/8] chore: remove dead write-only isRiskAccepted state
(coderabbit)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Write-only (declared + set, never read) — the cherry-picked handlers
re-added the setters onto state main had already reduced to a bare
declaration. No behavior change.
---
src/app/(mobile-ui)/add-money/[country]/bank/page.tsx | 3 ---
1 file changed, 3 deletions(-)
diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
index 25400d075..9c99f92cb 100644
--- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
@@ -71,7 +71,6 @@ export default function OnrampBankPage() {
// Local UI state (not URL-appropriate - transient)
const [showWarningModal, setShowWarningModal] = useState(false)
const [showKycModal, setShowKycModal] = useState(false)
- const [isRiskAccepted, setIsRiskAccepted] = useState(false)
const { setError, error, setOnrampData, onrampData } = useOnrampFlow()
const { balance } = useWallet()
@@ -313,7 +312,6 @@ export default function OnrampBankPage() {
}
setShowWarningModal(false)
- setIsRiskAccepted(false)
try {
const onrampDataResponse = await createOnramp({
amount: rawTokenAmount,
@@ -354,7 +352,6 @@ export default function OnrampBankPage() {
const handleWarningCancel = () => {
setShowWarningModal(false)
- setIsRiskAccepted(false)
}
// Redirect to inputAmount if showDetails is accessed without required data (deep link / back navigation)
From ae974e116f66a956dae5d1189b5bc6a1e534ba21 Mon Sep 17 00:00:00 2001
From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com>
Date: Fri, 3 Jul 2026 12:29:40 +0530
Subject: [PATCH 8/8] refactor: narrow GateState union instead of casting
(coderabbit)
Replace the ad-hoc gate casts in useWaitingOnProviderModal (userMessage) and
upliftTriggerFromGate (reason.code) with discriminated-union narrowing, so a
future rename of reason.code / userMessage fails the build instead of
silently returning undefined. No behavior change.
---
src/hooks/useWaitingOnProviderModal.ts | 4 +++-
src/utils/eea-uplift.utils.ts | 4 +++-
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/hooks/useWaitingOnProviderModal.ts b/src/hooks/useWaitingOnProviderModal.ts
index a9cc183be..5637854b9 100644
--- a/src/hooks/useWaitingOnProviderModal.ts
+++ b/src/hooks/useWaitingOnProviderModal.ts
@@ -24,7 +24,9 @@ export function useWaitingOnProviderModal(gate: GateState) {
const [requested, setRequested] = useState(false)
const isWaiting = gate.kind === 'waiting-on-provider'
const isOpen = requested && isWaiting
- const message = isOpen ? ((gate as { userMessage?: string | null }).userMessage ?? undefined) : undefined
+ // narrow on the discriminated union rather than casting, so a rename of
+ // `userMessage` fails the build instead of silently returning undefined.
+ const message = isOpen && gate.kind === 'waiting-on-provider' ? (gate.userMessage ?? undefined) : undefined
const open = useCallback(() => {
markSubmitted() // arm the poller immediately
diff --git a/src/utils/eea-uplift.utils.ts b/src/utils/eea-uplift.utils.ts
index c0281c7c6..4370b9c2a 100644
--- a/src/utils/eea-uplift.utils.ts
+++ b/src/utils/eea-uplift.utils.ts
@@ -46,7 +46,9 @@ export type UpliftStartTrigger = {
* ready-to-fire trigger when it's an uplift remediation, else null.
*/
export function upliftTriggerFromGate(gate: GateState): UpliftStartTrigger | null {
- const code = (gate as { reason?: { code?: string } }).reason?.code
+ // narrow on the union ('reason' is native to the gate variants that carry
+ // it) rather than casting, so a rename of reason.code fails the build.
+ const code = 'reason' in gate ? gate.reason?.code : undefined
if (!code?.startsWith('eea_uplift')) return null
return { requirementKey: code, source: 'blocking' }
}