From 2bb2dd8063db23fc51ebe6c777c1ed2e56ec8b52 Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Thu, 2 Jul 2026 12:33:37 +0530 Subject: [PATCH 1/7] refactor(assets): name the mascot a mascot, not a logo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PEANUTMAN_LOGO was the peanut-character illustration mislabeled as a "logo" — distinct from the app icon (peanut-icon.svg) and the PEANUT wordmark (PEANUT_LOGO). The name described where it was first used (a header), not what it depicts. - Rename export PEANUTMAN_LOGO -> PEANUTMAN and file mascot/peanut-logo.svg -> mascot/peanutman.svg; update all call sites, the local ASSET_ var, and the mascot alt text ("logo" -> "Peanut mascot"). - Move PEANUT_LOGO_BLACK out of illustrations/ into logos/ (it's the brand logo); fix the one subpath import. No behavior change — same pixels, accurate names. public/peanutman-logo.svg is retained unchanged as the external email/embed alias. Co-authored-by: kushagrasarathe --- src/app/(mobile-ui)/notifications/page.tsx | 4 ++-- .../(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx | 2 +- src/app/dev/loading-words/page.tsx | 4 ++-- src/assets/illustrations/index.ts | 1 - src/assets/logos/index.ts | 1 + src/assets/{illustrations => logos}/peanut-logo-dark.svg | 0 src/assets/mascot/index.ts | 2 +- src/assets/mascot/{peanut-logo.svg => peanutman.svg} | 0 src/components/Badges/badge.utils.ts | 4 ++-- src/components/Card/CardFace.tsx | 4 ++-- src/components/Card/share-asset/PixelatedCardFace.tsx | 8 ++++---- src/components/Claim/Link/Initial.view.tsx | 4 ++-- src/components/Claim/Link/SendLinkActionList.tsx | 6 +++--- src/components/Global/ConfirmInviteModal/index.tsx | 4 ++-- src/components/Global/CreateAccountButton/index.tsx | 4 ++-- src/components/Global/NoMoreJailModal/index.tsx | 4 ++-- src/components/Global/PeanutLoading/CyclingLoading.tsx | 4 ++-- src/components/Global/PeanutLoading/index.tsx | 4 ++-- src/components/Global/QRScanner/index.tsx | 4 ++-- src/components/Profile/components/PublicProfile.tsx | 4 ++-- src/components/TransactionDetails/TransactionCard.tsx | 4 ++-- .../TransactionDetails/TransactionDetailsHeaderCard.tsx | 4 ++-- .../payments/shared/components/SendWithPeanutCta.tsx | 4 ++-- 23 files changed, 40 insertions(+), 40 deletions(-) rename src/assets/{illustrations => logos}/peanut-logo-dark.svg (100%) rename src/assets/mascot/{peanut-logo.svg => peanutman.svg} (100%) diff --git a/src/app/(mobile-ui)/notifications/page.tsx b/src/app/(mobile-ui)/notifications/page.tsx index c64d7400d..a471b20eb 100644 --- a/src/app/(mobile-ui)/notifications/page.tsx +++ b/src/app/(mobile-ui)/notifications/page.tsx @@ -9,7 +9,7 @@ import { notificationsApi, type InAppItem } from '@/services/notifications' import { formatGroupHeaderDate, getDateGroup, getDateGroupKey } from '@/utils/dateGrouping.utils' import React, { useEffect, useMemo, useRef, useState } from 'react' import Image from 'next/image' -import { PEANUTMAN_LOGO } from '@/assets' +import { PEANUTMAN } from '@/assets' import Link from 'next/link' import EmptyState from '@/components/Global/EmptyStates/EmptyState' import { Button } from '@/components/0_Bruddle/Button' @@ -172,7 +172,7 @@ export default function NotificationsPage() { onClick={() => handleNotificationClick(notif.id)} > icon ({ // The page imports PeanutThinking from @/assets/mascot and STAR_STRAIGHT_ICON // from @/assets/icons directly — mock those paths, not the @/assets barrel, and -// keep sibling exports (e.g. PEANUTMAN_LOGO, ETHEREUM_ICON used by QRScanner) intact. +// keep sibling exports (e.g. PEANUTMAN, ETHEREUM_ICON used by QRScanner) intact. jest.mock('@/assets/mascot', () => ({ ...jest.requireActual('@/assets/mascot'), PeanutThinking: '/peanut-guy.gif', diff --git a/src/app/dev/loading-words/page.tsx b/src/app/dev/loading-words/page.tsx index 3f0ae1b91..89a649728 100644 --- a/src/app/dev/loading-words/page.tsx +++ b/src/app/dev/loading-words/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' import { PAYMENT_LOADING_WORDS } from '@/components/Global/PeanutLoading/words' import { useEffect, useState } from 'react' @@ -41,7 +41,7 @@ export default function LoadingWordsPreview() {
- Peanut + Peanut
diff --git a/src/assets/illustrations/index.ts b/src/assets/illustrations/index.ts index 28f5a8537..9e711b84f 100644 --- a/src/assets/illustrations/index.ts +++ b/src/assets/illustrations/index.ts @@ -8,7 +8,6 @@ export { default as HandThumbsUpV2 } from './hand-thumbs-up-v2.svg' export { default as HandToken } from './new-hand-token.svg' export { default as AboutPeanut } from './new-hero-description.svg' //export { default as PeanutArmHoldingBeer } from './peanut-arm-holding-beer.svg' -export { default as PEANUT_LOGO_BLACK } from './peanut-logo-dark.svg' export { default as PeanutsBG } from './peanuts-bg.svg' export { default as Sparkle } from './sparkle.svg' export { default as Star } from './star.svg' diff --git a/src/assets/logos/index.ts b/src/assets/logos/index.ts index 878328846..4cb16fce1 100644 --- a/src/assets/logos/index.ts +++ b/src/assets/logos/index.ts @@ -1,4 +1,5 @@ export { default as PEANUT_LOGO } from './peanut-logo.svg'; +export { default as PEANUT_LOGO_BLACK } from './peanut-logo-dark.svg'; export { default as MEPA_ARGENTINA_LOGO } from './mepa-arg.svg'; export { default as PIX_BRZ_LOGO } from './pix-brz.svg'; export { default as DEVCONNECT_LOGO } from './devconnect.svg'; \ No newline at end of file diff --git a/src/assets/illustrations/peanut-logo-dark.svg b/src/assets/logos/peanut-logo-dark.svg similarity index 100% rename from src/assets/illustrations/peanut-logo-dark.svg rename to src/assets/logos/peanut-logo-dark.svg diff --git a/src/assets/mascot/index.ts b/src/assets/mascot/index.ts index 6092e03b1..e11d769ba 100644 --- a/src/assets/mascot/index.ts +++ b/src/assets/mascot/index.ts @@ -15,7 +15,7 @@ export { default as PeanutWalking } from './peanut-walking.webp' // mid-stride, export { default as PeanutPointingDown } from './peanut-pointing-down.webp' // both hands pointing down — marketing CTA // Stills -export { default as PEANUTMAN_LOGO } from './peanut-logo.svg' +export { default as PEANUTMAN } from './peanutman.svg' export { default as PEANUTMAN_PFP } from './peanut-pfp.svg' export { default as PEANUTMAN_HOLDING_BEER } from './peanut-beer.svg' export { default as PEANUTMAN_MOBILE } from './peanut-club.webp' diff --git a/src/assets/mascot/peanut-logo.svg b/src/assets/mascot/peanutman.svg similarity index 100% rename from src/assets/mascot/peanut-logo.svg rename to src/assets/mascot/peanutman.svg diff --git a/src/components/Badges/badge.utils.ts b/src/components/Badges/badge.utils.ts index a8e442e02..5bfef6423 100644 --- a/src/components/Badges/badge.utils.ts +++ b/src/components/Badges/badge.utils.ts @@ -9,7 +9,7 @@ // Codes here must match the backend registry (peanut-api-ts // src/acknowledgments/seed-definitions.ts). How the whole system works + how to // add a badge: peanut-api-ts/docs/BADGES.md. -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' export type BadgeMeta = { path: string @@ -236,7 +236,7 @@ export const BADGE_CODES: readonly string[] = Object.keys(BADGES) export function getBadgeIcon(code?: string): string { // .src: the svg import is StaticImageData (typed `any` by the module shim, so the // annotation alone can't enforce this) — raw consumers need a string URL. - return (code && BADGES[code]?.path) || PEANUTMAN_LOGO.src + return (code && BADGES[code]?.path) || PEANUTMAN.src } // returns the public-facing description for a badge code (third-person perspective) diff --git a/src/components/Card/CardFace.tsx b/src/components/Card/CardFace.tsx index 14bff2756..8577681ac 100644 --- a/src/components/Card/CardFace.tsx +++ b/src/components/Card/CardFace.tsx @@ -4,7 +4,7 @@ import Image from 'next/image' import { twMerge } from 'tailwind-merge' import { Icon } from '@/components/Global/Icons/Icon' import { PEANUT_CARD_HAND, VISA_BRAND_MARK } from '@/assets/cards' -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' export interface RevealedCardDetails { pan: string @@ -86,7 +86,7 @@ const CardFace: FC = ({
{/* Top row: peanut icon (no wordmark) + Visa */}
- + Visa
diff --git a/src/components/Card/share-asset/PixelatedCardFace.tsx b/src/components/Card/share-asset/PixelatedCardFace.tsx index f8f0216d5..c8af37514 100644 --- a/src/components/Card/share-asset/PixelatedCardFace.tsx +++ b/src/components/Card/share-asset/PixelatedCardFace.tsx @@ -23,11 +23,11 @@ import { type FC, type CSSProperties } from 'react' import { CARD_W, CARD_H } from './shareAssetLayout' -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' import PEANUT_CARD_HAND_PIXEL_ASSET from '@/assets/cards/peanut-card-hand-pixel.png' import VISA_BRAND_MARK_ASSET from '@/assets/cards/visa-brand-mark.png' -const ASSET_PEANUTMAN_LOGO = PEANUTMAN_LOGO.src +const ASSET_PEANUTMAN = PEANUTMAN.src const ASSET_VISA_BRAND = VISA_BRAND_MARK_ASSET.src const ASSET_CARD_HAND_PIXEL = PEANUT_CARD_HAND_PIXEL_ASSET.src @@ -93,9 +93,9 @@ export const PixelatedCardFace: FC = ({ style={{ top: 24, left: 28, right: 28, zIndex: 2 }} > {blurAll ? ( - + ) : ( - + )} {!hideVisa && (blurAll ? ( diff --git a/src/components/Claim/Link/Initial.view.tsx b/src/components/Claim/Link/Initial.view.tsx index 970a57d1e..2e0e699a3 100644 --- a/src/components/Claim/Link/Initial.view.tsx +++ b/src/components/Claim/Link/Initial.view.tsx @@ -42,7 +42,7 @@ import { evmChainIdToRhinoName } from '@/constants/rhino.consts' import { getTokenSymbol } from '@/utils/general.utils' import { Button } from '@/components/0_Bruddle/Button' import Image from 'next/image' -import { PEANUT_LOGO_BLACK, PEANUTMAN_LOGO } from '@/assets' +import { PEANUT_LOGO_BLACK, PEANUTMAN } from '@/assets' import { GuestVerificationModal } from '@/components/Global/GuestVerificationModal' import { useCapabilities } from '@/hooks/useCapabilities' import MantecaFlowManager from './MantecaFlowManager' @@ -822,7 +822,7 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => {
Receive on
- Peanut Logo + Peanut Logo Peanut Logo
diff --git a/src/components/Claim/Link/SendLinkActionList.tsx b/src/components/Claim/Link/SendLinkActionList.tsx index 1cf1bae36..3fa6d8754 100644 --- a/src/components/Claim/Link/SendLinkActionList.tsx +++ b/src/components/Claim/Link/SendLinkActionList.tsx @@ -25,10 +25,10 @@ import { useContext, useMemo, useState } from 'react' import ActionModal from '@/components/Global/ActionModal' import Divider from '../../0_Bruddle/Divider' import { Button } from '@/components/0_Bruddle/Button' -import { PEANUT_LOGO_BLACK } from '@/assets/illustrations' +import { PEANUT_LOGO_BLACK } from '@/assets/logos' import Image from 'next/image' import { useRouter } from 'next/navigation' -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' import { BankClaimType, useDetermineBankClaimType } from '@/hooks/useDetermineBankClaimType' import useSavedAccounts from '@/hooks/useSavedAccounts' import { tokenSelectorContext } from '@/context' @@ -243,7 +243,7 @@ export default function SendLinkActionList({ > {showDevconnectMethod ?
Claim on
:
Continue with
}
- Peanut Logo + Peanut Logo Peanut Logo
diff --git a/src/components/Global/ConfirmInviteModal/index.tsx b/src/components/Global/ConfirmInviteModal/index.tsx index a9bc915fb..8a811a8ae 100644 --- a/src/components/Global/ConfirmInviteModal/index.tsx +++ b/src/components/Global/ConfirmInviteModal/index.tsx @@ -1,7 +1,7 @@ 'use client' import { type FC } from 'react' import Image from 'next/image' -import { PEANUT_LOGO_BLACK, PEANUTMAN_LOGO } from '@/assets' +import { PEANUT_LOGO_BLACK, PEANUTMAN } from '@/assets' import Modal from '../Modal' import { Button } from '@/components/0_Bruddle/Button' import { PeanutWavingHello } from '@/assets/mascot' @@ -52,7 +52,7 @@ const ConfirmInviteModal: FC = ({ diff --git a/src/components/Global/CreateAccountButton/index.tsx b/src/components/Global/CreateAccountButton/index.tsx index 7cd3e0c48..fb3e2d4dd 100644 --- a/src/components/Global/CreateAccountButton/index.tsx +++ b/src/components/Global/CreateAccountButton/index.tsx @@ -1,6 +1,6 @@ 'use client' -import { PEANUT_LOGO_BLACK, PEANUTMAN_LOGO } from '@/assets' +import { PEANUT_LOGO_BLACK, PEANUTMAN } from '@/assets' import { Button } from '@/components/0_Bruddle/Button' import Image from 'next/image' @@ -13,7 +13,7 @@ const CreateAccountButton = ({ onClick }: CreateAccountButtonProps) => { diff --git a/src/components/Global/PeanutLoading/CyclingLoading.tsx b/src/components/Global/PeanutLoading/CyclingLoading.tsx index f5e63cc25..3595a741a 100644 --- a/src/components/Global/PeanutLoading/CyclingLoading.tsx +++ b/src/components/Global/PeanutLoading/CyclingLoading.tsx @@ -1,6 +1,6 @@ 'use client' -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' import { useEffect, useState } from 'react' import { PAYMENT_LOADING_WORDS } from './words' @@ -26,7 +26,7 @@ export default function CyclingLoading() {
- logo + logo {word}
diff --git a/src/components/Global/PeanutLoading/index.tsx b/src/components/Global/PeanutLoading/index.tsx index 6d4a2d02f..a2d566fd5 100644 --- a/src/components/Global/PeanutLoading/index.tsx +++ b/src/components/Global/PeanutLoading/index.tsx @@ -1,4 +1,4 @@ -import { PEANUTMAN_LOGO } from '@/assets' +import { PEANUTMAN } from '@/assets' import { twMerge } from 'tailwind-merge' export default function PeanutLoading({ @@ -18,7 +18,7 @@ export default function PeanutLoading({ )} >
- logo + Peanut mascot {message ?? 'Loading...'}
diff --git a/src/components/Global/QRScanner/index.tsx b/src/components/Global/QRScanner/index.tsx index cfb9ee688..816b9fd46 100644 --- a/src/components/Global/QRScanner/index.tsx +++ b/src/components/Global/QRScanner/index.tsx @@ -1,7 +1,7 @@ import { createPortal } from 'react-dom' import { Button } from '@/components/0_Bruddle/Button' import { MERCADO_PAGO, PIX } from '@/assets/payment-apps' -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' import { ETHEREUM_ICON } from '@/assets/icons' import Image from 'next/image' import { Icon } from '../Icons/Icon' @@ -14,7 +14,7 @@ import CameraPermissionModal from './CameraPermissionModal' // ============================================================================ const PAYMENT_METHODS = [ - { src: PEANUTMAN_LOGO, alt: 'Peanut', name: 'Peanut' }, + { src: PEANUTMAN, alt: 'Peanut', name: 'Peanut' }, { src: MERCADO_PAGO, alt: 'Mercado Pago', name: 'Mercado Pago' }, { src: PIX, alt: 'PIX', name: 'PIX' }, { src: ETHEREUM_ICON, alt: 'Ethereum and EVMs', name: 'ETH & EVMs' }, diff --git a/src/components/Profile/components/PublicProfile.tsx b/src/components/Profile/components/PublicProfile.tsx index 9578ebeba..72b33dfdb 100644 --- a/src/components/Profile/components/PublicProfile.tsx +++ b/src/components/Profile/components/PublicProfile.tsx @@ -1,6 +1,6 @@ 'use client' -import { HandThumbsUpV2, PEANUT_LOGO_BLACK, PEANUTMAN_LOGO } from '@/assets' +import { HandThumbsUpV2, PEANUT_LOGO_BLACK, PEANUTMAN } from '@/assets' import { Button } from '@/components/0_Bruddle/Button' import { Icon } from '@/components/Global/Icons/Icon' import NavHeader from '@/components/Global/NavHeader' @@ -76,7 +76,7 @@ const PublicProfile: React.FC = ({ username, isLoggedIn = fa
{!isLoggedIn ? (
- Peanut Logo + Peanut mascot Peanut Text
) : ( diff --git a/src/components/TransactionDetails/TransactionCard.tsx b/src/components/TransactionDetails/TransactionCard.tsx index c2a14631a..03881c27f 100644 --- a/src/components/TransactionDetails/TransactionCard.tsx +++ b/src/components/TransactionDetails/TransactionCard.tsx @@ -26,7 +26,7 @@ import { VerifiedUserLabel } from '../UserHeader' import { PerkIcon } from './PerkIcon' import { useHaptic } from 'use-haptic' import LazyLoadErrorBoundary from '@/components/Global/LazyLoadErrorBoundary' -import { PEANUTMAN_LOGO } from '@/assets/mascot' +import { PEANUTMAN } from '@/assets/mascot' import InvitesIcon from '../Home/InvitesIcon' // Lazy load transaction details drawer (~40KB) to reduce initial bundle size @@ -167,7 +167,7 @@ const TransactionCard: React.FC = ({ {isTestTransaction ? (
Peanut Logo
- Peanut Logo + Peanut Logo

Enjoy Peanut!

diff --git a/src/features/payments/shared/components/SendWithPeanutCta.tsx b/src/features/payments/shared/components/SendWithPeanutCta.tsx index cac78d970..7fb188424 100644 --- a/src/features/payments/shared/components/SendWithPeanutCta.tsx +++ b/src/features/payments/shared/components/SendWithPeanutCta.tsx @@ -8,7 +8,7 @@ * - logged in: "send with peanut" + executes payment */ -import { PEANUT_LOGO_BLACK, PEANUTMAN_LOGO } from '@/assets' +import { PEANUT_LOGO_BLACK, PEANUTMAN } from '@/assets' import { Button, type ButtonProps } from '@/components/0_Bruddle/Button' import type { IconName } from '@/components/Global/Icons/Icon' import { useAuth } from '@/context/authContext' @@ -100,7 +100,7 @@ export default function SendWithPeanutCta({ const peanutLogo = useMemo((): React.ReactNode => { return (
- Peanut Logo + Peanut Logo Peanut Logo
) From 7671c5f2b5f400e4586601b21e6b7f8b95a49810 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 2 Jul 2026 13:58:19 -0700 Subject: [PATCH 2/7] 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 3/7] 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) From bfdec3cd388bf82bd5efe0d1d5a30351cd39873c Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 2 Jul 2026 17:47:37 -0700 Subject: [PATCH 4/7] =?UTF-8?q?fix(kyc):=20don't=20strand=20users=20?= =?UTF-8?q?=E2=80=94=20verification=20poll=20holds=20a=20steady=20cadence,?= =?UTF-8?q?=20never=20hard-stops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 15-min KYC_POLL_CAP_MS stopped the fallback poll entirely, so a websocket event missed during a long manual review (laptop sleep, mobile background, network switch) left the user on 'Almost there' forever with onKycSuccess never firing. The poll now settles at the 60s floor and keeps going for the whole modal-open lifetime; the backend self-recovery cooldown bounds the per-call cost, so it's nothing like the fixed-5s battering ram the schedule replaced. Regression tests: keeps polling past 15 min (bounded ~10 calls in the following 10 min, not zero, not a flood) and a late APPROVED still fires onKycSuccess. --- src/hooks/__tests__/useSumsubKycFlow.test.ts | 45 +++++++++++++++----- src/hooks/useSumsubKycFlow.ts | 20 +++++---- 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/src/hooks/__tests__/useSumsubKycFlow.test.ts b/src/hooks/__tests__/useSumsubKycFlow.test.ts index c1deedc85..f97c444a7 100644 --- a/src/hooks/__tests__/useSumsubKycFlow.test.ts +++ b/src/hooks/__tests__/useSumsubKycFlow.test.ts @@ -236,8 +236,11 @@ describe('useSumsubKycFlow — terminal-error exits clear the user-initiated gua // 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. +// then holds a steady 60s cadence for as long as the modal is open — it never +// hard-stops, so a late/missed websocket event is always eventually recovered +// (the earlier 15-min cap stranded users on "Almost there"). 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() @@ -303,22 +306,44 @@ describe('useSumsubKycFlow — verification-progress poll backoff', () => { expect(mockInitiate).toHaveBeenCalledTimes(19) }) - it('stops polling entirely after the ~15 min cap', async () => { + it('keeps polling past 15 min (no strand) but stays bounded at the 60s steady cadence', 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) + const countAt15m = mockInitiate.mock.calls.length + // escalation kept the first 15 min far below a fixed-5s cadence (~180). + expect(countAt15m).toBeGreaterThan(0) + expect(countAt15m).toBeLessThan(40) - // past the cap the chain is not rescheduled — no further calls, ever. + // 10 more minutes at the 60s floor → ~10 further polls, NOT zero (the old + // cap stranded the user here) and NOT a 5s-cadence flood. await act(async () => { - await jest.advanceTimersByTimeAsync(30 * 60_000) + await jest.advanceTimersByTimeAsync(10 * 60_000) }) - expect(mockInitiate).toHaveBeenCalledTimes(countAtCap) + const delta = mockInitiate.mock.calls.length - countAt15m + expect(delta).toBeGreaterThanOrEqual(8) + expect(delta).toBeLessThanOrEqual(12) + }) + + it('a status that goes APPROVED after a long wait still surfaces (no strand)', async () => { + const onKycSuccess = jest.fn() + const view = renderHook(() => useSumsubKycFlow({ onKycSuccess })) + act(() => { + view.result.current.handleSdkComplete() + }) + + // 20 minutes of "still pending" — well past the old 15-min cap. + await act(async () => { + await jest.advanceTimersByTimeAsync(20 * 60_000) + }) + // Backend finally approves; the next poll picks it up. + mockInitiate.mockResolvedValue({ data: { token: null, applicantId: 'poll', status: 'APPROVED' } }) + await act(async () => { + await jest.advanceTimersByTimeAsync(60_000) + }) + expect(onKycSuccess).toHaveBeenCalled() }) it('cancels the pending timer when the modal closes', async () => { diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 99e2e89a2..d16af6deb 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -31,11 +31,15 @@ const KYC_POLL_SCHEDULE: ReadonlyArray<{ untilMs: number; delayMs: number }> = [ { untilMs: 120_000, delayMs: 10_000 }, { untilMs: 180_000, delayMs: 20_000 }, ] +// After the escalation schedule the poll settles at this steady cadence for as +// long as the modal stays open. It does NOT stop: a missed websocket event +// (laptop sleep, mobile background, network switch) can land at any time during +// a long manual review, and a hard stop would strand the user on "Almost there" +// forever with onKycSuccess never firing. The 60s floor plus the backend's own +// self-recovery cooldown (which short-circuits repeat submissions server-side) +// keeps the steady poll cheap — nothing like the fixed-5s battering ram this +// schedule replaced. 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) { @@ -149,8 +153,9 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: // (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. + // the delay grow as the modal stays open, settling at a steady 60s cadence — + // it keeps polling for the whole modal-open lifetime so a late/missed + // websocket event is always eventually recovered. useEffect(() => { if (!isVerificationProgressModalOpen) return @@ -175,9 +180,6 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: 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 From f2385e6638aee401b98417058dbc57c405998d0b Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 2 Jul 2026 18:05:24 -0700 Subject: [PATCH 5/7] feat(kyc): provide-email sheet for the no-email verification dead-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to peanut-api-ts#1119. Users whose email was never captured used to dead-end on 'message support' (their only fix was a ticket); the BE now emits a provide-email NextAction for those rails, and this renders it: an email form (ActionModal, BridgeTosStep shell) wired into the two places the block surfaces — the home activation CTA and the bank add/withdraw gate. Saving posts update-user (first-time email set is allowed while KYC-locked) and the BE flips the rails to PENDING and resubmits automatically. --- .../AddWithdraw/AddWithdrawCountriesList.tsx | 9 ++ src/components/Home/ActivationCTAs.tsx | 18 +++- src/components/Kyc/ProvideEmailStep.tsx | 97 +++++++++++++++++++ src/types/capabilities.ts | 2 +- src/utils/capability-gate.test.ts | 41 ++++++++ src/utils/capability-gate.ts | 15 +++ 6 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 src/components/Kyc/ProvideEmailStep.tsx diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index c00fc73de..3c35a330c 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -34,6 +34,7 @@ import { railJurisdictionForBank } from '@/utils/bridge.utils' import { getRegionIntent } from '@/utils/regions.utils' import { useTosGuard } from '@/hooks/useTosGuard' import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' +import ProvideEmailStep from '@/components/Kyc/ProvideEmailStep' import { useModalsContext } from '@/context/ModalsContext' import underMaintenanceConfig, { PIX_BRAZIL_ONRAMP_MAINTENANCE } from '@/config/underMaintenance.config' @@ -126,6 +127,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { const bankCountry = useMemo(() => railJurisdictionForBank(currentCountry?.id), [currentCountry?.id]) const gate = useMemo(() => gateFor('deposit', { channel: 'bank', country: bankCountry }), [gateFor, bankCountry]) const { guardWithTos, showBridgeTos, hideTos } = useTosGuard() + const [showProvideEmail, setShowProvideEmail] = useState(false) const { setIsSupportModalOpen } = useModalsContext() // stores the callback to replay after tos acceptance in the list view @@ -149,6 +151,8 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { if (gate.kind === 'accept-tos') { pendingAfterTosRef.current = onAfterTos ?? null guardWithTos() + } else if (gate.kind === 'provide-email') { + setShowProvideEmail(true) } else { setIsKycModalOpen(true) } @@ -379,6 +383,11 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { onSkip={hideTos} reasonCode={gate.kind === 'accept-tos' ? gate.reason?.code : undefined} /> + setShowProvideEmail(false)} + onSkip={() => setShowProvideEmail(false)} + /> ) diff --git a/src/components/Home/ActivationCTAs.tsx b/src/components/Home/ActivationCTAs.tsx index 6e6f7db8c..683f5c2a7 100644 --- a/src/components/Home/ActivationCTAs.tsx +++ b/src/components/Home/ActivationCTAs.tsx @@ -7,13 +7,14 @@ import { useRouter } from 'next/navigation' import { useModalsContext } from '@/context/ModalsContext' import Card from '../Global/Card' import CardLaunchCTABanner from '@/components/Home/CardLaunchCTA/CardLaunchCTABanner' -import { useEffect, useMemo, useRef } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { useCapabilities } from '@/hooks/useCapabilities' import { useIdentityVerification } from '@/hooks/useIdentityVerification' import { useAuth } from '@/context/authContext' import { buildContactSupportMessage } from '@/utils/contact-support.utils' +import ProvideEmailStep from '@/components/Kyc/ProvideEmailStep' interface ActivationCTAsProps { activationStep: ActivationStep @@ -103,6 +104,12 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa } }, [rails, channelOf]) + // Self-serve email recovery: the BE tags email-less submission failures + // with reason code 'email_required' + a provide-email action — the fix is + // an email form, not a support ticket. + const isEmailBlocked = blockedRail?.reason?.code === 'email_required' + const [showProvideEmail, setShowProvideEmail] = useState(false) + const lastTrackedStep = useRef(null) useEffect(() => { if (activationStep !== 'completed' && activationStep !== lastTrackedStep.current) { @@ -193,7 +200,9 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa shadowSize="4" className="mt-2 w-full" onClick={() => { - if (hasProviderRejection && hasBlockedRejection && !hasFixableRejection) { + if (isEmailBlocked) { + setShowProvideEmail(true) + } else if (hasProviderRejection && hasBlockedRejection && !hasFixableRejection) { // REQUIRES_SUPPORT class (or any blocked rail) — pre-fill Crisp // with the failure context so support can dispatch without // re-investigating the user's state. @@ -219,6 +228,11 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa )}
+ setShowProvideEmail(false)} + onSkip={() => setShowProvideEmail(false)} + /> ) } diff --git a/src/components/Kyc/ProvideEmailStep.tsx b/src/components/Kyc/ProvideEmailStep.tsx new file mode 100644 index 000000000..b8c7b8163 --- /dev/null +++ b/src/components/Kyc/ProvideEmailStep.tsx @@ -0,0 +1,97 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import ActionModal from '@/components/Global/ActionModal' +import ProfileEditField from '@/components/Profile/components/ProfileEditField' +import { updateUserById } from '@/app/actions/users' +import { useAuth } from '@/context/authContext' +import { isValidEmail } from '@/utils/format.utils' +import type { IconName } from '@/components/Global/Icons/Icon' + +interface ProvideEmailStepProps { + visible: boolean + onComplete: () => void + onSkip: () => void +} + +/** + * Self-serve recovery for the no-email KYC dead-end: the BE emits a + * `provide-email` NextAction when provider submission failed because no email + * was ever captured. Saving an email here flips the blocked rails back to + * PENDING server-side and re-runs submission automatically — no support + * ticket needed. (Shell mirrors BridgeTosStep.) + */ +export default function ProvideEmailStep({ visible, onComplete, onSkip }: ProvideEmailStepProps) { + const { user, fetchUser } = useAuth() + const [email, setEmail] = useState('') + const [isSaving, setIsSaving] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (visible) setError(null) + }, [visible]) + + const handleSave = useCallback(async () => { + const trimmed = email.trim() + if (!isValidEmail(trimmed)) { + setError('Please enter a valid email address.') + return + } + setIsSaving(true) + setError(null) + try { + const response = await updateUserById({ userId: user?.user?.userId, email: trimmed }) + if (response.error) { + setError(response.error) + return + } + // The BE resubmits the blocked rails on email set; refetch so the + // provide-email gate clears (rails go PENDING → the UI shows the + // normal in-progress state instead of this modal). + await fetchUser() + onComplete() + } catch { + setError('Something went wrong saving your email. Please try again.') + } finally { + setIsSaving(false) + } + }, [email, user?.user?.userId, fetchUser, onComplete]) + + return ( + + + {error &&

{error}

} +
+ } + /> + ) +} diff --git a/src/types/capabilities.ts b/src/types/capabilities.ts index 5471d7a30..e16693627 100644 --- a/src/types/capabilities.ts +++ b/src/types/capabilities.ts @@ -100,7 +100,7 @@ export interface RailCapability { * document (used for the country-not-supported CTA * on Manteca-only rails; user has a self-fix path). */ -export type NextActionKind = 'sumsub' | 'accept-tos' | 'wait' | 'contact-support' | 'restart-identity' +export type NextActionKind = 'sumsub' | 'accept-tos' | 'wait' | 'contact-support' | 'restart-identity' | 'provide-email' export interface NextAction { key: string // stable id, referenced by RailCapability.blockingActions diff --git a/src/utils/capability-gate.test.ts b/src/utils/capability-gate.test.ts index 72f86ac69..d0a01f369 100644 --- a/src/utils/capability-gate.test.ts +++ b/src/utils/capability-gate.test.ts @@ -578,3 +578,44 @@ describe('deriveGate — advisory pre-empt (future-dated requirement on a ready expect(gate.kind).toBe('fixable-rejection') }) }) + +describe('deriveGate — provide-email self-serve for email-blocked rails', () => { + const provideEmailAction: NextAction = { + key: 'provide-email', + kind: 'provide-email', + purpose: 'submission-failed-no-email', + } + + test('blocked rail carrying provide-email action → provide-email gate (self-fix path)', () => { + const rail = bankRail({ + status: 'blocked', + blockingActions: ['provide-email'], + reason: { + code: 'email_required', + userMessage: 'We need an email address to finish setting up this account.', + }, + }) + + const gate = deriveGate(state([rail], [provideEmailAction]), 'deposit', { channel: 'bank' }) + + expect(gate.kind).toBe('provide-email') + if (gate.kind === 'provide-email') { + expect(gate.userMessage).toMatch(/email address/) + expect(gate.reason?.code).toBe('email_required') + } + expect(getGateUserMessage(gate)).toMatch(/email address/) + }) + + test('blocked rail without provide-email action still dead-ends on blocked-rejection', () => { + const rail = bankRail({ + status: 'blocked', + blockingActions: ['contact-support'], + reason: { code: 'submission_failed', userMessage: 'We hit a snag.' }, + }) + const supportAction: NextAction = { key: 'contact-support', kind: 'contact-support', purpose: 'kyc-support' } + + const gate = deriveGate(state([rail], [supportAction]), 'deposit', { channel: 'bank' }) + + expect(gate.kind).toBe('blocked-rejection') + }) +}) diff --git a/src/utils/capability-gate.ts b/src/utils/capability-gate.ts index dbc183fe2..5de2f6f8f 100644 --- a/src/utils/capability-gate.ts +++ b/src/utils/capability-gate.ts @@ -68,6 +68,12 @@ export type GateState = * CTA opens a fresh Sumsub WebSDK after POST /users/identity/restart. */ | { kind: 'restart-identity'; userMessage: string | null; reason?: CapabilityReason } + /** + * Blocked rail whose pipeline failure is self-serviceable: no email was + * ever captured, so provider submission couldn't run. CTA opens an email + * form; on save the BE flips the rails back to PENDING and resubmits. + */ + | { kind: 'provide-email'; userMessage: string | null; reason?: CapabilityReason } | { kind: 'needs-identity' } | { kind: 'needs-enrollment' } @@ -202,6 +208,14 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat // the only path is contact-support. const blocked = candidates.find((rail) => rail.status === 'blocked') if (blocked) { + const hasProvideEmail = railActions(blocked, actionByKey).some((action) => action.kind === 'provide-email') + if (hasProvideEmail) { + return { + kind: 'provide-email', + userMessage: blocked.reason?.userMessage ?? null, + reason: blocked.reason, + } + } const hasRestart = railActions(blocked, actionByKey).some((action) => action.kind === 'restart-identity') if (hasRestart) { return { @@ -303,6 +317,7 @@ export function getGateUserMessage(gate: GateState): string | undefined { gate.kind === 'fixable-rejection' || gate.kind === 'blocked-rejection' || gate.kind === 'restart-identity' || + gate.kind === 'provide-email' || gate.kind === 'accept-tos' || gate.kind === 'waiting-on-provider' ) { From 35700e9132d9ac7b4c80dd4f360a3fa8e0d6710b Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 2 Jul 2026 18:19:15 -0700 Subject: [PATCH 6/7] =?UTF-8?q?fix(kyc):=20review=20findings=20=E2=80=94?= =?UTF-8?q?=20modal-variant=20floor,=20single=20detection=20contract,=20sh?= =?UTF-8?q?adowing,=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getKycModalVariant maps provide-email to 'blocked' so unwired gate consumers keep the contact-support floor instead of a bogus re-verify CTA; the gate and ActivationCTAs both detect via the provide-email action kind (one contract, no reason-code divergence) and prefer an email-blocked rail over an earlier terminal one; the home CTA copy now says 'Add your email / Add email' instead of promising support and opening a form; the sheet resets on reopen and guards a not-yet-loaded userId. --- src/components/Home/ActivationCTAs.tsx | 56 ++++++++++++++++--------- src/components/Kyc/ProvideEmailStep.tsx | 12 +++++- src/utils/capability-gate.ts | 27 ++++++++---- 3 files changed, 66 insertions(+), 29 deletions(-) diff --git a/src/components/Home/ActivationCTAs.tsx b/src/components/Home/ActivationCTAs.tsx index 683f5c2a7..380ff2225 100644 --- a/src/components/Home/ActivationCTAs.tsx +++ b/src/components/Home/ActivationCTAs.tsx @@ -77,7 +77,7 @@ const STEPS: Record, StepConfig> = { export default function ActivationCTAs({ activationStep, onDismissCard }: ActivationCTAsProps) { const router = useRouter() const { setIsQRScannerOpen, openSupportWithMessage } = useModalsContext() - const { rails, channelOf } = useCapabilities() + const { rails, channelOf, nextActionsForRail } = useCapabilities() const { user } = useAuth() // Suppress the "Unlock payments" verify CTA while identity is mid-flight // (Sumsub processing / action_required). The user already took the verify @@ -89,25 +89,30 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa // qr-only channels — never through card. Top-level status (not per-op // refinement): Manteca's pool tier reads `enabled` at the rail level even when // deposit/withdraw individually need an upgrade — that's not a rejection. - const { hasFixableRejection, hasBlockedRejection, primaryRejectionMessage, blockedRail } = useMemo(() => { - const rejectableRails = rails.filter((rail) => { - const channel = channelOf(rail) - return channel === 'bank' || channel === 'qr-only' - }) - const fixableRail = rejectableRails.find((rail) => rail.status === 'requires-info') - const blocked = rejectableRails.find((rail) => rail.status === 'blocked') - return { - hasFixableRejection: !!fixableRail, - hasBlockedRejection: !!blocked, - primaryRejectionMessage: (fixableRail ?? blocked)?.reason?.userMessage ?? null, - blockedRail: blocked, - } - }, [rails, channelOf]) + const { hasFixableRejection, hasBlockedRejection, primaryRejectionMessage, blockedRail, isEmailBlocked } = + useMemo(() => { + const rejectableRails = rails.filter((rail) => { + const channel = channelOf(rail) + return channel === 'bank' || channel === 'qr-only' + }) + const fixableRail = rejectableRails.find((rail) => rail.status === 'requires-info') + // Email-blocked rails carry a self-serve provide-email action (same + // contract the capability gate reads) — prefer one over an earlier + // blocked rail with a terminal reason, since one email fixes them all. + const emailBlocked = rejectableRails.find( + (rail) => + rail.status === 'blocked' && nextActionsForRail(rail.id).some((a) => a.kind === 'provide-email') + ) + const blocked = emailBlocked ?? rejectableRails.find((rail) => rail.status === 'blocked') + return { + hasFixableRejection: !!fixableRail, + hasBlockedRejection: !!blocked, + primaryRejectionMessage: (fixableRail ?? blocked)?.reason?.userMessage ?? null, + blockedRail: blocked, + isEmailBlocked: !!emailBlocked, + } + }, [rails, channelOf, nextActionsForRail]) - // Self-serve email recovery: the BE tags email-less submission failures - // with reason code 'email_required' + a provide-email action — the fix is - // an email form, not a support ticket. - const isEmailBlocked = blockedRail?.reason?.code === 'email_required' const [showProvideEmail, setShowProvideEmail] = useState(false) const lastTrackedStep = useRef(null) @@ -144,6 +149,18 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa href: '/profile/identity-verification', } } + // blocked on a missing email — self-serve, not a support ticket + if (isEmailBlocked) { + return { + icon: 'globe-lock', + iconBg: 'bg-primary-1', + title: 'Add your email', + description: + primaryRejectionMessage || 'We need an email address to finish setting up your account.', + ctaLabel: 'Add email', + href: '', // handled in onClick + } + } // blocked return { icon: 'globe-lock', @@ -160,6 +177,7 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa activationStep, hasProviderRejection, hasFixableRejection, + isEmailBlocked, primaryRejectionMessage, isIdentityProcessing, isIdentityActionRequired, diff --git a/src/components/Kyc/ProvideEmailStep.tsx b/src/components/Kyc/ProvideEmailStep.tsx index b8c7b8163..9668e5b8f 100644 --- a/src/components/Kyc/ProvideEmailStep.tsx +++ b/src/components/Kyc/ProvideEmailStep.tsx @@ -28,7 +28,10 @@ export default function ProvideEmailStep({ visible, onComplete, onSkip }: Provid const [error, setError] = useState(null) useEffect(() => { - if (visible) setError(null) + if (visible) { + setEmail('') + setError(null) + } }, [visible]) const handleSave = useCallback(async () => { @@ -37,10 +40,15 @@ export default function ProvideEmailStep({ visible, onComplete, onSkip }: Provid setError('Please enter a valid email address.') return } + const userId = user?.user?.userId + if (!userId) { + setError('Still loading your account — please try again in a moment.') + return + } setIsSaving(true) setError(null) try { - const response = await updateUserById({ userId: user?.user?.userId, email: trimmed }) + const response = await updateUserById({ userId, email: trimmed }) if (response.error) { setError(response.error) return diff --git a/src/utils/capability-gate.ts b/src/utils/capability-gate.ts index 5de2f6f8f..9237d702b 100644 --- a/src/utils/capability-gate.ts +++ b/src/utils/capability-gate.ts @@ -206,16 +206,23 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat // 3. blocked — split: if the rail carries a `restart-identity` action the // user can self-fix by re-verifying with a different document; otherwise // the only path is contact-support. + // provide-email is a USER-level fix (one email unblocks every email-blocked + // rail), so any blocked rail carrying it wins over an earlier blocked rail + // with a terminal reason — .find() order must not shadow the self-serve path. + const emailBlocked = candidates.find( + (rail) => + rail.status === 'blocked' && + railActions(rail, actionByKey).some((action) => action.kind === 'provide-email') + ) + if (emailBlocked) { + return { + kind: 'provide-email', + userMessage: emailBlocked.reason?.userMessage ?? null, + reason: emailBlocked.reason, + } + } const blocked = candidates.find((rail) => rail.status === 'blocked') if (blocked) { - const hasProvideEmail = railActions(blocked, actionByKey).some((action) => action.kind === 'provide-email') - if (hasProvideEmail) { - return { - kind: 'provide-email', - userMessage: blocked.reason?.userMessage ?? null, - reason: blocked.reason, - } - } const hasRestart = railActions(blocked, actionByKey).some((action) => action.kind === 'restart-identity') if (hasRestart) { return { @@ -301,6 +308,10 @@ export function getKycModalVariant( kind: GateState['kind'] ): 'blocked' | 'provider_rejection' | 'cross_region' | 'restart_identity' | 'default' { if (kind === 'blocked-rejection') return 'blocked' + // Floor for consumers not yet wired to render the email sheet: show the + // contact-support variant, never the 'default' re-verify CTA (the user's + // identity is already verified — bouncing them into Sumsub is wrong). + if (kind === 'provide-email') return 'blocked' if (kind === 'restart-identity') return 'restart_identity' if (kind === 'fixable-rejection') return 'provider_rejection' if (kind === 'needs-enrollment') return 'cross_region' From 5ee21c1859d6a24d6f27f255a40225a8ef458d0c Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 2 Jul 2026 19:16:39 -0700 Subject: [PATCH 7/7] fix(kyc): route email-blocked gates to the self-serve email sheet consistently (final /code-review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final pre-prod review of the #2342 provide-email surfaces found two consumer sites diverging from the canonical deriveGate order (email-blocked > fixable): - ActivationCTAs: the copy memo ranked hasFixableRejection above isEmailBlocked while the button onClick ranked isEmailBlocked first — so with both rail states the card said 'Upload document' but opened the email sheet, and hid the document path. Reordered the memo (+ primaryRejectionMessage) to email-blocked → fixable → terminal, matching onClick and deriveGate. - AddWithdrawCountriesList.handleFormSubmit: missing the provide-email branch that checkBridgeGate has, so a rail flipping to email-blocked between form-open and submit opened the contact-support KYC modal instead of the email sheet — the exact self-serve→support-ticket regression #2342 fixes. Added the branch; regression test pins provide-email → sheet, not modal. Follow-up (not blocking): ActivationCTAs re-implements gate ordering inline; it should consume deriveGate directly so consumers can't drift again. Full suite 1675 green; typecheck clean. --- .../AddWithdraw/AddWithdrawCountriesList.tsx | 5 ++++ .../AddWithdrawCountriesList.test.tsx | 20 +++++++++++++ src/components/Home/ActivationCTAs.tsx | 29 +++++++++++-------- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 3c35a330c..a63caaf6f 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -182,6 +182,11 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { } if (gate.kind === 'accept-tos') { guardWithTos() + } else if (gate.kind === 'provide-email') { + // A rail that flipped to email-blocked between form-open and submit + // is self-serve — open the email sheet, NOT the contact-support KYC + // modal (mirrors checkBridgeGate; the whole point of provide-email). + setShowProvideEmail(true) } else { setIsKycModalOpen(true) } diff --git a/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx b/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx index 0a1737561..f49360439 100644 --- a/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx +++ b/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx @@ -160,6 +160,10 @@ jest.mock('@/components/AddWithdraw/DynamicBankAccountForm', () => ({ DynamicBan jest.mock('@/components/Global/TokenAndNetworkConfirmationModal', () => ({ __esModule: true, default: () => null })) jest.mock('@/components/Kyc/SumsubKycModals', () => ({ SumsubKycModals: () => null })) jest.mock('@/components/Kyc/BridgeTosStep', () => ({ BridgeTosStep: () => null })) +jest.mock('@/components/Kyc/ProvideEmailStep', () => ({ + __esModule: true, + default: (props: any) => (props.visible ?
: null), +})) jest.mock('@/components/Kyc/InitiateKycModal', () => ({ InitiateKycModal: (props: any) => (props.visible ?
: null), })) @@ -235,6 +239,22 @@ describe('AddWithdrawCountriesList — bank gate', () => { expect(mockPush).not.toHaveBeenCalled() expect(screen.getByTestId('initiate-kyc-modal')).toBeInTheDocument() }) + + // provide-email is a self-serve gate (one email unblocks the rail) — it must + // open the email sheet, NEVER the contact-support KYC modal. Both the click + // gate (checkBridgeGate) and the form-submit gate (handleFormSubmit) must + // route it there; a missing branch on the submit path turned self-serve + // recovery into a support ticket (2026-07 review finding). + it('an email-blocked gate opens the provide-email sheet, not the contact-support KYC modal', () => { + setCapabilities('provide-email', [{ status: 'blocked', channel: 'bank', country: 'US' }]) + + render() + fireEvent.click(screen.getByTestId('method-bank')) + + expect(screen.getByTestId('provide-email-sheet')).toBeInTheDocument() + expect(screen.queryByTestId('initiate-kyc-modal')).toBeNull() + expect(mockPush).not.toHaveBeenCalled() + }) }) /** diff --git a/src/components/Home/ActivationCTAs.tsx b/src/components/Home/ActivationCTAs.tsx index 380ff2225..d34f2dd0e 100644 --- a/src/components/Home/ActivationCTAs.tsx +++ b/src/components/Home/ActivationCTAs.tsx @@ -107,7 +107,8 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa return { hasFixableRejection: !!fixableRail, hasBlockedRejection: !!blocked, - primaryRejectionMessage: (fixableRail ?? blocked)?.reason?.userMessage ?? null, + // Same precedence the copy/onClick use: email-blocked → fixable → terminal. + primaryRejectionMessage: (emailBlocked ?? fixableRail ?? blocked)?.reason?.userMessage ?? null, blockedRail: blocked, isEmailBlocked: !!emailBlocked, } @@ -139,17 +140,11 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa if (activationStep === 'verify' && isIdentityProcessing && !isIdentityActionRequired) return null if (hasProviderRejection) { - if (hasFixableRejection) { - return { - icon: 'globe-lock', - iconBg: 'bg-primary-1', - title: 'Complete your setup', - description: primaryRejectionMessage || 'We need an updated document before you can add money.', - ctaLabel: 'Upload document', - href: '/profile/identity-verification', - } - } - // blocked on a missing email — self-serve, not a support ticket + // Email-blocked (status=blocked) outranks a fixable RFI (status=requires-info) + // — the canonical `deriveGate` order, and the order this card's onClick + // already follows (isEmailBlocked first). Ranking fixable above email here + // made the copy say "Upload document" while the button opened the email + // sheet, and hid the document-upload path entirely when both coexisted. if (isEmailBlocked) { return { icon: 'globe-lock', @@ -161,6 +156,16 @@ export default function ActivationCTAs({ activationStep, onDismissCard }: Activa href: '', // handled in onClick } } + if (hasFixableRejection) { + return { + icon: 'globe-lock', + iconBg: 'bg-primary-1', + title: 'Complete your setup', + description: primaryRejectionMessage || 'We need an updated document before you can add money.', + ctaLabel: 'Upload document', + href: '/profile/identity-verification', + } + } // blocked return { icon: 'globe-lock',