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..9c99f92cb 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'
@@ -33,10 +33,13 @@ 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 { useWaitingOnProviderModal } from '@/hooks/useWaitingOnProviderModal'
import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal'
import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt'
import { useEeaUpliftFunnel } from '@/hooks/useEeaUpliftFunnel'
+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'
@@ -68,12 +71,11 @@ 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()
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.
@@ -131,6 +133,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.
@@ -141,9 +146,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.
+ // note: 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)
},
})
@@ -254,14 +260,25 @@ 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 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') {
+ pendingModal.open()
+ return
+ }
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 upliftTrigger = upliftTriggerFromGate(gate)
+ if (upliftTrigger) trackUpliftStarted(upliftTrigger)
setShowKycModal(true)
}
return
@@ -271,6 +288,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
+ // the funnel event as it opens.
+ const advisoryTrigger = upliftTriggerFromAdvisory(advisory)
+ if (advisoryTrigger) trackUpliftStarted(advisoryTrigger)
advisoryIntercept(() => {
posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, {
amount_usd: usdEquivalent,
@@ -291,7 +312,6 @@ export default function OnrampBankPage() {
}
setShowWarningModal(false)
- setIsRiskAccepted(false)
try {
const onrampDataResponse = await createOnramp({
amount: rawTokenAmount,
@@ -314,23 +334,24 @@ export default function OnrampBankPage() {
}
} catch (error) {
setShowWarningModal(false)
- const errorMessage = error instanceof Error ? error.message : 'Unknown error'
+ 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 — createOnramp carries the specific
+ // reason on the thrown Error, so we don't read any hook state here.
+ setError({
+ showError: true,
+ errorMessage,
})
- if (onrampError) {
- setError({
- showError: true,
- errorMessage: onrampError,
- })
- }
}
}
const handleWarningCancel = () => {
setShowWarningModal(false)
- setIsRiskAccepted(false)
}
// Redirect to inputAmount if showDetails is accessed without required data (deep link / back navigation)
@@ -454,7 +475,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()
@@ -471,6 +497,7 @@ export default function OnrampBankPage() {
}}
onContactSupport={() => {
setShowKycModal(false)
+ resetUpliftFunnel()
setIsSupportModalOpen(true)
}}
isLoading={sumsubFlow.isLoading}
@@ -482,6 +509,12 @@ export default function OnrampBankPage() {
+
+
) {
// 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({
@@ -898,7 +921,6 @@ function applyDefaults() {
mockUseCreateOnramp.mockReturnValue({
createOnramp: jest.fn(),
isLoading: false,
- error: null,
})
mockUseLimitsValidation.mockReturnValue({
@@ -1286,7 +1308,6 @@ describe('GROUP 5: Bridge Bank Onramp', () => {
mockUseCreateOnramp.mockReturnValue({
createOnramp: mockCreateOnramp,
isLoading: false,
- error: null,
})
resetQueryState({ step: 'inputAmount', amount: '100' })
@@ -1308,10 +1329,11 @@ describe('GROUP 5: Bridge Bank Onramp', () => {
test('onramp error displays ErrorAlert', async () => {
const mockCreateOnramp = jest.fn().mockRejectedValue(new Error('Service unavailable'))
+ // 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: 'Service unavailable',
})
resetQueryState({ step: 'inputAmount', amount: '100' })
@@ -1327,8 +1349,31 @@ 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 the reverification pending modal instead of a dead button', async () => {
+ setGate('waiting-on-provider')
+ const mockCreateOnramp = jest.fn()
+ mockUseCreateOnramp.mockReturnValue({
+ createOnramp: mockCreateOnramp,
+ isLoading: false,
+ })
+ resetQueryState({ step: 'inputAmount', amount: '100' })
+
+ renderWithProviders()
+
+ await act(async () => {
+ fireEvent.click(screen.getByText('Continue'))
+ })
+
+ // no doomed transfer attempt; the user sees the bridge-review pending modal
+ expect(mockCreateOnramp).not.toHaveBeenCalled()
+ 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 060ddb02c..8dde7460d 100644
--- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
@@ -29,10 +29,13 @@ 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 { useWaitingOnProviderModal } from '@/hooks/useWaitingOnProviderModal'
import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal'
import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt'
import { useEeaUpliftFunnel } from '@/hooks/useEeaUpliftFunnel'
+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'
@@ -90,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 {
@@ -117,9 +123,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.
+ // 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()
- trackUpliftStarted(advisory)
return sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey)
},
})
@@ -211,12 +218,23 @@ 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') {
+ pendingModal.open()
+ return
+ }
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 upliftTrigger = upliftTriggerFromGate(gate)
+ if (upliftTrigger) trackUpliftStarted(upliftTrigger)
setShowKycModal(true)
}
return
@@ -346,7 +364,13 @@ 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 = () => {
+ const advisoryTrigger = upliftTriggerFromAdvisory(advisory)
+ if (advisoryTrigger) trackUpliftStarted(advisoryTrigger)
+ advisoryIntercept(() => void proceedWithOfframp())
+ }
const countryCodeForFlag = () => {
if (!bankAccount?.details?.countryCode) return ''
@@ -543,7 +567,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()
@@ -560,6 +589,7 @@ export default function WithdrawBankPage() {
}}
onContactSupport={() => {
setShowKycModal(false)
+ resetUpliftFunnel()
setIsSupportModalOpen(true)
}}
isLoading={sumsubFlow.isLoading}
@@ -569,6 +599,12 @@ export default function WithdrawBankPage() {
regionName={getCountryFromPath(country)?.title}
/>
+
+
)
diff --git a/src/components/Kyc/KycReverificationPendingModal.tsx b/src/components/Kyc/KycReverificationPendingModal.tsx
new file mode 100644
index 000000000..a9b0e42f2
--- /dev/null
+++ b/src/components/Kyc/KycReverificationPendingModal.tsx
@@ -0,0 +1,50 @@
+import { useRouter } from 'next/navigation'
+import ActionModal from '@/components/Global/ActionModal'
+
+interface KycReverificationPendingModalProps {
+ isOpen: boolean
+ onClose: () => void
+ // backend-specific review copy (gate.userMessage); falls back to generic text.
+ message?: string
+}
+
+/**
+ * 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, message }: KycReverificationPendingModalProps) => {
+ const router = useRouter()
+
+ return (
+
+ {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={[
+ {
+ text: 'Go to Home',
+ onClick: () => {
+ onClose()
+ router.push('/home')
+ },
+ variant: 'purple',
+ className: 'w-full',
+ shadowSize: '4',
+ },
+ ]}
+ />
+ )
+}
diff --git a/src/hooks/__tests__/useCreateOnramp.test.ts b/src/hooks/__tests__/useCreateOnramp.test.ts
new file mode 100644
index 000000000..f9b3a46a2
--- /dev/null
+++ b/src/hooks/__tests__/useCreateOnramp.test.ts
@@ -0,0 +1,89 @@
+import { renderHook, act } from '@testing-library/react'
+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
+// 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('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())
+
+ await act(async () => {
+ await expect(result.current.createOnramp({ amount: '100', country })).rejects.toThrow(
+ 'Could not create transfer: customer is under review'
+ )
+ })
+ })
+
+ // 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')
+ },
+ })
+ const { result } = renderHook(() => useCreateOnramp())
+
+ await act(async () => {
+ await expect(result.current.createOnramp({ amount: '100', country })).rejects.toThrow(GENERIC_ONRAMP_ERROR)
+ })
+ })
+
+ it('returns the onramp data on success', async () => {
+ const data = { transferId: 't-1', depositInstructions: { amount: '100', currency: 'eur' } }
+ apiFetchMock.mockResolvedValue({ ok: true, status: 200, 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)
+ })
+})
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/useCreateOnramp.ts b/src/hooks/useCreateOnramp.ts
index ef3836f1b..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,19 +64,22 @@ 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.'
- 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)
}
@@ -82,6 +90,5 @@ export const useCreateOnramp = (): UseCreateOnrampReturn => {
return {
createOnramp,
isLoading,
- error,
}
}
diff --git a/src/hooks/useEeaUpliftFunnel.test.ts b/src/hooks/useEeaUpliftFunnel.test.ts
index f6699601e..5c9edff91 100644
--- a/src/hooks/useEeaUpliftFunnel.test.ts
+++ b/src/hooks/useEeaUpliftFunnel.test.ts
@@ -1,46 +1,87 @@
import { act, renderHook } from '@testing-library/react'
import posthog from 'posthog-js'
import { useEeaUpliftFunnel } from './useEeaUpliftFunnel'
+import type { UpliftStartTrigger } from '@/utils/eea-uplift.utils'
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('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(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 +93,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 +103,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..ef35b9f1a 100644
--- a/src/hooks/useEeaUpliftFunnel.ts
+++ b/src/hooks/useEeaUpliftFunnel.ts
@@ -1,54 +1,65 @@
import { useCallback, useRef } from 'react'
import posthog from 'posthog-js'
import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
-import type { GateAdvisory } from '@/utils/capability-gate'
+import type { UpliftStartTrigger } from '@/utils/eea-uplift.utils'
type UpliftChannel = 'deposit' | 'withdraw'
+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
+}
+
/**
* 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.
+ *
+ * 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. `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
+ * `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.
+ * 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`.
+ * `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 startedRef = useRef(null)
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) => {
+ // 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, upliftEventProps(channel, trigger))
},
[channel]
)
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,
- })
+ posthog.capture(ANALYTICS_EVENTS.EEA_UPLIFT_COMPLETED, upliftEventProps(channel, trigger))
}, [channel])
const reset = useCallback(() => {
diff --git a/src/hooks/useWaitingOnProviderModal.ts b/src/hooks/useWaitingOnProviderModal.ts
new file mode 100644
index 000000000..5637854b9
--- /dev/null
+++ b/src/hooks/useWaitingOnProviderModal.ts
@@ -0,0 +1,52 @@
+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
+ // 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
+ 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 }
+}
diff --git a/src/utils/eea-uplift.utils.test.ts b/src/utils/eea-uplift.utils.test.ts
new file mode 100644
index 000000000..79adfdace
--- /dev/null
+++ b/src/utils/eea-uplift.utils.test.ts
@@ -0,0 +1,58 @@
+import { upliftTriggerFromGate, upliftTriggerFromAdvisory, EEA_UPLIFT_REQUIREMENT_KEYS } from './eea-uplift.utils'
+import type { GateState, GateAdvisory } from '@/utils/capability-gate'
+
+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(upliftTriggerFromGate(gate as GateState)).toEqual({ requirementKey: 'eea_uplift', source: 'blocking' })
+ })
+
+ test('matches the eea_uplift_with_tin variant', () => {
+ const gate = {
+ kind: 'fixable-rejection',
+ userMessage: 'x',
+ reason: { code: 'eea_uplift_with_tin', userMessage: 'x' },
+ }
+ expect(upliftTriggerFromGate(gate as GateState)?.requirementKey).toBe('eea_uplift_with_tin')
+ })
+
+ test('returns null for a non-uplift rejection', () => {
+ const gate = {
+ kind: 'fixable-rejection',
+ userMessage: 'x',
+ reason: { code: 'document_rejected', userMessage: 'x' },
+ }
+ expect(upliftTriggerFromGate(gate as GateState)).toBeNull()
+ })
+
+ 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('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(upliftTriggerFromAdvisory(advisory)).toEqual({
+ requirementKey,
+ actionKey: 'k',
+ effectiveDate: '2026-12-31',
+ source: 'advisory',
+ })
+ })
+
+ 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(upliftTriggerFromAdvisory(advisory)).toBeNull()
+ })
+
+ test('is safe for undefined / keyless advisory', () => {
+ 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
new file mode 100644
index 000000000..4370b9c2a
--- /dev/null
+++ b/src/utils/eea-uplift.utils.ts
@@ -0,0 +1,68 @@
+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.
+ *
+ * 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',
+ 'has_foreign_tax_registration',
+ 'place_of_birth_missing',
+ 'nationalities',
+])
+
+/**
+ * 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 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 {
+ // 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' }
+}
+
+/**
+ * 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',
+ }
+}