diff --git a/src/hooks/__tests__/useSumsubKycFlow.test.ts b/src/hooks/__tests__/useSumsubKycFlow.test.ts index 6d660a5ad..c1deedc85 100644 --- a/src/hooks/__tests__/useSumsubKycFlow.test.ts +++ b/src/hooks/__tests__/useSumsubKycFlow.test.ts @@ -230,3 +230,141 @@ describe('useSumsubKycFlow — terminal-error exits clear the user-initiated gua await waitFor(() => expect(onKycSuccess).toHaveBeenCalledTimes(1)) }) }) + +// Incident 2026-07-02: while the verification-progress modal was open, this hook +// fired initiateSumsubKyc — a MUTATING endpoint — on a fixed 5s setInterval for +// the entire modal-open, re-running provider submissions for approved-LATAM +// self-recovery users (86 in 20 min for one user). The poll now backs off on a +// time-escalating schedule (5s for the first minute, then 10s → 20s → 60s) and +// stops entirely after a ~15 min cap. Backoff is time-based, not error-based: +// the poll returns HTTP 200 even when the backend reprocess fails. +describe('useSumsubKycFlow — verification-progress poll backoff', () => { + beforeEach(() => { + jest.useFakeTimers() + mockInitiate.mockReset() + // PENDING is a keep-open status (the transition effect only closes on a + // terminal non-APPROVED state), so the modal stays open across polls and + // every recorded call is attributable to the poll timer. + mockInitiate.mockResolvedValue({ data: { token: null, applicantId: 'poll', status: 'PENDING' } }) + mockWs.handler = undefined + }) + afterEach(() => { + jest.clearAllTimers() + jest.useRealTimers() + }) + + // Open the modal via handleSdkComplete (the SDK-submitted path): it flips + // isVerificationProgressModalOpen without itself calling initiateSumsubKyc. + // regionIntent is left undefined so the mount-time status fetch short-circuits. + const openModal = () => { + const view = renderHook(() => useSumsubKycFlow({})) + act(() => { + view.result.current.handleSdkComplete() + }) + return view + } + + it('escalates the poll cadence 5s → 10s → 20s as the modal stays open', async () => { + openModal() + + // setInterval-parity: nothing fires immediately, the first poll is one delay out. + expect(mockInitiate).toHaveBeenCalledTimes(0) + + // first ~1 min: 5s cadence → 12 polls by t=60s. + await act(async () => { + await jest.advanceTimersByTimeAsync(60_000) + }) + expect(mockInitiate).toHaveBeenCalledTimes(12) + + // the next poll is now 10s out, not 5s: +5s yields nothing, +5s more yields one. + await act(async () => { + await jest.advanceTimersByTimeAsync(5_000) + }) + expect(mockInitiate).toHaveBeenCalledTimes(12) + await act(async () => { + await jest.advanceTimersByTimeAsync(5_000) + }) + expect(mockInitiate).toHaveBeenCalledTimes(13) + + // 60–120s band holds the 10s cadence → 18 polls by t=120s. + await act(async () => { + await jest.advanceTimersByTimeAsync(50_000) + }) + expect(mockInitiate).toHaveBeenCalledTimes(18) + + // 120–180s band escalates to 20s: +19s nothing, +1s one. + await act(async () => { + await jest.advanceTimersByTimeAsync(19_000) + }) + expect(mockInitiate).toHaveBeenCalledTimes(18) + await act(async () => { + await jest.advanceTimersByTimeAsync(1_000) + }) + expect(mockInitiate).toHaveBeenCalledTimes(19) + }) + + it('stops polling entirely after the ~15 min cap', async () => { + openModal() + + await act(async () => { + await jest.advanceTimersByTimeAsync(15 * 60_000) + }) + const countAtCap = mockInitiate.mock.calls.length + // escalation kept this far below a fixed-5s cadence (~180 calls over 15 min). + expect(countAtCap).toBeGreaterThan(0) + expect(countAtCap).toBeLessThan(40) + + // past the cap the chain is not rescheduled — no further calls, ever. + await act(async () => { + await jest.advanceTimersByTimeAsync(30 * 60_000) + }) + expect(mockInitiate).toHaveBeenCalledTimes(countAtCap) + }) + + it('cancels the pending timer when the modal closes', async () => { + const { result } = openModal() + + await act(async () => { + await jest.advanceTimersByTimeAsync(5_000) + }) + expect(mockInitiate).toHaveBeenCalledTimes(1) + + act(() => { + result.current.closeVerificationProgressModal() + }) + + await act(async () => { + await jest.advanceTimersByTimeAsync(60_000) + }) + expect(mockInitiate).toHaveBeenCalledTimes(1) + }) + + it('cancels the pending timer on unmount', async () => { + const { unmount } = openModal() + + await act(async () => { + await jest.advanceTimersByTimeAsync(5_000) + }) + expect(mockInitiate).toHaveBeenCalledTimes(1) + + unmount() + + await act(async () => { + await jest.advanceTimersByTimeAsync(60_000) + }) + expect(mockInitiate).toHaveBeenCalledTimes(1) + }) + + it('polls initiateSumsubKyc with the same region/level/country args as before', async () => { + openModal() + + await act(async () => { + await jest.advanceTimersByTimeAsync(5_000) + }) + expect(mockInitiate).toHaveBeenCalledWith({ + regionIntent: undefined, + levelName: undefined, + targetCountry: undefined, + }) + }) +}) diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 1c77a60c5..99e2e89a2 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -18,6 +18,32 @@ interface UseSumsubKycFlowOptions { regionIntent?: KYCRegionIntent } +// Time-escalating schedule for the verification-progress-modal status poll. +// initiateSumsubKyc is a MUTATING endpoint — for approved-LATAM users in the +// self-recovery state each call re-runs a full provider submission. A fixed 5s +// interval hammered it for the entire modal-open (incident 2026-07-02: 86 +// re-submissions in 20 min for one user). We keep the fast 5s cadence only for +// the first minute (the common quick transition), then back off. The backoff is +// purely time-based, NOT error-based: the poll returns HTTP 200 even when the +// backend reprocess fails, so an error count would never escalate. +const KYC_POLL_SCHEDULE: ReadonlyArray<{ untilMs: number; delayMs: number }> = [ + { untilMs: 60_000, delayMs: 5_000 }, // first ~1 min: fast path for the common quick transition + { untilMs: 120_000, delayMs: 10_000 }, + { untilMs: 180_000, delayMs: 20_000 }, +] +const KYC_POLL_MAX_DELAY_MS = 60_000 +// Stop polling entirely after this long per modal-open. The modal stays in its +// long-running "Almost there" state and the websocket remains the only signal; +// re-opening the modal restarts polling fresh. +const KYC_POLL_CAP_MS = 15 * 60_000 + +const getKycPollDelayMs = (elapsedMs: number): number => { + for (const { untilMs, delayMs } of KYC_POLL_SCHEDULE) { + if (elapsedMs < untilMs) return delayMs + } + return KYC_POLL_MAX_DELAY_MS +} + export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: UseSumsubKycFlowOptions = {}) => { const { user } = useUserStore() const router = useRouter() @@ -118,12 +144,20 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: fetchCurrentStatus() }, [regionIntent]) - // polling fallback for missed websocket events. - // when the verification progress modal is open, poll status every 5s - // so the flow can transition even if the websocket event never arrives. + // polling fallback for missed websocket events. while the verification + // progress modal is open, re-check status on a time-escalating schedule + // (KYC_POLL_SCHEDULE) so the flow can transition even if the websocket event + // never arrives — without hammering the mutating initiate endpoint. A + // self-rescheduling setTimeout chain (rather than a fixed setInterval) lets + // the delay grow as the modal stays open; polling stops entirely once the + // ~15 min cap is reached, leaving the modal in its long-running state. useEffect(() => { if (!isVerificationProgressModalOpen) return + const startedAt = Date.now() + let timeoutId: ReturnType + let cancelled = false + const pollStatus = async () => { try { const response = await initiateSumsubKyc({ @@ -139,8 +173,25 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: } } - const interval = setInterval(pollStatus, 5000) - return () => clearInterval(interval) + const scheduleNext = () => { + const elapsed = Date.now() - startedAt + // active-polling cap reached — stop rescheduling; the websocket is the + // only signal from here, and re-opening the modal restarts polling. + if (elapsed >= KYC_POLL_CAP_MS) return + timeoutId = setTimeout(async () => { + await pollStatus() + // the modal may have closed (cleanup ran) while the poll was in + // flight — don't re-arm a timer after teardown. + if (cancelled) return + scheduleNext() + }, getKycPollDelayMs(elapsed)) + } + + scheduleNext() + return () => { + cancelled = true + clearTimeout(timeoutId) + } }, [isVerificationProgressModalOpen]) const handleInitiateKyc = useCallback(