From 7671c5f2b5f400e4586601b21e6b7f8b95a49810 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 2 Jul 2026 13:58:19 -0700 Subject: [PATCH 1/2] fix(kyc): back off verification-progress poll instead of a fixed 5s interval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verification-progress modal polled initiateSumsubKyc — a mutating endpoint — every 5s for the whole modal-open as a websocket fallback. For approved-LATAM users in the self-recovery state each call re-runs a full provider submission (86 in 20 min for one user, 2026-07-02); even with the BE cooldown each poll still costs ~3 provider calls + DB writes. Replace the fixed setInterval with a self-rescheduling setTimeout chain on a time-escalating schedule (5s for the first minute, then 10s → 20s → 60s) and stop polling after a ~15 min cap. The backoff is time-based, not error-based: the poll returns HTTP 200 even when the backend reprocess fails, so an error count would never escalate. The websocket stays the primary signal and the modal keeps its existing long-running state after the cap; re-opening restarts polling fresh. --- src/hooks/__tests__/useSumsubKycFlow.test.ts | 138 +++++++++++++++++++ src/hooks/useSumsubKycFlow.ts | 61 +++++++- 2 files changed, 194 insertions(+), 5 deletions(-) diff --git a/src/hooks/__tests__/useSumsubKycFlow.test.ts b/src/hooks/__tests__/useSumsubKycFlow.test.ts index 6d660a5ad..9b1be2ffc 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 { result, 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( From 74e7c73358975f485c1efe0a2137ecde27ee2d91 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 2 Jul 2026 14:00:09 -0700 Subject: [PATCH 2/2] test(kyc): drop unused renderHook binding (eslint no-unused-vars) --- src/hooks/__tests__/useSumsubKycFlow.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/__tests__/useSumsubKycFlow.test.ts b/src/hooks/__tests__/useSumsubKycFlow.test.ts index 9b1be2ffc..c1deedc85 100644 --- a/src/hooks/__tests__/useSumsubKycFlow.test.ts +++ b/src/hooks/__tests__/useSumsubKycFlow.test.ts @@ -340,7 +340,7 @@ describe('useSumsubKycFlow — verification-progress poll backoff', () => { }) it('cancels the pending timer on unmount', async () => { - const { result, unmount } = openModal() + const { unmount } = openModal() await act(async () => { await jest.advanceTimersByTimeAsync(5_000)