diff --git a/e2e/shots/fixtures.spec.ts b/e2e/shots/fixtures.spec.ts index ffdfdfc877..ac87fc799e 100644 --- a/e2e/shots/fixtures.spec.ts +++ b/e2e/shots/fixtures.spec.ts @@ -110,7 +110,9 @@ for (const [name, fixture] of Object.entries(FIXTURES)) { await page.clock.setFixedTime(FROZEN_NOW) await page.addInitScript(seenOnceModals) - await page.goto(`${fixture.route}?${FIXTURE_PARAM}=${name}`, { waitUntil: 'domcontentloaded' }) + // A fixture route may carry its own query (deep-linked flow steps). + const sep = fixture.route.includes('?') ? '&' : '?' + await page.goto(`${fixture.route}${sep}${FIXTURE_PARAM}=${name}`, { waitUntil: 'domcontentloaded' }) await settle(page) // A build without NEXT_PUBLIC_VERCEL_ENV=preview ignores the param and diff --git a/src/app/(mobile-ui)/dev/ds/_components/nav-config.ts b/src/app/(mobile-ui)/dev/ds/_components/nav-config.ts index 0afdec5c90..9f2c51201b 100644 --- a/src/app/(mobile-ui)/dev/ds/_components/nav-config.ts +++ b/src/app/(mobile-ui)/dev/ds/_components/nav-config.ts @@ -77,6 +77,13 @@ export const SIDEBAR_CONFIG: Record = { description: 'Inline field-level error (Body/XS, foreground-error) — flow errors stay Notification', status: 'production', }, + { + label: 'Field', + icon: 'docs', + href: '/dev/ds/primitives/field', + description: 'Form-field chrome: label + control + helper/error line — error is text only, never borders', + status: 'production', + }, { label: 'BaseSelect', icon: 'clip', diff --git a/src/app/(mobile-ui)/dev/ds/primitives/field/page.tsx b/src/app/(mobile-ui)/dev/ds/primitives/field/page.tsx new file mode 100644 index 0000000000..d3cb74320c --- /dev/null +++ b/src/app/(mobile-ui)/dev/ds/primitives/field/page.tsx @@ -0,0 +1,70 @@ +'use client' + +import { useState } from 'react' +import { BaseInput } from '@/components/0_Bruddle/BaseInput' +import { Field } from '@/components/0_Bruddle/Field' +import { DocHeader } from '../../_components/DocHeader' +import { DocSection } from '../../_components/DocSection' +import { DocPage } from '../../_components/DocPage' +import { CodeBlock } from '../../_components/CodeBlock' + +export default function FieldPage() { + const [bic, setBic] = useState('NOTABIC') + const bicInvalid = bic.length > 0 && bic.length !== 8 && bic.length !== 11 + + return ( + + + + + + + + + + + + +`} + /> + + + + + + + setBic(e.target.value)} /> + + + + ( + + + + )} +/>`} + /> + + + + ) +} diff --git a/src/app/(mobile-ui)/recover-funds/page.tsx b/src/app/(mobile-ui)/recover-funds/page.tsx index 9fd1cb60ea..49e7390197 100644 --- a/src/app/(mobile-ui)/recover-funds/page.tsx +++ b/src/app/(mobile-ui)/recover-funds/page.tsx @@ -13,7 +13,7 @@ import { fetchWalletBalances } from '@/services/tokens-price' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts' import { nativeCurrencyAddresses } from '@/constants/general.consts' import { areEvmAddressesEqual, isTxReverted, getExplorerUrl, getChainName, getTokenLogo } from '@/utils/general.utils' -import { type RecipientState } from '@/context/WithdrawFlowContext' +import { type RecipientState } from '@/components/Global/GeneralRecipientInput/types' import GeneralRecipientInput, { type GeneralRecipientUpdate } from '@/components/Global/GeneralRecipientInput' import { Button } from '@/components/0_Bruddle/Button' import Card from '@/components/Global/Card' diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 30af6d5719..fccdc727d2 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -1,435 +1,48 @@ 'use client' -import { Button } from '@/components/0_Bruddle/Button' -import { Notification } from '@/components/0_Bruddle/Notification' -import { ALL_COUNTRIES_ALPHA3_TO_ALPHA2 } from '@/components/AddMoney/consts' -import Card from '@/components/Global/Card' import NavHeader from '@/components/Global/NavHeader' -import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard' -import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' -import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants/zerodev.consts' -import { useWithdrawFlow } from '@/context/WithdrawFlowContext' -import { useWallet } from '@/hooks/wallet/useWallet' -import { usePendingTransactions } from '@/hooks/wallet/usePendingTransactions' -import { AccountType, type Account } from '@/interfaces/interfaces' -import { formatIban, shortenStringLong, isTxReverted } from '@/utils/general.utils' -import { useParams, useRouter, useSearchParams } from 'next/navigation' -import { useEffect, useMemo, useState } from 'react' -import { useQueryClient } from '@tanstack/react-query' -import { TRANSACTIONS } from '@/constants/query.consts' +import { useRouter } from 'next/navigation' import PaymentSuccessView from '@/features/payments/shared/components/PaymentSuccessView' -import { useFriendlyError } from '@/hooks/useFriendlyError' -import { isAmountWithinBalance } from '@/utils/balance.utils' -import { getBridgeChainName } from '@/utils/bridge-accounts.utils' -import { getOfframpConfigFromAccount, getCountryFromPath, railJurisdictionForBank } from '@/utils/bridge.utils' -import { createOfframp, confirmOfframp } from '@/app/actions/offramp' -import { useAuth } from '@/context/authContext' -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 { - resolveKycModalVariant, - getGateUserMessage, - getGateReasonCode, - isVerifiableGate, -} from '@/utils/capability-gate' import { useModalsContext } from '@/context/ModalsContext' -import ExchangeRate from '@/components/ExchangeRate' -import countryCurrencyMappings, { isNonEuroSepaCountry } from '@/constants/countryCurrencyMapping' -import { isBridgeSupportedCountry, getRegionIntent } from '@/utils/regions.utils' -import { PointsAction } from '@/services/services.types' -import { usePointsCalculation } from '@/hooks/usePointsCalculation' -import posthog from 'posthog-js' -import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import { withdrawCountryUrl } from '@/utils/native-routes' -import { useSafeBack } from '@/hooks/useSafeBack' -import { useSendFlowOrigin } from '@/hooks/useSendFlowOrigin' +import { resolveKycModalVariant, getGateUserMessage, getGateReasonCode } from '@/utils/capability-gate' +import { getCountryFromPath } from '@/utils/bridge.utils' +import { getRegionIntent } from '@/utils/regions.utils' +import { shortenStringLong } from '@/utils/general.utils' import { useLocale, useTranslations } from 'next-intl' import { localizedCountryTitle } from '@/utils/country-name.utils' -import { resolveSettledTxHash } from '@/utils/settled-tx-hash.utils' - -type View = 'INITIAL' | 'SUCCESS' - +import { useBridgeOfframpFlow } from '@/features/withdraw/useBridgeOfframpFlow' +import { WithdrawBankReviewView } from '@/features/withdraw/views/WithdrawBankReviewView' + +/** + * Bridge bank-withdraw review page. Steps live in the URL + * (`?step=review|success`); the amount arrives as `?amount=` from the shared + * amount step; the account comes from the /withdraw-scoped flow context. + * Logic: useBridgeOfframpFlow. NOTE: scripts/native-build.js copies this file + * to `(mobile-ui)/withdraw/_withdraw-bank.tsx` — keep every import `@/`-based. + */ export default function WithdrawBankPage() { const locale = useLocale() - const t = useTranslations('withdraw') const tNav = useTranslations('navigation') - const tCommon = useTranslations('common') - const tErrors = useTranslations('errors') - const toFriendlyError = useFriendlyError() - // Copy shown when the on-chain deposit to the Bridge address succeeded but the - // subsequent `/bridge/transfers/:id/confirm` call failed (most often a - // fetchWithSentry timeout). The Bridge transfer row exists on the BE; the - // poller / Bridge webhook will eventually complete it. We MUST NOT show a - // Retry button in this state — retrying re-runs sendMoney() and would send - // funds to the deposit address a second time (Sentry PEANUT-UI-QH9, 2026-06-01). - const confirmPendingCopy = t('bank.confirmPending') - const { - amountToWithdraw, - selectedBankAccount: bankAccount, - error, - setError, - setAmountToWithdraw, - setSelectedMethod, - } = useWithdrawFlow() - const { user, fetchUser } = useAuth() - const { address, sendMoney, spendableBalance: balance } = useWallet() - const { guardWithTos, showBridgeTos, hideTos } = useTosGuard() - const queryClient = useQueryClient() const router = useRouter() - const searchParams = useSearchParams() - const [isLoading, setIsLoading] = useState(false) - const [view, setView] = useState('INITIAL') - // Set as soon as the on-chain wallet→Bridge tx confirms. If a subsequent - // confirmOfframp() call fails, this gates the UI into a "processing" state - // instead of showing a Retry button that would re-fire sendMoney(). - const [submittedTxHash, setSubmittedTxHash] = useState(null) - const params = useParams() - // read country from path params (web) or query params (native/capacitor) - const country = (params.country as string) || searchParams.get('country') || '' - const [balanceErrorMessage, setBalanceErrorMessage] = useState(null) - const { hasPendingTransactions } = usePendingTransactions() - // Country-scoped bank-channel withdraw gate. Same rationale as the - // add-money/[country]/bank page: scope to the rail jurisdiction this page - // actually withdraws to (PT/DE/… → EU SEPA; US → ACH; etc.) so a stuck - // PENDING rail in an unrelated jurisdiction can't block this page. - const { gateFor } = useCapabilities() - const bankCountry = useMemo(() => railJurisdictionForBank(getCountryFromPath(country)?.id), [country]) - const countryFromPath = getCountryFromPath(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 { - trackStarted: trackUpliftStarted, - trackCompleted: trackUpliftCompleted, - reset: resetUpliftFunnel, - } = useEeaUpliftFunnel('withdraw') - - const sumsubFlow = useMultiPhaseKycFlow({ - // Fire completed at Sumsub approval (verification submitted), not at - // end-of-flow — so it isn't lost if the user drops during the - // post-approval ToS / preparing steps. - onKycApproved: () => trackUpliftCompleted(), - // Abandoned attempt: clear the pending start so a later unrelated KYC - // success on this page can't mis-fire eea_uplift_completed. - onManualClose: resetUpliftFunnel, - }) - // A ready bank rail can still carry a pending Bridge requirement (the gate's - // `advisory`). Enforce it as a mandatory, non-skippable pre-empt before the - // withdrawal — the offramp cannot proceed until it's completed. - const advisory = gate.kind === 'ready' ? gate.advisory : undefined - const { intercept: advisoryIntercept, modalProps: advisoryModalProps } = useAdvisoryPreempt({ - advisory, - isLoading: sumsubFlow.isLoading, - // 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() - return sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey) - }, - }) - const [showKycModal, setShowKycModal] = useState(false) + const flow = useBridgeOfframpFlow() const { setIsSupportModalOpen } = useModalsContext() - // close kyc modal when sumsub sdk opens - useEffect(() => { - if (sumsubFlow.showWrapper) setShowKycModal(false) - }, [sumsubFlow.showWrapper]) - - // only bank reaches this page, so the bank-specific flag is the right one here - const { isBankFromSend: fromSendFlow } = useSendFlowOrigin() - - // validate country is supported for bank withdrawals - useEffect(() => { - if (country) { - const countryInfo = getCountryFromPath(country) - if (!countryInfo || !isBridgeSupportedCountry(countryInfo.id)) { - router.replace(`/withdraw${fromSendFlow ? '?method=bank' : ''}`) - } - } - }, [country, router, fromSendFlow]) - - const onBack = useSafeBack(fromSendFlow ? '/send' : '/withdraw') - - const nonEuroCurrency = countryCurrencyMappings.find( - (currency) => - country.toLowerCase() === currency.country.toLowerCase() || - currency.path?.toLowerCase() === country.toLowerCase() - )?.currencyCode - - // non-eur sepa countries that are currently experiencing issues - const isNonEuroSepa = isNonEuroSepaCountry(nonEuroCurrency) - - // Calculate points API call - const { pointsData } = usePointsCalculation( - PointsAction.BRIDGE_TRANSFER, + const { + step, amountToWithdraw, - !!(amountToWithdraw && bankAccount), - bankAccount?.id - ) - - useEffect(() => { - // Skip redirects when on success view — clearing state during navigation - // would race with router.push('/home') and redirect back to /withdraw - if (view === 'SUCCESS') return - // Both targets keep ?method=bank: land on a bare /withdraw and the step - // the user is sent back to silently reverts to withdraw copy. - const sendMarker = fromSendFlow ? '?method=bank' : '' - if (!amountToWithdraw) { - // If no amount, go back to main page - router.replace(`/withdraw${sendMarker}`) - } else if (!bankAccount && amountToWithdraw) { - // If amount is set but no bank account, go to country method selection - router.replace(withdrawCountryUrl(country, sendMarker)) - } - }, [bankAccount, router, amountToWithdraw, country, view, fromSendFlow]) - - const destinationDetails = (account: Account) => { - // Derive currency + rail from the account's actual type (GB→GBP, IBAN→EUR, - // US→USD, CLABE→MXN) rather than re-deriving from a country switch whose - // `default` returned an empty currency/rail. A UK account that arrived typed - // anything but GB (the pre-BANK_GB BE mistype, or a Prisma-shaped 'BANK_GB' - // string) fell through that default → empty payload → "External account ID - // is missing.". getOfframpConfigFromAccount tolerates both the projected - // ('gb') and Prisma-shaped ('BANK_GB') strings and keeps this flow - // consistent with the Claim flow (BankFlowManager). Manteca accounts never - // reach this Bridge page (separate /withdraw/manteca route), so its throw - // cannot fire here. - const { currency, paymentRail } = getOfframpConfigFromAccount(account) - return { - currency, - paymentRail, - externalAccountId: account.bridgeAccountId, - } - } - - const getBicAndRoutingNumber = () => { - if (bankAccount && bankAccount.type === AccountType.IBAN) { - return bankAccount.bic?.toUpperCase() ?? 'N/A' - } else if (bankAccount && bankAccount.type === AccountType.US) { - return bankAccount.routingNumber?.toUpperCase() ?? 'N/A' - } else if (bankAccount && bankAccount.type === AccountType.CLABE) { - return bankAccount.identifier?.toUpperCase() ?? 'N/A' - } else if (bankAccount && bankAccount.type === AccountType.GB) { - return bankAccount.sortCode ?? 'N/A' - } - - return 'N/A' - } - - const proceedWithOfframp = async () => { - if (gate.kind !== 'ready') { - // 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. - // Same rule as the deposit page: every gate the user cannot act on - // waits here, or `pending` falls through to the identity screen and - // is offered a verification run only time can clear. - if (!isVerifiableGate(gate.kind) && gate.kind !== 'accept-tos') { - 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 - } - - setIsLoading(true) - setError({ showError: false, errorMessage: '' }) - - if (!bankAccount || !user?.user.bridgeCustomerId || !address) { - setError({ showError: true, errorMessage: t('errors.userDetailsMissing') }) - setIsLoading(false) - return - } - - if (!bankAccount.bridgeAccountId) { - setError({ showError: true, errorMessage: t('errors.bankAccountMissing') }) - setIsLoading(false) - return - } - - posthog.capture(ANALYTICS_EVENTS.WITHDRAW_CONFIRMED, { - amount_usd: amountToWithdraw, - method_type: 'bridge', - country, - }) - - // Set alongside every pre-throw setError below: those messages are already - // the right copy (backend-authored, or the confirm-pending notice), and the - // catch must not overwrite them with the generic mapper output. Replaces a - // check of the mapper's OUTPUT against the English literal "Something - // failed. Please try again." — a comparison of a translated string to a - // literal that exists in no catalog, so it was dead in every locale. - let errorAlreadyDisplayed = false - - try { - // Step 1: create the transfer to get deposit instructions - const destination = destinationDetails(bankAccount) - if (!destination.externalAccountId) { - throw new Error('External account ID is missing.') - } - - const createPayload = { - // note: for bank withdrawals, minimum $1 is required - // reference: https://apidocs.bridge.xyz/docs/transaction-costs - amount: amountToWithdraw, - developer_fee: '0', - onBehalfOf: user.user.bridgeCustomerId, - source: { - currency: PEANUT_WALLET_TOKEN_SYMBOL.toLowerCase(), - paymentRail: getBridgeChainName(PEANUT_WALLET_CHAIN.id.toString()) ?? 'arbitrum', // source blockchain, bridge expects this to be arbitrum not arbitrum one - fromAddress: address, - }, - destination: { - ...destination, - externalAccountId: destination.externalAccountId, - }, - } - const { data, error } = await createOfframp(createPayload) - - if (error) { - setError({ showError: true, errorMessage: error }) - errorAlreadyDisplayed = true - throw new Error(error) - } - - if (!data?.depositInstructions?.toAddress || !data.transferId) { - setError({ showError: true, errorMessage: t('errors.depositAddressFailed') }) - errorAlreadyDisplayed = true - throw new Error('Failed to get deposit address from the backend.') - } - - // Step 2: prepare and send the transaction from peanut wallet to the deposit address - const { receipt, userOpHash, txHash } = await sendMoney( - data.depositInstructions.toAddress as `0x${string}`, - createPayload.amount, - { kind: 'FIAT_OFFRAMP' } - ) - - if (receipt !== null && isTxReverted(receipt)) { - throw new Error('Transaction reverted by the network.') - } - - // Step 3: Confirm the transfer with the backend to make it visible in history. - // Prefer the on-chain tx hash; fall back to the collateral withdraw tx hash - // (collateral-only path) BEFORE the userOp hash. confirmOfframp expects a real - // 32-byte tx hash — userOpHash is an account-abstraction bundler hash, not a - // chain tx hash, and the BE rejects it. - const txIdentifier = resolveSettledTxHash({ receipt, txHash, userOpHash }, 'withdraw-bank').hash - if (!txIdentifier) throw new Error('No transaction identifier returned from sendMoney') - - // Mark the on-chain leg done BEFORE confirmOfframp. From this point on - // any error path (including a confirm timeout) must NOT offer Retry — - // re-running this handler would call sendMoney() again and double-pay. - setSubmittedTxHash(txIdentifier) - - const confirmResult = await confirmOfframp(data.transferId, txIdentifier) - - if (confirmResult.error) { - // On-chain tx succeeded, backend confirm failed. Bridge will still - // process the deposit (the funds are at the deposit address and the - // BE has the transfer row). Show a processing state, NOT an error - // with a Retry button — see CONFIRM_PENDING_COPY + the gate below. - setError({ - showError: true, - errorMessage: confirmPendingCopy, - }) - errorAlreadyDisplayed = true - throw new Error(confirmResult.error) - } - - // Invalidate the transactions query so the Activity widget shows - // the pending OFFRAMP entry immediately, instead of waiting up to - // 30s tanstack staleTime + Bridge polling cadence. - queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] }) - - setView('SUCCESS') - posthog.capture(ANALYTICS_EVENTS.WITHDRAW_COMPLETED, { - amount_usd: amountToWithdraw, - method_type: 'bridge', - country, - }) - } catch (e) { - const error = toFriendlyError(e) - posthog.capture(ANALYTICS_EVENTS.WITHDRAW_FAILED, { - method_type: 'bridge', - error_message: error, - }) - if (!errorAlreadyDisplayed) { - setError({ showError: true, errorMessage: error }) - } - } finally { - setIsLoading(false) - } - } - - // Enforce the mandatory verification pre-empt, then run the offramp. When the - // gate isn't `ready` (or there's no pending requirement) this is a no-op and - // proceedWithOfframp runs straight away (it handles the not-ready cases). - // upcoming (future-dated) eea uplift opens the advisory modal here — fire the - // 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 '' - const code = - ALL_COUNTRIES_ALPHA3_TO_ALPHA2[bankAccount.details.countryCode ?? ''] ?? bankAccount.details.countryCode - return code.toLowerCase() - } - - useEffect(() => { - fetchUser() - }, []) - - // Balance validation - useEffect(() => { - // Skip balance check if transaction is pending - // isLoading covers the gap between sendMoney completing and confirmOfframp completing - if (hasPendingTransactions || isLoading) { - return - } - - if (!amountToWithdraw || amountToWithdraw === '0' || isNaN(Number(amountToWithdraw)) || balance === undefined) { - setBalanceErrorMessage(null) - return - } - - // gate on the displayed total; an in-transit shortfall passes here and - // fails late with the settling message at execution. - setBalanceErrorMessage( - isAmountWithinBalance(amountToWithdraw, balance) ? null : tErrors('notEnoughBalanceAddFunds') - ) - }, [amountToWithdraw, balance, hasPendingTransactions, isLoading, tErrors]) + bankAccount, + country, + countryFromPath, + fromSendFlow, + gate, + sumsubFlow, + pendingModal, + } = flow if (!bankAccount) { return null @@ -439,163 +52,61 @@ export default function WithdrawBankPage() {
{ - if (view === 'SUCCESS') { - // Navigate first, then reset — otherwise clearing amountToWithdraw - // triggers the useEffect redirect to /withdraw, overriding /home + if (step === 'success') { + // the flow provider is /withdraw-scoped — navigation IS the reset router.push('/home') - setAmountToWithdraw('') - setSelectedMethod(null) } else { - onBack() + flow.onBack() } }} /> - {view === 'INITIAL' && ( -
- - - {/* Warning for non-EUR SEPA countries (not UK — UK uses Faster Payments with GBP) */} - {isNonEuroSepa && bankAccount?.type !== AccountType.GB && ( - - {t('bank.eurDescription')} - - )} - - - - {bankAccount?.type === AccountType.IBAN ? ( - <> - - - - ) : bankAccount?.type === AccountType.CLABE ? ( - <> - - - ) : bankAccount?.type === AccountType.GB ? ( - <> - - - - ) : ( - <> - - - - )} - - - - - {submittedTxHash ? ( - // On-chain leg already fired. Even if confirmOfframp failed - // we must NOT offer Retry — it would re-run sendMoney() and - // double-pay (Sentry PEANUT-UI-QH9). Surface the in-progress - // state and a Done button that takes the user home. - - ) : error.showError ? ( - - ) : ( - - )} - {submittedTxHash ? ( - - {confirmPendingCopy} - - ) : ( - error.showError && {error.errorMessage} - )} - {balanceErrorMessage && {balanceErrorMessage}} -
+ {step === 'review' && ( + router.push('/home')} + /> )} - {view === 'SUCCESS' && ( + {step === 'success' && ( { - setAmountToWithdraw('') - setSelectedMethod(null) - }} + points={flow.pointsData?.estimatedPoints} /> )} { - hideTos() - handleCreateAndInitiateOfframp() + flow.hideTos() + flow.handleCreateAndInitiateOfframp() }} - onSkip={hideTos} + onSkip={flow.hideTos} reasonCode={gate.kind === 'accept-tos' ? gate.reason?.code : undefined} /> { // dismiss = abandon: clear the uplift latch so a later // unrelated KYC success can't mis-fire eea_uplift_completed. - setShowKycModal(false) - resetUpliftFunnel() + flow.setShowKycModal(false) + flow.resetUpliftFunnel() }} onVerify={async () => { if (gate.kind === 'restart-identity') { @@ -612,8 +123,8 @@ export default function WithdrawBankPage() { } }} onContactSupport={() => { - setShowKycModal(false) - resetUpliftFunnel() + flow.setShowKycModal(false) + flow.resetUpliftFunnel() setIsSupportModalOpen(true) }} isLoading={sumsubFlow.isLoading} @@ -623,7 +134,7 @@ export default function WithdrawBankPage() { reasonCode={getGateReasonCode(gate)} regionName={countryFromPath && localizedCountryTitle(locale, countryFromPath)} /> - + ({ // ---------- hooks & services ---------- -const mockSetAmountToWithdraw = jest.fn() const mockSetError = jest.fn() -const mockSetUsdAmount = jest.fn() const mockSetSelectedBankAccount = jest.fn() const mockSetSelectedMethod = jest.fn() -const mockSetShowAllWithdrawMethods = jest.fn() const mockWithdrawFlow = { - amountToWithdraw: '', - setAmountToWithdraw: mockSetAmountToWithdraw, - setError: mockSetError, error: { showError: false, errorMessage: '' }, - setUsdAmount: mockSetUsdAmount, + setError: mockSetError, selectedMethod: null as any, selectedBankAccount: null as any, setSelectedBankAccount: mockSetSelectedBankAccount, setSelectedMethod: mockSetSelectedMethod, - setShowAllWithdrawMethods: mockSetShowAllWithdrawMethods, } -jest.mock('@/context/WithdrawFlowContext', () => ({ +jest.mock('@/features/withdraw/WithdrawFlowContext', () => ({ useWithdrawFlow: () => mockWithdrawFlow, })) @@ -84,14 +80,6 @@ jest.mock('@/hooks/wallet/useWallet', () => ({ useWallet: () => mockUseWallet(), })) -jest.mock('@/context/tokenSelector.context', () => ({ - tokenSelectorContext: React.createContext({ - selectedTokenData: { price: 1 }, - selectedTokenAddress: '', - selectedChainID: '', - }), -})) - jest.mock('@/utils/general.utils', () => ({ formatAmount: jest.fn((v: any) => v ?? '0'), formatNumberForDisplay: jest.fn((v: any) => v ?? '0'), @@ -202,14 +190,19 @@ jest.mock('@/components/AddWithdraw/AddWithdrawCountriesList', () => ({ default: () =>
, })) -jest.mock('@/components/AddWithdraw/AddWithdrawRouterView', () => ({ - AddWithdrawRouterView: (props: any) => ( -
+// The method step's composition (saved accounts / country list) has its own +// suite — here it stands in as a probe for titles + flow wiring. +jest.mock('@/features/withdraw/views/WithdrawMethodView', () => ({ + WithdrawMethodView: (props: any) => ( +
{props.pageTitle} {props.mainHeading} - +
), })) @@ -236,18 +229,19 @@ function renderWithdraw(params: Record = {}) { setSearchParams(params) const queryClient = createQueryClient() return render( - - - - - + + + + + + + ) } // ---------- default mock values ---------- function applyDefaults() { - mockWithdrawFlow.amountToWithdraw = '' mockWithdrawFlow.error = { showError: false, errorMessage: '' } mockWithdrawFlow.selectedMethod = null mockWithdrawFlow.selectedBankAccount = null @@ -285,28 +279,45 @@ beforeEach(() => { }) // ============================================================ -// GROUP 1: Method Selection +// GROUP 1: Method Selection (?step absent → method step) // ============================================================ describe('GROUP 1: Method Selection', () => { - test('No method selected shows AddWithdrawRouterView', () => { + test('No step in the URL shows the method view', () => { renderWithdraw() - expect(screen.getByTestId('add-withdraw-router-view')).toBeInTheDocument() + expect(screen.getByTestId('withdraw-method-view')).toBeInTheDocument() expect(screen.getByTestId('main-heading')).toHaveTextContent('How would you like to withdraw?') }) test('Method=bank from send flow shows "Send" title and send heading', () => { renderWithdraw({ method: 'bank' }) - expect(screen.getByTestId('add-withdraw-router-view')).toBeInTheDocument() + expect(screen.getByTestId('withdraw-method-view')).toBeInTheDocument() expect(screen.getByTestId('page-title')).toHaveTextContent('Send') expect(screen.getByTestId('main-heading')).toHaveTextContent('How would you like to send?') }) + test('?step=amount with no method in flow memory falls back to the method view (guard)', () => { + // refresh/deep-link into the amount step after the flow memory died — + // the stepper guard resolves to method selection, never a dead screen + renderWithdraw({ step: 'amount' }) + + expect(screen.getByTestId('withdraw-method-view')).toBeInTheDocument() + expect(screen.queryByTestId('amount-input')).not.toBeInTheDocument() + }) + + test('Choosing a method advances to the amount step in place', async () => { + mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } + renderWithdraw() + + fireEvent.click(screen.getByTestId('method-view-choose')) + expect(await screen.findByTestId('amount-input')).toBeInTheDocument() + }) + test('Back from method selection navigates to /home', () => { renderWithdraw() - fireEvent.click(screen.getByTestId('router-view-back')) + fireEvent.click(screen.getByTestId('method-view-back')) expect(mockRouterPush).toHaveBeenCalledWith('/home') }) @@ -315,7 +326,7 @@ describe('GROUP 1: Method Selection', () => { test('Back honours ?returnTo when the flow was entered from another screen', () => { renderWithdraw({ returnTo: '/profile/exchange-rate?from=USD&to=EUR' }) - fireEvent.click(screen.getByTestId('router-view-back')) + fireEvent.click(screen.getByTestId('method-view-back')) expect(mockRouterPush).toHaveBeenCalledWith('/profile/exchange-rate?from=USD&to=EUR') expect(mockRouterPush).not.toHaveBeenCalledWith('/home') }) @@ -323,62 +334,66 @@ describe('GROUP 1: Method Selection', () => { test('Back ignores an off-origin ?returnTo and still resets to /home', () => { renderWithdraw({ returnTo: 'https://evil.example/phish' }) - fireEvent.click(screen.getByTestId('router-view-back')) + fireEvent.click(screen.getByTestId('method-view-back')) expect(mockRouterPush).toHaveBeenCalledWith('/home') }) test('Back from the send flow still goes to /send, ignoring ?returnTo', () => { renderWithdraw({ method: 'bank', returnTo: '/profile/exchange-rate' }) - fireEvent.click(screen.getByTestId('router-view-back')) + fireEvent.click(screen.getByTestId('method-view-back')) expect(mockRouterPush).toHaveBeenCalledWith('/send') }) test('Back from bank send method selection navigates to /send', () => { renderWithdraw({ method: 'bank' }) - fireEvent.click(screen.getByTestId('router-view-back')) + fireEvent.click(screen.getByTestId('method-view-back')) expect(mockRouterPush).toHaveBeenCalledWith('/send') }) }) // ============================================================ -// GROUP 2: Amount Input +// GROUP 2: Amount Input (?step=amount) // ============================================================ describe('GROUP 2: Amount Input', () => { test('With method selected shows amount input and continue button', () => { mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } - renderWithdraw() + renderWithdraw({ step: 'amount' }) expect(screen.getByTestId('amount-input')).toBeInTheDocument() expect(screen.getByText('Continue')).toBeInTheDocument() expect(screen.getByText('Amount to withdraw')).toBeInTheDocument() }) - test('With method=crypto from send flow shows "Amount to send" heading', () => { + test('?method=crypto entry lands on the amount step without a step param (send hand-off)', async () => { + // /withdraw?method=crypto is send's entry URL — the method is implied, + // so the flow commits it and moves to the amount step by itself mockWithdrawFlow.selectedMethod = { type: 'crypto' } renderWithdraw({ method: 'crypto' }) + expect(await screen.findByTestId('amount-input')).toBeInTheDocument() + }) + + test('With method=crypto from send flow shows "Amount to send" heading', () => { + mockWithdrawFlow.selectedMethod = { type: 'crypto' } + renderWithdraw({ method: 'crypto', step: 'amount' }) + expect(screen.getByText('Amount to send')).toBeInTheDocument() }) test('Send flow shows "Send" in nav header', () => { mockWithdrawFlow.selectedMethod = { type: 'crypto' } - renderWithdraw({ method: 'crypto' }) + renderWithdraw({ method: 'crypto', step: 'amount' }) expect(screen.getByTestId('nav-header')).toHaveTextContent('Send') }) - test.skip('Balance displayed in amount input', () => { - // SKIP 2026-04-24: post feat/card-ui merge, AmountInput no longer - // receives `walletBalance` through this code path; the value comes - // from useWithdrawFlow internally. Test mock signature drifted. - // FOLLOW-UP: rewrite to assert against the unified spendable balance - // surfaced by card-ui's wallet refactor (see useRainCardOverview). + test('The URL amount pre-fills the input (refresh-safe)', () => { mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } - renderWithdraw() + renderWithdraw({ step: 'amount', amount: '42' }) - expect(screen.getByTestId('wallet-balance')).toBeInTheDocument() + expect(screen.getByTestId('amount-field')).toHaveValue('42') }) }) @@ -388,21 +403,21 @@ describe('GROUP 2: Amount Input', () => { describe('GROUP 3: Amount Validation', () => { test('Empty amount disables continue button', () => { mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } - renderWithdraw() + renderWithdraw({ step: 'amount' }) const continueBtn = screen.getByText('Continue') expect(continueBtn).toBeDisabled() }) - test('Error state shows ErrorAlert', () => { + test('Error state shows the error banner', () => { mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } mockWithdrawFlow.error = { showError: true, errorMessage: 'Not enough balance. Add funds to continue.' } - renderWithdraw() + renderWithdraw({ step: 'amount' }) expect(screen.getByTestId('error-alert')).toHaveTextContent('Not enough balance. Add funds to continue.') }) - test('Error hidden when limits blocking card is displayed', () => { + test('Error hidden when limits blocking card is displayed (fiat)', () => { mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } mockWithdrawFlow.error = { showError: true, errorMessage: 'Some error' } mockUseLimitsValidation.mockReturnValue({ @@ -417,13 +432,31 @@ describe('GROUP 3: Amount Validation', () => { message: 'Monthly limit exceeded', }) - renderWithdraw() + renderWithdraw({ step: 'amount' }) - // ErrorAlert should NOT be shown when limits is blocking + // the banner yields to the limits card — the card is the one reason shown expect(screen.queryByTestId('error-alert')).not.toBeInTheDocument() expect(screen.getByTestId('limits-warning-card')).toBeInTheDocument() }) + test('Crypto: the balance error stays visible even while limits are blocking (TASK-21666)', () => { + // Regression: above the off-ramp limit, crypto rendered NOTHING — the + // limits card never renders for crypto and the banner was suppressed. + mockWithdrawFlow.selectedMethod = { type: 'crypto' } + mockWithdrawFlow.error = { showError: true, errorMessage: 'Not enough balance. Add funds to continue.' } + mockUseLimitsValidation.mockReturnValue({ + isBlocking: true, + isWarning: false, + isLoading: false, + currency: 'USD', + }) + + renderWithdraw({ step: 'amount' }) + + expect(screen.queryByTestId('limits-warning-card')).not.toBeInTheDocument() + expect(screen.getByTestId('error-alert')).toHaveTextContent('Not enough balance. Add funds to continue.') + }) + test('Crypto withdrawal has no amount-step minimum (parity with send-via-link)', () => { // Regression: the shared amount step applied the bank $1 minimum to // crypto (getMinimumAmount('') → 1), blocking sub-$1 on-chain sends @@ -431,36 +464,42 @@ describe('GROUP 3: Amount Validation', () => { // have no minimum at all; Rhino's per-network bridge minimums are // enforced at review time, once the destination is known. mockWithdrawFlow.selectedMethod = { type: 'crypto' } - mockWithdrawFlow.amountToWithdraw = '0.4' - renderWithdraw() + renderWithdraw({ step: 'amount', amount: '0.4' }) const continueBtn = screen.getByText('Continue') expect(continueBtn).not.toBeDisabled() fireEvent.click(continueBtn) - expect(mockRouterPush).toHaveBeenCalledWith('/withdraw/crypto') + expect(mockRouterPush).toHaveBeenCalledWith('/withdraw/crypto?amount=0.4') }) - test('Crypto send forwards the send marker to the next step', () => { - // `?method=` is the ONLY send-vs-withdraw signal. Drop it on this hop and - // every screen after the amount step reverts to withdraw copy — the user - // picks "Send -> Exchange or Wallet" and the next screen says - // "You're withdrawing". Losing it here is the original bug. + test('Crypto send forwards the send marker AND the amount to the next step', () => { + // `?method=` is the ONLY send-vs-withdraw signal, and `?amount=` is the + // one typed amount — both must survive the hop (TASK-21664/21665). mockWithdrawFlow.selectedMethod = { type: 'crypto' } - mockWithdrawFlow.amountToWithdraw = '25' - renderWithdraw({ method: 'crypto' }) + renderWithdraw({ method: 'crypto', step: 'amount', amount: '25' }) fireEvent.click(screen.getByText('Continue')) - expect(mockRouterPush).toHaveBeenCalledWith('/withdraw/crypto?method=crypto') + expect(mockRouterPush).toHaveBeenCalledWith('/withdraw/crypto?method=crypto&amount=25') + }) + + test('Manteca method carries the amount into the manteca flow (TASK-21664)', () => { + mockWithdrawFlow.selectedMethod = { type: 'manteca', countryPath: 'argentina', title: 'Bank Transfer' } + + renderWithdraw({ step: 'amount', amount: '50' }) + + fireEvent.click(screen.getByText('Continue')) + expect(mockRouterPush).toHaveBeenCalledWith(expect.stringContaining('/withdraw/manteca')) + expect(mockRouterPush).toHaveBeenCalledWith(expect.stringContaining('country=argentina')) + expect(mockRouterPush).toHaveBeenCalledWith(expect.stringContaining('amount=50')) }) test('Bank withdrawal keeps the $1 minimum for sub-$1 amounts', async () => { mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } - mockWithdrawFlow.amountToWithdraw = '0.5' - renderWithdraw() + renderWithdraw({ step: 'amount', amount: '0.5' }) expect(screen.getByText('Continue')).toBeDisabled() // validation is debounced 300ms behind typing @@ -475,12 +514,11 @@ describe('GROUP 3: Amount Validation', () => { test('Stale bank method entering via ?method=crypto keeps the bank minimum', () => { // Regression: the crypto exemption must follow selectedMethod (the // routing source of truth), not the URL param. A leftover bank method - // from an abandoned withdraw survives in the app-wide context and - // still routes Continue to the bank flow — so sub-$1 must stay blocked. + // from an abandoned withdraw still routes Continue to the bank flow — + // so sub-$1 must stay blocked. mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } - mockWithdrawFlow.amountToWithdraw = '0.5' - renderWithdraw({ method: 'crypto' }) + renderWithdraw({ method: 'crypto', step: 'amount', amount: '0.5' }) expect(screen.getByText('Continue')).toBeDisabled() }) @@ -504,7 +542,7 @@ describe('GROUP 4: Limits Validation', () => { message: 'Monthly limit exceeded', }) - renderWithdraw() + renderWithdraw({ step: 'amount' }) expect(screen.getByTestId('limits-warning-card')).toBeInTheDocument() expect(screen.getByText('Continue')).toBeDisabled() @@ -512,7 +550,6 @@ describe('GROUP 4: Limits Validation', () => { test('Limits warning for bank withdrawal shows LimitsWarningCard but keeps continue enabled', () => { mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } - mockWithdrawFlow.amountToWithdraw = '50' mockUseLimitsValidation.mockReturnValue({ isBlocking: false, isWarning: true, @@ -525,7 +562,7 @@ describe('GROUP 4: Limits Validation', () => { message: 'Approaching limit', }) - renderWithdraw() + renderWithdraw({ step: 'amount', amount: '50' }) expect(screen.getByTestId('limits-warning-card')).toBeInTheDocument() }) @@ -544,7 +581,7 @@ describe('GROUP 4: Limits Validation', () => { message: 'Monthly limit exceeded', }) - renderWithdraw() + renderWithdraw({ step: 'amount' }) expect(screen.queryByTestId('limits-warning-card')).not.toBeInTheDocument() }) @@ -556,20 +593,19 @@ describe('GROUP 4: Limits Validation', () => { describe('GROUP 5: Navigation', () => { test('Back from crypto send navigates to /send', () => { mockWithdrawFlow.selectedMethod = { type: 'crypto' } - renderWithdraw({ method: 'crypto' }) + renderWithdraw({ method: 'crypto', step: 'amount' }) fireEvent.click(screen.getByTestId('nav-back')) expect(mockSetSelectedMethod).toHaveBeenCalledWith(null) expect(mockRouterPush).toHaveBeenCalledWith('/send') }) - test('Back from bank withdraw resets method and goes to method selection', () => { + test('Back from bank withdraw resets method and account (stepper owns the step)', () => { mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } - renderWithdraw() + renderWithdraw({ step: 'amount' }) fireEvent.click(screen.getByTestId('nav-back')) expect(mockSetSelectedMethod).toHaveBeenCalledWith(null) - expect(mockSetAmountToWithdraw).toHaveBeenCalledWith('') expect(mockSetSelectedBankAccount).toHaveBeenCalledWith(null) }) }) @@ -585,16 +621,10 @@ describe('GROUP 6: Continue never dead-buttons', () => { // feedback (Sentry: incomplete-app-router-transaction, 6 users/14d). mockGetCountryFromAccount.mockReturnValue(undefined) - mockUseWallet.mockReturnValue({ - spendableBalance: parseUnits('100', 6), - formattedSpendableBalance: '100.00', - hasSufficientSpendableBalance: (amt: string | number) => Number(amt) <= 100, - }) mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } mockWithdrawFlow.selectedBankAccount = { type: 'iban', details: { countryName: '', countryCode: '' } } - mockWithdrawFlow.amountToWithdraw = '50' - renderWithdraw() + renderWithdraw({ step: 'amount', amount: '50' }) // Pressing Continue must NOT throw and must NOT navigate... expect(() => fireEvent.click(screen.getByText('Continue'))).not.toThrow() @@ -610,16 +640,10 @@ describe('GROUP 6: Continue never dead-buttons', () => { // Manteca (AR/BR) accounts set selectedBankAccount too; the manteca // method check must win over the generic bank branch so they reach // /withdraw/manteca rather than the Bridge bank page (or the throw). - mockUseWallet.mockReturnValue({ - spendableBalance: parseUnits('100', 6), - formattedSpendableBalance: '100.00', - hasSufficientSpendableBalance: (amt: string | number) => Number(amt) <= 100, - }) mockWithdrawFlow.selectedMethod = { type: 'manteca', countryPath: 'argentina', title: 'Bank Transfer' } mockWithdrawFlow.selectedBankAccount = { type: 'manteca', details: { countryName: 'argentina' } } - mockWithdrawFlow.amountToWithdraw = '50' - renderWithdraw() + renderWithdraw({ step: 'amount', amount: '50' }) fireEvent.click(screen.getByText('Continue')) expect(mockRouterPush).toHaveBeenCalledWith(expect.stringContaining('/withdraw/manteca')) @@ -651,11 +675,13 @@ describe('GROUP 7: Native sub-view mounting', () => { const queryClient = createQueryClient() rerender( - - - - - + + + + + + + ) // synchronously after the re-render — no awaiting a second import diff --git a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx index de5019928c..a332778e41 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx @@ -78,8 +78,9 @@ jest.mock('@/utils/cross-chain-fee.utils', () => ({ isWithdrawFeeDisproportionate: () => false, })) +const mockIsAmountWithinBalance = jest.fn((..._args: unknown[]) => true) jest.mock('@/utils/balance.utils', () => ({ - isAmountWithinBalance: () => true, + isAmountWithinBalance: (...args: unknown[]) => mockIsAmountWithinBalance(...args), })) jest.mock('@/utils/withdraw.utils', () => ({ @@ -89,6 +90,7 @@ jest.mock('@/utils/withdraw.utils', () => ({ jest.mock('@/utils/general.utils', () => ({ isTxReverted: (receipt: { status?: string } | null) => receipt?.status === 'reverted', printableAddress: (address: string) => `${address.slice(0, 6)}...${address.slice(-4)}`, + validateEnsName: () => false, })) jest.mock('@/utils/url.utils', () => ({ @@ -117,7 +119,7 @@ jest.mock('@/services/requests', () => ({ // ---------- view mocks ---------- -jest.mock('@/components/Withdraw/views/Confirm.withdraw.view', () => ({ +jest.mock('@/features/withdraw/views/ConfirmWithdrawView', () => ({ __esModule: true, default: (props: { onConfirm: () => void }) => ( + ), })) jest.mock('@/features/payments/shared/components/PaymentSuccessView', () => ({ @@ -167,7 +188,27 @@ const CHARGE_UUID = 'charge-uuid-123' const RECIPIENT = '0x1111111111111111111111111111111111111111' const USER_ADDRESS = '0x2222222222222222222222222222222222222222' -const mockSetCurrentView = jest.fn() +// URL stepper: the page renders by stepper.step and advances via goTo +const mockStepperGoTo = jest.fn() +const mockStepper = { + step: 'review' as string, + goTo: mockStepperGoTo, + back: jest.fn(), + reset: jest.fn(), + isFirst: false, +} +const mockStepperOptions = jest.fn() +jest.mock('@/hooks/useFlowStepper', () => ({ + useFlowStepper: (options: unknown) => { + mockStepperOptions(options) + return mockStepper + }, +})) +// mutable so tests can hand the page a tampered ?amount= (Chip round 4) +let mockUrlAmount = '50' +jest.mock('@/features/withdraw/useWithdrawAmount', () => ({ + useWithdrawAmount: () => [mockUrlAmount, jest.fn()], +})) const mockSetPaymentDetails = jest.fn() const mockSetTransactionHash = jest.fn() const mockSetPaymentError = jest.fn() @@ -190,13 +231,14 @@ const withdrawData = { amount: '50', } +const mockSetRecipient = jest.fn() +const mockSetIsValidRecipient = jest.fn() const mockWithdrawFlow = { - amountToWithdraw: '50', - usdAmount: '50', - setAmountToWithdraw: jest.fn(), - currentView: 'CONFIRM', - setCurrentView: mockSetCurrentView, withdrawData, + recipient: { address: RECIPIENT, name: '' }, + transactionHash: null as string | null, + setRecipient: mockSetRecipient, + setIsValidRecipient: mockSetIsValidRecipient, setWithdrawData: jest.fn(), showCompatibilityModal: false, setShowCompatibilityModal: jest.fn(), @@ -213,7 +255,7 @@ const mockWithdrawFlow = { resetWithdrawFlow: jest.fn(), } -jest.mock('@/context/WithdrawFlowContext', () => ({ +jest.mock('@/features/withdraw/WithdrawFlowContext', () => ({ useWithdrawFlow: () => mockWithdrawFlow, })) @@ -275,6 +317,10 @@ beforeEach(() => { jest.clearAllMocks() mockRecordPayment.mockResolvedValue(PAYMENT_RESULT) Object.assign(mockCrossChainTransfer, { isXChain: false, isDiffToken: false }) + mockUrlAmount = '50' + mockStepper.step = 'review' + mockIsAmountWithinBalance.mockReset() + mockIsAmountWithinBalance.mockImplementation(() => true) }) // ---------- tests ---------- @@ -291,7 +337,7 @@ describe('crypto withdraw confirm — charge completion', () => { await confirm() - await waitFor(() => expect(mockSetCurrentView).toHaveBeenCalledWith('STATUS')) + await waitFor(() => expect(mockStepperGoTo).toHaveBeenCalledWith('success')) expect(mockSendMoney).toHaveBeenCalledWith( RECIPIENT, '50', @@ -316,7 +362,7 @@ describe('crypto withdraw confirm — charge completion', () => { await confirm() - await waitFor(() => expect(mockSetCurrentView).toHaveBeenCalledWith('STATUS')) + await waitFor(() => expect(mockStepperGoTo).toHaveBeenCalledWith('success')) // The mined tx hash (not the userOp hash) must reach the validator. expect(mockRecordPayment).toHaveBeenCalledWith( expect.objectContaining({ chargeId: CHARGE_UUID, txHash: '0xmined' }) @@ -336,7 +382,7 @@ describe('crypto withdraw confirm — charge completion', () => { await confirm() - await waitFor(() => expect(mockSetCurrentView).toHaveBeenCalledWith('STATUS')) + await waitFor(() => expect(mockStepperGoTo).toHaveBeenCalledWith('success')) // The failing call MUST have been attempted — pre-fix code never called // recordPayment on this path, which is the bug. expect(mockRecordPayment).toHaveBeenCalled() @@ -359,7 +405,7 @@ describe('crypto withdraw confirm — charge completion', () => { await confirm() - await waitFor(() => expect(mockSetCurrentView).toHaveBeenCalledWith('STATUS')) + await waitFor(() => expect(mockStepperGoTo).toHaveBeenCalledWith('success')) expect(mockRecordPayment).toHaveBeenCalled() expect(mockCaptureMessage).toHaveBeenCalled() expect(mockPosthogCapture).not.toHaveBeenCalledWith('withdraw_failed', expect.anything()) @@ -376,7 +422,7 @@ describe('crypto withdraw confirm — charge completion', () => { await confirm() - await waitFor(() => expect(mockSetCurrentView).toHaveBeenCalledWith('STATUS')) + await waitFor(() => expect(mockStepperGoTo).toHaveBeenCalledWith('success')) expect(mockRecordPayment).not.toHaveBeenCalled() expect(mockCaptureMessage).toHaveBeenCalled() expect(mockSetPaymentDetails).toHaveBeenCalledWith(null) @@ -394,7 +440,7 @@ describe('crypto withdraw confirm — charge completion', () => { await confirm() - await waitFor(() => expect(mockSetCurrentView).toHaveBeenCalledWith('STATUS')) + await waitFor(() => expect(mockStepperGoTo).toHaveBeenCalledWith('success')) // Cross-chain keeps recording whatever hash it has (pre-existing // behavior): the BE validator's cross-chain branch completes from the // source-chain submission and never runs same-chain tx matching, so @@ -420,7 +466,7 @@ describe('crypto withdraw confirm — charge completion', () => { await waitFor(() => expect(mockPosthogCapture).toHaveBeenCalledWith('withdraw_failed', expect.anything())) expect(mockSendMoney).not.toHaveBeenCalled() - expect(mockSetCurrentView).not.toHaveBeenCalledWith('STATUS') + expect(mockStepperGoTo).not.toHaveBeenCalledWith('success') expect(mockSetWithdrawError).toHaveBeenCalledWith(expect.objectContaining({ showError: true })) }) @@ -437,7 +483,7 @@ describe('crypto withdraw confirm — charge completion', () => { await confirm() await waitFor(() => expect(mockPosthogCapture).toHaveBeenCalledWith('withdraw_failed', expect.anything())) - expect(mockSetCurrentView).not.toHaveBeenCalledWith('STATUS') + expect(mockStepperGoTo).not.toHaveBeenCalledWith('success') expect(mockSetWithdrawError).toHaveBeenCalledWith(expect.objectContaining({ showError: true })) }) }) @@ -462,7 +508,7 @@ describe('crypto withdraw retry — record-only replay (TASK-19581 double-spend) expect(mockSendMoney).toHaveBeenCalledTimes(1) fireEvent.click(screen.getByTestId('confirm-withdraw')) - await waitFor(() => expect(mockSetCurrentView).toHaveBeenCalledWith('STATUS')) + await waitFor(() => expect(mockStepperGoTo).toHaveBeenCalledWith('success')) // The on-chain leg ran exactly once across both attempts. expect(mockSendMoney).toHaveBeenCalledTimes(1) @@ -486,9 +532,147 @@ describe('crypto withdraw retry — record-only replay (TASK-19581 double-spend) await waitFor(() => expect(mockPosthogCapture).toHaveBeenCalledWith('withdraw_failed', expect.anything())) fireEvent.click(screen.getByTestId('confirm-withdraw')) - await waitFor(() => expect(mockSetCurrentView).toHaveBeenCalledWith('STATUS')) + await waitFor(() => expect(mockStepperGoTo).toHaveBeenCalledWith('success')) // No spend happened on attempt 1, so attempt 2 legitimately broadcasts. expect(mockSendMoney).toHaveBeenCalledTimes(2) }) }) + +describe('crypto withdraw — success step demands execution proof (Chip review PR #2917)', () => { + afterEach(() => { + mockWithdrawFlow.transactionHash = null + }) + + const lastGuards = () => mockStepperOptions.mock.calls.at(-1)?.[0]?.guards as Record + + test('with prepared charge data but no broadcast tx, the success guard refuses (?step=success tampering)', () => { + render() + expect(lastGuards().review.ok).toBe(true) + expect(lastGuards().success.ok).toBe(false) + }) + + test('after execution the success guard admits the step', () => { + mockWithdrawFlow.transactionHash = '0xabc' + render() + expect(lastGuards().success.ok).toBe(true) + }) +}) + +describe('crypto withdraw — unmount keeps the flow selection (Chip review PR #2917)', () => { + test('unmount clears transient state only — never resetWithdrawFlow', () => { + // Back from the recipient screen is an intra-/withdraw transition: + // the root amount guard needs selectedMethod to survive, or back + // bounces to method selection instead of the amount step. The page + // must clear its transient state (charge, route, recipient) without + // the blanket reset. + const { unmount } = render() + unmount() + + expect(mockWithdrawFlow.resetWithdrawFlow).not.toHaveBeenCalled() + expect(mockWithdrawFlow.setChargeDetails).toHaveBeenCalledWith(null) + expect(mockWithdrawFlow.setWithdrawData).toHaveBeenCalledWith(null) + expect(mockSetTransactionHash).toHaveBeenCalledWith(null) + expect(mockSetPaymentDetails).toHaveBeenCalledWith(null) + expect(mockSetRecipient).toHaveBeenCalledWith({ address: '', name: '' }) + expect(mockSetIsValidRecipient).toHaveBeenCalledWith(false) + }) +}) + +describe('crypto withdraw — URL amount validation (Chip review round 4)', () => { + // the setup path: recipient screen → Review click → request/charge creation + const clickReview = () => { + mockStepper.step = 'recipient' + const view = render() + fireEvent.click(screen.getByTestId('review-cta')) + return view + } + + const { requestsApi } = jest.requireMock('@/services/requests') as { + requestsApi: { create: jest.Mock } + } + const { chargesApi } = jest.requireMock('@/services/charges') as { + chargesApi: { create: jest.Mock; get: jest.Mock } + } + + const armHappyPersistence = () => { + requestsApi.create.mockResolvedValue({ uuid: 'req-1' }) + chargesApi.create.mockResolvedValue({ data: { id: CHARGE_UUID } }) + chargesApi.get.mockResolvedValue(chargeDetails) + } + + test.each([['0'], ['abc'], ['-5']])('?amount=%s persists no request and no charge', async (tampered) => { + mockUrlAmount = tampered + clickReview() + + // error surfaced, nothing persisted + await waitFor(() => expect(mockSetPaymentError).toHaveBeenCalled()) + expect(requestsApi.create).not.toHaveBeenCalled() + expect(chargesApi.create).not.toHaveBeenCalled() + }) + + test('an over-balance ?amount= persists no request and no charge', async () => { + mockUrlAmount = '150' + mockIsAmountWithinBalance.mockImplementation(() => false) + clickReview() + + await waitFor(() => expect(mockSetPaymentError).toHaveBeenCalled()) + expect(requestsApi.create).not.toHaveBeenCalled() + expect(chargesApi.create).not.toHaveBeenCalled() + }) + + test('a valid amount persists the request/charge with the normalized value', async () => { + armHappyPersistence() + clickReview() + + await waitFor(() => expect(chargesApi.get).toHaveBeenCalledWith(CHARGE_UUID)) + // a new review invalidates the previous attempt's execution proof — + // otherwise ?step=success re-renders the old success screen (Chip round 7) + expect(mockSetTransactionHash).toHaveBeenCalledWith(null) + expect(requestsApi.create).toHaveBeenCalledWith(expect.objectContaining({ tokenAmount: '50.000000' })) + expect(chargesApi.create).toHaveBeenCalledWith( + expect.objectContaining({ local_price: { amount: '50', currency: 'USD' } }) + ) + }) + + test('a post-review ?amount= edit does not change the broadcast — the charge-pinned amount moves', async () => { + armHappyPersistence() + mockSendMoney.mockResolvedValue({ + txHash: '0xbetx', + userOpHash: undefined, + receipt: null, + strategy: 'collateral-only', + }) + + const view = clickReview() + await waitFor(() => expect(chargesApi.get).toHaveBeenCalled()) + + // tamper the URL after the records exist, then confirm + mockUrlAmount = '999999' + mockStepper.step = 'review' + view.rerender() + fireEvent.click(screen.getByTestId('confirm-withdraw')) + + await waitFor(() => expect(mockSendMoney).toHaveBeenCalled()) + expect(mockSendMoney).toHaveBeenCalledWith(RECIPIENT, '50', expect.anything()) + }) + + test('a tampered over-balance amount at confirm never reaches sendMoney', async () => { + // no setup ran in this mount (no pin) — the URL amount is all there is + mockUrlAmount = '150' + mockIsAmountWithinBalance.mockImplementation(() => false) + await confirm() + + await waitFor(() => expect(mockSetPaymentError).toHaveBeenCalled()) + expect(mockSendMoney).not.toHaveBeenCalled() + expect(mockSendTransactions).not.toHaveBeenCalled() + }) + + test('a malformed amount at confirm never reaches sendMoney', async () => { + mockUrlAmount = 'abc' + await confirm() + + await waitFor(() => expect(mockSetPaymentError).toHaveBeenCalled()) + expect(mockSendMoney).not.toHaveBeenCalled() + }) +}) diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index 6b62b78d38..881f6fc761 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -5,9 +5,14 @@ import SlideToConfirm from '@/components/0_Bruddle/SlideToConfirm' import AddressLink from '@/components/Global/AddressLink' import Loading from '@/components/Global/Loading' import PaymentSuccessView from '@/features/payments/shared/components/PaymentSuccessView' -import ConfirmWithdrawView from '@/components/Withdraw/views/Confirm.withdraw.view' -import InitialWithdrawView from '@/components/Withdraw/views/Initial.withdraw.view' -import { useWithdrawFlow, type WithdrawData } from '@/context/WithdrawFlowContext' +import ConfirmWithdrawView from '@/features/withdraw/views/ConfirmWithdrawView' +import InitialWithdrawView from '@/features/withdraw/views/InitialWithdrawView' +import { useWithdrawFlow } from '@/features/withdraw/WithdrawFlowContext' +import { useWithdrawAmount } from '@/features/withdraw/useWithdrawAmount' +import { useFlowStepper } from '@/hooks/useFlowStepper' +import { WITHDRAW_CRYPTO_STEPS, type WithdrawData } from '@/features/withdraw/types' +import { cryptoStepGuards } from '@/features/withdraw/step-guards' +import { validateCryptoWithdrawAmount } from '@/features/withdraw/amount-validation' import { useWallet } from '@/hooks/wallet/useWallet' import { chargesApi } from '@/services/charges' import { requestsApi } from '@/services/requests' @@ -49,6 +54,7 @@ export default function WithdrawCryptoPage() { const router = useRouter() const t = useTranslations('withdraw') const tCommon = useTranslations('common') + const tErrors = useTranslations('errors') const tNav = useTranslations('navigation') const toFriendlyError = useFriendlyError() // Send → Exchange or Wallet lands here as /withdraw/crypto?method=crypto. @@ -57,16 +63,14 @@ export default function WithdrawCryptoPage() { // Forward the marker verbatim rather than assuming crypto: entering as // /withdraw?method=bank and then picking Crypto lands here as method=bank, // and rewriting it to crypto would change the amount step's back behaviour. + // step=amount lands on the amount screen directly — the root stepper's + // guard falls back to method selection if the flow memory is gone. const { isFromSendFlow, sendFlowMethod } = useSendFlowOrigin() - const amountStepHref = isFromSendFlow ? `/withdraw?method=${sendFlowMethod}` : '/withdraw' + const amountStepHref = isFromSendFlow ? `/withdraw?step=amount&method=${sendFlowMethod}` : '/withdraw?step=amount' const onBack = useSafeBack(amountStepHref) const { address, sendTransactions, sendMoney, spendableBalance } = useWallet() const { resetTokenContextProvider } = useContext(tokenSelectorContext) const { - amountToWithdraw, - usdAmount, - currentView, - setCurrentView, withdrawData, setWithdrawData, showCompatibilityModal, @@ -78,13 +82,33 @@ export default function WithdrawCryptoPage() { setError: setWithdrawError, chargeDetails, setChargeDetails, + transactionHash, setTransactionHash, paymentDetails, setPaymentDetails, - resetWithdrawFlow, + setRecipient, + setIsValidRecipient, recipient, } = useWithdrawFlow() + // the one typed amount (USD), carried in the URL from the shared amount step + const [amountToWithdraw] = useWithdrawAmount() + const usdAmount = amountToWithdraw + + // recipient → review → success as named screen ids in the URL. The guards + // cover refresh/deep-link into a step whose prepared state (charge, route) + // did not survive — and the success step additionally demands EXECUTION + // proof (the broadcast transaction identifier), so a hand-edited + // ?step=success can never render a success screen for a transfer that + // never ran (Chip review, PR #2917). + const stepper = useFlowStepper({ + steps: WITHDRAW_CRYPTO_STEPS, + guards: cryptoStepGuards({ + prepared: !!(chargeDetails && withdrawData), + executed: !!transactionHash, + }), + }) + // hooks for route calculation and payment recording const { transactions, @@ -114,10 +138,21 @@ export default function WithdrawCryptoPage() { strategy: 'collateral-only' | 'smart-only' | 'mixed' | undefined } | null>(null) + // The USD amount the request/charge rows were created for, pinned to the + // charge id. The confirm leg broadcasts THIS, not the still-editable + // `?amount=` — otherwise an edit between review and confirm moves a + // different amount on-chain than the records say (Chip review round 4). + const setupAmountRef = useRef<{ chargeId: string; amountUsd: string } | null>(null) + const { triggerHaptic } = useAppHaptic() // local state for transaction execution const [isSendingTx, setIsSendingTx] = useState(false) + // The USD amount the executed withdrawal actually moved (the charge-pinned + // broadcast amount). The success screen and the completion analytics read + // THIS — `?amount=` stays user-editable after execution, and rendering it + // would let a URL edit forge the receipt (Chip round 7). + const [executedAmountUsd, setExecutedAmountUsd] = useState(null) // combined processing state const isProcessing = useMemo(() => isSendingTx || isRecording, [isSendingTx, isRecording]) @@ -164,17 +199,22 @@ export default function WithdrawCryptoPage() { } }, [routeError, recordError, setPaymentError]) - // prepare transaction when entering confirm view + // prepare transaction when entering the review step useEffect(() => { - if (currentView === 'CONFIRM' && chargeDetails && withdrawData && address) { + if (stepper.step === 'review' && chargeDetails && withdrawData && address) { calculateRoute({ source: { address: address as Address, tokenAddress: PEANUT_WALLET_TOKEN as Address, chainId: PEANUT_WALLET_CHAIN.id.toString(), - // amountToWithdraw is USD-denominated; source token is USDC (1:1). - // Required for the bridge path's 'pay' mode (cross-chain ETH/etc). - tokenAmount: amountToWithdraw, + // USD-denominated; source token is USDC (1:1). Required for + // the bridge path's 'pay' mode (cross-chain ETH/etc). Pinned + // to the amount the charge was created for — the URL param + // stays editable and must not re-route the quote. + tokenAmount: + setupAmountRef.current?.chargeId === chargeDetails.uuid + ? setupAmountRef.current.amountUsd + : amountToWithdraw, }, destination: { recipientAddress: chargeDetails.requestLink.recipientAddress as Address, @@ -190,7 +230,7 @@ export default function WithdrawCryptoPage() { skipGasEstimate: true, // peanut wallet handles gas }) } - }, [currentView, chargeDetails, withdrawData, calculateRoute, address, amountToWithdraw]) + }, [stepper.step, chargeDetails, withdrawData, calculateRoute, address, amountToWithdraw]) const handleSetupReview = useCallback( async (data: Omit) => { @@ -200,6 +240,25 @@ export default function WithdrawCryptoPage() { return } + // `?amount=` is user-editable URL text — validate and normalize it + // BEFORE any request/charge is persisted (Chip review round 4): + // finite, positive, plain-decimal, within the loaded balance. + // Same-chain USDC has no rail minimum, so `0` and malformed values + // used to sail past the Rhino-only minimum check below and persist + // request+charge records that could never sign. + const amountCheck = validateCryptoWithdrawAmount(amountToWithdraw, spendableBalance) + if (!amountCheck.ok) { + setError( + amountCheck.reason === 'insufficientBalance' + ? tErrors('notEnoughBalanceAddFunds') + : amountCheck.reason === 'balanceLoading' + ? t('errors.prepareFailed') + : t('errors.invalidAmount') + ) + return + } + const amountUsd = amountCheck.normalized + // Same-chain USDC is a direct transfer — no Rhino, no minimum // (parity with send-via-link). Every other destination/token rides // Rhino, which parks (doesn't auto-refund) deposits below the route @@ -209,7 +268,7 @@ export default function WithdrawCryptoPage() { data.chain.chainId.toString() === PEANUT_WALLET_CHAIN.id.toString() && data.token.address.toLowerCase() === PEANUT_WALLET_TOKEN.toLowerCase() if (!isSameChainUsdc) { - const usdToWithdraw = parseFloat(amountToWithdraw) + const usdToWithdraw = parseFloat(amountUsd) const minUsd = getMinWithdrawUsdForChain(data.chain.chainId) if (!Number.isFinite(usdToWithdraw) || usdToWithdraw < minUsd) { const minDisplay = minUsd % 1 === 0 ? `$${minUsd}` : `$${minUsd.toFixed(2)}` @@ -222,6 +281,11 @@ export default function WithdrawCryptoPage() { clearErrors() setChargeDetails(null) + // a NEW attempt invalidates the previous one's execution proof — + // without this, ?step=success re-renders the old success screen + // while the new attempt is mid-flight (Chip round 7) + setTransactionHash(null) + setExecutedAmountUsd(null) setIsPreparingReview(true) try { @@ -230,10 +294,10 @@ export default function WithdrawCryptoPage() { // units before persisting the request/charge — otherwise meta // ends up with `tokenAmount: "1"` + `tokenSymbol: "ETH"` and // history renders "1 ETH" for what was actually a $1 withdraw. - const usdValue = parseFloat(amountToWithdraw) + const usdValue = parseFloat(amountUsd) const tokenPrice = data.token.price ?? 0 const destinationTokenAmount = - tokenPrice > 0 ? (usdValue / tokenPrice).toFixed(Number(data.token.decimals)) : amountToWithdraw + tokenPrice > 0 ? (usdValue / tokenPrice).toFixed(Number(data.token.decimals)) : amountUsd const completeWithdrawData = { ...data, amount: destinationTokenAmount } setWithdrawData(completeWithdrawData) @@ -287,6 +351,10 @@ export default function WithdrawCryptoPage() { const fullChargeDetails = await chargesApi.get(createdCharge.data.id) + // the confirm leg broadcasts the amount these records were + // created for — never re-read from the editable URL + setupAmountRef.current = { chargeId: fullChargeDetails.uuid, amountUsd } + setChargeDetails(fullChargeDetails) setShowCompatibilityModal(true) } catch (err) { @@ -299,26 +367,29 @@ export default function WithdrawCryptoPage() { }, [ amountToWithdraw, + spendableBalance, clearErrors, setChargeDetails, + setTransactionHash, setIsPreparingReview, setWithdrawData, setShowCompatibilityModal, setError, recipient, t, + tErrors, ] ) const handleCompatibilityProceed = useCallback(() => { setShowCompatibilityModal(false) if (chargeDetails && withdrawData) { - setCurrentView('CONFIRM') + void stepper.goTo('review') } else { console.error('Proceeding to confirm, but charge details or withdraw data are missing.') setError(t('errors.confirmDetailsFailed')) } - }, [chargeDetails, withdrawData, setCurrentView, setShowCompatibilityModal, setError, t]) + }, [chargeDetails, withdrawData, stepper, setShowCompatibilityModal, setError, t]) // True when the withdraw needs a Rhino path (SDA or bridge swap) rather // than a direct USDC transfer. Crosses a chain boundary OR a token @@ -343,6 +414,30 @@ export default function WithdrawCryptoPage() { return } + // Broadcast the amount the charge was created for (pinned at setup) — + // `?amount=` stays editable between review and confirm, and re-reading + // it here would move a different amount on-chain than the records say. + // Re-validate it against the LIVE balance right before the money moves + // (Chip review round 4). The record-only replay path is exempt: funds + // already moved for that charge and only the bookkeeping replays. + const pinnedAmount = + setupAmountRef.current?.chargeId === chargeDetails.uuid ? setupAmountRef.current.amountUsd : null + let broadcastAmount = pinnedAmount ?? amountToWithdraw + if (executedSpendRef.current?.chargeId !== chargeDetails.uuid) { + const amountCheck = validateCryptoWithdrawAmount(broadcastAmount, spendableBalance) + if (!amountCheck.ok) { + setError( + amountCheck.reason === 'insufficientBalance' + ? tErrors('notEnoughBalanceAddFunds') + : amountCheck.reason === 'balanceLoading' + ? t('errors.prepareFailed') + : t('errors.invalidAmount') + ) + return + } + broadcastAmount = amountCheck.normalized + } + clearErrors() setIsSendingTx(true) @@ -385,7 +480,7 @@ export default function WithdrawCryptoPage() { txHash, receipt: r, strategy: s, - } = await sendMoney(withdrawData.address as Address, amountToWithdraw, { + } = await sendMoney(withdrawData.address as Address, broadcastAmount, { kind: 'CRYPTO_WITHDRAW', // Lets the backend settle the charge directly when the spend // routes through Rain card collateral (collateral-only): the @@ -492,11 +587,13 @@ export default function WithdrawCryptoPage() { executedSpendRef.current = null setTransactionHash(finalTxHash) + setExecutedAmountUsd(broadcastAmount) setPaymentDetails(payment) triggerHaptic() - setCurrentView('STATUS') + void stepper.goTo('success') posthog.capture(ANALYTICS_EVENTS.WITHDRAW_COMPLETED, { - amount_usd: usdAmount, + // the amount that moved, not the still-editable URL param + amount_usd: broadcastAmount, method_type: 'crypto', }) } catch (err) { @@ -527,6 +624,7 @@ export default function WithdrawCryptoPage() { chargeDetails, withdrawData, amountToWithdraw, + spendableBalance, address, transactions, payAmount, @@ -535,34 +633,60 @@ export default function WithdrawCryptoPage() { sendMoney, isCrossChainWithdrawal, recordPayment, - setCurrentView, + stepper, setTransactionHash, setPaymentDetails, clearErrors, setError, triggerHaptic, t, + tErrors, toFriendlyError, ]) const handleBackFromConfirm = useCallback(() => { - setCurrentView('INITIAL') + void stepper.goTo('recipient') clearErrors() setChargeDetails(null) - }, [setCurrentView, clearErrors, setChargeDetails]) - - // reset withdraw flow when this component unmounts. Resetting on unmount (rather - // than in the success view's onComplete) avoids a race: a synchronous reset clears - // amountToWithdraw and flips currentView off STATUS, which re-triggers the guard - // below and pushes '/withdraw' over the '/home' navigation from "Back to home". + }, [stepper, clearErrors, setChargeDetails]) + + // Clear crypto-TRANSIENT flow memory when this page unmounts (charge, + // route, recipient, token selection) — on unmount rather than in the + // success view's onComplete to avoid a race with the '/home' navigation + // from "Back to home". Deliberately NOT resetWithdrawFlow(): back from the + // recipient screen is an intra-/withdraw transition, and nuking + // selectedMethod here made the root amount guard bounce that back-nav to + // method selection instead of the amount step (Chip review, PR #2917). + // Leaving /withdraw entirely unmounts the provider, which clears the rest. useEffect(() => { return () => { resetRouteCalculation() resetPaymentRecorder() resetTokenContextProvider() // reset token selector context to make sure previously selected token is not cached - resetWithdrawFlow() + setWithdrawData(null) + setChargeDetails(null) + setTransactionHash(null) + setPaymentDetails(null) + setRecipient({ address: '', name: '' }) + setIsValidRecipient(false) + setPaymentError(null) + setWithdrawError({ showError: false, errorMessage: '' }) + setShowCompatibilityModal(false) } - }, [resetRouteCalculation, resetPaymentRecorder, resetTokenContextProvider, resetWithdrawFlow]) + }, [ + resetRouteCalculation, + resetPaymentRecorder, + resetTokenContextProvider, + setWithdrawData, + setChargeDetails, + setTransactionHash, + setPaymentDetails, + setRecipient, + setIsValidRecipient, + setPaymentError, + setWithdrawError, + setShowCompatibilityModal, + ]) // Display payment errors first (user actions), then route errors (system limitations) const displayError = paymentError @@ -612,9 +736,9 @@ export default function WithdrawCryptoPage() { // effect — navigating during render is a React violation ("Cannot update // Router while rendering WithdrawCryptoPage") that hard-errors the Next 16 // dev overlay on direct entry/refresh of this route. - // Guard against STATUS view: resetWithdrawFlow() clears amountToWithdraw, - // which would override the router.push('/home') in handleDone - const needsAmountRedirect = !amountToWithdraw && currentView !== 'STATUS' + // Guard against the success step: it must stay rendered while the "Back to + // home" navigation is in flight. + const needsAmountRedirect = !amountToWithdraw && stepper.step !== 'success' useEffect(() => { if (needsAmountRedirect) router.push(amountStepHref) }, [needsAmountRedirect, router, amountStepHref]) @@ -625,7 +749,7 @@ export default function WithdrawCryptoPage() { return (
- {currentView === 'INITIAL' && ( + {stepper.step === 'recipient' && ( )} - {currentView === 'CONFIRM' && withdrawData && chargeDetails && ( + {stepper.step === 'review' && withdrawData && chargeDetails && ( )} - {currentView === 'STATUS' && withdrawData && chargeDetails && ( + {stepper.step === 'success' && withdrawData && chargeDetails && ( <> {children} + return ( + + {children} + + ) } diff --git a/src/app/(mobile-ui)/withdraw/manteca/__tests__/manteca-withdraw-gates.test.tsx b/src/app/(mobile-ui)/withdraw/manteca/__tests__/manteca-withdraw-gates.test.tsx new file mode 100644 index 0000000000..824c2c4f61 --- /dev/null +++ b/src/app/(mobile-ui)/withdraw/manteca/__tests__/manteca-withdraw-gates.test.tsx @@ -0,0 +1,410 @@ +/** + * Manteca withdraw — submit-time balance/limits gates (Chip review round 5). + * + * The page re-checks the async balance/limits gates at the two money + * boundaries: before locking a price (handleBankDetailsSubmit) and right + * before signing/submitting (handleWithdraw). A gate that flips to blocking + * while the user sits on review must bounce back to the amount step WITHOUT + * calling signSpend / mantecaApi.withdrawWithSignedTx; with all gates clear + * the withdraw call fires once with the locked priceLockCode + usdAmount. + * + * Strategy (same as crypto-withdraw-confirm.test.tsx): mock every hook and + * UI component at the module level, drive the real page component. The flow + * reaches review through the REAL useMantecaAmountSeed (?amount= hand-off) + * and the real price-lock handler (mantecaApi.initiateWithdraw mocked). + */ +import React from 'react' +import { render as rtlRender, screen, fireEvent, waitFor } from '@testing-library/react' + +// ---------- module-level mocks ---------- + +const mockRouterReplace = jest.fn() +const searchParamsMap: Record = { + country: 'argentina', + destination: '0000003100010000000001', +} +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: jest.fn(), back: jest.fn(), replace: mockRouterReplace, prefetch: jest.fn() }), + useSearchParams: () => ({ get: (k: string) => searchParamsMap[k] ?? null }), + usePathname: () => '/withdraw/manteca', +})) + +jest.mock('next-intl', () => ({ + useTranslations: (ns: string) => (key: string) => `${ns}.${key}`, + useLocale: () => 'en', +})) + +const mockPosthogCapture = jest.fn() +jest.mock('posthog-js', () => ({ + __esModule: true, + default: { capture: (...args: unknown[]) => mockPosthogCapture(...args), init: jest.fn() }, +})) + +jest.mock('@sentry/nextjs', () => ({ + captureMessage: jest.fn(), + captureException: jest.fn(), +})) + +jest.mock('@/constants/zerodev.consts', () => ({ + PEANUT_WALLET_CHAIN: { id: 42161 }, + PEANUT_WALLET_TOKEN_DECIMALS: 6, +})) + +jest.mock('@/constants/analytics.consts', () => ({ + ANALYTICS_EVENTS: { + WITHDRAW_CONFIRMED: 'withdraw_confirmed', + WITHDRAW_COMPLETED: 'withdraw_completed', + WITHDRAW_FAILED: 'withdraw_failed', + }, +})) + +// ---------- UI component stubs ---------- + +jest.mock('@/components/0_Bruddle/Button', () => ({ + Button: (props: { onClick?: () => void; disabled?: boolean; children?: React.ReactNode }) => ( + + ), +})) +jest.mock('@/components/0_Bruddle/Card', () => ({ + Card: (props: { children?: React.ReactNode }) =>
{props.children}
, +})) +jest.mock('@/components/0_Bruddle/IconBubble', () => ({ IconBubble: () => null })) +jest.mock('@/components/0_Bruddle/Notification', () => ({ + Notification: (props: { children?: React.ReactNode }) =>
{props.children}
, +})) +jest.mock('@/components/0_Bruddle/LinkButton', () => ({ LinkButton: () => null })) +jest.mock('@/components/0_Bruddle/BaseSelect', () => ({ __esModule: true, default: () => null })) +jest.mock('@/components/Global/NavHeader', () => ({ __esModule: true, default: () => null })) +jest.mock('@/components/Global/Icons/Icon', () => ({ Icon: () => null })) +jest.mock('@/components/Global/Loading', () => ({ __esModule: true, default: () =>
})) +jest.mock('@/components/Global/RateUnavailable/RateGateScreen', () => ({ __esModule: true, default: () => null })) +jest.mock('@/components/Global/SoundPlayer', () => ({ SoundPlayer: () => null })) +jest.mock('@/components/Global/ValidatedInput', () => ({ + __esModule: true, + default: (props: { + value: string + onUpdate: (u: { value: string; isValid: boolean; isChanging: boolean }) => void + }) => ( + props.onUpdate({ value: e.target.value, isValid: true, isChanging: false })} + /> + ), +})) +jest.mock('@/components/Global/AmountInput', () => ({ + __esModule: true, + default: () =>
, +})) +jest.mock('@/components/Payment/PaymentInfoRow', () => ({ PaymentInfoRow: () => null })) +jest.mock('@/components/Common/PointsCard', () => ({ __esModule: true, default: () => null })) +jest.mock('@/features/limits/components/LimitsWarningCard', () => ({ __esModule: true, default: () => null })) +jest.mock('@/components/Kyc/SumsubKycModals', () => ({ SumsubKycModals: () => null })) +jest.mock('@/components/Kyc/InitiateKycModal', () => ({ InitiateKycModal: () => null })) +jest.mock('@/components/Kyc/SumsubKycWrapper', () => ({ SumsubKycWrapper: () => null })) +jest.mock('@/components/Global/Banner/MantecaTransfersMaintenanceView', () => ({ + MantecaTransfersMaintenanceView: () =>
, +})) +jest.mock('@/features/withdraw/views/PixKeySendView', () => ({ __esModule: true, default: () => null })) +jest.mock('next/image', () => ({ + __esModule: true, + default: (props: { src: string; alt?: string }) => {props.alt, +})) + +// ---------- hook mocks ---------- + +let mockBalance: bigint | undefined = 100n * 10n ** 6n // 100 USDC +jest.mock('@/hooks/wallet/useWallet', () => ({ + useWallet: () => ({ spendableBalance: mockBalance, formattedSpendableBalance: '$100.00' }), +})) + +const mockSignSpend = jest.fn() +jest.mock('@/hooks/wallet/useSignSpendBundle', () => ({ + useSignSpendBundle: () => ({ signSpend: mockSignSpend }), +})) +jest.mock('@/hooks/wallet/useStaleSessionGuard', () => ({ + useStaleSessionGuard: () => jest.fn(), +})) +jest.mock('@/hooks/wallet/spendPreflight', () => ({ + SessionKeyGrantRequiredError: class SessionKeyGrantRequiredError extends Error {}, +})) +jest.mock('@/hooks/useRainCardOverview', () => ({ + useRainCardOverview: () => ({ overview: null }), +})) +jest.mock('@/context/loadingStates.context', () => { + const ReactActual = jest.requireActual('react') + return { + loadingStateContext: ReactActual.createContext({ + isLoading: false, + loadingState: 'Idle', + setLoadingState: jest.fn(), + }), + } +}) +jest.mock('@/context/ModalsContext', () => ({ + useModalsContext: () => ({ setIsSupportModalOpen: jest.fn(), openSupportWithMessage: jest.fn() }), +})) +jest.mock('@/hooks/useCapabilities', () => ({ + useCapabilities: () => ({ rails: [], nextActions: undefined }), +})) +jest.mock('@/hooks/useIdentityVerification', () => ({ + useIdentityVerification: () => ({ isVerified: true }), +})) +jest.mock('@/utils/provider-rejection.utils', () => ({ + deriveProviderRejection: () => ({ state: 'none', userMessage: null }), +})) +const sumsubFlowStub = { + isLoading: false, + error: null, + showWrapper: false, + accessToken: null, + handleRestartIdentity: jest.fn(), + handleFixableRejection: jest.fn(), + handleInitiateKyc: jest.fn(), + handleSelfHealResubmit: jest.fn(), + handleClose: jest.fn(), + handleSdkComplete: jest.fn(), + refreshToken: jest.fn(), +} +jest.mock('@/hooks/useMultiPhaseKycFlow', () => ({ + useMultiPhaseKycFlow: () => sumsubFlowStub, +})) +jest.mock('@/hooks/useSumsubActionFlow', () => ({ + useSumsubActionFlow: () => ({ + showWrapper: false, + accessToken: null, + isLoading: false, + handleInitiate: jest.fn(), + handleClose: jest.fn(), + handleSdkComplete: jest.fn(), + refreshToken: jest.fn(), + }), +})) +jest.mock('@/app/actions/increase-limits', () => ({ + initiateIncreaseLimits: jest.fn(), +})) +jest.mock('@/hooks/wallet/usePendingTransactions', () => ({ + usePendingTransactions: () => ({ hasPendingTransactions: false }), +})) +jest.mock('@/hooks/usePointsConfetti', () => ({ usePointsConfetti: () => {} })) +jest.mock('@/hooks/usePointsCalculation', () => ({ + usePointsCalculation: () => ({ pointsData: null, pointsDivRef: { current: null } }), +})) +// mutable limits verdict — the gate under test +const mockLimitsValidation = { + isBlocking: false, + isLoading: false, + isWarning: false, + message: null as string | null, + remainingLimit: null, + totalLimit: null, + daysUntilReset: null, + currency: 'ARS', + limitCurrency: null, +} +jest.mock('@/features/limits/hooks/useLimitsValidation', () => ({ + useLimitsValidation: () => mockLimitsValidation, +})) +jest.mock('@/features/limits/utils', () => ({ + getLimitsWarningCardProps: () => null, + isBrUserEligibleForLimitIncrease: () => false, +})) +jest.mock('@/hooks/useLimits', () => ({ + useLimits: () => ({ mantecaLimits: null, refetch: jest.fn() }), +})) +jest.mock('@/utils/regions.utils', () => ({ + ...jest.requireActual('@/utils/regions.utils'), + isVerifiedForCountry: () => true, +})) +jest.mock('@/hooks/useCurrency', () => ({ + useCurrency: () => ({ + code: 'ARS', + price: { sell: 1500, buy: 1490 }, + isLoading: false, + refetch: jest.fn(), + }), +})) +const mockInitiateWithdraw = jest.fn() +const mockWithdrawWithSignedTx = jest.fn() +jest.mock('@/services/manteca', () => ({ + mantecaApi: { + initiateWithdraw: (...args: unknown[]) => mockInitiateWithdraw(...args), + withdrawWithSignedTx: (...args: unknown[]) => mockWithdrawWithSignedTx(...args), + }, +})) +jest.mock('@/utils/friendly-error.utils', () => ({ + friendlyError: () => ({ kind: 'text' }), +})) +jest.mock('@/hooks/useFriendlyError', () => ({ + useFriendlyError: () => (err: unknown) => (err instanceof Error ? err.message : String(err)), +})) +jest.mock('@/utils/network-triage', () => ({ + captureNetworkTriagedFailure: jest.fn(), + isNetworkLayerFailure: () => false, +})) +jest.mock('@/utils/sentry-critical-flow', () => ({ + criticalFlowTags: () => ({}), +})) +jest.mock('@/utils/native-routes', () => ({ + withdrawCountryUrl: (country: string) => `/withdraw/${country}`, +})) +jest.mock('@/hooks/useSafeBack', () => ({ + useSafeBack: () => jest.fn(), +})) +jest.mock('@/constants/countryCurrencyMapping', () => ({ + getFlagUrl: () => '/flag.png', +})) +jest.mock('@/utils/country-name.utils', () => ({ + localizedCountryTitle: () => 'Argentina', +})) +jest.mock('@/i18n/app/loading-states', () => ({ + loadingStateKey: (s: string) => s, +})) +jest.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ invalidateQueries: jest.fn() }), +})) + +// URL stepper: mutable step, like crypto-withdraw-confirm.test.tsx +const mockStepperGoTo = jest.fn() +const mockStepper = { + step: 'amount' as string, + goTo: mockStepperGoTo, + back: jest.fn(), + reset: jest.fn(), + isFirst: false, +} +jest.mock('@/hooks/useFlowStepper', () => ({ + useFlowStepper: () => mockStepper, +})) + +let mockUrlAmount = '50' +jest.mock('@/features/withdraw/useWithdrawAmount', () => ({ + useWithdrawAmount: () => [mockUrlAmount, jest.fn()], +})) + +import MantecaWithdrawFlow from '../page' + +// ---------- helpers ---------- + +const PRICE_LOCK = { priceLockCode: 'lock-1', fiatAmount: '75000.00' } + +const render = (ui: React.ReactElement) => rtlRender(ui) + +/** + * Drive the real flow to the review step: the seed consumes ?amount=50 on the + * amount step (advancing via the mocked stepper), bank-details submits with + * the ?destination=-prefilled CBU (locks the price), review renders Confirm. + */ +const reachReview = async () => { + mockInitiateWithdraw.mockResolvedValue({ data: PRICE_LOCK }) + mockStepper.step = 'amount' + const view = render() + // the seed consumed ?amount= and asked to advance + await waitFor(() => expect(mockStepperGoTo).toHaveBeenCalledWith('bank-details')) + + mockStepper.step = 'bank-details' + view.rerender() + // mark the (?destination=-prefilled) CBU valid, then submit for the price lock + fireEvent.change(screen.getByTestId('destination-input'), { target: { value: '0000003100010000000009' } }) + fireEvent.click(screen.getByText('withdraw.review')) + // the price locked and the flow asked for review + await waitFor(() => expect(mockInitiateWithdraw).toHaveBeenCalledWith({ amount: '50.00', currency: 'ARS' })) + await waitFor(() => expect(mockStepperGoTo).toHaveBeenCalledWith('review')) + + mockStepper.step = 'review' + view.rerender() + return view +} + +const clickConfirm = () => fireEvent.click(screen.getByText('navigation.withdraw')) + +beforeEach(() => { + jest.clearAllMocks() + mockBalance = 100n * 10n ** 6n + mockUrlAmount = '50' + mockStepper.step = 'amount' + Object.assign(mockLimitsValidation, { isBlocking: false, isLoading: false }) +}) + +// ---------- tests ---------- + +describe('manteca withdraw — submit-time gates (Chip review round 5)', () => { + it('all gates clear: the withdraw fires once with the locked price and amount', async () => { + mockSignSpend.mockResolvedValue({ + strategy: 'smart-only', + signedUserOp: { signedUserOp: '0xsigned', chainId: '42161', entryPointAddress: '0xep' }, + }) + mockWithdrawWithSignedTx.mockResolvedValue({ data: { ok: true } }) + + await reachReview() + clickConfirm() + + await waitFor(() => expect(mockWithdrawWithSignedTx).toHaveBeenCalledTimes(1)) + expect(mockWithdrawWithSignedTx).toHaveBeenCalledWith( + expect.objectContaining({ priceLockCode: 'lock-1', amount: '50.00' }) + ) + expect(mockSignSpend).toHaveBeenCalledTimes(1) + }) + + it('limits flip to blocking on review: Confirm bounces to the amount step and moves no money', async () => { + const view = await reachReview() + + // the async limits verdict flips while the user sits on review + mockLimitsValidation.isBlocking = true + view.rerender() + mockStepperGoTo.mockClear() + clickConfirm() + + await waitFor(() => expect(mockStepperGoTo).toHaveBeenCalledWith('amount')) + expect(mockSignSpend).not.toHaveBeenCalled() + expect(mockWithdrawWithSignedTx).not.toHaveBeenCalled() + }) + + it('balance becomes undefined on review (refetch gap): Confirm bounces and moves no money', async () => { + const view = await reachReview() + + mockBalance = undefined + view.rerender() + mockStepperGoTo.mockClear() + clickConfirm() + + await waitFor(() => expect(mockStepperGoTo).toHaveBeenCalledWith('amount')) + expect(mockSignSpend).not.toHaveBeenCalled() + expect(mockWithdrawWithSignedTx).not.toHaveBeenCalled() + }) + + it('balance drops below the amount on review: Confirm bounces synchronously and moves no money', async () => { + const view = await reachReview() + + // the live balance drops under the $50 amount while the user sits on + // review — the gate must ask the LIVE balance, not the effect-lagged + // message state (Chip round 6) + mockBalance = 10n * 10n ** 6n + view.rerender() + mockStepperGoTo.mockClear() + clickConfirm() + + await waitFor(() => expect(mockStepperGoTo).toHaveBeenCalledWith('amount')) + expect(mockSignSpend).not.toHaveBeenCalled() + expect(mockWithdrawWithSignedTx).not.toHaveBeenCalled() + }) + + it('limits still loading at the price-lock boundary: bank-details submit bounces to amount', async () => { + mockInitiateWithdraw.mockResolvedValue({ data: PRICE_LOCK }) + mockStepper.step = 'amount' + const view = render() + await waitFor(() => expect(mockStepperGoTo).toHaveBeenCalledWith('bank-details')) + + mockStepper.step = 'bank-details' + mockLimitsValidation.isLoading = true + view.rerender() + mockStepperGoTo.mockClear() + fireEvent.change(screen.getByTestId('destination-input'), { target: { value: '0000003100010000000009' } }) + fireEvent.click(screen.getByText('withdraw.review')) + + await waitFor(() => expect(mockStepperGoTo).toHaveBeenCalledWith('amount')) + expect(mockInitiateWithdraw).not.toHaveBeenCalled() + }) +}) diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 23ec610313..88b8152fa9 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -70,15 +70,18 @@ import { initiateIncreaseLimits } from '@/app/actions/increase-limits' import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' import { useLimits } from '@/hooks/useLimits' import { isVerifiedForCountry } from '@/utils/regions.utils' -import PixKeySendView from '@/components/Withdraw/views/PixKeySend.view' +import PixKeySendView from '@/features/withdraw/views/PixKeySendView' +import { useFlowStepper } from '@/hooks/useFlowStepper' +import { useWithdrawAmount } from '@/features/withdraw/useWithdrawAmount' +import { useMantecaAmountSeed } from '@/features/withdraw/useMantecaAmountSeed' +import { WITHDRAW_MANTECA_STEPS } from '@/features/withdraw/types' +import { mantecaStepGuards, type MantecaOutcome } from '@/features/withdraw/step-guards' import underMaintenanceConfig from '@/config/underMaintenance.config' import { MantecaTransfersMaintenanceView } from '@/components/Global/Banner/MantecaTransfersMaintenanceView' import { useLocale, useTranslations } from 'next-intl' import { localizedCountryTitle } from '@/utils/country-name.utils' import { loadingStateKey } from '@/i18n/app/loading-states' -type MantecaWithdrawStep = 'amountInput' | 'bankDetails' | 'review' | 'success' | 'failure' - export default function MantecaWithdrawFlow() { const searchParams = useSearchParams() // Brazil PIX sends go through the Manteca QR-payment endpoint (send to any @@ -114,8 +117,9 @@ function MantecaBankWithdrawFlow() { const [usdAmount, setUsdAmount] = useState(undefined) // store original currency amount before price lock to restore on back navigation const [originalCurrencyAmount, setOriginalCurrencyAmount] = useState(undefined) - const [step, setStep] = useState('amountInput') const [balanceErrorMessage, setBalanceErrorMessage] = useState(null) + // USD amount handed over by the shared /withdraw amount step (TASK-21664) + const [urlAmount] = useWithdrawAmount() const searchParams = useSearchParams() const paramAddress = searchParams.get('destination') const isSavedAccount = searchParams.get('isSavedAccount') === 'true' @@ -136,6 +140,22 @@ function MantecaBankWithdrawFlow() { // price lock state - holds the locked price from /withdraw/init const [priceLock, setPriceLock] = useState(null) const [isLockingPrice, setIsLockingPrice] = useState(false) + // Execution proof for the terminal steps: set only by the withdrawal + // submission. A hand-edited ?step=success (or =failure) with no completed + // operation falls back to a working step (Chip review, PR #2917). + const [outcome, setOutcome] = useState(null) + // amount → bank-details → review → success|failure as named screen ids in + // the URL. Guards bounce a refresh/deep-link into a step whose local state + // did not survive back to the amount step (which re-seeds from ?amount=). + const stepper = useFlowStepper({ + steps: WITHDRAW_MANTECA_STEPS, + guards: mantecaStepGuards({ + hasAmount: !!usdAmount, + priceLocked: !!priceLock, + outcome, + }), + }) + const step = stepper.step const router = useRouter() const { spendableBalance: balance, formattedSpendableBalance } = useWallet() const { signSpend } = useSignSpendBundle() @@ -191,6 +211,50 @@ function MantecaBankWithdrawFlow() { currency: selectedCountry?.currency, }) + // Synchronous twin of the balanceErrorMessage effect below (same + // minimum/ceiling predicates, live balance). Effect-set state lags the + // render by a tick, so every decision that must not outrun the balance — + // the ?amount= seed advance, the price lock, and the submission itself — + // asks this instead of the message state (Chip rounds 3+6). + const isAmountWithinLiveBalance = useCallback( + (usd: string) => { + if (balance === undefined) return false + const paymentAmount = parseUnits(usd, PEANUT_WALLET_TOKEN_DECIMALS) + if (paymentAmount < parseUnits(MIN_MANTECA_WITHDRAW_AMOUNT.toString(), PEANUT_WALLET_TOKEN_DECIMALS)) { + return false + } + return isAmountWithinBalance(usd, balance) + }, + [balance] + ) + + // Blanket mount reset — registered BEFORE the ?amount= seed below, so a + // fresh mount clears leftover flow state FIRST and the seed then arms on + // clean state. Registered after, the reset ran after the seed's mount + // effects and clobbered the seeded amounts (the hand-off silently died — + // caught by manteca-withdraw-gates.test.tsx). resetState is defined below; + // the callback runs post-render, when it exists. + useEffect(() => { + resetState() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // ?amount= hand-off from the shared amount step: seed both denominations + // and advance past this flow's amount screen ONLY once its balance/limits + // gates pass for the seeded amount (Chip review round 3). A blocked amount + // stays on the amount screen, which renders the reason. + const { seededFromUrl, resetSeed } = useMantecaAmountSeed({ + urlAmount, + currencyPriceSell: currencyPrice?.sell, + step, + isAmountAllowed: isAmountWithinLiveBalance, + limitsLoading: limitsValidation.isLoading, + limitsBlocking: limitsValidation.isBlocking, + setUsdAmount, + setCurrencyAmount, + goToBankDetails: () => void stepper.goTo('bank-details'), + }) + // BR self-service limit increase flow const { mantecaLimits, refetch: refetchLimits } = useLimits() const isBrEligible = isBrUserEligibleForLimitIncrease(mantecaLimits) @@ -308,6 +372,22 @@ function MantecaBankWithdrawFlow() { return } + // The amount may have arrived via the user-editable ?amount= and the + // async balance/limits gates live on the amount screen — re-check them + // before locking a price, against the LIVE balance (the message state + // is effect-set and lags a render). A failure returns to the amount + // screen, which renders the reason (Chip review rounds 3+6). + if ( + balance === undefined || + balanceErrorMessage || + (usdAmount && !isAmountWithinLiveBalance(usdAmount)) || + limitsValidation.isLoading || + limitsValidation.isBlocking + ) { + void stepper.goTo('amount') + return + } + // lock the price before showing review screen // this ensures user sees the exact amount they'll receive if (!usdAmount || !currencyCode) return @@ -331,7 +411,7 @@ function MantecaBankWithdrawFlow() { setPriceLock(result.data) // update the displayed fiat amount to the locked amount setCurrencyAmount(result.data.fiatAmount) - setStep('review') + void stepper.goTo('review') } } catch (error) { void captureNetworkTriagedFailure(error, { @@ -350,9 +430,15 @@ function MantecaBankWithdrawFlow() { usdAmount, currencyCode, currencyAmount, + isAmountWithinLiveBalance, isUserMantecaKycApprovedForCountry, isLockingPrice, handleOnboardingError, + balance, + balanceErrorMessage, + limitsValidation.isLoading, + limitsValidation.isBlocking, + stepper, t, setErrorMessage, ]) @@ -360,6 +446,22 @@ function MantecaBankWithdrawFlow() { const handleWithdraw = async () => { if (!destinationAddress || !usdAmount || !currencyCode || !priceLock) return + // last line of defense before the money operation: the balance and the + // async LATAM limits must hold for this amount RIGHT NOW — checked + // against the live balance, not the effect-lagged message state — and + // a stale amount that outran the gates goes back to the amount screen, + // which renders the reason (Chip review rounds 3+6) + if ( + balance === undefined || + balanceErrorMessage || + !isAmountWithinLiveBalance(usdAmount) || + limitsValidation.isLoading || + limitsValidation.isBlocking + ) { + void stepper.goTo('amount') + return + } + posthog.capture(ANALYTICS_EVENTS.WITHDRAW_CONFIRMED, { amount_usd: usdAmount, method_type: 'manteca', @@ -478,14 +580,16 @@ function MantecaBankWithdrawFlow() { setErrorMessage(t('errors.ownAccountOnly')) } else if (result.error === 'Unexpected error') { setErrorMessage(t('errors.unexpected')) - setStep('failure') + setOutcome('failure') + void stepper.goTo('failure') } else { setErrorMessage(result.message ?? result.error) } return } - setStep('success') + setOutcome('success') + void stepper.goTo('success') posthog.capture(ANALYTICS_EVENTS.WITHDRAW_COMPLETED, { amount_usd: usdAmount, method_type: 'manteca', @@ -511,14 +615,18 @@ function MantecaBankWithdrawFlow() { }, }) setErrorMessage(t('errors.unexpected')) - setStep('failure') + setOutcome('failure') + void stepper.goTo('failure') } finally { setLoadingState('Idle') } } + // clears the flow's local fields; the step itself lives in the URL — + // "Try again" pairs this with stepper.reset() const resetState = () => { - setStep('amountInput') + resetSeed() + setOutcome(null) setCurrencyAmount(undefined) setUsdAmount(undefined) setOriginalCurrencyAmount(undefined) @@ -533,10 +641,6 @@ function MantecaBankWithdrawFlow() { setIsLockingPrice(false) } - useEffect(() => { - resetState() - }, []) - useEffect(() => { // Skip balance check if transaction is being processed // Use hasPendingTransactions to prevent race condition with optimistic updates @@ -658,7 +762,13 @@ function MantecaBankWithdrawFlow() { {errorMessage} - setIsSupportModalOpen(true)} className="self-center"> @@ -733,16 +843,23 @@ function MantecaBankWithdrawFlow() { setCurrencyAmount(originalCurrencyAmount) setOriginalCurrencyAmount(undefined) } - setStep('bankDetails') - } else if (step === 'bankDetails') { - setStep('amountInput') + void stepper.goTo('bank-details') + } else if (step === 'bank-details') { + // an amount seeded from ?amount= was entered on the root + // amount step — back returns there, not to a second + // amount entry (TASK-21664) + if (seededFromUrl) { + onBack() + return + } + void stepper.goTo('amount') } else { onBack() } }} /> - {step === 'amountInput' && ( + {step === 'amount' && (
{t('amountToWithdraw')}
)} - {step === 'bankDetails' && ( + {step === 'bank-details' && (
{/* Amount Display Card */} diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index 520fa71d06..8f98517f5c 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -1,388 +1,26 @@ 'use client' -import { Button } from '@/components/0_Bruddle/Button' -import { Notification } from '@/components/0_Bruddle/Notification' -import { AddWithdrawRouterView } from '@/components/AddWithdraw/AddWithdrawRouterView' -import NavHeader from '@/components/Global/NavHeader' -import AmountInput from '@/components/Global/AmountInput' -import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' -import { useWithdrawFlow } from '@/context/WithdrawFlowContext' -import { useWallet } from '@/hooks/wallet/useWallet' -import { tokenSelectorContext } from '@/context/tokenSelector.context' -import { getCountryFromAccount, getCountryFromPath, getMinimumAmount } from '@/utils/bridge.utils' -import useGetExchangeRate from '@/hooks/useGetExchangeRate' -import { useSendFlowOrigin } from '@/hooks/useSendFlowOrigin' -import { AccountType } from '@/interfaces/interfaces' -import { useRouter, useSearchParams } from 'next/navigation' -import React, { useCallback, useContext, useEffect, useMemo, useState, useRef } from 'react' -import { formatUnits } from 'viem' -import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation' -import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard' -import { getLimitsWarningCardProps } from '@/features/limits/utils' -import posthog from 'posthog-js' -import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import { withdrawBankUrl, withdrawCountryUrl } from '@/utils/native-routes' -import { readReturnTo } from '@/utils/return-to.utils' -import { useTranslations } from 'next-intl' +import React from 'react' +import { useSearchParams } from 'next/navigation' +import WithdrawRoot from '@/features/withdraw/WithdrawRoot' // Module scope on purpose. React.lazy() mints a fresh, unresolved lazy on every // call, so creating these inside the render body made the subtree suspend again // on EVERY re-render: React hid the rendered view and swapped in the Suspense // fallback (null) until the import re-resolved a microtask later. On the native // ?country=…&view=bank route that showed up as the withdraw screen blanking and -// loading a second time — once on arrival, then again on the next re-render, -// which the success view triggers itself when it invalidates the transactions -// query. Hoisted, the lazy resolves once and later renders pass straight -// through. +// loading a second time. Hoisted, the lazy resolves once and later renders pass +// straight through. const WithdrawBankPage = React.lazy(() => import('./_withdraw-bank')) const AddWithdrawCountriesList = React.lazy(() => import('@/components/AddWithdraw/AddWithdrawCountriesList')) -type WithdrawStep = 'inputAmount' | 'selectMethod' - export default function WithdrawPage() { - const router = useRouter() const searchParams = useSearchParams() - const t = useTranslations('withdraw') - const tNav = useTranslations('navigation') - const tCommon = useTranslations('common') - const tErrors = useTranslations('errors') - const { selectedTokenData } = useContext(tokenSelectorContext) - - // check if coming from send flow based on method query param - const methodParam = searchParams.get('method') - const { isFromSendFlow, isCryptoFromSend, isBankFromSend } = useSendFlowOrigin() - // native app passes country as query param instead of path segment + // native app passes country as a query param instead of a path segment const countryFromQuery = searchParams.get('country') - - const { - amountToWithdraw: amountFromContext, - setAmountToWithdraw, - setError, - error, - setUsdAmount, - selectedMethod, - selectedBankAccount, - setSelectedBankAccount, - setSelectedMethod, - setShowAllWithdrawMethods, - } = useWithdrawFlow() - - // only go to input amount if method is selected OR if it's crypto from send (bank needs method selection first) - const initialStep: WithdrawStep = selectedMethod || isCryptoFromSend ? 'inputAmount' : 'selectMethod' - - const [step, setStep] = useState(initialStep) - - // automatically set crypto method when coming from send flow with method=crypto - useEffect(() => { - if (isCryptoFromSend && !selectedMethod) { - setSelectedMethod({ - type: 'crypto', - title: 'Crypto', - countryPath: undefined, - }) - } else if (isBankFromSend && !selectedMethod) { - // for bank from send flow, prefer showing saved accounts first - setShowAllWithdrawMethods(false) - } - }, [isCryptoFromSend, isBankFromSend, selectedMethod, setSelectedMethod, setShowAllWithdrawMethods]) - - // flag to know if user has manually entered something - const userTypedRef = useRef(false) - - // initialise the amount input with the value from context (if any) - // state to keep track of the token input key to force-remount the component - const [_tokenInputKey, setTokenInputKey] = useState(0) - - // raw amount currently typed in the input - const [rawTokenAmount, setRawTokenAmount] = useState(amountFromContext || '') - - const { spendableBalance: balance, formattedSpendableBalance } = useWallet() - - // Spend ceiling = the displayed total spendable. We gate on display (not an - // available-now subset) so we never block funds the live withdraw could route; - // an in-transit shortfall fails late with a settling message. See useWallet. - const maxDecimalAmount = useMemo(() => { - return balance !== undefined ? Number(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : 0 - }, [balance]) - - // Displayed total spendable (smart + collateral), single-sourced + formatted - // by the hook. Empty while loading so we don't flash "$0.00". - const peanutWalletBalance = useMemo(() => { - return balance === undefined ? '' : formattedSpendableBalance - }, [balance, formattedSpendableBalance]) - - // derive country and account type for minimum amount validation - const { countryIso2, rateAccountType } = useMemo(() => { - if (selectedBankAccount) { - const country = getCountryFromAccount(selectedBankAccount) - return { countryIso2: country?.iso2 || '', rateAccountType: selectedBankAccount.type as AccountType } - } - if (selectedMethod?.countryPath) { - const country = getCountryFromPath(selectedMethod.countryPath) - const iso2 = country?.iso2 || '' - let accountType: AccountType = AccountType.IBAN - if (iso2 === 'US') accountType = AccountType.US - else if (iso2 === 'GB') accountType = AccountType.GB - else if (iso2 === 'MX') accountType = AccountType.CLABE - return { countryIso2: iso2, rateAccountType: accountType } - } - return { countryIso2: '', rateAccountType: AccountType.US } - }, [selectedBankAccount, selectedMethod]) - - // crypto withdrawals are plain on-chain transfers — fiat-rail minimums don't - // apply. selectedMethod is the routing source of truth (a stale bank method - // from an abandoned withdraw still routes to the bank flow, so it must keep - // its minimum); the URL param only covers the first render before the mount - // effect commits the crypto method. - const isCryptoWithdraw = selectedMethod ? selectedMethod.type === 'crypto' : isCryptoFromSend - - // fetch exchange rate for non-USD countries to convert local minimum to USD - const { exchangeRate } = useGetExchangeRate({ - accountType: rateAccountType, - enabled: !isCryptoWithdraw && rateAccountType !== AccountType.US && countryIso2 !== '', - }) - - // compute minimum withdrawal in USD using the exchange rate - const minUsdAmount = useMemo(() => { - // no amount-step minimum for crypto: same-chain (Arbitrum) withdrawals - // are direct transfers with no floor, matching send-via-link. Rhino's - // per-network bridge minimums ($0.50, ETH $5, Tron $10) are enforced - // chain-aware at review time (see withdraw/crypto), once the - // destination is known. - if (isCryptoWithdraw) return 0 - const localMin = getMinimumAmount(countryIso2) - // for US or unknown, minimum is already in USD - if (!countryIso2 || countryIso2 === 'US') return localMin - // for EUR countries, €1 ≈ $1 - if (localMin === 1) return 1 - // convert local minimum to USD: sellRate = local currency per 1 USD - const rate = parseFloat(exchangeRate || '0') - if (rate <= 0) return 1 // fallback while rate is loading - return Math.ceil(localMin / rate) - }, [isCryptoWithdraw, countryIso2, exchangeRate]) - - // validate against user's limits for bank withdrawals - // note: crypto withdrawals don't have fiat limits - const limitsValidation = useLimitsValidation({ - flowType: 'offramp', - amount: rawTokenAmount, - currency: 'USD', - }) - - // clear errors and reset any persisted state when component mounts to ensure clean state - useEffect(() => { - setError({ showError: false, errorMessage: '' }) - // clear any potential persisted token input state by resetting to empty - if (!amountFromContext) { - setRawTokenAmount('') - setTokenInputKey((k) => k + 1) - } - }, [setError, amountFromContext]) - - useEffect(() => { - if (selectedMethod || isCryptoFromSend) { - setStep('inputAmount') - if (amountFromContext && !rawTokenAmount) { - setRawTokenAmount(amountFromContext) - } - } else if (!selectedMethod) { - setStep('selectMethod') - // clear the raw token amount when switching back to method selection - if (step !== 'selectMethod') { - setRawTokenAmount('') - setTokenInputKey((k) => k + 1) - } - } - }, [selectedMethod, isCryptoFromSend, amountFromContext, step, rawTokenAmount]) - - useEffect(() => { - // If amount is available (i.e) user clicked back from select method view, show all methods - if (amountFromContext) { - setShowAllWithdrawMethods(true) - } - }, []) - - const validateAmount = useCallback( - (amountStr: string): boolean => { - if (!amountStr) { - setError({ showError: false, errorMessage: '' }) - return true - } - - const amount = Number(amountStr) - if (!Number.isFinite(amount) || amount <= 0) { - setError({ showError: true, errorMessage: t('errors.invalidNumber') }) - return false - } - - // AmountInput is USD-pinned on this page (price: 1), so the typed - // value IS the USD value — scaling by the app-wide token price let - // a stale non-USD price loosen or false-trip the minimums. - const usdEquivalent = amount - - // While the balance is still loading, maxDecimalAmount is 0 — skip the - // balance check so a pre-filled amount isn't false-blocked; the effect - // re-validates once it lands (validateAmount is in its deps). - const balanceLoaded = balance !== undefined - if (usdEquivalent >= minUsdAmount && (!balanceLoaded || amount <= maxDecimalAmount)) { - setError({ showError: false, errorMessage: '' }) - return true - } - - // determine message - let message = '' - if (usdEquivalent < minUsdAmount) { - const minDisplay = minUsdAmount % 1 === 0 ? `$${minUsdAmount}` : `$${minUsdAmount.toFixed(2)}` - message = isFromSendFlow - ? t('errors.minimumSend', { amount: minDisplay }) - : t('errors.minimumWithdrawal', { amount: minDisplay }) - } else if (balanceLoaded && amount > maxDecimalAmount) { - message = tErrors('notEnoughBalanceAddFunds') - } else { - message = t('errors.invalidAmount') - } - setError({ showError: true, errorMessage: message }) - return false - }, - [balance, maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount, t, tErrors] - ) - - const handleTokenAmountChange = useCallback( - (value: string | undefined) => { - let newValue = value || '' - // treat leading "0" from initial AmountInput mount as empty - if (newValue === '0') { - newValue = '' - } - setRawTokenAmount(newValue) - - // ignore programmatically injected tiny residual amounts (<1) before user interaction - const numericVal = parseFloat(newValue) - if (!userTypedRef.current && numericVal > 0 && numericVal < 1) { - return // do not update state at all - } - - // mark that the user has interacted once they type anything >= 1 or delete everything - if (newValue === '' || numericVal >= 1) { - userTypedRef.current = true - } - - // clear any existing errors when user starts typing - if (error.showError) { - setError({ showError: false, errorMessage: '' }) - } - }, - [setRawTokenAmount, error.showError, setError] - ) - - // only validate when rawTokenAmount changes and we're in inputAmount step - useEffect(() => { - if (step === 'inputAmount') { - if (rawTokenAmount === '') { - setError({ showError: false, errorMessage: '' }) - } else { - // add a small delay to avoid validating while user is still typing - const timeoutId = setTimeout(() => { - validateAmount(rawTokenAmount) - }, 300) - - return () => clearTimeout(timeoutId) - } - } - return undefined - }, [rawTokenAmount, validateAmount, setError, step]) - - const handleAmountContinue = () => { - if (validateAmount(rawTokenAmount) && selectedMethod) { - setAmountToWithdraw(rawTokenAmount) - const usdVal = parseFloat(rawTokenAmount) - setUsdAmount(usdVal.toString()) - posthog.capture(ANALYTICS_EVENTS.WITHDRAW_AMOUNT_ENTERED, { - amount_usd: usdVal, - method_type: selectedMethod.type, - country: selectedMethod.countryPath, - from_send_flow: isFromSendFlow, - }) - - // Route based on selected method type (check method type first to avoid stale bank account taking priority) - // preserve method param if coming from send flow - const methodQueryParam = isFromSendFlow ? `method=${methodParam}` : '' - - if (selectedMethod.type === 'crypto') { - const queryParams = isFromSendFlow ? `?${methodQueryParam}` : '' - router.push(`/withdraw/crypto${queryParams}`) - } else if (selectedMethod.type === 'manteca') { - // Manteca (AR/BR) accounts route to the Manteca flow. Checked BEFORE - // the generic saved-bank-account branch below — that branch targets - // the Bridge bank page via getCountryFromAccount and would both - // mis-route a Manteca account and throw when its country can't be - // resolved. Route directly with method + country params instead. - const mantecaMethodParam = selectedMethod.title?.toLowerCase().replace(/\s+/g, '-') || 'bank-transfer' - const additionalParams = isFromSendFlow ? `&${methodQueryParam}` : '' - router.push( - `/withdraw/manteca?method=${mantecaMethodParam}&country=${selectedMethod.countryPath}${additionalParams}` - ) - } else if (selectedBankAccount) { - const country = getCountryFromAccount(selectedBankAccount) - if (country) { - const queryParams = isFromSendFlow ? `?${methodQueryParam}` : '' - router.push(withdrawBankUrl(country.path, queryParams)) - } else { - // Never throw inside the click handler: a synchronous throw aborts - // the router transition with no UI feedback, so the button silently - // dies ("press Continue, nothing happens"). Surface a recoverable - // error and log for observability instead. - console.error('[withdraw] could not resolve country from saved bank account', { - type: selectedBankAccount.type, - countryName: selectedBankAccount.details?.countryName, - countryCode: selectedBankAccount.details?.countryCode, - }) - setError({ - showError: true, - errorMessage: t('errors.countryUnresolved'), - }) - } - } else if (selectedMethod.type === 'bridge' && selectedMethod.countryPath) { - // Bridge countries go to country page for bank account form - const queryParams = isFromSendFlow ? `?${methodQueryParam}` : '' - router.push(withdrawCountryUrl(selectedMethod.countryPath, queryParams)) - } else if (selectedMethod.countryPath) { - // Other countries go to their country pages - const queryParams = isFromSendFlow ? `?${methodQueryParam}` : '' - router.push(withdrawCountryUrl(selectedMethod.countryPath, queryParams)) - } else { - // No branch matched the selected method — surface an error rather - // than leaving the user with a silently-dead Continue button. - console.error('[withdraw] no route matched for selected method', { - type: selectedMethod.type, - countryPath: selectedMethod.countryPath, - hasBankAccount: !!selectedBankAccount, - }) - setError({ - showError: true, - errorMessage: t('errors.setupFailed'), - }) - } - } - } - - // check if continue button should be disabled - const isContinueDisabled = useMemo(() => { - if (!rawTokenAmount) return true - - const numericAmount = parseFloat(rawTokenAmount) - if (!Number.isFinite(numericAmount) || numericAmount <= 0) return true - - if (numericAmount < minUsdAmount) return true // below the method's USD minimum - - // only apply the balance ceiling once it has loaded (maxDecimalAmount is 0 - // while spendableBalance is undefined) — else Continue is disabled during load - return (balance !== undefined && numericAmount > maxDecimalAmount) || error.showError - }, [rawTokenAmount, balance, maxDecimalAmount, error.showError, minUsdAmount]) - - // native app: render country-specific views when ?country= is present const viewFromQuery = searchParams.get('view') + if (countryFromQuery) { // native app: render country-specific views. // stub exists for web build; real component is injected by native build script. @@ -400,102 +38,5 @@ export default function WithdrawPage() { ) } - if (step === 'inputAmount') { - // only show limits card for bank/manteca withdrawals, not crypto - const showLimitsCard = !isCryptoWithdraw && (limitsValidation.isBlocking || limitsValidation.isWarning) - - return ( -
- { - // if crypto from send, go back to send page - if (isCryptoFromSend) { - setSelectedMethod(null) - router.push('/send') - } else { - // otherwise go back to method selection - // clear amount so it doesn't carry over to a different method - setAmountToWithdraw('') - setUsdAmount('') - setSelectedMethod(null) - setSelectedBankAccount(null) - setStep('selectMethod') - } - }} - /> -
-
-
- {isFromSendFlow ? t('amountToSend') : t('amountToWithdraw')} -
-
- - - {/* limits warning/error card for bank withdrawals */} - {showLimitsCard && - (() => { - const limitsCardProps = getLimitsWarningCardProps({ - validation: limitsValidation, - flowType: 'offramp', - currency: 'USD', - }) - return limitsCardProps ? : null - })()} - - - {/* only show error if limits blocking card is not displayed (warnings can coexist) */} - {error.showError && !!error.errorMessage && !limitsValidation.isBlocking && ( - - {error.errorMessage} - - )} -
-
- ) - } - - if (step === 'selectMethod' && !selectedMethod) { - return ( - { - // if bank from send flow, go back to send page - if (isBankFromSend) { - router.push('/send') - return - } - // an explicit origin (e.g. the exchange-rate widget's "Try it!" CTA) - // wins over the /home reset, which only fits tab-bar entries - const returnTo = readReturnTo(searchParams, '/withdraw') - router.push(returnTo ?? '/home') - }} - /> - ) - } - - return null + return } diff --git a/src/app/actions/supported-chains.ts b/src/app/actions/supported-chains.ts index 62fda927ce..83b934b6b9 100644 --- a/src/app/actions/supported-chains.ts +++ b/src/app/actions/supported-chains.ts @@ -1,5 +1,6 @@ import type { ChainWithTokens } from '@/interfaces/chain-meta' import { supportedPeanutChains, peanutTokenDetails } from '@/constants/token-registry.consts' +import { CHAIN_REGISTRY } from '@/constants/chainRegistry.consts' import ARBITRUM_ICON from '@/assets/chains/arbitrum.svg' import MANTLE_ICON from '@/assets/chains/mantle.svg' @@ -32,13 +33,29 @@ const TOKEN_LOGO_OVERRIDES: Record = { '5000:MNT': MANTLE_ICON, } +// TASK-21667: chain-details.json ships mostly SVG icon URLs, and next/image +// refuses image/svg+xml without dangerouslyAllowSVG — so Ethereum, Optimism, +// BNB and friends rendered as letter initials in the withdraw network list. +// The chain registry already curates a raster logo per withdraw-eligible +// chain; those win over the chain-details icon. CHAIN_ICON_OVERRIDES stays +// first (bundled/native-webview rationale above). +const REGISTRY_CHAIN_LOGOS: Record = Object.fromEntries( + CHAIN_REGISTRY.flatMap((c) => + c.logoUrl ? [c.id, ...(c.aliasIds ?? [])].map((id) => [id, c.logoUrl!] as const) : [] + ) +) + export async function getSupportedChainsAndTokens(): Promise> { const result: Record = {} for (const chain of supportedPeanutChains) { if (!chain.mainnet) continue result[chain.chainId] = { chainId: chain.chainId, - chainIconURI: CHAIN_ICON_OVERRIDES[String(chain.chainId)] ?? chain.icon?.url ?? '', + chainIconURI: + CHAIN_ICON_OVERRIDES[String(chain.chainId)] ?? + REGISTRY_CHAIN_LOGOS[String(chain.chainId)] ?? + chain.icon?.url ?? + '', networkName: chain.name, tokens: [], } diff --git a/src/app/recover-wallet/page.tsx b/src/app/recover-wallet/page.tsx index 7b98dca67a..1290a7a218 100644 --- a/src/app/recover-wallet/page.tsx +++ b/src/app/recover-wallet/page.tsx @@ -36,7 +36,7 @@ import { PEANUT_WALLET_TOKEN_DECIMALS, PEANUT_WALLET_TOKEN_SYMBOL, } from '@/constants/zerodev.consts' -import { type RecipientState } from '@/context/WithdrawFlowContext' +import { type RecipientState } from '@/components/Global/GeneralRecipientInput/types' import { areEvmAddressesEqual, getExplorerUrl, isTxReverted } from '@/utils/general.utils' import { decodeRecoveryKey, toRescueWebAuthnKey, type RecoveryKeyInput } from '@/utils/walletRescue.utils' import { captureException } from '@sentry/nextjs' diff --git a/src/components/0_Bruddle/Field.tsx b/src/components/0_Bruddle/Field.tsx new file mode 100644 index 0000000000..fdf4f06017 --- /dev/null +++ b/src/components/0_Bruddle/Field.tsx @@ -0,0 +1,48 @@ +import { type HTMLAttributes, type ReactNode, useId } from 'react' +import { twMerge } from '@/utils/tw' +import { FieldError } from '@/components/0_Bruddle/FieldError' + +interface FieldProps extends Omit, 'children'> { + /** Field label, Label/L per the form-field board. Omit for label-less fields (rare). */ + label?: ReactNode + /** id of the labelled control. When the control cannot carry an id (e.g. a radix trigger button), omit it — the label then has no htmlFor and the control needs an aria-label. */ + htmlFor?: string + /** Helper line under the control, Body/XS secondary. Replaced by the error when one is set — never both (board rule). */ + helper?: ReactNode + /** Field-level validation error. Red text only — never an input border (TASK-21454 DS call). */ + error?: ReactNode + /** The control: BaseInput, BaseSelect, or any single form control. */ + children: ReactNode +} + +/** + * Form-field chrome from the form board (figma `17802:61539`): label + control + * + one helper/error line in a single column. The error is text only + * (`FieldError`) and replaces the helper — Field never paints error borders on + * its control. Flow-level failures stay `Notification priority="error"` (see + * design.md "error display"). + * + * react-hook-form is the expected state owner: wrap the control in a + * `Controller` (reference: `AddWithdraw/DynamicBankAccountForm`) and pass + * `fieldState.error?.message` as `error`. + */ +const Field = ({ label, htmlFor, helper, error, className, children, ...props }: FieldProps) => { + const errorId = useId() + return ( +
+ {label && ( + + )} + {children} + {error ? ( + {error} + ) : ( + helper &&

{helper}

+ )} +
+ ) +} + +export { Field } diff --git a/src/components/0_Bruddle/__tests__/Field.test.tsx b/src/components/0_Bruddle/__tests__/Field.test.tsx new file mode 100644 index 0000000000..4703f7af33 --- /dev/null +++ b/src/components/0_Bruddle/__tests__/Field.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from '@testing-library/react' +import { Field } from '@/components/0_Bruddle/Field' +import BaseInput from '@/components/0_Bruddle/BaseInput' + +describe('Field', () => { + it('renders label wired to the control via htmlFor', () => { + render( + + + + ) + expect(screen.getByLabelText('IBAN')).toBeInTheDocument() + }) + + it('shows the helper line when there is no error', () => { + render( + + + + ) + expect(screen.getByText('8 or 11 characters')).toBeInTheDocument() + }) + + it('replaces the helper with the error — never both (board rule)', () => { + render( + + + + ) + expect(screen.getByRole('alert')).toHaveTextContent('BIC is invalid') + expect(screen.queryByText('8 or 11 characters')).not.toBeInTheDocument() + }) + + it('renders the error as text only — no border class on the control (DS call, TASK-21454)', () => { + render( + + + + ) + // Field never flips the control into its error state; red is the text. + expect(screen.getByLabelText('BIC')).not.toHaveAttribute('aria-invalid') + }) + + it('renders no error and no helper when neither is set', () => { + render( + + + + ) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) +}) diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index d514136a76..e7407a9df0 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -20,12 +20,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { DynamicBankAccountForm, type IBankAccountDetails } from './DynamicBankAccountForm' import { addBankAccount } from '@/app/actions/users' import { type AddBankAccountPayload } from '@/app/actions/types/users.types' -import { useWithdrawFlow } from '@/context/WithdrawFlowContext' +import { useOptionalWithdrawFlow } from '@/features/withdraw/WithdrawFlowContext' +import { useWithdrawAmount } from '@/features/withdraw/useWithdrawAmount' import { type Account } from '@/interfaces/interfaces' import { getCountryCodeForWithdraw } from '@/utils/withdraw.utils' import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' -import { useAppDispatch } from '@/redux/hooks' -import { bankFormActions } from '@/redux/slices/bank-form-slice' import { ListItem } from '@/components/0_Bruddle/ListItem' import TokenAndNetworkConfirmationModal from '../Global/TokenAndNetworkConfirmationModal' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' @@ -68,8 +67,11 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { // hooks const { deviceType } = useDeviceType() const { user, fetchUser } = useAuth() - const { setSelectedBankAccount, amountToWithdraw, setSelectedMethod, setAmountToWithdraw } = useWithdrawFlow() - const dispatch = useAppDispatch() + // Withdraw flow memory is scoped to /withdraw — null under /add-money. + // Every write below is inside a `flow === 'withdraw'` branch. + const withdrawFlow = useOptionalWithdrawFlow() + // the one typed amount, carried in the URL across /withdraw/* routes + const [urlAmount, setUrlAmount] = useWithdrawAmount() // inline sumsub kyc flow for bridge bank users who need verification // regionIntent is NOT passed here to avoid creating a backend record on mount. @@ -100,7 +102,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { }) // component level states - const [view, setView] = useState<'list' | 'form'>(flow === 'withdraw' && amountToWithdraw ? 'form' : 'list') + const [view, setView] = useState<'list' | 'form'>(flow === 'withdraw' && urlAmount ? 'form' : 'list') const [isKycModalOpen, setIsKycModalOpen] = useState(false) const formRef = useRef<{ handleSubmit: () => void }>(null) const [isSupportedTokensModalOpen, setIsSupportedTokensModalOpen] = useState(false) @@ -226,7 +228,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { const newAccount = updatedUser?.accounts.find((acc) => !currentAccountIds.has(acc.id)) if (newAccount) { - setSelectedBankAccount(newAccount) + withdrawFlow?.setSelectedBankAccount(newAccount) } else { // fallback to the previous method if we can't find the new account // this can happen if the user object is not updated immediately @@ -247,12 +249,16 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { bankName: newAccountFromResponse.details?.bankName || null, accountOwnerName: `${payload.accountOwnerName.firstName} ${payload.accountOwnerName.lastName}`, } - setSelectedBankAccount(newAccountFromResponse) + withdrawFlow?.setSelectedBankAccount(newAccountFromResponse) } if (currentCountry) { - const queryParams = isBankFromSend ? `?method=${methodParam}` : '' - router.push(withdrawBankUrl(currentCountry.path, queryParams)) + // carry the typed amount + send marker to the review screen + const params = new URLSearchParams() + if (isBankFromSend && methodParam) params.set('method', methodParam) + if (urlAmount) params.set('amount', urlAmount) + const qs = params.toString() + router.push(withdrawBankUrl(currentCountry.path, qs ? `?${qs}` : '')) } return {} } @@ -272,9 +278,6 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { } const handleWithdrawMethodClick = (method: SpecificPaymentMethod) => { - // preserve method param only if coming from bank send flow (not crypto) - const methodQueryParam = isBankFromSend ? `?method=${methodParam}` : '' - if (method.path && method.path.includes('/manteca')) { // Manteca methods route directly (has own amount input) const extraParams = isBankFromSend ? `method=${methodParam}` : undefined @@ -282,22 +285,22 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { } else if (method.id.includes('default-bank-withdraw') || method.id.includes('sepa-instant-withdraw')) { if (checkBridgeGate(() => handleWithdrawMethodClick(method))) return - // Bridge methods: Set in context and navigate for amount input - setSelectedMethod({ + // Bridge methods: set in context and land on the amount step + withdrawFlow?.setSelectedMethod({ type: 'bridge', countryPath: currentCountry?.path, currency: currentCountry?.currency, title: method.title, }) - router.push(`/withdraw${methodQueryParam}`) + router.push(`/withdraw?step=amount${isBankFromSend ? `&method=${methodParam}` : ''}`) return } else if (method.id.includes('crypto-withdraw')) { - setSelectedMethod({ + withdrawFlow?.setSelectedMethod({ type: 'crypto', countryPath: 'crypto', title: 'Crypto', }) - router.push(`/withdraw${methodQueryParam}`) + router.push(`/withdraw?step=amount${isBankFromSend ? `&method=${methodParam}` : ''}`) } else if (method.path) { // other methods with paths — rewrite dynamic routes for native const extraParams = isBankFromSend ? `method=${methodParam}` : undefined @@ -423,28 +426,26 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { flow === 'withdraw' ? (isBankFromSend ? tNav('send') : tNav('withdraw')) : tAddMoney('title') } onPrev={() => { - // clear dynamicbankaccountform data - dispatch(bankFormActions.clearFormData()) - setAmountToWithdraw('') + void setUrlAmount(null) // ensure kyc modal isn't open so late success events don't flip view setIsKycModalOpen(false) // if coming from send flow, go back to amount input on /withdraw?method=bank if (flow === 'withdraw' && isBankFromSend) { if (currentCountry) { - setSelectedMethod({ + withdrawFlow?.setSelectedMethod({ type: 'bridge', countryPath: currentCountry.path, currency: currentCountry.currency, title: 'To Bank', }) } - router.push(`/withdraw?method=${methodParam}`) + router.push(`/withdraw?step=amount&method=${methodParam}`) return } // otherwise go back to list - setSelectedMethod(null) + withdrawFlow?.setSelectedMethod(null) setView('list') }} /> @@ -454,6 +455,17 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { onSuccess={handleFormSubmit} initialData={{}} error={null} + amountDisplay={urlAmount} + onExistingAccount={(account) => { + // the typed account already exists — select it and go + // straight to review, keeping amount + send marker + withdrawFlow?.setSelectedBankAccount(account) + const params = new URLSearchParams() + if (isBankFromSend && methodParam) params.set('method', methodParam) + if (urlAmount) params.set('amount', urlAmount) + const qs = params.toString() + router.push(withdrawBankUrl(currentCountry.path, qs ? `?${qs}` : '')) + }} /> {sharedModals}
@@ -540,20 +552,19 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { { - setAmountToWithdraw('') if (flow === 'add') { router.push('/add-money?method=bank') } else if (isBankFromSend) { // if coming from bank send flow: set method and go to amount input view - setSelectedMethod({ + withdrawFlow?.setSelectedMethod({ type: 'bridge', countryPath: currentCountry.path, currency: currentCountry.currency, title: 'To Bank', }) - router.push(`/withdraw?method=${methodParam}`) + router.push(`/withdraw?step=amount&method=${methodParam}`) } else { - setSelectedMethod(null) + withdrawFlow?.setSelectedMethod(null) onBack() } }} diff --git a/src/components/AddWithdraw/AddWithdrawRouterView.tsx b/src/components/AddWithdraw/AddWithdrawRouterView.tsx deleted file mode 100644 index c612164087..0000000000 --- a/src/components/AddWithdraw/AddWithdrawRouterView.tsx +++ /dev/null @@ -1,441 +0,0 @@ -'use client' -import { Button } from '@/components/0_Bruddle/Button' -import { type DepositMethod, DepositMethodList } from '@/components/AddMoney/components/DepositMethodList' -import { countryData } from '@/components/AddMoney/consts' -import NavHeader from '@/components/Global/NavHeader' -import { - type RecentMethod, - getUserPreferences, - updateUserPreferences, - getFromLocalStorage, -} from '@/utils/general.utils' -import { useRouter, useSearchParams } from 'next/navigation' -import { useSendFlowOrigin } from '@/hooks/useSendFlowOrigin' -import { useGeoFilteredPaymentOptions } from '@/hooks/useGeoFilteredPaymentOptions' -import { addMoneyCountryUrl, withdrawCountryUrl, rewriteMethodPath } from '@/utils/native-routes' -import { type FC, useEffect, useRef, useState, useTransition, useCallback } from 'react' -import { useUserStore } from '@/redux/hooks' -import { AccountType, type Account } from '@/interfaces/interfaces' -import { useWithdrawFlow } from '@/context/WithdrawFlowContext' -import { useOnrampFlow } from '@/context/OnrampFlowContext' -import { isMantecaCountry } from '@/constants/manteca.consts' -import Card from '@/components/Global/Card' -import { IconBubble } from '@/components/0_Bruddle/IconBubble' -import { CountryList } from '../Common/CountryList' -import Loading from '../Global/Loading' -import SavedAccountsView from '../Common/SavedAccountsView' -import TokenAndNetworkConfirmationModal from '../Global/TokenAndNetworkConfirmationModal' -import posthog from 'posthog-js' -import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import { useTranslations } from 'next-intl' - -interface AddWithdrawRouterViewProps { - flow: 'add' | 'withdraw' - pageTitle: string - mainHeading: string - onBackClick?: () => void -} - -const MAX_RECENT_METHODS = 5 - -// A recent method stores the whole URL, and localStorage outlives any deploy. -// Rename a country slug and every saved entry still points at the old route, -// which now resolves to "Country not found". The country id never moves, so -// re-derive the path from it on read. This also repairs an entry saved on the -// web and opened in the native shell, where the URL shape itself differs. -// An entry whose country is gone from the catalog keeps what it stored. -export function withCurrentCountryPath(method: RecentMethod): RecentMethod { - if (method.type !== 'country') return method - const country = countryData.find((c) => c.type === 'country' && c.id === method.id) - return country ? { ...method, path: addMoneyCountryUrl(country.path) } : method -} - -function saveRecentMethod(userId: string, method: DepositMethod, path?: string) { - const newRecentMethod: RecentMethod = { - id: method.id, - type: method.type as 'crypto' | 'country', - title: method.title, - description: method.description, - iconUrl: method.iconUrl, - currency: method.currency, - path: path ?? method.path, - } - - const prefs = getUserPreferences(userId) || {} - const currentRecentList = prefs.recentAddMethods || [] - - const filteredList = currentRecentList.filter((m) => m.id !== newRecentMethod.id) - - const updatedRecentList = [newRecentMethod, ...filteredList].slice(0, MAX_RECENT_METHODS) - - updateUserPreferences(userId, { ...prefs, recentAddMethods: updatedRecentList }) -} - -export const AddWithdrawRouterView: FC = ({ - flow, - pageTitle, - mainHeading, - onBackClick, -}) => { - const router = useRouter() - const { user } = useUserStore() - const t = useTranslations('withdraw') - const tAddMoney = useTranslations('addMoney') - const tCommon = useTranslations('common') - const { setSelectedBankAccount, showAllWithdrawMethods, setShowAllWithdrawMethods, setSelectedMethod } = - useWithdrawFlow() - const onrampFlowContext = useOnrampFlow() - const { setFromBankSelected } = onrampFlowContext - const [recentMethodsState, setRecentMethodsState] = useState([]) - const [savedAccounts, setSavedAccounts] = useState([]) - // local flag only for add flow; for withdraw we derive from context - const [localShowAllMethods, setLocalShowAllMethods] = useState(false) - const [isSupportedTokensModalOpen, setIsSupportedTokensModalOpen] = useState(false) - const [, startTransition] = useTransition() - const searchParams = useSearchParams() - const currencyCode = searchParams.get('currencyCode') - - // check if coming from send flow - const methodParam = searchParams.get('method') - // withdraw board 17832:80463: the Mercado Pago add-new-account row follows - // the same geo gate as the send method list (hidden in brazil). gate on - // !isLoading too — countryCode is null while geo resolves, and the filter - // only removes mercadopago once it knows the user is in BR - const { filteredMethods: geoMethods, isLoading: isGeoLoading } = useGeoFilteredPaymentOptions() - const isMercadoPagoAvailable = !isGeoLoading && geoMethods.some((m) => m.id === 'mercadopago') - // this view also serves the add-money flow, so the flow guard stays local - const isBankFromSend = useSendFlowOrigin().isBankFromSend && flow === 'withdraw' - - // determine if we should show the full list of methods (countries/crypto) instead of the default view - let shouldShowAllMethods = flow === 'withdraw' ? showAllWithdrawMethods : localShowAllMethods - const setShouldShowAllMethods = flow === 'withdraw' ? setShowAllWithdrawMethods : setLocalShowAllMethods - const [isLoadingPreferences, setIsLoadingPreferences] = useState(true) - - // if currencyCode is present, show all methods - if (currencyCode) { - shouldShowAllMethods = true - } - - // apply the default view (saved accounts vs all methods) only once per mount. - // the user query re-dispatches a fresh `user` object on every refetch (window - // focus, 4s pending-rail poll), and re-running the default unconditionally - // yanked an open country list back to the saved-accounts view. - const hasAppliedDefaultView = useRef(false) - - useEffect(() => { - setIsLoadingPreferences(true) - if (flow === 'withdraw') { - const bankAccounts = - user?.accounts.filter( - (acc) => - acc.type === AccountType.IBAN || - acc.type === AccountType.US || - acc.type === AccountType.CLABE || - acc.type === AccountType.GB || - acc.type === AccountType.MANTECA - ) ?? [] - - if (bankAccounts.length > 0) { - setSavedAccounts(bankAccounts as unknown as Account[]) - if (!hasAppliedDefaultView.current) setShouldShowAllMethods(false) - } else { - setSavedAccounts([]) - } - } else if (!hasAppliedDefaultView.current) { - // 'add' flow: the default view is a one-shot decision, so skip the - // localstorage re-read + state churn on later user refetches - const prefs = user ? getUserPreferences(user.user.userId) : undefined - const currentRecentMethods = (prefs?.recentAddMethods ?? []).map(withCurrentCountryPath) - if (currentRecentMethods.length > 0) { - setRecentMethodsState(currentRecentMethods) - setShouldShowAllMethods(false) - } else { - setShouldShowAllMethods(true) - } - } - // latch only once the user has loaded, so the first real resolution - // (not the pre-auth null render) decides the default view - if (user) hasAppliedDefaultView.current = true - setIsLoadingPreferences(false) - }, [flow, user, setShouldShowAllMethods]) - - const handleMethodSelected = useCallback( - (method: DepositMethod) => { - if (flow === 'add' && user) { - saveRecentMethod(user.user.userId, method) - posthog.capture(ANALYTICS_EVENTS.DEPOSIT_METHOD_SELECTED, { - method_type: method.type === 'crypto' ? 'crypto' : 'bank', - country: method.path?.split('?')[0].split('/').filter(Boolean).at(-1), - }) - } - - // Handle "From Bank" specially for add flow - if (flow === 'add' && method.id === 'bank-transfer-add') { - setFromBankSelected(true) - return - } - - if (flow === 'add' && method.id === 'crypto') { - setIsSupportedTokensModalOpen(true) - return - } - - // NEW: For withdraw flow, set selected method in context instead of navigating - if (flow === 'withdraw') { - const methodType = - method.type === 'crypto' ? 'crypto' : isMantecaCountry(method.path) ? 'manteca' : 'bridge' - - posthog.capture(ANALYTICS_EVENTS.WITHDRAW_METHOD_SELECTED, { - method_type: methodType, - country: method.path?.split('?')[0].split('/').filter(Boolean).at(-1), - }) - - setSelectedMethod({ - type: methodType, - countryPath: method.path, - currency: method.currency, - title: method.title, - }) - - // Don't navigate - let the main withdraw page handle the flow - return - } - - if (method.path) { - router.push(rewriteMethodPath(method.path)) - } - }, - [flow, user] - ) - - const defaultBackNavigation = () => router.push('/home') - - // check if we're coming from request fulfillment or similar flow - const fromRequestFulfillment = typeof window !== 'undefined' && getFromLocalStorage('fromRequestFulfillment') - - if (isLoadingPreferences) { - return ( -
- -
- ) - } - - if (flow === 'withdraw' && savedAccounts.length === 0 && !shouldShowAllMethods) { - return ( -
- - -
- -
-

{t('noAccountsTitle')}

-

- {t.rich('noAccountsDescription', { br: () =>
})} -

-
-
- -
-
- ) - } - - // Render saved accounts for withdraw flow if they exist and we're not in 'showAll' mode - if (flow === 'withdraw' && !shouldShowAllMethods && savedAccounts.length > 0) { - return ( - { - setSelectedBankAccount(account) - const countryPath = account.details?.countryName || path || '' - setSelectedMethod({ - type: account.type === AccountType.MANTECA ? 'manteca' : 'bridge', - countryPath, - title: 'To Bank', - }) - if (account.type === AccountType.MANTECA) { - // preserve method param if coming from send flow - const additionalParams = isBankFromSend ? `&method=${methodParam}` : '' - router.push( - `/withdraw/manteca?country=${encodeURIComponent(countryPath)}&destination=${encodeURIComponent(account.identifier)}&isSavedAccount=true${additionalParams}` - ) - } - }} - onSelectNewMethodClick={() => setShouldShowAllMethods(true)} - onCryptoClick={() => - // set method in context, no navigation — the withdraw page owns - // the amount step (same rationale as CountryList onCryptoClick) - handleMethodSelected({ id: 'crypto', type: 'crypto', title: 'Crypto', path: 'crypto' }) - } - onMercadoPagoClick={ - isMercadoPagoAvailable - ? () => { - posthog.capture(ANALYTICS_EVENTS.WITHDRAW_METHOD_SELECTED, { - method_type: 'manteca', - country: 'argentina', - }) - router.push('/withdraw/manteca?method=mercadopago&country=argentina') - } - : undefined - } - /> - ) - } - - // Render recent methods for add flow - if (flow === 'add' && !shouldShowAllMethods && recentMethodsState.length > 0) { - // Transform recent methods to ensure proper type compatibility - const recentMethodsWithType = recentMethodsState.map((method) => ({ - ...method, - type: (method.type || 'country') as 'crypto' | 'country', - path: method.path || '', - })) - - return ( -
- -
-

{tAddMoney('recentMethods')}

- -
- -
-
- {tCommon('or')} -
-
- - { - setIsSupportedTokensModalOpen(false) - }} - onAccept={() => { - router.push('/add-money/crypto') - }} - isVisible={isSupportedTokensModalOpen} - /> -
- ) - } - - // show all methods view for both flows - return ( -
- { - // if coming from request fulfillment or similar external flow, go back immediately - if (fromRequestFulfillment) { - if (onBackClick) { - onBackClick() - } else { - defaultBackNavigation() - } - return - } - - // otherwise, use toggle logic for better ux when user manually navigated to "select new method" - if (shouldShowAllMethods && (recentMethodsState.length > 0 || savedAccounts.length > 0)) { - setShouldShowAllMethods(false) - } else if (onBackClick) { - onBackClick() - } else { - defaultBackNavigation() - } - }} - /> - - { - if (flow === 'add') { - posthog.capture(ANALYTICS_EVENTS.DEPOSIT_METHOD_SELECTED, { - method_type: 'bank', - country: country.path, - }) - } else { - posthog.capture(ANALYTICS_EVENTS.WITHDRAW_METHOD_SELECTED, { - method_type: isMantecaCountry(country.path) ? 'manteca' : 'bridge', - country: country.path, - }) - } - - // from send flow (bank): set method in context and stay on /withdraw?method=bank - if (flow === 'withdraw' && isBankFromSend) { - if (isMantecaCountry(country.path)) { - const route = `/withdraw/manteca?method=bank-transfer&country=${country.path}` - startTransition(() => { - router.push(route) - }) - return - } - - // set selected method and let withdraw page move to amount input - setSelectedMethod({ - type: 'bridge', - countryPath: country.path, - currency: country.currency, - title: country.title, - }) - return - } - - // default behaviour: navigate to country page - const queryParams = isBankFromSend ? `?method=${methodParam}` : '' - const countryUrl = - flow === 'withdraw' - ? withdrawCountryUrl(country.path, queryParams) - : addMoneyCountryUrl(country.path) - if (flow === 'add' && user) { - saveRecentMethod(user.user.userId, country, countryUrl) - } - - // use transition for smoother navigation, keeps ui responsive during route change - startTransition(() => { - router.push(countryUrl) - }) - }} - onCryptoClick={() => { - if (flow === 'add') { - posthog.capture(ANALYTICS_EVENTS.DEPOSIT_METHOD_SELECTED, { - method_type: 'crypto', - country: 'crypto', - }) - setIsSupportedTokensModalOpen(true) - } else { - // shared withdraw handler: analytics + set method in context, no - // navigation — the withdraw page owns the amount step and navigates - // to /withdraw/crypto after Continue. navigating here (pre-amount) - // trips the crypto page's "no amount" redirect guard, whose unmount - // cleanup resets the whole flow back to saved accounts. - handleMethodSelected({ id: 'crypto', type: 'crypto', title: 'Crypto', path: 'crypto' }) - } - }} - flow={flow} - /> - - { - setIsSupportedTokensModalOpen(false) - }} - onAccept={() => { - router.push('/add-money/crypto') - }} - isVisible={isSupportedTokensModalOpen} - /> -
- ) -} diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx index f918bc5e5d..72109fbd77 100644 --- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx +++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx @@ -1,6 +1,6 @@ 'use client' import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react' -import { FieldError } from '@/components/0_Bruddle/FieldError' +import { Field } from '@/components/0_Bruddle/Field' import { Notification } from '@/components/0_Bruddle/Notification' import { useForm, Controller, type ControllerRenderProps, type FieldPath, type RegisterOptions } from 'react-hook-form' import { useAuth } from '@/context/authContext' @@ -9,7 +9,7 @@ import { type AddBankAccountPayload, BridgeAccountOwnerType, BridgeAccountType } import BaseInput from '@/components/0_Bruddle/BaseInput' import BaseSelect, { type BaseSelectOption } from '@/components/0_Bruddle/BaseSelect' import { BRIDGE_ALPHA3_TO_ALPHA2, ALL_COUNTRIES_ALPHA3_TO_ALPHA2 } from '@/components/AddMoney/consts' -import { useParams, useRouter, useSearchParams } from 'next/navigation' +import { useParams, useSearchParams } from 'next/navigation' import { useSendFlowOrigin } from '@/hooks/useSendFlowOrigin' import { validateIban, @@ -21,7 +21,7 @@ import { } from '@/utils/bridge-accounts.utils' import { getBicFromIban } from '@/app/actions/ibanToBic' import PeanutActionDetailsCard, { type PeanutActionDetailsCardProps } from '../Global/PeanutActionDetailsCard' -import { useWithdrawFlow } from '@/context/WithdrawFlowContext' +import { type Account } from '@/interfaces/interfaces' import { getCountryFromIban, getCountryCodeForWithdraw, @@ -30,11 +30,8 @@ import { } from '@/utils/withdraw.utils' import { createSmartPasteHandler, type PasteFieldKind } from '@/utils/clipboard-extract.utils' import useSavedAccounts from '@/hooks/useSavedAccounts' -import { useAppDispatch, useAppSelector } from '@/redux/hooks' -import { bankFormActions } from '@/redux/slices/bank-form-slice' import { useDebounce } from '@/hooks/useDebounce' import { MX_STATES, US_STATES } from '@/constants/stateCodes.consts' -import { withdrawBankUrl } from '@/utils/native-routes' import { PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants/zerodev.consts' import { useTranslations } from 'next-intl' @@ -73,6 +70,11 @@ interface DynamicBankAccountFormProps { actionDetailsProps?: Partial error: string | null hideEmailInput?: boolean + /** Amount shown on the details card (withdraw flow passes the URL amount). */ + amountDisplay?: string + /** Withdraw flow: the typed account already exists — select it and skip the add. + * When omitted (claim flow) submission proceeds normally. */ + onExistingAccount?: (account: Account) => void } export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, DynamicBankAccountFormProps>( @@ -86,6 +88,8 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D countryName: countryNameFromProps, error, hideEmailInput = false, + amountDisplay, + onExistingAccount, }, ref ) => { @@ -96,7 +100,6 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D const { user } = useAuth() const t = useTranslations('withdraw.bankForm') const tWithdraw = useTranslations('withdraw') - const dispatch = useAppDispatch() const [isSubmitting, setIsSubmitting] = useState(false) const [submissionError, setSubmissionError] = useState(null) const { country: countryNameParams } = useParams() @@ -107,8 +110,6 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D // This form also serves the claim flow, where the send marker is meaningless. const { isFromSendFlow } = useSendFlowOrigin() const framedAsSend = isFromSendFlow && flow === 'withdraw' - const { amountToWithdraw, setSelectedBankAccount } = useWithdrawFlow() - const router = useRouter() const savedAccounts = useSavedAccounts() const [isCheckingBICValid, setisCheckingBICValid] = useState(false) const STREET_ADDRESS_MAX_LENGTH = 35 // From bridge docs: street address can be max 35 characters @@ -121,9 +122,6 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D '' ).toLowerCase() - // Get persisted form data from Redux - const persistedFormData = useAppSelector((state) => state.bankForm.formData) - // for claim flow: pre-fill accountOwnerName from user if logged in, for withdraw flow: keep empty const defaultAccountOwnerName = flow === 'claim' && user?.user.fullName ? user.user.fullName : '' @@ -150,7 +148,6 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D state: '', postalCode: '', ...initialData, - ...persistedFormData, // Redux persisted data takes precedence }, mode: 'onBlur', reValidateMode: 'onSubmit', @@ -190,11 +187,13 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D (account) => account.identifier === (data.accountNumber.toLowerCase() || data.clabe.toLowerCase()) ) - // Skip adding account if the account already exists for the logged in user - if (existingAccount) { - setSelectedBankAccount(existingAccount) - // keep the send marker, or the review screen it lands on reverts to withdraw copy - router.push(withdrawBankUrl(country, framedAsSend ? '?method=bank' : '')) + // The account already exists for the logged-in user: the withdraw + // flow selects it and routes to review (handler owns navigation). + // Without a handler (claim flow) submission proceeds normally — + // the old behavior pushed a claim user into the withdraw flow, + // which dead-ended on its no-amount guard. + if (existingAccount && onExistingAccount) { + onExistingAccount(existingAccount) return } @@ -299,14 +298,6 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D if (!result.silent) setSubmissionError(result.error) setIsSubmitting(false) } else { - // Save form data to Redux after successful submission - const formDataToSave = { - ...data, - country, - firstName: firstName.trim(), - lastName: lastName.trim(), - } - dispatch(bankFormActions.setFormData(formDataToSave)) setIsSubmitting(false) } } catch (error) { @@ -349,10 +340,11 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D ) => { const smartPasteKind = smartPasteKindFor(name) return ( -
- +
void }, D ? createSmartPasteHandler(smartPasteKind, field.onChange) : undefined } - state={errors[name] && touchedFields[name] ? 'error' : 'default'} className="text-body-s" onBlur={async (_e) => { // remove any whitespace from the input field @@ -392,8 +383,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D )} />
- {errors[name] && touchedFields[name] && {errors[name]?.message ?? ''}} -
+ ) } @@ -404,9 +394,11 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D options: BaseSelectOption[], rules: RegisterOptions ) => ( -
- {/* the trigger is a button, so htmlFor cannot name it — aria-label does */} - + // the trigger is a button, so htmlFor cannot name it — aria-label does + void }, D value={field.value} onValueChange={field.onChange} onBlur={field.onBlur} - error={!!(errors[name] && touchedFields[name])} className="h-12 w-full rounded-sm text-body-s" /> )} /> - {errors[name] && touchedFields[name] && {errors[name]?.message ?? ''}} -
+ ) const countryCodeForFlag = useMemo(() => { @@ -440,7 +430,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D transactionType={'WITHDRAW_BANK_ACCOUNT'} recipientType={'BANK_ACCOUNT'} recipientName={country} - amount={amountToWithdraw} + amount={amountDisplay ?? ''} tokenSymbol={PEANUT_WALLET_TOKEN_SYMBOL} {...actionDetailsProps} // after the spread: the flow-guarded value stays authoritative even diff --git a/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx b/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx index c63718ccff..e8c87d28da 100644 --- a/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx +++ b/src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx @@ -14,20 +14,24 @@ * gate is NOT ready — so the fix didn't just delete the guard wholesale. */ import React from 'react' -import { render as rtlRender, screen, fireEvent, within } from '@testing-library/react' +import { render as rtlRender, screen, fireEvent, within, act } from '@testing-library/react' import { IntlWrapper } from '@/test-utils/intl' import AddWithdrawCountriesList from '../AddWithdrawCountriesList' import underMaintenanceConfig from '@/config/underMaintenance.config' +import { addBankAccount } from '@/app/actions/users' const render = (ui: React.ReactElement) => rtlRender({ui}) // ---- routing ---- const mockPush = jest.fn() const mockParams: Record = { country: 'testland' } +// mutable: the send-flow hand-off case needs ?method=bank visible to the REAL +// useSendFlowOrigin (which reads useSearchParams) +let mockSearchParams = new URLSearchParams() jest.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }), useParams: () => mockParams, - useSearchParams: () => new URLSearchParams(), + useSearchParams: () => mockSearchParams, })) // ---- consts: one country ('testland', id 'US') with a bank add-method and a @@ -87,17 +91,24 @@ function setCapabilities(gateKind: string, rails: Array<{ status: string; channe } // ---- light mocks for everything else the component imports ---- +// fetchUser is configurable: the new-account submit path refetches the user +// and picks the account that appeared (Chip round 10) +const mockFetchUser = jest.fn().mockResolvedValue(undefined) jest.mock('@/context/authContext', () => ({ - useAuth: () => ({ user: { accounts: [] }, fetchUser: jest.fn() }), + useAuth: () => ({ user: { accounts: [] }, fetchUser: mockFetchUser }), })) -jest.mock('@/context/WithdrawFlowContext', () => ({ - useWithdrawFlow: () => ({ - setSelectedBankAccount: jest.fn(), - amountToWithdraw: '', - setSelectedMethod: jest.fn(), - setAmountToWithdraw: jest.fn(), +const mockSetSelectedBankAccount = jest.fn() +const mockSetSelectedMethod = jest.fn() +jest.mock('@/features/withdraw/WithdrawFlowContext', () => ({ + useOptionalWithdrawFlow: () => ({ + setSelectedBankAccount: mockSetSelectedBankAccount, + setSelectedMethod: mockSetSelectedMethod, }), })) +let mockUrlAmount = '' +jest.mock('@/features/withdraw/useWithdrawAmount', () => ({ + useWithdrawAmount: () => [mockUrlAmount, jest.fn()], +})) jest.mock('@/context/ModalsContext', () => ({ useModalsContext: () => ({ setIsSupportModalOpen: jest.fn() }), })) @@ -123,7 +134,7 @@ jest.mock('@/redux/slices/bank-form-slice', () => ({ bankFormActions: { clearFor jest.mock('@/app/actions/users', () => ({ addBankAccount: jest.fn() })) jest.mock('@/utils/native-routes', () => ({ rewriteMethodPath: (p: string) => p, - withdrawBankUrl: (p: string) => `/withdraw/${p}`, + withdrawBankUrl: (p: string, qs: string = '') => `/withdraw/${p}/bank${qs}`, })) jest.mock('@/utils/capacitor', () => ({ isCapacitor: () => false })) jest.mock('@/utils/color.utils', () => ({ getColorForUsername: () => ({ lightShade: '#fff' }) })) @@ -158,7 +169,15 @@ jest.mock('@/components/Global/Badges/StatusBadge', () => ({ })) jest.mock('@/components/Profile/AvatarWithBadge', () => ({ __esModule: true, default: () => })) jest.mock('@/components/Global/EmptyStates/EmptyState', () => ({ __esModule: true, default: () =>
})) -jest.mock('@/components/AddWithdraw/DynamicBankAccountForm', () => ({ DynamicBankAccountForm: () =>
})) +// capture the props the list hands the bank form — the existing-account +// handler is the withdraw destination selector (Chip round 9) +const mockBankFormProps = jest.fn() +jest.mock('@/components/AddWithdraw/DynamicBankAccountForm', () => ({ + DynamicBankAccountForm: (props: unknown) => { + mockBankFormProps(props) + return
+ }, +})) jest.mock('@/components/Global/TokenAndNetworkConfirmationModal', () => ({ __esModule: true, default: () => null })) jest.mock('@/components/Kyc/SumsubKycModals', () => ({ SumsubKycModals: () => null })) jest.mock('@/components/Kyc/BridgeTosStep', () => ({ BridgeTosStep: () => null })) @@ -228,7 +247,8 @@ describe('AddWithdrawCountriesList — bank gate', () => { render() fireEvent.click(screen.getByText('To Bank')) - expect(mockPush).toHaveBeenCalledWith('/withdraw') + // method chosen → land on the amount step (named screen id in the URL) + expect(mockPush).toHaveBeenCalledWith('/withdraw?step=amount') expect(screen.queryByTestId('initiate-kyc-modal')).toBeNull() }) @@ -303,46 +323,104 @@ describe('AddWithdrawCountriesList — PIX onramp maintenance tag', () => { }) }) +describe('AddWithdrawCountriesList — existing-account shortcut (Chip round 9)', () => { + beforeEach(() => { + mockPush.mockClear() + mockSetSelectedBankAccount.mockClear() + mockBankFormProps.mockClear() + mockUrlAmount = '50' + setCapabilities('ready', [{ status: 'enabled', channel: 'bank', country: 'US' }]) + }) + + afterEach(() => { + mockUrlAmount = '' + }) + + it('withdraw flow: a typed account that already exists selects it and routes to review with the amount', () => { + render() + + // flow=withdraw + ?amount= lands straight on the bank form + expect(screen.getByTestId('bank-form')).toBeInTheDocument() + const props = mockBankFormProps.mock.calls.at(-1)?.[0] as { + onExistingAccount?: (account: unknown) => void + } + expect(typeof props.onExistingAccount).toBe('function') + + const existing = { id: 'acct-1', identifier: 'de89370400440532013000', type: 'iban' } + props.onExistingAccount!(existing) + + // the account becomes the withdraw flow's destination… + expect(mockSetSelectedBankAccount).toHaveBeenCalledWith(existing) + // …and the push carries the typed amount into the review page + expect(mockPush).toHaveBeenCalledWith('/withdraw/testland/bank?amount=50') + }) +}) + /** - * When the BRL-via-PIX onramp degrades, the Pix option gets flagged "under - * maintenance" (config: pixBrazilOnrampMaintenance) — warn-only: it stays - * visible and clickable. + * The first-time path on the same screen (Chip round 10): a successfully + * ADDED account must become the flow's destination AND the push must carry + * the typed amount — the amount no longer travels in flow context, so a + * dropped ?amount= makes useBridgeOfframpFlow's prerequisite effect bounce + * the user back to /withdraw right after they typed their bank details. */ -describe('AddWithdrawCountriesList — PIX onramp maintenance tag', () => { - // snapshot/restore the shipped flag so each test can flip it without leaking - // state — and without coupling the restore to the committed default - let originalPixMaintenance: boolean +describe('AddWithdrawCountriesList — new-account submit hand-off (Chip round 10)', () => { + const newAccount = { id: 'acct-new', bridgeAccountId: 'ext-new', identifier: 'de89370400440532013000' } + const payload = { + countryCode: 'US', + countryName: 'Testland', + accountOwnerName: { firstName: 'Ada', lastName: 'Lovelace' }, + } + + const submitForm = async () => { + const props = mockBankFormProps.mock.calls.at(-1)?.[0] as { + onSuccess: (payload: unknown, rawData: unknown) => Promise<{ error?: string }> + } + expect(typeof props.onSuccess).toBe('function') + let result: { error?: string } | undefined + await act(async () => { + result = await props.onSuccess(payload, {}) + }) + return result + } beforeEach(() => { mockPush.mockClear() - // a ready gate so a click can navigate — proving the option is not blocked + mockSetSelectedBankAccount.mockClear() + mockBankFormProps.mockClear() + mockUrlAmount = '50' setCapabilities('ready', [{ status: 'enabled', channel: 'bank', country: 'US' }]) - originalPixMaintenance = underMaintenanceConfig.pixBrazilOnrampMaintenance + ;(addBankAccount as jest.Mock).mockResolvedValue({ data: { id: newAccount.id } }) + // the refetched user carries the freshly added account + mockFetchUser.mockResolvedValue({ accounts: [newAccount] }) }) afterEach(() => { - underMaintenanceConfig.pixBrazilOnrampMaintenance = originalPixMaintenance + mockUrlAmount = '' + mockSearchParams = new URLSearchParams() + ;(addBankAccount as jest.Mock).mockReset() + mockFetchUser.mockReset() + mockFetchUser.mockResolvedValue(undefined) }) - it('tags the Pix option "Maintenance" but keeps it clickable (warn-only)', () => { - underMaintenanceConfig.pixBrazilOnrampMaintenance = true - - render() + it('withdraw flow: the added account becomes the destination and the push carries ?amount= to review', async () => { + render() + expect(screen.getByTestId('bank-form')).toBeInTheDocument() - const pixCard = screen.getByTestId('method-pix') - expect(within(pixCard).getByText('Maintenance')).toBeInTheDocument() + const result = await submitForm() - // warn-only: still navigates into the deposit flow - fireEvent.click(pixCard) - expect(mockPush).toHaveBeenCalledWith('/add-money/brazil/manteca') + expect(result).toEqual({}) + expect(mockSetSelectedBankAccount).toHaveBeenCalledWith(newAccount) + expect(mockPush).toHaveBeenCalledWith('/withdraw/testland/bank?amount=50') }) - it('shows no maintenance tag when the flag is off, and never tags non-Pix methods', () => { - underMaintenanceConfig.pixBrazilOnrampMaintenance = false + it('entered from the send flow, the method marker rides along with the amount', async () => { + mockSearchParams = new URLSearchParams('method=bank') + render() - render() + const result = await submitForm() - expect(within(screen.getByTestId('method-pix')).queryByText('Maintenance')).toBeNull() - expect(within(screen.getByTestId('method-bank')).queryByText('Maintenance')).toBeNull() + expect(result).toEqual({}) + expect(mockSetSelectedBankAccount).toHaveBeenCalledWith(newAccount) + expect(mockPush).toHaveBeenCalledWith('/withdraw/testland/bank?method=bank&amount=50') }) }) diff --git a/src/components/AddWithdraw/__tests__/AddWithdrawRouterView.test.tsx b/src/components/AddWithdraw/__tests__/AddWithdrawRouterView.test.tsx deleted file mode 100644 index 53a72d18bc..0000000000 --- a/src/components/AddWithdraw/__tests__/AddWithdrawRouterView.test.tsx +++ /dev/null @@ -1,242 +0,0 @@ -/** - * AddWithdrawRouterView — regression tests for the withdraw method-selection bounce. - * - * two regressions pinned here: - * 1. clicking "Crypto" must set the method in context WITHOUT navigating to - * /withdraw/crypto (navigating pre-amount trips that page's "no amount" - * redirect guard, whose unmount cleanup resets the whole flow). - * 2. a user-object refetch (new identity, same data) must NOT force the view - * back from the country list to saved accounts. - * - * uses the real WithdrawFlowContextProvider (pure useState, no heavy deps) so - * the tests exercise the actual context wiring instead of a hand-rolled copy. - */ -import React, { useEffect } from 'react' -import { render as rtlRender, screen, fireEvent } from '@testing-library/react' -import { IntlWrapper } from '@/test-utils/intl' - -const mockRouterPush = jest.fn() -jest.mock('next/navigation', () => ({ - useRouter: () => ({ push: mockRouterPush, back: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }), - useSearchParams: () => ({ get: () => null }), - usePathname: () => '/withdraw', -})) - -jest.mock('posthog-js', () => ({ - __esModule: true, - default: { capture: jest.fn(), init: jest.fn() }, -})) - -jest.mock('@/utils/general.utils', () => ({ - getUserPreferences: jest.fn(() => undefined), - updateUserPreferences: jest.fn(), - getFromLocalStorage: jest.fn(() => null), -})) - -jest.mock('@/utils/native-routes', () => ({ - addMoneyCountryUrl: (p: string) => `/add-money/${p}`, - withdrawCountryUrl: (p: string, q?: string) => `/withdraw/${p}${q ?? ''}`, - rewriteMethodPath: (p: string) => p, -})) - -// spread the real module: the view now imports countryData, whose module-level -// setup reads MANTECA_SUPPORTED_EXCHANGES. Only isMantecaCountry is faked. -jest.mock('@/constants/manteca.consts', () => ({ - ...jest.requireActual('@/constants/manteca.consts'), - isMantecaCountry: jest.fn(() => false), -})) - -interface MockUser { - user: { userId: string } - accounts: Array<{ type: string; identifier: string; details: Record }> -} - -let mockUser: MockUser | null -jest.mock('@/redux/hooks', () => ({ - useUserStore: () => ({ user: mockUser }), -})) - -jest.mock('@/context/OnrampFlowContext', () => ({ - useOnrampFlow: () => ({ setFromBankSelected: jest.fn() }), -})) - -jest.mock('@/components/0_Bruddle/Button', () => ({ - Button: (props: { onClick?: () => void; disabled?: boolean; children?: React.ReactNode }) => ( - - ), -})) - -jest.mock('@/components/AddMoney/components/DepositMethodList', () => ({ - DepositMethodList: () =>
, -})) - -jest.mock('@/components/Global/NavHeader', () => ({ - __esModule: true, - default: (props: { title?: string }) =>
{props.title}
, -})) - -jest.mock('@/components/Global/Card', () => ({ - __esModule: true, - default: (props: { children?: React.ReactNode }) =>
{props.children}
, -})) - -jest.mock('@/components/Profile/AvatarWithBadge', () => ({ - __esModule: true, - default: () =>
, -})) - -jest.mock('../../Common/CountryList', () => ({ - CountryList: (props: { onCryptoClick?: () => void }) => ( -
- -
- ), -})) - -jest.mock('../../Global/Loading', () => ({ - __esModule: true, - default: (props: any) => - props.variant === 'mascot' ? ( -
{props.message && {props.message}}
- ) : ( -
- ), -})) - -jest.mock('../../Common/SavedAccountsView', () => ({ - __esModule: true, - default: (props: { onSelectNewMethodClick?: () => void }) => ( -
- -
- ), -})) - -jest.mock('../../Global/TokenAndNetworkConfirmationModal', () => ({ - __esModule: true, - default: () => null, -})) - -import { AddWithdrawRouterView, withCurrentCountryPath } from '../AddWithdrawRouterView' -import { countryData } from '@/components/AddMoney/consts' -import type { RecentMethod } from '@/utils/general.utils' -import { WithdrawFlowContextProvider, useWithdrawFlow } from '@/context/WithdrawFlowContext' - -// these components call useTranslations; IntlWrapper supplies the en catalog -// so the English assertions below keep asserting the real shipped copy -const render = (ui: Parameters[0]) => rtlRender(ui, { wrapper: IntlWrapper }) - -const makeUser = (): MockUser => ({ - user: { userId: 'user-1' }, - accounts: [{ type: 'iban', identifier: 'BE10905272880104', details: {} }], -}) - -// exposes the real context's selectedMethod so tests can assert on it -const onSelectedMethodChange = jest.fn() -function SelectedMethodProbe() { - const { selectedMethod } = useWithdrawFlow() - useEffect(() => { - if (selectedMethod) onSelectedMethodChange(selectedMethod) - }, [selectedMethod]) - return null -} - -function Harness({ user }: { user: MockUser }) { - mockUser = user - return ( - - - - - - - ) -} - -describe('AddWithdrawRouterView — withdraw method selection', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - test('shows saved accounts by default when bank accounts exist', () => { - render() - expect(screen.getByTestId('saved-accounts-view')).toBeInTheDocument() - }) - - test('clicking Crypto sets the method in context and does NOT navigate', () => { - render() - fireEvent.click(screen.getByTestId('select-new-method')) - fireEvent.click(screen.getByTestId('crypto-option')) - - expect(onSelectedMethodChange).toHaveBeenCalledWith( - expect.objectContaining({ type: 'crypto', title: 'Crypto' }) - ) - expect(mockRouterPush).not.toHaveBeenCalled() - }) - - test('a user refetch (new object identity) does not bounce the country list back to saved accounts', () => { - const { rerender } = render() - fireEvent.click(screen.getByTestId('select-new-method')) - expect(screen.getByTestId('country-list')).toBeInTheDocument() - - // simulate the 4s pending-rail poll / window-focus refetch dispatching a fresh user object - rerender() - - expect(screen.getByTestId('country-list')).toBeInTheDocument() - expect(screen.queryByTestId('saved-accounts-view')).not.toBeInTheDocument() - }) -}) - -// Recent methods live in localStorage and outlive any deploy, so a country slug -// rename (TASK-21136 czechia, TASK-21138 saint-barthelemy) would otherwise leave -// saved entries pointing at a route that no longer resolves. -describe('withCurrentCountryPath — stale saved routes after a slug rename', () => { - const saved = (over: Partial = {}): RecentMethod => ({ - type: 'country', - id: 'CZE', - title: 'Czechia', - path: '/add-money/czech-republic', - ...over, - }) - - test('repairs an entry saved under the old slug', () => { - expect(withCurrentCountryPath(saved()).path).toBe('/add-money/czechia') - }) - - test('repairs the de-accented slug too', () => { - const stale = saved({ id: 'BL', title: 'Saint Barthélemy', path: '/add-money/saint-barthélemy' }) - expect(withCurrentCountryPath(stale).path).toBe('/add-money/saint-barthelemy') - }) - - test('leaves an already-current entry untouched', () => { - const current = saved({ path: '/add-money/czechia' }) - expect(withCurrentCountryPath(current)).toEqual(current) - }) - - test('never rewrites a crypto entry', () => { - const crypto: RecentMethod = { type: 'crypto', id: 'crypto', title: 'Crypto', path: '/add-money/crypto' } - expect(withCurrentCountryPath(crypto)).toBe(crypto) - }) - - test('keeps the stored path when the country is gone from the catalog', () => { - const orphan = saved({ id: 'NOT_A_COUNTRY', path: '/add-money/atlantis' }) - expect(withCurrentCountryPath(orphan).path).toBe('/add-money/atlantis') - }) - - test('every stored country id still resolves, so no saved entry is orphaned', () => { - const countries = countryData.filter((c) => c.type === 'country') - const ids = countries.map((c) => c.id) - expect(new Set(ids).size).toBe(ids.length) - for (const c of countries) { - expect( - withCurrentCountryPath({ type: 'country', id: c.id, title: c.title, path: '/add-money/stale' }).path - ).toBe(`/add-money/${c.path}`) - } - }) -}) diff --git a/src/components/AddWithdraw/__tests__/DynamicBankAccountForm.test.tsx b/src/components/AddWithdraw/__tests__/DynamicBankAccountForm.test.tsx new file mode 100644 index 0000000000..c957ef7465 --- /dev/null +++ b/src/components/AddWithdraw/__tests__/DynamicBankAccountForm.test.tsx @@ -0,0 +1,138 @@ +/** + * DynamicBankAccountForm — the existing-account branch (Chip round 9). + * + * With `onExistingAccount` (withdraw flow) a typed account that matches a + * saved one short-circuits: the handler receives the saved account and no + * add/`onSuccess` runs. WITHOUT the handler (claim flow) submission proceeds + * to `onSuccess` — the old unconditional short-circuit hijacked claim users + * into the withdraw flow, which dead-ended on its no-amount guard; the + * backend add is idempotent for the same user's account, so falling through + * is safe. + */ +import React from 'react' +import { render, act } from '@testing-library/react' + +// ---------- module-level mocks ---------- + +jest.mock('next/navigation', () => ({ + useParams: () => ({}), + useSearchParams: () => ({ get: () => null }), +})) + +jest.mock('next-intl', () => ({ + useTranslations: (ns: string) => { + const t = (key: string) => `${ns}.${key}` + t.rich = (key: string) => `${ns}.${key}` + return t + }, +})) + +jest.mock('@/context/authContext', () => ({ + useAuth: () => ({ user: { user: { fullName: 'John Doe', email: 'john@doe.co' } } }), +})) + +jest.mock('@/hooks/useSendFlowOrigin', () => ({ + useSendFlowOrigin: () => ({ isFromSendFlow: false }), +})) + +const SAVED_US_ACCOUNT = { + id: 'acct-1', + identifier: '123456789', + type: 'us', + details: { countryCode: 'USA' }, +} +jest.mock('@/hooks/useSavedAccounts', () => ({ + __esModule: true, + default: () => [SAVED_US_ACCOUNT], +})) + +jest.mock('@/app/actions/ibanToBic', () => ({ + getBicFromIban: jest.fn(async () => ({ bic: null })), +})) + +jest.mock('@/components/Global/PeanutActionDetailsCard', () => ({ + __esModule: true, + default: () => null, +})) + +jest.mock('@/components/0_Bruddle/BaseSelect', () => ({ + __esModule: true, + default: () => null, +})) + +import { DynamicBankAccountForm } from '../DynamicBankAccountForm' + +// ---------- helpers ---------- + +// complete, valid US bank details — matches SAVED_US_ACCOUNT's identifier +const US_INITIAL_DATA = { + accountOwnerName: 'John Doe', + firstName: 'John', + lastName: 'Doe', + email: 'john@doe.co', + accountNumber: '123456789', + routingNumber: '021000021', // valid ABA checksum + street: '1 Main St', + city: 'New York', + state: 'NY', + postalCode: '10001', +} + +const renderForm = (props: { + onSuccess: jest.Mock + onExistingAccount?: (account: unknown) => void + flow: 'claim' | 'withdraw' +}) => { + const ref = React.createRef<{ handleSubmit: () => void }>() + render( + + ) + return ref +} + +beforeEach(() => { + jest.clearAllMocks() +}) + +// ---------- tests ---------- + +describe('DynamicBankAccountForm — existing-account branch (Chip round 9)', () => { + it('withdraw flow: a typed account that already exists goes to onExistingAccount, never onSuccess', async () => { + const onSuccess = jest.fn(async () => ({})) + const onExistingAccount = jest.fn() + const ref = renderForm({ flow: 'withdraw', onSuccess, onExistingAccount }) + + await act(async () => { + ref.current!.handleSubmit() + }) + + expect(onExistingAccount).toHaveBeenCalledWith(expect.objectContaining({ identifier: '123456789' })) + expect(onSuccess).not.toHaveBeenCalled() + }) + + it('claim flow (no handler): an already-saved account proceeds to onSuccess instead of navigating', async () => { + // the backend add is idempotent for the same user's account — the old + // unconditional short-circuit pushed claim users into the withdraw + // flow's no-amount dead end + const onSuccess = jest.fn(async () => ({})) + const ref = renderForm({ flow: 'claim', onSuccess }) + + await act(async () => { + ref.current!.handleSubmit() + }) + + expect(onSuccess).toHaveBeenCalledTimes(1) + expect(onSuccess).toHaveBeenCalledWith( + expect.objectContaining({ accountNumber: '123456789' }), + expect.objectContaining({ accountNumber: '123456789' }) + ) + }) +}) diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index ac0758fb60..a29a9aa2f6 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -27,8 +27,6 @@ import { ConfirmBankClaimView } from './Confirm.bank-claim.view' import { CountryListRouter } from '@/components/Common/CountryListRouter' import NavHeader from '@/components/Global/NavHeader' import { getCountryCodeForWithdraw } from '@/utils/withdraw.utils' -import { useAppDispatch } from '@/redux/hooks' -import { bankFormActions } from '@/redux/slices/bank-form-slice' import { sendLinksApi } from '@/services/sendLinks' import { useSearchParams } from 'next/navigation' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' @@ -81,7 +79,6 @@ export const BankFlowManager = (props: IClaimScreenProps) => { const savedAccounts = useSavedAccounts() const { isLoading, setLoadingState } = useContext(loadingStateContext) const { claimLink } = useClaimLink() - const dispatch = useAppDispatch() // Provider-blind bank-rail gate via the canonical `useCapabilities().gateFor` // primitive. The bank-claim gate only fires for logged-in users (guest claims // leverage the sender's KYC and bypass `gate` entirely below), so this reads @@ -547,7 +544,6 @@ export const BankFlowManager = (props: IClaimScreenProps) => { { - dispatch(bankFormActions.clearFormData()) // clear DynamicBankAccountForm data if (savedAccounts.length > 0) { setClaimBankFlowStep(ClaimBankFlowStep.SavedAccountsList) } else { diff --git a/src/components/Global/GeneralRecipientInput/types.ts b/src/components/Global/GeneralRecipientInput/types.ts new file mode 100644 index 0000000000..32473adaf9 --- /dev/null +++ b/src/components/Global/GeneralRecipientInput/types.ts @@ -0,0 +1,6 @@ +/** Recipient input state: the resolved address plus the name that produced it + * (ENS/username), when there is one. Produced by `GeneralRecipientInput`. */ +export interface RecipientState { + name: string | undefined + address: string +} diff --git a/src/components/Send/__tests__/send-states.test.tsx b/src/components/Send/__tests__/send-states.test.tsx index e53cd54f10..537e37ef0e 100644 --- a/src/components/Send/__tests__/send-states.test.tsx +++ b/src/components/Send/__tests__/send-states.test.tsx @@ -173,10 +173,6 @@ jest.mock('../views/Contacts.view', () => ({ })) // withdraw-flow context — SendRouterView resets it when a click enters the withdraw flow -const mockResetWithdrawFlow = jest.fn() -jest.mock('@/context/WithdrawFlowContext', () => ({ - useWithdrawFlow: () => ({ resetWithdrawFlow: mockResetWithdrawFlow }), -})) // ---------- import component under test AFTER all mocks ---------- import { SendRouterView } from '../views/SendRouter.view' @@ -387,53 +383,36 @@ describe('GROUP 3: Contacts View', () => { // ============================================================ describe('GROUP 4: Method Selection', () => { test('Clicking bank resets the withdraw flow, then navigates to /withdraw?method=bank', () => { - // Regression: browser back from an abandoned /withdraw?method=crypto skips the - // in-app NavHeader reset, so a stale selectedMethod survives in the app-wide - // context. Without the reset, Bank skips method selection and lands on the - // crypto amount step (continuing into /withdraw/crypto?method=bank). + // The withdraw provider is scoped to /withdraw (TASK-21816): a fresh + // navigation mounts clean state, so no reset call is needed here. renderSend() fireEvent.click(screen.getByTestId('action-card-Bank')) - expect(mockResetWithdrawFlow).toHaveBeenCalledTimes(1) expect(mockRouterPush).toHaveBeenCalledWith('/withdraw?method=bank') - // reset must land before navigation hands off to /withdraw - expect(mockResetWithdrawFlow.mock.invocationCallOrder[0]).toBeLessThan( - mockRouterPush.mock.invocationCallOrder[0] - ) }) - test('Clicking exchange-or-wallet resets the withdraw flow, then navigates to /withdraw?method=crypto', () => { + test('Clicking exchange-or-wallet navigates to /withdraw?method=crypto', () => { renderSend() fireEvent.click(screen.getByTestId('action-card-Exchange or Wallet')) - expect(mockResetWithdrawFlow).toHaveBeenCalledTimes(1) expect(mockRouterPush).toHaveBeenCalledWith('/withdraw?method=crypto') - expect(mockResetWithdrawFlow.mock.invocationCallOrder[0]).toBeLessThan( - mockRouterPush.mock.invocationCallOrder[0] - ) }) - test('Pix also resets the withdraw flow before navigating', () => { + test('Pix navigates into the manteca PIX flow', () => { mockUseGeoFilteredPaymentOptions.mockReturnValue({ filteredMethods: [{ id: 'pix', title: 'Pix', description: '', icons: [], soon: false }], }) renderSend() fireEvent.click(screen.getByTestId('action-card-Pix')) - expect(mockResetWithdrawFlow).toHaveBeenCalledTimes(1) expect(mockRouterPush).toHaveBeenCalledWith('/withdraw/manteca?method=pix&country=brazil') - expect(mockResetWithdrawFlow.mock.invocationCallOrder[0]).toBeLessThan( - mockRouterPush.mock.invocationCallOrder[0] - ) }) - test('Clicking Peanut contacts navigates to /send?view=contacts without touching the withdraw flow', () => { + test('Clicking Peanut contacts navigates to /send?view=contacts', () => { renderSend() fireEvent.click(screen.getByTestId('action-card-Peanut contacts')) expect(mockRouterPush).toHaveBeenCalledWith('/send?view=contacts') - // contacts is not a withdraw entry — never clobber an unrelated flow's state - expect(mockResetWithdrawFlow).not.toHaveBeenCalled() }) test('Back from main send falls back to /home on a cold deep-link', () => { diff --git a/src/components/Send/views/SendRouter.view.tsx b/src/components/Send/views/SendRouter.view.tsx index 792594b44b..1304aea7b3 100644 --- a/src/components/Send/views/SendRouter.view.tsx +++ b/src/components/Send/views/SendRouter.view.tsx @@ -12,7 +12,6 @@ import { ACTION_METHODS, type PaymentMethod } from '@/constants/actionlist.const import Image from 'next/image' import { useGeoFilteredPaymentOptions } from '@/hooks/useGeoFilteredPaymentOptions' import { useSafeBack } from '@/hooks/useSafeBack' -import { useWithdrawFlow } from '@/context/WithdrawFlowContext' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { useMemo } from 'react' @@ -51,7 +50,6 @@ export const SendRouterView = () => { ? decodeURIComponent(window.location.pathname.replace('/send/', '').split('/')[0]) : null const recipientUsername = recipientFromQuery || recipientFromPath || null - const { resetWithdrawFlow } = useWithdrawFlow() const goBack = useSafeBack('/home') // replace, not push: a pushed fallback would mint a history entry that the // base view's own safe-back then walks right back into the subview (loop) @@ -83,21 +81,16 @@ export const SendRouterView = () => { router.push('/send?view=contacts') break case 'bank': - // navigate to send via bank flow. - // fresh click = fresh intent: browser back skips the in-app NavHeader - // reset, and a stale selectedMethod left in the app-wide context would - // hijack the routing (Bank landing on the crypto amount step) - resetWithdrawFlow() + // navigate to send via bank flow. Fresh entry IS a fresh flow: + // the withdraw provider is scoped to /withdraw and mounts clean. router.push('/withdraw?method=bank') break case 'exchange-or-wallet': // navigate to external wallet send flow - resetWithdrawFlow() router.push('/withdraw?method=crypto') break case 'pix': // navigate to pix send flow - resetWithdrawFlow() router.push('/withdraw/manteca?method=pix&country=brazil') break default: diff --git a/src/context/appFlowProviders.tsx b/src/context/appFlowProviders.tsx index 492268df15..62ebc4b638 100644 --- a/src/context/appFlowProviders.tsx +++ b/src/context/appFlowProviders.tsx @@ -6,7 +6,6 @@ import { OnrampFlowContextProvider } from './OnrampFlowContext' import { KernelClientProvider } from './kernelClient.context' import { LoadingStateContextProvider } from './loadingStates.context' import { TokenContextProvider } from './tokenSelector.context' -import { WithdrawFlowContextProvider } from './WithdrawFlowContext' import { ClaimBankFlowContextProvider } from './ClaimBankFlowContext' import { RequestFulfilmentFlowContextProvider } from './RequestFulfillmentFlowContext' import { PasskeySupportProvider } from './passkeySupportContext' @@ -41,15 +40,13 @@ export const AppFlowProviders = ({ children }: { children: React.ReactNode }) => - - - - - {children} - - - - + + + + {children} + + + diff --git a/src/dev/fixtures/__tests__/fixtures.test.ts b/src/dev/fixtures/__tests__/fixtures.test.ts index 7c073e0652..db2ac6cb31 100644 --- a/src/dev/fixtures/__tests__/fixtures.test.ts +++ b/src/dev/fixtures/__tests__/fixtures.test.ts @@ -7,9 +7,12 @@ const names = Object.keys(FIXTURES) const APP_DIR = join(process.cwd(), 'src', 'app', '(mobile-ui)') // A dynamic segment is a real route: /limits/manteca is served by limits/[provider]. +// A fixture route may carry a query string (deep-linked flow steps) — only the +// pathname resolves against the filesystem. function routeExists(route: string): boolean { let dir = APP_DIR - for (const segment of route.split('/').filter(Boolean)) { + const pathname = route.split('?')[0] + for (const segment of pathname.split('/').filter(Boolean)) { if (existsSync(join(dir, segment))) { dir = join(dir, segment) continue diff --git a/src/dev/fixtures/registry.ts b/src/dev/fixtures/registry.ts index 1c68182085..536bdaf048 100644 --- a/src/dev/fixtures/registry.ts +++ b/src/dev/fixtures/registry.ts @@ -163,6 +163,20 @@ export const FIXTURES: Record = { about: 'Withdraw with two saved bank accounts (a Spanish IBAN and a US account).', responses: { 'GET /users/me': { accounts: [WALLET_ACCOUNT, ...BANK_ACCOUNTS] } }, }, + // ?method=crypto is send's hand-off: the flow commits the crypto method and + // lands straight on the shared amount step (TASK-21816 URL stepper). + 'withdraw-amount': { + route: '/withdraw?method=crypto', + about: 'Withdraw amount step (crypto): USD amount entry with balance and Continue.', + responses: { 'GET /users/me': { accounts: [WALLET_ACCOUNT, ...BANK_ACCOUNTS] } }, + }, + // ?amount= arrives from the shared amount step and opens the bank-details + // form directly — the first Field-composed form (TASK-21454). + 'withdraw-bank-form': { + route: '/withdraw/spain?amount=50', + about: 'Bridge bank-account form for Spain, amount pre-entered — Field label/error chrome.', + responses: { 'GET /users/me': { accounts: [WALLET_ACCOUNT, ...BANK_ACCOUNTS] } }, + }, limits: { route: '/limits', about: 'Payment limits: the unlocked regions and the crypto note.' }, send: { route: '/send', about: 'Send: the method picker — link, contacts, bank or Mercado Pago.' }, request: { route: '/request', about: 'Request money: amount entry.' }, diff --git a/src/features/home/__tests__/useHomeFlow.test.ts b/src/features/home/__tests__/useHomeFlow.test.ts index 555fd52544..7837f554f1 100644 --- a/src/features/home/__tests__/useHomeFlow.test.ts +++ b/src/features/home/__tests__/useHomeFlow.test.ts @@ -6,7 +6,6 @@ import { useHomeFlow } from '../useHomeFlow' const mockFetchUser = jest.fn() const mockResetClaimBankFlow = jest.fn() -const mockResetWithdrawFlow = jest.fn() const mockDisconnect = jest.fn() let mockUser: any = null @@ -28,9 +27,6 @@ jest.mock('@/hooks/useActivationStatus', () => ({ jest.mock('@/context/ClaimBankFlowContext', () => ({ useClaimBankFlow: () => ({ resetFlow: mockResetClaimBankFlow }), })) -jest.mock('@/context/WithdrawFlowContext', () => ({ - useWithdrawFlow: () => ({ resetWithdrawFlow: mockResetWithdrawFlow }), -})) jest.mock('@/hooks/useCardInfo', () => ({ useCardInfo: jest.fn(() => ({})), })) @@ -69,7 +65,6 @@ describe('useHomeFlow', () => { renderHook(() => useHomeFlow()) expect(mockFetchUser).toHaveBeenCalledTimes(1) expect(mockResetClaimBankFlow).toHaveBeenCalled() - expect(mockResetWithdrawFlow).toHaveBeenCalled() }) it('disconnects an external wallet on home', () => { diff --git a/src/features/home/useHomeFlow.ts b/src/features/home/useHomeFlow.ts index 128727bb61..958e6214f9 100644 --- a/src/features/home/useHomeFlow.ts +++ b/src/features/home/useHomeFlow.ts @@ -2,7 +2,6 @@ import { useAuth } from '@/context/authContext' import { useClaimBankFlow } from '@/context/ClaimBankFlowContext' -import { useWithdrawFlow } from '@/context/WithdrawFlowContext' import { useActivationStatus } from '@/hooks/useActivationStatus' import { useCardInfo } from '@/hooks/useCardInfo' import { useWallet } from '@/hooks/wallet/useWallet' @@ -21,7 +20,6 @@ export function useHomeFlow() { const { isFetchingUser, fetchUser } = useAuth() const { isActivated, activationStep, dismissCardStep } = useActivationStatus() const { resetFlow: resetClaimBankFlow } = useClaimBankFlow() - const { resetWithdrawFlow } = useWithdrawFlow() const { isConnected: isWagmiConnected } = useAccount() const { disconnect: disconnectWagmi } = useDisconnect() @@ -38,11 +36,12 @@ export function useHomeFlow() { fetchUser() }, []) // eslint-disable-line react-hooks/exhaustive-deps - // landing on home resets any in-progress money flows + // landing on home resets any in-progress money flows. (The withdraw flow + // no longer needs a reset here: its provider is scoped to /withdraw and + // unmounts on exit — TASK-21816.) useEffect(() => { resetClaimBankFlow() - resetWithdrawFlow() - }, [resetClaimBankFlow, resetWithdrawFlow]) + }, [resetClaimBankFlow]) // always reset external wallet connection on home page useEffect(() => { diff --git a/src/context/WithdrawFlowContext.tsx b/src/features/withdraw/WithdrawFlowContext.tsx similarity index 67% rename from src/context/WithdrawFlowContext.tsx rename to src/features/withdraw/WithdrawFlowContext.tsx index 44d564b8d2..2328d396c0 100644 --- a/src/context/WithdrawFlowContext.tsx +++ b/src/features/withdraw/WithdrawFlowContext.tsx @@ -1,45 +1,24 @@ 'use client' -import { type ITokenPriceData, type Account } from '@/interfaces/interfaces' +import { type Account } from '@/interfaces/interfaces' import { type TRequestChargeResponse, type PaymentCreationResponse } from '@/services/services.types' -import type { ChainWithTokens } from '@/interfaces/chain-meta' +import type { RecipientState } from '@/components/Global/GeneralRecipientInput/types' import React, { createContext, type ReactNode, useContext, useMemo, useState, useCallback } from 'react' +import type { FlowErrorState, WithdrawData, WithdrawMethod } from './types' -export interface WithdrawMethod { - type: 'bridge' | 'manteca' | 'crypto' - countryPath?: string - currency?: string - minimumAmount?: number - savedAccount?: Account - title?: string -} - -export type WithdrawView = 'INITIAL' | 'CONFIRM' | 'STATUS' - -export interface WithdrawData { - token: ITokenPriceData - chain: ChainWithTokens - address: string - amount: string -} - -export interface InitialViewErrorState { - showError: boolean - errorMessage: string -} - -export interface RecipientState { - name: string | undefined - address: string -} - +/** + * Withdraw flow memory that cannot live in the URL: the selected method and + * account objects, provider responses (charge, payment), and transient + * submission state. Mounted at the /withdraw layout — NOT app-global — so it + * dies when the user leaves the flow. URL state (step, amount, showAll) lives + * in nuqs params next to it; see TASK-21816. + * + * The old app-global mount was the root cause of the stale-method hijack class + * (TASK-21203 / TASK-20806): abandoned withdraw state survived into the next + * send/withdraw entry and every consumer compensated with hand-written resets. + * Scoped here, a fresh entry IS the reset, and those compensations are gone. + */ interface WithdrawFlowContextType { - amountToWithdraw: string - setAmountToWithdraw: (amount: string) => void - usdAmount: string - setUsdAmount: (amount: string) => void - currentView: WithdrawView - setCurrentView: (view: WithdrawView) => void withdrawData: WithdrawData | null setWithdrawData: (data: WithdrawData | null) => void showCompatibilityModal: boolean @@ -54,15 +33,12 @@ interface WithdrawFlowContextType { setInputChanging: (isChanging: boolean) => void recipient: RecipientState setRecipient: (recipient: RecipientState) => void - error: InitialViewErrorState - setError: (error: InitialViewErrorState) => void + error: FlowErrorState + setError: (error: FlowErrorState) => void selectedBankAccount: Account | null setSelectedBankAccount: (account: Account | null) => void - showAllWithdrawMethods: boolean - setShowAllWithdrawMethods: (show: boolean) => void selectedMethod: WithdrawMethod | null setSelectedMethod: (method: WithdrawMethod | null) => void - // charge and payment state (local to withdraw flow) chargeDetails: TRequestChargeResponse | null setChargeDetails: (charge: TRequestChargeResponse | null) => void transactionHash: string | null @@ -74,46 +50,31 @@ interface WithdrawFlowContextType { const WithdrawFlowContext = createContext(undefined) -export const WithdrawFlowContextProvider: React.FC<{ children: ReactNode }> = ({ children }) => { - const [amountToWithdraw, setAmountToWithdraw] = useState('') - const [usdAmount, setUsdAmount] = useState('') - const [currentView, setCurrentView] = useState('INITIAL') +export const WithdrawFlowProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const [withdrawData, setWithdrawData] = useState(null) const [showCompatibilityModal, setShowCompatibilityModal] = useState(false) const [isPreparingReview, setIsPreparingReview] = useState(false) const [paymentError, setPaymentError] = useState(null) - const [isValidRecipient, setIsValidRecipient] = useState(false) const [inputChanging, setInputChanging] = useState(false) const [recipient, setRecipient] = useState({ address: '', name: '' }) - const [error, setError] = useState({ - showError: false, - errorMessage: '', - }) + const [error, setError] = useState({ showError: false, errorMessage: '' }) const [selectedBankAccount, setSelectedBankAccount] = useState(null) - const [showAllWithdrawMethods, setShowAllWithdrawMethods] = useState(false) const [selectedMethod, setSelectedMethod] = useState(null) - - // charge and payment state (local to withdraw flow) const [chargeDetails, setChargeDetails] = useState(null) const [transactionHash, setTransactionHash] = useState(null) const [paymentDetails, setPaymentDetails] = useState(null) const resetWithdrawFlow = useCallback(() => { - setAmountToWithdraw('') // browser-back with the compatibility modal open leaves it armed for the // next /withdraw/crypto entry — reset must close it like everything else setShowCompatibilityModal(false) - setCurrentView('INITIAL') setWithdrawData(null) setSelectedBankAccount(null) setRecipient({ address: '', name: '' }) setError({ showError: false, errorMessage: '' }) setPaymentError(null) - setShowAllWithdrawMethods(false) - setUsdAmount('') setSelectedMethod(null) - // reset charge and payment state setChargeDetails(null) setTransactionHash(null) setPaymentDetails(null) @@ -121,12 +82,6 @@ export const WithdrawFlowContextProvider: React.FC<{ children: ReactNode }> = ({ const value = useMemo( () => ({ - amountToWithdraw, - setAmountToWithdraw, - usdAmount, - setUsdAmount, - currentView, - setCurrentView, withdrawData, setWithdrawData, showCompatibilityModal, @@ -145,8 +100,6 @@ export const WithdrawFlowContextProvider: React.FC<{ children: ReactNode }> = ({ setError, selectedBankAccount, setSelectedBankAccount, - showAllWithdrawMethods, - setShowAllWithdrawMethods, selectedMethod, setSelectedMethod, chargeDetails, @@ -158,19 +111,15 @@ export const WithdrawFlowContextProvider: React.FC<{ children: ReactNode }> = ({ resetWithdrawFlow, }), [ - amountToWithdraw, - currentView, withdrawData, showCompatibilityModal, isPreparingReview, paymentError, - usdAmount, isValidRecipient, inputChanging, recipient, error, selectedBankAccount, - showAllWithdrawMethods, selectedMethod, chargeDetails, transactionHash, @@ -185,7 +134,17 @@ export const WithdrawFlowContextProvider: React.FC<{ children: ReactNode }> = ({ export const useWithdrawFlow = (): WithdrawFlowContextType => { const context = useContext(WithdrawFlowContext) if (context === undefined) { - throw new Error('useWithdrawFlow must be used within a WithdrawFlowContextProvider') + throw new Error('useWithdrawFlow must be used within a WithdrawFlowProvider') } return context } + +/** + * For components that serve the withdraw flow AND other flows (the dual-flow + * AddWithdraw components render under /add-money too, where no provider is + * mounted). Returns null outside the provider — callers must flow-guard their + * writes. + */ +export const useOptionalWithdrawFlow = (): WithdrawFlowContextType | null => { + return useContext(WithdrawFlowContext) ?? null +} diff --git a/src/features/withdraw/WithdrawRoot.tsx b/src/features/withdraw/WithdrawRoot.tsx new file mode 100644 index 0000000000..531aac4a79 --- /dev/null +++ b/src/features/withdraw/WithdrawRoot.tsx @@ -0,0 +1,45 @@ +'use client' + +import { useTranslations } from 'next-intl' +import { useWithdrawRootFlow } from './useWithdrawRootFlow' +import { WithdrawAmountView } from './views/WithdrawAmountView' +import { WithdrawMethodView } from './views/WithdrawMethodView' + +/** + * Root /withdraw flow: method → amount, both as named screen ids in the URL + * (`?step=amount`). State machine lives in useWithdrawRootFlow; the views are + * dumb. Downstream routes (/withdraw/crypto, /withdraw/manteca, + * /withdraw/[country]/bank) receive the amount via `?amount=`. + */ +export default function WithdrawRoot() { + const t = useTranslations('withdraw') + const tNav = useTranslations('navigation') + const flow = useWithdrawRootFlow() + + if (flow.stepper.step === 'amount') { + return ( + + ) + } + + return ( + void flow.stepper.back()} + onMethodChosen={() => void flow.stepper.goTo('amount')} + /> + ) +} diff --git a/src/context/__tests__/WithdrawFlowContext.test.tsx b/src/features/withdraw/__tests__/WithdrawFlowContext.test.tsx similarity index 71% rename from src/context/__tests__/WithdrawFlowContext.test.tsx rename to src/features/withdraw/__tests__/WithdrawFlowContext.test.tsx index ba25767aa1..9c14324d8f 100644 --- a/src/context/__tests__/WithdrawFlowContext.test.tsx +++ b/src/features/withdraw/__tests__/WithdrawFlowContext.test.tsx @@ -1,5 +1,5 @@ import { render, act } from '@testing-library/react' -import { WithdrawFlowContextProvider, useWithdrawFlow } from '../WithdrawFlowContext' +import { WithdrawFlowProvider, useWithdrawFlow } from '../WithdrawFlowContext' // probe that surfaces the real provider's state + actions to the test let ctx: ReturnType @@ -11,17 +11,14 @@ function Probe() { describe('WithdrawFlowContext resetWithdrawFlow', () => { test('clears abandoned flow state, including the compatibility modal', () => { render( - + - + ) act(() => { ctx.setSelectedMethod({ type: 'crypto', title: 'Crypto' }) - ctx.setAmountToWithdraw('50') - ctx.setUsdAmount('50') ctx.setShowCompatibilityModal(true) - ctx.setShowAllWithdrawMethods(true) }) expect(ctx.selectedMethod).toEqual({ type: 'crypto', title: 'Crypto' }) expect(ctx.showCompatibilityModal).toBe(true) @@ -31,10 +28,7 @@ describe('WithdrawFlowContext resetWithdrawFlow', () => { }) expect(ctx.selectedMethod).toBeNull() - expect(ctx.amountToWithdraw).toBe('') - expect(ctx.usdAmount).toBe('') expect(ctx.selectedBankAccount).toBeNull() - expect(ctx.showAllWithdrawMethods).toBe(false) // the reset used to leave a browser-back-abandoned modal armed for the // next /withdraw/crypto entry — pin that it closes with everything else expect(ctx.showCompatibilityModal).toBe(false) diff --git a/src/features/withdraw/__tests__/amount-gating.test.ts b/src/features/withdraw/__tests__/amount-gating.test.ts new file mode 100644 index 0000000000..19c1fe78ce --- /dev/null +++ b/src/features/withdraw/__tests__/amount-gating.test.ts @@ -0,0 +1,20 @@ +import { shouldShowAmountError } from '../amount-gating' + +describe('shouldShowAmountError (TASK-21666)', () => { + it('never renders without an error', () => { + expect(shouldShowAmountError({ showError: false, isCryptoWithdraw: true, limitsBlocking: true })).toBe(false) + expect(shouldShowAmountError({ showError: false, isCryptoWithdraw: false, limitsBlocking: false })).toBe(false) + }) + + it('crypto: the balance error shows at every magnitude — even while limits are blocking', () => { + // The regression: amount above both balance and the off-ramp limit + // rendered nothing (no limits card for crypto + banner suppressed). + expect(shouldShowAmountError({ showError: true, isCryptoWithdraw: true, limitsBlocking: true })).toBe(true) + expect(shouldShowAmountError({ showError: true, isCryptoWithdraw: true, limitsBlocking: false })).toBe(true) + }) + + it('fiat: the limits card replaces the banner while blocking', () => { + expect(shouldShowAmountError({ showError: true, isCryptoWithdraw: false, limitsBlocking: true })).toBe(false) + expect(shouldShowAmountError({ showError: true, isCryptoWithdraw: false, limitsBlocking: false })).toBe(true) + }) +}) diff --git a/src/features/withdraw/__tests__/amount-validation.test.ts b/src/features/withdraw/__tests__/amount-validation.test.ts new file mode 100644 index 0000000000..ce71c39bd0 --- /dev/null +++ b/src/features/withdraw/__tests__/amount-validation.test.ts @@ -0,0 +1,86 @@ +import { parseUnits } from 'viem' +import { validateBankOfframpAmount, bankWithdrawMinUsd, bankWithdrawMinNeedsRate } from '../amount-validation' + +// The bank-offramp amount arrives via a user-editable URL param — the submit +// handler revalidates it synchronously (Chip review, PR #2917). +describe('validateBankOfframpAmount', () => { + const balance = parseUnits('100', 6) + + it('rejects zero', () => { + expect(validateBankOfframpAmount('0', balance)).toEqual({ ok: false, reason: 'invalid' }) + }) + + it('rejects malformed and non-finite values', () => { + for (const raw of ['abc', '', 'NaN', 'Infinity', '-5', '1e309']) { + expect(validateBankOfframpAmount(raw, balance)).toEqual({ ok: false, reason: 'invalid' }) + } + }) + + it('rejects non-plain-decimal raw syntax even when Number() would accept it (Chip P13)', () => { + for (const raw of ['5e1', '0x10', ' 50', '50 ', '+5', '5,5']) { + expect(validateBankOfframpAmount(raw, balance)).toEqual({ ok: false, reason: 'invalid' }) + } + }) + + it('rejects amounts under the $1 Bridge floor', () => { + expect(validateBankOfframpAmount('0.5', balance)).toEqual({ ok: false, reason: 'belowMinimum' }) + }) + + it('rejects amounts over the displayed spendable balance', () => { + expect(validateBankOfframpAmount('150', balance)).toEqual({ ok: false, reason: 'insufficientBalance' }) + }) + + it('refuses while the balance is still loading — no ceiling means no pass (Chip round 3)', () => { + expect(validateBankOfframpAmount('150', undefined)).toEqual({ ok: false, reason: 'balanceLoading' }) + expect(validateBankOfframpAmount('5', undefined)).toEqual({ ok: false, reason: 'balanceLoading' }) + }) + + it('accepts and normalizes valid amounts — the wire never sees the raw param', () => { + expect(validateBankOfframpAmount('50', balance)).toEqual({ ok: true, normalized: '50' }) + expect(validateBankOfframpAmount('050.10', balance)).toEqual({ ok: true, normalized: '50.1' }) + // honest mid-typing decimals are tolerated and normalized + expect(validateBankOfframpAmount('2.', balance)).toEqual({ ok: true, normalized: '2' }) + expect(validateBankOfframpAmount('50.', balance)).toEqual({ ok: true, normalized: '50' }) + }) +}) + +describe('bankWithdrawMinUsd', () => { + it('US and unknown destinations: the $1 floor, no rate needed', () => { + expect(bankWithdrawMinUsd('US', undefined)).toBe(1) + expect(bankWithdrawMinUsd('', undefined)).toBe(1) + expect(bankWithdrawMinNeedsRate('US')).toBe(false) + }) + + it('EUR destinations: €1 ≈ $1, no rate needed', () => { + expect(bankWithdrawMinUsd('PT', undefined)).toBe(1) + expect(bankWithdrawMinNeedsRate('PT')).toBe(false) + }) + + it('GB: £3 converts through the sell rate, rounded up', () => { + expect(bankWithdrawMinUsd('GB', '0.79')).toBe(4) // ceil(3 / 0.79) + expect(bankWithdrawMinNeedsRate('GB')).toBe(true) + }) + + it('MX: 50 MXN converts through the sell rate, rounded up', () => { + expect(bankWithdrawMinUsd('MX', '17')).toBe(3) // ceil(50 / 17) + expect(bankWithdrawMinNeedsRate('MX')).toBe(true) + }) + + it('falls back to the $1 Bridge floor while the rate loads — callers gate on bankWithdrawMinNeedsRate', () => { + expect(bankWithdrawMinUsd('GB', undefined)).toBe(1) + expect(bankWithdrawMinUsd('MX', '0')).toBe(1) + }) +}) + +describe('validateBankOfframpAmount with a destination rail minimum (Chip round 5)', () => { + const balance = 100n * 10n ** 6n + + it('blocks below the converted minimum, passes at or above it', () => { + expect(validateBankOfframpAmount('2', balance, 4)).toEqual({ ok: false, reason: 'belowMinimum' }) + expect(validateBankOfframpAmount('4', balance, 4)).toEqual({ ok: true, normalized: '4' }) + }) + + it('the $1 Bridge floor always applies beneath the destination minimum', () => { + expect(validateBankOfframpAmount('0.5', balance, 0)).toEqual({ ok: false, reason: 'belowMinimum' }) + }) +}) diff --git a/src/features/withdraw/__tests__/step-guards.test.ts b/src/features/withdraw/__tests__/step-guards.test.ts new file mode 100644 index 0000000000..01151466e2 --- /dev/null +++ b/src/features/withdraw/__tests__/step-guards.test.ts @@ -0,0 +1,60 @@ +import { bankStepGuards, cryptoStepGuards, mantecaStepGuards } from '../step-guards' + +// URL-tampering regressions (Chip review, PR #2917): the ?step= param is +// user-editable, so a terminal screen must demand execution proof — state set +// only after the money operation succeeded. Guard behavior itself (fallback +// resolution, URL rewrite) is covered by useFlowStepper.test. + +describe('bankStepGuards', () => { + it('refuses ?step=success before confirmOfframp succeeded — even with account+amount present', () => { + expect(bankStepGuards({ executed: false }).success).toEqual({ ok: false, fallback: 'review' }) + }) + + it('admits the success step once the offramp completed', () => { + expect(bankStepGuards({ executed: true }).success?.ok).toBe(true) + }) +}) + +describe('cryptoStepGuards', () => { + it('refuses ?step=success with prepared charge data but no broadcast tx', () => { + const guards = cryptoStepGuards({ prepared: true, executed: false }) + expect(guards.success?.ok).toBe(false) + expect(guards.review?.ok).toBe(true) + }) + + it('refuses review and success with nothing prepared', () => { + const guards = cryptoStepGuards({ prepared: false, executed: false }) + expect(guards.review?.ok).toBe(false) + expect(guards.success?.ok).toBe(false) + }) + + it('admits success only with charge data AND a transaction identifier', () => { + expect(cryptoStepGuards({ prepared: true, executed: true }).success?.ok).toBe(true) + // an execution marker without prepared data is not a renderable success + expect(cryptoStepGuards({ prepared: false, executed: true }).success?.ok).toBe(false) + }) +}) + +describe('mantecaStepGuards', () => { + it('refuses ?step=success and ?step=failure before the submission ran', () => { + const guards = mantecaStepGuards({ hasAmount: true, priceLocked: true, outcome: null }) + expect(guards.success?.ok).toBe(false) + expect(guards.failure?.ok).toBe(false) + expect(guards.review?.ok).toBe(true) + }) + + it('admits exactly the step matching the recorded outcome', () => { + const success = mantecaStepGuards({ hasAmount: true, priceLocked: true, outcome: 'success' }) + expect(success.success?.ok).toBe(true) + expect(success.failure?.ok).toBe(false) + const failure = mantecaStepGuards({ hasAmount: true, priceLocked: true, outcome: 'failure' }) + expect(failure.failure?.ok).toBe(true) + expect(failure.success?.ok).toBe(false) + }) + + it('keeps the pre-terminal prerequisites: amount for bank-details, price lock for review', () => { + const guards = mantecaStepGuards({ hasAmount: false, priceLocked: false, outcome: null }) + expect(guards['bank-details']?.ok).toBe(false) + expect(guards.review?.ok).toBe(false) + }) +}) diff --git a/src/features/withdraw/__tests__/useBridgeOfframpFlow.test.tsx b/src/features/withdraw/__tests__/useBridgeOfframpFlow.test.tsx new file mode 100644 index 0000000000..17e7a1e7fe --- /dev/null +++ b/src/features/withdraw/__tests__/useBridgeOfframpFlow.test.tsx @@ -0,0 +1,411 @@ +/** + * Bank offramp submit path — the money leg of the withdraw rebuild + * (createOfframp → sendMoney → confirmOfframp), exercised through the REAL + * useBridgeOfframpFlow hook under the nuqs testing adapter (Chip review + * round 4). + * + * The headline regression: the submit handler must be a fresh closure every + * render. A useCallback with lifetime-stable deps froze the FIRST render's + * gate/balance, so a click after capabilities resolved ran the stale + * `gate.kind === 'loading'` no-op forever (dead button until remount). + */ +import React from 'react' +import { renderHook, act } from '@testing-library/react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' + +// ---------- module-level mocks ---------- + +const mockRouterReplace = jest.fn() +jest.mock('next/navigation', () => ({ + useParams: () => ({ country: 'us' }), + useRouter: () => ({ push: jest.fn(), replace: mockRouterReplace, back: jest.fn(), prefetch: jest.fn() }), +})) + +jest.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ invalidateQueries: jest.fn() }), +})) + +// namespaced key-echo so error copy is assertable per namespace +jest.mock('next-intl', () => ({ + useTranslations: (ns: string) => (key: string) => `${ns}.${key}`, +})) + +jest.mock('posthog-js', () => ({ + __esModule: true, + default: { capture: jest.fn(), init: jest.fn() }, +})) + +jest.mock('@/constants/analytics.consts', () => ({ + ANALYTICS_EVENTS: { + WITHDRAW_CONFIRMED: 'withdraw_confirmed', + WITHDRAW_COMPLETED: 'withdraw_completed', + WITHDRAW_FAILED: 'withdraw_failed', + }, +})) + +jest.mock('@/constants/zerodev.consts', () => ({ + PEANUT_WALLET_CHAIN: { id: 42161 }, + PEANUT_WALLET_TOKEN_SYMBOL: 'USDC', +})) + +jest.mock('@/hooks/useFriendlyError', () => ({ + useFriendlyError: () => (err: unknown) => (err instanceof Error ? err.message : String(err)), +})) + +jest.mock('@/utils/general.utils', () => ({ + isTxReverted: () => false, +})) + +jest.mock('@/utils/bridge-accounts.utils', () => ({ + getBridgeChainName: () => 'arbitrum', +})) + +// mutable country so the GB/MX rail-minimum cases can flip the destination. +// Records mirror the REAL country table shapes — the UK is { id: 'GBR', +// iso2: 'GB' }, which is exactly what round 6 caught an id-keyed ternary on. +let mockCountryId = 'US' +jest.mock('@/utils/bridge.utils', () => ({ + getOfframpConfigFromAccount: () => ({ currency: 'usd', paymentRail: 'ach' }), + getCountryFromPath: () => + mockCountryId === 'GB' + ? { id: 'GBR', iso2: 'GB', title: 'United Kingdom' } + : { id: 'US', iso2: 'US', title: 'United States' }, + railJurisdictionForBank: () => 'US', + // mirrors the real per-country local-currency minimums ($1 / £3 / 50 MXN) + getMinimumAmount: (id: string) => (id === 'MX' ? 50 : id === 'GB' || id === 'GBR' ? 3 : 1), +})) + +// sell rate: local currency per 1 USD (0.79 GBP ≈ 1 USD → £3 ≈ $4) +let mockExchangeRate: string | undefined = '0.79' +const mockExchangeRateCalls: Array<{ accountType: unknown; enabled?: boolean }> = [] +jest.mock('@/hooks/useGetExchangeRate', () => ({ + __esModule: true, + default: (args: { accountType: unknown; enabled?: boolean }) => { + mockExchangeRateCalls.push(args) + return { exchangeRate: mockExchangeRate, isFetchingRate: false } + }, +})) + +jest.mock('@/utils/regions.utils', () => ({ + isBridgeSupportedCountry: () => true, +})) + +jest.mock('@/utils/capability-gate', () => ({ + isVerifiableGate: () => true, +})) + +jest.mock('@/utils/eea-uplift.utils', () => ({ + upliftTriggerFromGate: () => null, + upliftTriggerFromAdvisory: () => null, +})) + +jest.mock('@/utils/native-routes', () => ({ + withdrawCountryUrl: (country: string, marker: string) => `/withdraw/${country}${marker}`, +})) + +jest.mock('@/utils/settled-tx-hash.utils', () => ({ + resolveSettledTxHash: ({ txHash }: { txHash?: string }) => ({ hash: txHash ?? null }), +})) + +jest.mock('@/hooks/useSafeBack', () => ({ + useSafeBack: () => jest.fn(), +})) + +// mutable: the recovery cases assert the send marker rides along +let mockIsBankFromSend = false +jest.mock('@/hooks/useSendFlowOrigin', () => ({ + useSendFlowOrigin: () => ({ isBankFromSend: mockIsBankFromSend }), +})) + +const mockPointsCalls: unknown[][] = [] +jest.mock('@/hooks/usePointsCalculation', () => ({ + usePointsCalculation: (...args: unknown[]) => { + mockPointsCalls.push(args) + return { pointsData: null } + }, +})) + +jest.mock('@/hooks/wallet/usePendingTransactions', () => ({ + usePendingTransactions: () => ({ hasPendingTransactions: false }), +})) + +jest.mock('@/hooks/useTosGuard', () => ({ + useTosGuard: () => ({ guardWithTos: jest.fn(), showBridgeTos: false, hideTos: jest.fn() }), +})) + +jest.mock('@/hooks/useMultiPhaseKycFlow', () => ({ + useMultiPhaseKycFlow: () => ({ isLoading: false, showWrapper: false, handleSelfHealResubmit: jest.fn() }), +})) + +jest.mock('@/hooks/useWaitingOnProviderModal', () => ({ + useWaitingOnProviderModal: () => ({ open: jest.fn(), isOpen: false }), +})) + +// the advisory pre-empt is pass-through here — its own behavior has its own +// tests. IMPORTANT: render-stable singletons, like the real hooks (their +// returns are useCallback-stable) — unstable mocks would recompute the old +// memoized submit handler and hide the frozen-closure regression. +const stableAdvisoryPreempt = { intercept: (fn: () => void) => fn(), modalProps: {} } +jest.mock('@/hooks/useAdvisoryPreempt', () => ({ + useAdvisoryPreempt: () => stableAdvisoryPreempt, +})) + +const stableUpliftFunnel = { trackStarted: jest.fn(), trackCompleted: jest.fn(), reset: jest.fn() } +jest.mock('@/hooks/useEeaUpliftFunnel', () => ({ + useEeaUpliftFunnel: () => stableUpliftFunnel, +})) + +jest.mock('@/context/authContext', () => ({ + useAuth: () => ({ user: { user: { bridgeCustomerId: 'cust-1' } }, fetchUser: jest.fn() }), +})) + +const mockCreateOfframp = jest.fn() +const mockConfirmOfframp = jest.fn() +jest.mock('@/app/actions/offramp', () => ({ + createOfframp: (...args: unknown[]) => mockCreateOfframp(...args), + confirmOfframp: (...args: unknown[]) => mockConfirmOfframp(...args), +})) + +// mutable gate + balance: the stale-closure regression flips these mid-test. +// gateFor is a fresh function each render, so the hook's gate memo recomputes. +let mockGateKind: string = 'ready' +jest.mock('@/hooks/useCapabilities', () => ({ + useCapabilities: () => ({ gateFor: () => ({ kind: mockGateKind, advisory: undefined }) }), +})) + +const mockSendMoney = jest.fn() +let mockBalance: bigint | undefined = 100n * 10n ** 6n // 100 USDC +jest.mock('@/hooks/wallet/useWallet', () => ({ + useWallet: () => ({ address: '0xuser', sendMoney: mockSendMoney, spendableBalance: mockBalance }), +})) + +const mockSetError = jest.fn() +const bankAccount = { id: 'acct-1', bridgeAccountId: 'ext-1' } +// mutable: the context-loss recovery cases simulate a refresh that remounted +// the withdraw-scoped provider without a selected account +let mockBankAccount: typeof bankAccount | null = bankAccount +jest.mock('@/features/withdraw/WithdrawFlowContext', () => ({ + useWithdrawFlow: () => ({ + selectedBankAccount: mockBankAccount, + error: { showError: false, errorMessage: '' }, + setError: mockSetError, + }), +})) + +import { useBridgeOfframpFlow } from '../useBridgeOfframpFlow' +import { useWithdrawAmount } from '../useWithdrawAmount' +import { AccountType } from '@/interfaces/interfaces' + +// ---------- helpers ---------- + +const renderFlow = (searchParams: Record) => + renderHook(() => useBridgeOfframpFlow(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + }) + +const armHappyOfframp = () => { + mockCreateOfframp.mockResolvedValue({ + data: { depositInstructions: { toAddress: '0xdead' }, transferId: 'tr-1' }, + }) + mockSendMoney.mockResolvedValue({ receipt: null, userOpHash: undefined, txHash: '0xtx' }) + mockConfirmOfframp.mockResolvedValue({}) +} + +beforeEach(() => { + jest.clearAllMocks() + mockGateKind = 'ready' + mockBalance = 100n * 10n ** 6n + mockCountryId = 'US' + mockExchangeRate = '0.79' + mockExchangeRateCalls.length = 0 + mockPointsCalls.length = 0 + mockBankAccount = bankAccount + mockIsBankFromSend = false +}) + +// ---------- tests ---------- + +describe('useBridgeOfframpFlow — submit path (Chip review round 4)', () => { + it('runs create → send → confirm with the normalized URL amount', async () => { + armHappyOfframp() + const view = renderFlow({ amount: '50', step: 'review' }) + + await act(async () => { + view.result.current.handleCreateAndInitiateOfframp() + }) + + expect(mockCreateOfframp).toHaveBeenCalledWith(expect.objectContaining({ amount: '50' })) + expect(mockSendMoney).toHaveBeenCalledWith('0xdead', '50', { kind: 'FIAT_OFFRAMP' }) + expect(mockConfirmOfframp).toHaveBeenCalledWith('tr-1', '0xtx') + }) + + it('a click after the gate and balance resolve runs the offramp (regression: memoized handler froze the loading gate)', async () => { + armHappyOfframp() + mockGateKind = 'loading' + mockBalance = undefined + const view = renderFlow({ amount: '50', step: 'review' }) + + // first render: capabilities + balance still loading — the click no-ops + expect(view.result.current.isSubmitReady).toBe(false) + await act(async () => { + view.result.current.handleCreateAndInitiateOfframp() + }) + expect(mockCreateOfframp).not.toHaveBeenCalled() + + // capabilities + balance land; the SAME mounted hook must now proceed + mockGateKind = 'ready' + mockBalance = 100n * 10n ** 6n + view.rerender() + expect(view.result.current.isSubmitReady).toBe(true) + + await act(async () => { + view.result.current.handleCreateAndInitiateOfframp() + }) + expect(mockCreateOfframp).toHaveBeenCalledWith(expect.objectContaining({ amount: '50' })) + expect(mockConfirmOfframp).toHaveBeenCalledWith('tr-1', '0xtx') + }) + + it('a tampered over-balance ?amount= never reaches createOfframp or sendMoney', async () => { + const view = renderFlow({ amount: '150', step: 'review' }) + + await act(async () => { + view.result.current.handleCreateAndInitiateOfframp() + }) + + expect(mockCreateOfframp).not.toHaveBeenCalled() + expect(mockSendMoney).not.toHaveBeenCalled() + expect(mockSetError).toHaveBeenCalledWith({ + showError: true, + errorMessage: 'errors.notEnoughBalanceAddFunds', + }) + }) + + it('a below-minimum ?amount= never reaches createOfframp', async () => { + const view = renderFlow({ amount: '0.5', step: 'review' }) + + await act(async () => { + view.result.current.handleCreateAndInitiateOfframp() + }) + + expect(mockCreateOfframp).not.toHaveBeenCalled() + expect(mockSendMoney).not.toHaveBeenCalled() + expect(mockSetError).toHaveBeenCalledWith({ + showError: true, + errorMessage: 'withdraw.errors.minimumWithdrawal', + }) + }) + + it('a post-completion ?amount= edit cannot forge the success amount — executedAmountUsd stays pinned (Chip round 8)', async () => { + armHappyOfframp() + // probe hook alongside: the amount setter drives the SAME nuqs adapter + const view = renderHook(() => ({ flow: useBridgeOfframpFlow(), amountState: useWithdrawAmount() }), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + }) + + await act(async () => { + view.result.current.flow.handleCreateAndInitiateOfframp() + }) + expect(view.result.current.flow.executedAmountUsd).toBe('50') + + // tamper the URL after completion + await act(async () => { + await view.result.current.amountState[1]('5000') + }) + expect(view.result.current.flow.amountToWithdraw).toBe('5000') + // the success screen renders executedAmountUsd — still the moved amount + expect(view.result.current.flow.executedAmountUsd).toBe('50') + // the points estimate keys off the executed amount too — never the + // edited URL (Chip round 9) + expect(mockPointsCalls.at(-1)?.[1]).toBe('50') + }) + + it('GB: an amount below the converted £3 rail minimum never reaches createOfframp (Chip round 5)', async () => { + mockCountryId = 'GB' // real record: { id: 'GBR', iso2: 'GB' }; £3 ÷ 0.79 → $4 minimum + const view = renderFlow({ amount: '2', step: 'review' }) + + await act(async () => { + view.result.current.handleCreateAndInitiateOfframp() + }) + + expect(mockCreateOfframp).not.toHaveBeenCalled() + expect(mockSetError).toHaveBeenCalledWith({ + showError: true, + errorMessage: 'withdraw.errors.minimumWithdrawal', + }) + // the £3 minimum must convert through the GBP rate, not fall through + // to IBAN/EUR on the 'GBR' id (Chip round 6) + expect(mockExchangeRateCalls.some((c) => c.accountType === AccountType.GB && c.enabled)).toBe(true) + }) + + it('GB: an amount above the converted minimum proceeds', async () => { + armHappyOfframp() + mockCountryId = 'GB' + const view = renderFlow({ amount: '5', step: 'review' }) + + await act(async () => { + view.result.current.handleCreateAndInitiateOfframp() + }) + + expect(mockCreateOfframp).toHaveBeenCalledWith(expect.objectContaining({ amount: '5' })) + }) + + it('GB: while the FX rate behind the minimum loads, submit is not ready and the click no-ops', async () => { + armHappyOfframp() + mockCountryId = 'GB' + mockExchangeRate = undefined + const view = renderFlow({ amount: '50', step: 'review' }) + + expect(view.result.current.isSubmitReady).toBe(false) + await act(async () => { + view.result.current.handleCreateAndInitiateOfframp() + }) + expect(mockCreateOfframp).not.toHaveBeenCalled() + }) + + it('a malformed ?amount= never reaches createOfframp', async () => { + const view = renderFlow({ amount: 'abc', step: 'review' }) + + await act(async () => { + view.result.current.handleCreateAndInitiateOfframp() + }) + + expect(mockCreateOfframp).not.toHaveBeenCalled() + expect(mockSendMoney).not.toHaveBeenCalled() + expect(mockSetError).toHaveBeenCalledWith({ + showError: true, + errorMessage: 'withdraw.errors.invalidAmount', + }) + }) +}) + +// A refresh on the review page remounts the withdraw-scoped provider without +// the selected account. The URL is the sole durable amount store — the +// recovery redirect must carry ?amount= forward, or the user re-enters the +// amount they already typed (Chip round 10). +describe('useBridgeOfframpFlow — context-loss recovery preserves the URL amount (Chip round 10)', () => { + it('review without a selected account: recovery to country selection carries ?amount=', () => { + mockBankAccount = null + renderFlow({ amount: '50', step: 'review' }) + + expect(mockRouterReplace).toHaveBeenCalledWith('/withdraw/us?amount=50') + }) + + it('from the send flow, the method marker rides along with the amount', () => { + mockBankAccount = null + mockIsBankFromSend = true + renderFlow({ amount: '50', step: 'review' }) + + expect(mockRouterReplace).toHaveBeenCalledWith('/withdraw/us?method=bank&amount=50') + }) + + it('no amount at all: recovery to the flow entry keeps only the send marker', () => { + mockIsBankFromSend = true + renderFlow({ step: 'review' }) + + expect(mockRouterReplace).toHaveBeenCalledWith('/withdraw?method=bank') + }) +}) diff --git a/src/features/withdraw/__tests__/useMantecaAmountSeed.test.tsx b/src/features/withdraw/__tests__/useMantecaAmountSeed.test.tsx new file mode 100644 index 0000000000..6d250dc13d --- /dev/null +++ b/src/features/withdraw/__tests__/useMantecaAmountSeed.test.tsx @@ -0,0 +1,118 @@ +import { act, renderHook } from '@testing-library/react' +import { useMantecaAmountSeed } from '../useMantecaAmountSeed' + +// TASK-21664 / Chip round 3: the ?amount= hand-off into the Manteca flow. +// The param is user-editable, so the seed must not outrun the amount screen's +// balance/limits gates — and the seeded flow's back/retry paths must behave. + +const BASE = { + urlAmount: '50', + currencyPriceSell: 1500, // ARS per USD — the AmountInput primary-denomination direction + step: 'amount', + isAmountAllowed: (() => true) as (usd: string) => boolean, + limitsLoading: false, + limitsBlocking: false, +} + +function harness(overrides: Partial = {}) { + const setUsdAmount = jest.fn() + const setCurrencyAmount = jest.fn() + const goToBankDetails = jest.fn() + const view = renderHook( + (props: Partial) => + useMantecaAmountSeed({ + ...BASE, + ...props, + setUsdAmount, + setCurrencyAmount, + goToBankDetails, + }), + { initialProps: overrides } + ) + return { ...view, setUsdAmount, setCurrencyAmount, goToBankDetails } +} + +describe('useMantecaAmountSeed', () => { + it('seeds BOTH denominations from ?amount= — USD verbatim, local = usd × sell — and advances to bank-details', () => { + const { setUsdAmount, setCurrencyAmount, goToBankDetails } = harness() + expect(setUsdAmount).toHaveBeenCalledWith('50.00') + // the conversion direction must match AmountInput's primary price + // (sell = local per 1 USD): 50 USD × 1500 = 75000 ARS + expect(setCurrencyAmount).toHaveBeenCalledWith('75000.00') + expect(goToBankDetails).toHaveBeenCalledTimes(1) + }) + + it('does NOT advance while the gates block — the amount screen shows the reason (Chip round 3)', () => { + for (const blocked of [ + { limitsBlocking: true }, + { limitsLoading: true }, + // over-balance, below-minimum, and balance-still-loading all + // arrive through the synchronous validator + { isAmountAllowed: () => false }, + ]) { + const { setUsdAmount, goToBankDetails } = harness(blocked) + // the amounts still seed (the screen shows them + the blocking card) + expect(setUsdAmount).toHaveBeenCalledWith('50.00') + expect(goToBankDetails).not.toHaveBeenCalled() + } + }) + + it('asks the validator about the normalized seeded amount, synchronously', () => { + const isAmountAllowed = jest.fn(() => true) + harness({ isAmountAllowed }) + expect(isAmountAllowed).toHaveBeenCalledWith('50.00') + }) + + it('advances once a blocking gate clears', async () => { + const { rerender, goToBankDetails } = harness({ limitsLoading: true }) + expect(goToBankDetails).not.toHaveBeenCalled() + await act(async () => rerender({ limitsLoading: false })) + expect(goToBankDetails).toHaveBeenCalledTimes(1) + }) + + it('ignores malformed, missing, and exponential amounts and never advances', () => { + // '1e21' crashes downstream parseUnits if it survives (Chip round 7) + for (const urlAmount of ['', '0', '-5', 'abc', '1e21', '1e-3x']) { + const { setUsdAmount, goToBankDetails } = harness({ urlAmount }) + expect(setUsdAmount).not.toHaveBeenCalled() + expect(goToBankDetails).not.toHaveBeenCalled() + } + }) + + it('waits for the FX rate before converting', async () => { + const { rerender, setCurrencyAmount, goToBankDetails } = harness({ currencyPriceSell: undefined }) + expect(setCurrencyAmount).not.toHaveBeenCalled() + await act(async () => rerender({ currencyPriceSell: 1500 })) + expect(setCurrencyAmount).toHaveBeenCalledWith('75000.00') + expect(goToBankDetails).toHaveBeenCalledTimes(1) + }) + + it('reports seededFromUrl so back from bank-details returns to the ROOT amount step', () => { + const seeded = harness() + expect(seeded.result.current.seededFromUrl).toBe(true) + const notSeeded = harness({ urlAmount: '' }) + expect(notSeeded.result.current.seededFromUrl).toBe(false) + }) + + it('Try again re-arms: resetSeed + returning to the amount step re-seeds and re-advances', async () => { + const { result, setUsdAmount, goToBankDetails } = harness() + expect(goToBankDetails).toHaveBeenCalledTimes(1) + + // terminal failure → resetState clears the amounts and resets the seed; + // the flow is back on the amount step, so the seed re-arms immediately + setUsdAmount.mockClear() + goToBankDetails.mockClear() + await act(async () => result.current.resetSeed()) + + expect(setUsdAmount).toHaveBeenCalledWith('50.00') + expect(goToBankDetails).toHaveBeenCalledTimes(1) + }) + + it('seeds only once per arm — later renders do not clobber a user-corrected amount', async () => { + const { rerender, setUsdAmount } = harness() + expect(setUsdAmount).toHaveBeenCalledTimes(1) + await act(async () => rerender({ isAmountAllowed: () => false })) + await act(async () => rerender({ isAmountAllowed: () => true })) + expect(setUsdAmount).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/features/withdraw/amount-gating.ts b/src/features/withdraw/amount-gating.ts new file mode 100644 index 0000000000..e93e54fca3 --- /dev/null +++ b/src/features/withdraw/amount-gating.ts @@ -0,0 +1,23 @@ +/** + * One place for the "which message renders under the amount input" rule. + * + * TASK-21666: on crypto withdraws the limits card never renders, and the + * balance error used to be suppressed while the limits validation was + * blocking — above the off-ramp limit the user got a dead Continue with no + * message at all. The rule: the flow-error banner yields to the limits card + * only when that card actually renders. + */ +export function shouldShowAmountError({ + showError, + isCryptoWithdraw, + limitsBlocking, +}: { + showError: boolean + isCryptoWithdraw: boolean + limitsBlocking: boolean +}): boolean { + if (!showError) return false + // The limits card renders for fiat withdrawals only — it may replace the + // banner there. For crypto there is no card, so the banner must never hide. + return isCryptoWithdraw || !limitsBlocking +} diff --git a/src/features/withdraw/amount-validation.ts b/src/features/withdraw/amount-validation.ts new file mode 100644 index 0000000000..bc3cf72bb5 --- /dev/null +++ b/src/features/withdraw/amount-validation.ts @@ -0,0 +1,96 @@ +import { isAmountWithinBalance } from '@/utils/balance.utils' +import { getMinimumAmount } from '@/utils/bridge.utils' + +/** + * Bridge bank offramps have a $1 wire minimum + * (https://apidocs.bridge.xyz/docs/transaction-costs). Per-country minimums + * are enforced on the amount step; this is the hard floor the submit handler + * re-checks synchronously — the amount arrives via a user-editable URL param. + */ +export const BRIDGE_OFFRAMP_MIN_USD = 1 + +export type WithdrawAmountCheck = + | { ok: true; normalized: string } + | { ok: false; reason: 'invalid' | 'belowMinimum' | 'insufficientBalance' | 'balanceLoading' } + +/** + * Fail-closed parse of a user-supplied USD amount string: a finite positive + * number that round-trips to a plain decimal, or null. The RAW string must be + * plain-decimal syntax before it is normalized — `Number()` alone also accepts + * exponential (`5e1`), hex (`0x10`), `Infinity` and whitespace-padded forms, + * and checking only the normalized output let those through (Chip P13). + * Downstream `parseUnits` calls throw on scientific notation, so oversized + * forms (`1e21`) must never survive parsing either (Chip round 7). `.5` and + * `5.` are tolerated as honest mid-typing decimals. + */ +export function parseUsdAmount(amount: string): string | null { + if (!/^(\d+\.?\d*|\.\d+)$/.test(amount)) return null + const value = Number(amount) + if (!Number.isFinite(value) || value <= 0) return null + const normalized = value.toString() + if (!/^\d+(\.\d+)?$/.test(normalized)) return null + return normalized +} + +function checkWithdrawUsdAmount(amount: string, balance: bigint | undefined, minUsd: number): WithdrawAmountCheck { + const normalized = parseUsdAmount(amount) + if (normalized === null) return { ok: false, reason: 'invalid' } + if (Number(normalized) < minUsd) return { ok: false, reason: 'belowMinimum' } + // an unloaded balance is NOT a pass: without the ceiling an edited + // ?amount= above the user's funds could reach the provider before the + // wallet send rejects it (Chip review round 3) — the submit stays + // disabled until the balance is real + if (balance === undefined) return { ok: false, reason: 'balanceLoading' } + if (!isAmountWithinBalance(normalized, balance)) { + return { ok: false, reason: 'insufficientBalance' } + } + return { ok: true, normalized } +} + +/** + * The bank-withdraw minimum in USD for a destination country — the same + * conversion the amount step applies (getMinimumAmount is local-currency: + * GB £3, MX 50 MXN; sell rate = local per 1 USD; €1 ≈ $1). While the rate + * has not loaded it falls back to the $1 Bridge floor — callers gate + * submission on the rate for countries that need one (bankWithdrawMinNeedsRate). + */ +export function bankWithdrawMinUsd(countryIso2: string, exchangeRate: string | null | undefined): number { + const localMin = getMinimumAmount(countryIso2) + if (!countryIso2 || countryIso2 === 'US') return localMin + if (localMin === 1) return 1 // EUR countries: €1 ≈ $1 + const rate = parseFloat(exchangeRate || '0') + if (rate <= 0) return BRIDGE_OFFRAMP_MIN_USD // fallback while the rate loads + return Math.ceil(localMin / rate) +} + +/** True when the country's minimum is local-currency and needs the FX rate. */ +export function bankWithdrawMinNeedsRate(countryIso2: string): boolean { + return !!countryIso2 && countryIso2 !== 'US' && getMinimumAmount(countryIso2) !== 1 +} + +/** + * Validate + normalize the USD amount right before creating a bank offramp + * (Chip review, PR #2917): the URL string must be a finite positive number at + * or above the rail floor and within the displayed spendable balance. The + * normalized decimal string is what goes on the wire — never the raw param. + * `minUsd` carries the destination's converted rail minimum (Chip round 5) — + * the $1 Bridge floor always applies beneath it. + */ +export function validateBankOfframpAmount( + amount: string, + balance: bigint | undefined, + minUsd: number = BRIDGE_OFFRAMP_MIN_USD +): WithdrawAmountCheck { + return checkWithdrawUsdAmount(amount, balance, Math.max(BRIDGE_OFFRAMP_MIN_USD, minUsd)) +} + +/** + * Same contract for the crypto withdraw page (Chip review round 4): `?amount=` + * must be a finite positive plain-decimal within the loaded balance before any + * request/charge is persisted, and again before broadcast. No rail floor here — + * same-chain USDC has no minimum (parity with send-via-link); the per-chain + * Rhino route minimums are enforced separately by the page. + */ +export function validateCryptoWithdrawAmount(amount: string, balance: bigint | undefined): WithdrawAmountCheck { + return checkWithdrawUsdAmount(amount, balance, 0) +} diff --git a/src/features/withdraw/routes.ts b/src/features/withdraw/routes.ts new file mode 100644 index 0000000000..208581a404 --- /dev/null +++ b/src/features/withdraw/routes.ts @@ -0,0 +1,14 @@ +/** Route builders for the withdraw flow's cross-route navigations. */ + +/** + * /withdraw/manteca with its query contract (method, country, amount, + * destination, isSavedAccount). Undefined/empty values are omitted. + */ +export function mantecaWithdrawUrl(params: Record): string { + const search = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value) search.set(key, value) + } + const qs = search.toString() + return qs ? `/withdraw/manteca?${qs}` : '/withdraw/manteca' +} diff --git a/src/features/withdraw/step-guards.ts b/src/features/withdraw/step-guards.ts new file mode 100644 index 0000000000..1b18cf8b06 --- /dev/null +++ b/src/features/withdraw/step-guards.ts @@ -0,0 +1,57 @@ +import type { FlowStepGuard } from '@/hooks/useFlowStepper.types' +import type { WithdrawBankStep, WithdrawCryptoStep, WithdrawMantecaStep } from './types' + +/** + * Entry guards for the withdraw flows' URL steps. The step param is + * user-editable, so every terminal screen is gated on flow-local EXECUTION + * proof — state that is only set after the money operation succeeded — never + * on pre-execution data alone. A hand-edited `?step=success` (or a refresh + * that lost flow memory) falls back to the working step instead of rendering + * a success screen for a withdrawal that never ran (Chip review, PR #2917). + */ + +export function bankStepGuards({ + executed, +}: { + /** confirmOfframp succeeded — the money leg is real. */ + executed: boolean +}): Partial>> { + return { + success: { ok: executed, fallback: 'review' }, + } +} + +export function cryptoStepGuards({ + prepared, + executed, +}: { + /** charge + route data exist (pre-execution). */ + prepared: boolean + /** the transfer broadcast and returned a transaction identifier. */ + executed: boolean +}): Partial>> { + return { + review: { ok: prepared }, + success: { ok: prepared && executed }, + } +} + +export type MantecaOutcome = 'success' | 'failure' | null + +export function mantecaStepGuards({ + hasAmount, + priceLocked, + outcome, +}: { + hasAmount: boolean + priceLocked: boolean + /** set only by the withdrawal submission — success or terminal failure. */ + outcome: MantecaOutcome +}): Partial>> { + return { + 'bank-details': { ok: hasAmount }, + review: { ok: hasAmount && priceLocked }, + success: { ok: outcome === 'success' }, + failure: { ok: outcome === 'failure' }, + } +} diff --git a/src/features/withdraw/types.ts b/src/features/withdraw/types.ts new file mode 100644 index 0000000000..3c6426c0c1 --- /dev/null +++ b/src/features/withdraw/types.ts @@ -0,0 +1,41 @@ +import { type ITokenPriceData, type Account } from '@/interfaces/interfaces' +import type { ChainWithTokens } from '@/interfaces/chain-meta' + +export interface WithdrawMethod { + type: 'bridge' | 'manteca' | 'crypto' + countryPath?: string + currency?: string + minimumAmount?: number + savedAccount?: Account + title?: string +} + +export interface WithdrawData { + token: ITokenPriceData + chain: ChainWithTokens + address: string + amount: string +} + +/** Flow-level error banner state ({@link FlowErrorState.showError} + copy). + * Field-level validation errors are `FieldError` under their input instead. */ +export interface FlowErrorState { + showError: boolean + errorMessage: string +} + +/** Named screen ids for the root /withdraw page — these appear verbatim in the URL. */ +export const WITHDRAW_ROOT_STEPS = ['method', 'amount'] as const +export type WithdrawRootStep = (typeof WITHDRAW_ROOT_STEPS)[number] + +/** Named screen ids for /withdraw/crypto. */ +export const WITHDRAW_CRYPTO_STEPS = ['recipient', 'review', 'success'] as const +export type WithdrawCryptoStep = (typeof WITHDRAW_CRYPTO_STEPS)[number] + +/** Named screen ids for /withdraw/[country]/bank. */ +export const WITHDRAW_BANK_STEPS = ['review', 'success'] as const +export type WithdrawBankStep = (typeof WITHDRAW_BANK_STEPS)[number] + +/** Named screen ids for /withdraw/manteca. */ +export const WITHDRAW_MANTECA_STEPS = ['amount', 'bank-details', 'review', 'success', 'failure'] as const +export type WithdrawMantecaStep = (typeof WITHDRAW_MANTECA_STEPS)[number] diff --git a/src/features/withdraw/useBridgeOfframpFlow.ts b/src/features/withdraw/useBridgeOfframpFlow.ts new file mode 100644 index 0000000000..7f894f2ee4 --- /dev/null +++ b/src/features/withdraw/useBridgeOfframpFlow.ts @@ -0,0 +1,497 @@ +'use client' + +import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants/zerodev.consts' +import { useWallet } from '@/hooks/wallet/useWallet' +import { usePendingTransactions } from '@/hooks/wallet/usePendingTransactions' +import { isTxReverted } from '@/utils/general.utils' +import { useParams, useRouter } from 'next/navigation' +import { useEffect, useMemo, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { TRANSACTIONS } from '@/constants/query.consts' +import { useFriendlyError } from '@/hooks/useFriendlyError' +import { isAmountWithinBalance } from '@/utils/balance.utils' +import { getBridgeChainName } from '@/utils/bridge-accounts.utils' +import { getOfframpConfigFromAccount, getCountryFromPath, railJurisdictionForBank } from '@/utils/bridge.utils' +import { createOfframp, confirmOfframp } from '@/app/actions/offramp' +import { useAuth } from '@/context/authContext' +import { useTosGuard } from '@/hooks/useTosGuard' +import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' +import { useWaitingOnProviderModal } from '@/hooks/useWaitingOnProviderModal' +import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt' +import { useEeaUpliftFunnel } from '@/hooks/useEeaUpliftFunnel' +import { upliftTriggerFromGate, upliftTriggerFromAdvisory } from '@/utils/eea-uplift.utils' +import { useCapabilities } from '@/hooks/useCapabilities' +import { isVerifiableGate } from '@/utils/capability-gate' +import { isBridgeSupportedCountry } from '@/utils/regions.utils' +import { PointsAction } from '@/services/services.types' +import { usePointsCalculation } from '@/hooks/usePointsCalculation' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { withdrawCountryUrl } from '@/utils/native-routes' +import { useSafeBack } from '@/hooks/useSafeBack' +import { useSendFlowOrigin } from '@/hooks/useSendFlowOrigin' +import { useTranslations } from 'next-intl' +import { resolveSettledTxHash } from '@/utils/settled-tx-hash.utils' +import { type Account } from '@/interfaces/interfaces' +import { parseAsString, useQueryState } from 'nuqs' +import { useFlowStepper } from '@/hooks/useFlowStepper' +import { useWithdrawFlow } from './WithdrawFlowContext' +import { useWithdrawAmount } from './useWithdrawAmount' +import { bankStepGuards } from './step-guards' +import { validateBankOfframpAmount, bankWithdrawMinUsd, bankWithdrawMinNeedsRate } from './amount-validation' +import useGetExchangeRate from '@/hooks/useGetExchangeRate' +import { AccountType } from '@/interfaces/interfaces' +import { WITHDRAW_BANK_STEPS } from './types' + +/** + * Flow hook for the Bridge bank-withdraw review page + * (/withdraw/[country]/bank): the review → success stepper (named screen ids + * in the URL), the offramp submission (create → send on-chain → confirm), the + * capability gates and the KYC/advisory modal state. The amount arrives in the + * URL (`?amount=`, TASK-21664/21665); the selected account lives in the + * /withdraw-scoped flow context. + */ +export function useBridgeOfframpFlow() { + const t = useTranslations('withdraw') + const tErrors = useTranslations('errors') + const toFriendlyError = useFriendlyError() + // Copy shown when the on-chain deposit to the Bridge address succeeded but the + // subsequent `/bridge/transfers/:id/confirm` call failed (most often a + // fetchWithSentry timeout). The Bridge transfer row exists on the BE; the + // poller / Bridge webhook will eventually complete it. We MUST NOT show a + // Retry button in this state — retrying re-runs sendMoney() and would send + // funds to the deposit address a second time (Sentry PEANUT-UI-QH9, 2026-06-01). + const confirmPendingCopy = t('bank.confirmPending') + + const { selectedBankAccount: bankAccount, error, setError } = useWithdrawFlow() + const [amountToWithdraw] = useWithdrawAmount() + const { user, fetchUser } = useAuth() + const { address, sendMoney, spendableBalance: balance } = useWallet() + const { guardWithTos, showBridgeTos, hideTos } = useTosGuard() + const queryClient = useQueryClient() + const router = useRouter() + // native/capacitor passes the country as ?country= instead of a path segment + const [countryFromQuery] = useQueryState('country', parseAsString.withDefault('')) + const [isLoading, setIsLoading] = useState(false) + // Set as soon as the on-chain wallet→Bridge tx confirms. If a subsequent + // confirmOfframp() call fails, this gates the UI into a "processing" state + // instead of showing a Retry button that would re-fire sendMoney(). + const [submittedTxHash, setSubmittedTxHash] = useState(null) + // Execution proof for the success step: set only after confirmOfframp + // succeeded. The ?step= param is user-editable — without this, a + // hand-edited ?step=success rendered a success screen for a withdrawal + // that never ran. + const [completedTxHash, setCompletedTxHash] = useState(null) + // The USD amount the completed offramp actually moved. The success screen + // renders THIS — `?amount=` stays user-editable after completion, and + // rendering it would let a URL edit forge the confirmation (Chip round 8). + const [executedAmountUsd, setExecutedAmountUsd] = useState(null) + const params = useParams() + // read country from path params (web) or query params (native/capacitor) + const country = (params.country as string) || countryFromQuery + const [balanceErrorMessage, setBalanceErrorMessage] = useState(null) + const { hasPendingTransactions } = usePendingTransactions() + + const stepper = useFlowStepper({ + steps: WITHDRAW_BANK_STEPS, + guards: bankStepGuards({ executed: !!completedTxHash }), + }) + const step = stepper.step + + // Country-scoped bank-channel withdraw gate. Same rationale as the + // add-money/[country]/bank page: scope to the rail jurisdiction this page + // actually withdraws to (PT/DE/… → EU SEPA; US → ACH; etc.) so a stuck + // PENDING rail in an unrelated jurisdiction can't block this page. + const { gateFor } = useCapabilities() + const bankCountry = useMemo(() => railJurisdictionForBank(getCountryFromPath(country)?.id), [country]) + const countryFromPath = getCountryFromPath(country) + + // The destination's rail minimum, in USD — the amount step enforces it and + // the submit re-checks it (Chip round 5: the flat $1 floor bypassed the + // GB £3 / MX 50 MXN minimums). GB/MX minimums are local-currency, so they + // convert through the same sell rate the amount step uses; until that rate + // loads the submit stays disabled rather than under-enforcing. + // iso2, not id: the UK record is { id: 'GBR', iso2: 'GB' } and an id-keyed + // ternary silently picked the EUR rate for the £3 minimum (Chip round 6) + const countryIso2 = countryFromPath?.iso2 ?? countryFromPath?.id ?? '' + const minNeedsRate = bankWithdrawMinNeedsRate(countryIso2) + const { exchangeRate } = useGetExchangeRate({ + accountType: + countryIso2 === 'GB' ? AccountType.GB : countryIso2 === 'MX' ? AccountType.CLABE : AccountType.IBAN, + enabled: minNeedsRate, + }) + const minUsd = bankWithdrawMinUsd(countryIso2, exchangeRate) + const isMinReady = !minNeedsRate || parseFloat(exchangeRate || '0') > 0 + 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 { + trackStarted: trackUpliftStarted, + trackCompleted: trackUpliftCompleted, + reset: resetUpliftFunnel, + } = useEeaUpliftFunnel('withdraw') + + const sumsubFlow = useMultiPhaseKycFlow({ + // Fire completed at Sumsub approval (verification submitted), not at + // end-of-flow — so it isn't lost if the user drops during the + // post-approval ToS / preparing steps. + onKycApproved: () => trackUpliftCompleted(), + // Abandoned attempt: clear the pending start so a later unrelated KYC + // success on this page can't mis-fire eea_uplift_completed. + onManualClose: resetUpliftFunnel, + }) + // A ready bank rail can still carry a pending Bridge requirement (the gate's + // `advisory`). Enforce it as a mandatory, non-skippable pre-empt before the + // withdrawal — the offramp cannot proceed until it's completed. + const advisory = gate.kind === 'ready' ? gate.advisory : undefined + const { intercept: advisoryIntercept, modalProps: advisoryModalProps } = useAdvisoryPreempt({ + advisory, + isLoading: sumsubFlow.isLoading, + // 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. + onCompleteNow: () => { + if (!advisory) return Promise.resolve() + return sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey) + }, + }) + const [showKycModal, setShowKycModal] = useState(false) + + // close kyc modal when sumsub sdk opens + useEffect(() => { + if (sumsubFlow.showWrapper) setShowKycModal(false) + }, [sumsubFlow.showWrapper]) + + // only bank reaches this page, so the bank-specific flag is the right one here + const { isBankFromSend: fromSendFlow } = useSendFlowOrigin() + + // validate country is supported for bank withdrawals + useEffect(() => { + if (country) { + const countryInfo = getCountryFromPath(country) + if (!countryInfo || !isBridgeSupportedCountry(countryInfo.id)) { + router.replace(`/withdraw${fromSendFlow ? '?method=bank' : ''}`) + } + } + }, [country, router, fromSendFlow]) + + const onBack = useSafeBack(fromSendFlow ? '/send' : '/withdraw') + + // Calculate points API call. Once the offramp executed, the estimate keys + // off the EXECUTED amount — the URL stays editable after completion, and + // keying off it let ?amount=5000 fetch a false $5,000 points preview onto + // the success screen (Chip round 9). + const pointsAmount = executedAmountUsd ?? amountToWithdraw + const { pointsData } = usePointsCalculation( + PointsAction.BRIDGE_TRANSFER, + pointsAmount, + !!(pointsAmount && bankAccount), + bankAccount?.id + ) + + useEffect(() => { + // Prerequisites live in the URL (amount) and the flow context (account). + // A refresh on the success step loses the context — send the user back + // to the flow entry rather than rendering a dead screen. + // The recovery targets keep ?method=bank (land on a bare /withdraw and + // the step the user is sent back to silently reverts to withdraw copy) + // AND ?amount= (the URL is the sole durable amount store — dropping it + // on the context-loss redirect forced a second amount entry, Chip + // round 10). + const recovery = new URLSearchParams() + if (fromSendFlow) recovery.set('method', 'bank') + if (amountToWithdraw) recovery.set('amount', amountToWithdraw) + const recoveryQs = recovery.toString() + const recoveryQuery = recoveryQs ? `?${recoveryQs}` : '' + if (step === 'success') { + if (!bankAccount) router.replace(`/withdraw${recoveryQuery}`) + return + } + if (!amountToWithdraw) { + // If no amount, go back to main page + router.replace(`/withdraw${recoveryQuery}`) + } else if (!bankAccount && amountToWithdraw) { + // If amount is set but no bank account, go to country method selection + router.replace(withdrawCountryUrl(country, recoveryQuery)) + } + }, [bankAccount, router, amountToWithdraw, country, step, fromSendFlow]) + + const destinationDetails = (account: Account) => { + // Derive currency + rail from the account's actual type (GB→GBP, IBAN→EUR, + // US→USD, CLABE→MXN) rather than re-deriving from a country switch whose + // `default` returned an empty currency/rail. getOfframpConfigFromAccount + // tolerates both the projected ('gb') and Prisma-shaped ('BANK_GB') + // strings and keeps this flow consistent with the Claim flow + // (BankFlowManager). Manteca accounts never reach this Bridge page + // (separate /withdraw/manteca route), so its throw cannot fire here. + const { currency, paymentRail } = getOfframpConfigFromAccount(account) + return { + currency, + paymentRail, + externalAccountId: account.bridgeAccountId, + } + } + + const proceedWithOfframp = async () => { + if (gate.kind !== 'ready') { + // 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 (!isVerifiableGate(gate.kind) && gate.kind !== 'accept-tos') { + 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 + } + + // The GB/MX rail minimum converts through the FX rate — the submit is + // disabled until it loads; reaching here early is a race, not a user + // error: no-op rather than under-enforce. + if (!isMinReady) return + + // The amount is a user-editable URL param — revalidate synchronously + // before anything fires (Chip review, PR #2917): finite, positive, at + // or above the destination's rail minimum (round 5 — was a flat $1), + // within the displayed balance. The normalized string goes on the wire. + const amountCheck = validateBankOfframpAmount(amountToWithdraw, balance, minUsd) + if (!amountCheck.ok) { + // the submit button is disabled until the balance loads — reaching + // here with balanceLoading is a race, not a user error: no-op. + if (amountCheck.reason === 'balanceLoading') return + const errorMessage = + amountCheck.reason === 'insufficientBalance' + ? tErrors('notEnoughBalanceAddFunds') + : amountCheck.reason === 'belowMinimum' + ? t('errors.minimumWithdrawal', { amount: `$${minUsd}` }) + : t('errors.invalidAmount') + setError({ showError: true, errorMessage }) + return + } + const amountUsd = amountCheck.normalized + + setIsLoading(true) + setError({ showError: false, errorMessage: '' }) + + if (!bankAccount || !user?.user.bridgeCustomerId || !address) { + setError({ showError: true, errorMessage: t('errors.userDetailsMissing') }) + setIsLoading(false) + return + } + + if (!bankAccount.bridgeAccountId) { + setError({ showError: true, errorMessage: t('errors.bankAccountMissing') }) + setIsLoading(false) + return + } + + posthog.capture(ANALYTICS_EVENTS.WITHDRAW_CONFIRMED, { + amount_usd: amountUsd, + method_type: 'bridge', + country, + }) + + // Set alongside every pre-throw setError below: those messages are already + // the right copy (backend-authored, or the confirm-pending notice), and the + // catch must not overwrite them with the generic mapper output. + let errorAlreadyDisplayed = false + + try { + // Step 1: create the transfer to get deposit instructions + const destination = destinationDetails(bankAccount) + if (!destination.externalAccountId) { + throw new Error('External account ID is missing.') + } + + const createPayload = { + // note: for bank withdrawals, minimum $1 is required + // reference: https://apidocs.bridge.xyz/docs/transaction-costs + amount: amountUsd, + developer_fee: '0', + onBehalfOf: user.user.bridgeCustomerId, + source: { + currency: PEANUT_WALLET_TOKEN_SYMBOL.toLowerCase(), + paymentRail: getBridgeChainName(PEANUT_WALLET_CHAIN.id.toString()) ?? 'arbitrum', // source blockchain, bridge expects this to be arbitrum not arbitrum one + fromAddress: address, + }, + destination: { + ...destination, + externalAccountId: destination.externalAccountId, + }, + } + const { data, error } = await createOfframp(createPayload) + + if (error) { + setError({ showError: true, errorMessage: error }) + errorAlreadyDisplayed = true + throw new Error(error) + } + + if (!data?.depositInstructions?.toAddress || !data.transferId) { + setError({ showError: true, errorMessage: t('errors.depositAddressFailed') }) + errorAlreadyDisplayed = true + throw new Error('Failed to get deposit address from the backend.') + } + + // Step 2: prepare and send the transaction from peanut wallet to the deposit address + const { receipt, userOpHash, txHash } = await sendMoney( + data.depositInstructions.toAddress as `0x${string}`, + createPayload.amount, + { kind: 'FIAT_OFFRAMP' } + ) + + if (receipt !== null && isTxReverted(receipt)) { + throw new Error('Transaction reverted by the network.') + } + + // Step 3: Confirm the transfer with the backend to make it visible in history. + // Prefer the on-chain tx hash; fall back to the collateral withdraw tx hash + // (collateral-only path) BEFORE the userOp hash. confirmOfframp expects a real + // 32-byte tx hash — userOpHash is an account-abstraction bundler hash, not a + // chain tx hash, and the BE rejects it. + const txIdentifier = resolveSettledTxHash({ receipt, txHash, userOpHash }, 'withdraw-bank').hash + if (!txIdentifier) throw new Error('No transaction identifier returned from sendMoney') + + // Mark the on-chain leg done BEFORE confirmOfframp. From this point on + // any error path (including a confirm timeout) must NOT offer Retry — + // re-running this handler would call sendMoney() again and double-pay. + setSubmittedTxHash(txIdentifier) + + const confirmResult = await confirmOfframp(data.transferId, txIdentifier) + + if (confirmResult.error) { + // On-chain tx succeeded, backend confirm failed. Bridge will still + // process the deposit (the funds are at the deposit address and the + // BE has the transfer row). Show a processing state, NOT an error + // with a Retry button — see confirmPendingCopy + the gate below. + setError({ + showError: true, + errorMessage: confirmPendingCopy, + }) + errorAlreadyDisplayed = true + throw new Error(confirmResult.error) + } + + // Invalidate the transactions query so the Activity widget shows + // the pending OFFRAMP entry immediately, instead of waiting up to + // 30s tanstack staleTime + Bridge polling cadence. + queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] }) + + // proof first, then the step — the success guard reads it. The + // executed amount pins alongside it: the success screen must show + // what moved, not what the URL says now. + setCompletedTxHash(txIdentifier) + setExecutedAmountUsd(amountUsd) + void stepper.goTo('success') + posthog.capture(ANALYTICS_EVENTS.WITHDRAW_COMPLETED, { + amount_usd: amountUsd, + method_type: 'bridge', + country, + }) + } catch (e) { + const error = toFriendlyError(e) + posthog.capture(ANALYTICS_EVENTS.WITHDRAW_FAILED, { + method_type: 'bridge', + error_message: error, + }) + if (!errorAlreadyDisplayed) { + setError({ showError: true, errorMessage: error }) + } + } finally { + setIsLoading(false) + } + } + + // Enforce the mandatory verification pre-empt, then run the offramp. When the + // gate isn't `ready` (or there's no pending requirement) this is a no-op and + // proceedWithOfframp runs straight away (it handles the not-ready cases). + // upcoming (future-dated) eea uplift opens the advisory modal here — fire the + // funnel event as it opens. + // A fresh closure every render, on purpose (Chip review round 4): a + // useCallback here froze the FIRST render's proceedWithOfframp — its + // captured `gate`/`balance` never updated (the deps are all stable for + // the page's lifetime), so a click after capabilities resolved ran the + // stale `gate.kind === 'loading'` no-op forever. Nothing needs a stable + // identity: this is a button onClick, not an effect dep. + const handleCreateAndInitiateOfframp = () => { + const advisoryTrigger = upliftTriggerFromAdvisory(advisory) + if (advisoryTrigger) trackUpliftStarted(advisoryTrigger) + advisoryIntercept(() => void proceedWithOfframp()) + } + + useEffect(() => { + fetchUser() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Balance validation + useEffect(() => { + // Skip balance check if transaction is pending + // isLoading covers the gap between sendMoney completing and confirmOfframp completing + if (hasPendingTransactions || isLoading) { + return + } + + if (!amountToWithdraw || amountToWithdraw === '0' || isNaN(Number(amountToWithdraw)) || balance === undefined) { + setBalanceErrorMessage(null) + return + } + + // gate on the displayed total; an in-transit shortfall passes here and + // fails late with the settling message at execution. + setBalanceErrorMessage( + isAmountWithinBalance(amountToWithdraw, balance) ? null : tErrors('notEnoughBalanceAddFunds') + ) + }, [amountToWithdraw, balance, hasPendingTransactions, isLoading, tErrors]) + + return { + step, + stepper, + // submit stays disabled until the spendable balance has loaded — an + // unloaded balance must not be treated as headroom (Chip round 3) — + // and, for GB/MX, until the FX rate behind the rail minimum has + // loaded (Chip round 5) + isSubmitReady: balance !== undefined && isMinReady, + // the amount the completed offramp moved — success screens render this, + // never the still-editable ?amount= (Chip round 8) + executedAmountUsd, + amountToWithdraw, + bankAccount, + country, + countryFromPath, + fromSendFlow, + user, + error, + isLoading, + submittedTxHash, + balanceErrorMessage, + confirmPendingCopy, + pointsData, + onBack, + handleCreateAndInitiateOfframp, + // gate + modal surface + gate, + sumsubFlow, + showKycModal, + setShowKycModal, + resetUpliftFunnel, + showBridgeTos, + hideTos, + advisoryModalProps, + pendingModal, + } +} diff --git a/src/features/withdraw/useMantecaAmountSeed.ts b/src/features/withdraw/useMantecaAmountSeed.ts new file mode 100644 index 0000000000..04fa1edaad --- /dev/null +++ b/src/features/withdraw/useMantecaAmountSeed.ts @@ -0,0 +1,94 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { parseUsdAmount } from './amount-validation' + +interface MantecaAmountSeedInput { + /** the user-editable ?amount= (USD) handed over by the shared amount step */ + urlAmount: string + /** local currency per 1 USD — the same sell rate AmountInput's primary denomination uses */ + currencyPriceSell: number | undefined + /** the flow's current URL step */ + step: string + /** + * Synchronous balance/minimum verdict for a USD amount string. Must return + * false while the balance is still loading. Synchronous on purpose: the + * page's balanceErrorMessage is effect-set and lags the seeded amount by a + * render, which would let the seed outrun the gate it enforces. + */ + isAmountAllowed: (usd: string) => boolean + limitsLoading: boolean + limitsBlocking: boolean + setUsdAmount: (usd: string) => void + setCurrencyAmount: (local: string) => void + goToBankDetails: () => void +} + +/** + * TASK-21664: the shared /withdraw amount step already collected the USD + * amount — honor it: seed both denominations and skip this flow's own amount + * entry. But `?amount=` is user-editable and the amount screen is where the + * balance floor/ceiling and the async LATAM limits block — so the seed only + * ADVANCES once those gates pass for the seeded amount (Chip review round 3). + * A blocked amount stays on the amount screen, which renders the reason + * (limits card / balance error). + * + * `seededFromUrl` drives back-navigation: a seeded flow returns to the ROOT + * amount step, not to a second amount entry. `resetSeed` (Try again after a + * terminal failure) re-arms the seed so the flow re-enters bank-details + * instead of dead-ending on an empty amount screen. + */ +export function useMantecaAmountSeed({ + urlAmount, + currencyPriceSell, + step, + isAmountAllowed, + limitsLoading, + limitsBlocking, + setUsdAmount, + setCurrencyAmount, + goToBankDetails, +}: MantecaAmountSeedInput): { seededFromUrl: boolean; resetSeed: () => void } { + // State, not refs: the gate-clear and Try-again re-arm cases must re-run + // these effects, and a ref flip re-runs nothing. + const [seedState, setSeedState] = useState<'idle' | 'seeded' | 'advanced'>('idle') + + // seed the denominations once per arm. parseUsdAmount is fail-closed: a + // finite positive PLAIN decimal or nothing — an exponential `?amount=1e21` + // used to survive a bare parseFloat check, normalize to '1e+21', and crash + // the live-balance validator's parseUnits call (Chip round 7). + useEffect(() => { + if (seedState !== 'idle') return + if (!urlAmount || !currencyPriceSell) return + if (step !== 'amount') return + const normalized = parseUsdAmount(urlAmount) + if (normalized === null) return + const usd = Number(normalized) + setSeedState('seeded') + setUsdAmount(usd.toFixed(2)) + // currencyPriceSell = local currency per 1 USD (the review row renders + // `1 USD = `); this direction must match AmountInput's + // primary denomination price or the two entries would disagree + setCurrencyAmount((usd * currencyPriceSell).toFixed(2)) + }, [seedState, urlAmount, currencyPriceSell, step, setUsdAmount, setCurrencyAmount]) + + // advance past the amount screen only when its gates pass for the seeded + // amount — a blocked amount stays and shows why. All gates are synchronous + // against the render's live values (limits validation is a useMemo over + // the amount; isAmountAllowed reads the live balance), so the seed can + // never advance on a stale verdict. + useEffect(() => { + if (seedState !== 'seeded') return + if (step !== 'amount') return + if (limitsLoading || limitsBlocking) return + const normalized = parseUsdAmount(urlAmount) + if (normalized === null) return + if (!isAmountAllowed(Number(normalized).toFixed(2))) return + setSeedState('advanced') + goToBankDetails() + }, [seedState, step, urlAmount, isAmountAllowed, limitsLoading, limitsBlocking, goToBankDetails]) + + const resetSeed = useCallback(() => setSeedState('idle'), []) + + return { seededFromUrl: seedState !== 'idle', resetSeed } +} diff --git a/src/features/withdraw/useWithdrawAmount.ts b/src/features/withdraw/useWithdrawAmount.ts new file mode 100644 index 0000000000..8cd84c94c7 --- /dev/null +++ b/src/features/withdraw/useWithdrawAmount.ts @@ -0,0 +1,13 @@ +'use client' + +import { parseAsString, useQueryState } from 'nuqs' + +/** + * The one amount the user typed for this withdrawal, in USD, carried in the + * URL (`?amount=50`) across every /withdraw/* route so downstream screens + * honor it instead of re-collecting it (TASK-21664 / TASK-21665). Empty string + * means "not entered yet". + */ +export function useWithdrawAmount() { + return useQueryState('amount', parseAsString.withDefault('')) +} diff --git a/src/features/withdraw/useWithdrawRootFlow.ts b/src/features/withdraw/useWithdrawRootFlow.ts new file mode 100644 index 0000000000..0e49efe391 --- /dev/null +++ b/src/features/withdraw/useWithdrawRootFlow.ts @@ -0,0 +1,374 @@ +'use client' + +import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' +import { useWallet } from '@/hooks/wallet/useWallet' +import { getCountryFromAccount, getCountryFromPath } from '@/utils/bridge.utils' +import { bankWithdrawMinUsd } from './amount-validation' +import useGetExchangeRate from '@/hooks/useGetExchangeRate' +import { useSendFlowOrigin } from '@/hooks/useSendFlowOrigin' +import { AccountType } from '@/interfaces/interfaces' +import { useRouter } from 'next/navigation' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { formatUnits } from 'viem' +import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { withdrawBankUrl, withdrawCountryUrl } from '@/utils/native-routes' +import { readReturnTo, RETURN_TO_PARAM } from '@/utils/return-to.utils' +import { parseAsString, useQueryState } from 'nuqs' +import { useTranslations } from 'next-intl' +import { useFlowStepper } from '@/hooks/useFlowStepper' +import { useWithdrawFlow } from './WithdrawFlowContext' +import { useWithdrawAmount } from './useWithdrawAmount' +import { WITHDRAW_ROOT_STEPS } from './types' + +/** + * Flow hook for the root /withdraw page: the method → amount stepper (step in + * the URL as a named screen id), the USD amount (also in the URL), amount + * validation, and the per-method continue routing. Views stay dumb. + */ +export function useWithdrawRootFlow() { + const router = useRouter() + const t = useTranslations('withdraw') + const tErrors = useTranslations('errors') + + const [methodParam] = useQueryState('method', parseAsString) + const [returnToParam] = useQueryState(RETURN_TO_PARAM, parseAsString) + const { isFromSendFlow, isCryptoFromSend, isBankFromSend } = useSendFlowOrigin() + + const { error, setError, selectedMethod, selectedBankAccount, setSelectedBankAccount, setSelectedMethod } = + useWithdrawFlow() + + const [urlAmount, setUrlAmount] = useWithdrawAmount() + // raw amount currently typed in the input; the URL is the commit point + const [rawTokenAmount, setRawTokenAmount] = useState(urlAmount) + + const stepper = useFlowStepper({ + steps: WITHDRAW_ROOT_STEPS, + guards: { + // refresh/deep-link into the amount step with no method in flow + // memory falls back to method selection instead of a dead screen + amount: { ok: !!selectedMethod || isCryptoFromSend }, + }, + onExit: () => { + // back on the method step leaves the flow + if (isBankFromSend) { + router.push('/send') + return + } + // an explicit origin (e.g. the exchange-rate widget's "Try it!" CTA) + // wins over the /home reset, which only fits tab-bar entries + const returnTo = readReturnTo( + { get: (key: string) => (key === RETURN_TO_PARAM ? returnToParam : null) }, + '/withdraw' + ) + router.push(returnTo ?? '/home') + }, + }) + + // Send → Exchange or Wallet enters as /withdraw?method=crypto: the method + // is implied, so commit it and land straight on the amount step. + useEffect(() => { + if (!isCryptoFromSend) return + if (!selectedMethod) { + setSelectedMethod({ type: 'crypto', title: 'Crypto', countryPath: undefined }) + } + if (stepper.step === 'method') void stepper.goTo('amount') + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isCryptoFromSend, selectedMethod, stepper.step]) + + // flag to know if the user has manually entered something + const userTypedRef = useRef(false) + + const { spendableBalance: balance, formattedSpendableBalance } = useWallet() + + // Spend ceiling = the displayed total spendable. We gate on display (not an + // available-now subset) so we never block funds the live withdraw could route; + // an in-transit shortfall fails late with a settling message. See useWallet. + const maxDecimalAmount = useMemo(() => { + return balance !== undefined ? Number(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : 0 + }, [balance]) + + // Displayed total spendable (smart + collateral), single-sourced + formatted + // by the hook. Empty while loading so we don't flash "$0.00". + const walletBalance = balance === undefined ? '' : formattedSpendableBalance + + // derive country and account type for minimum amount validation + const { countryIso2, rateAccountType } = useMemo(() => { + if (selectedBankAccount) { + const country = getCountryFromAccount(selectedBankAccount) + return { countryIso2: country?.iso2 || '', rateAccountType: selectedBankAccount.type as AccountType } + } + if (selectedMethod?.countryPath) { + const country = getCountryFromPath(selectedMethod.countryPath) + const iso2 = country?.iso2 || '' + let accountType: AccountType = AccountType.IBAN + if (iso2 === 'US') accountType = AccountType.US + else if (iso2 === 'GB') accountType = AccountType.GB + else if (iso2 === 'MX') accountType = AccountType.CLABE + return { countryIso2: iso2, rateAccountType: accountType } + } + return { countryIso2: '', rateAccountType: AccountType.US } + }, [selectedBankAccount, selectedMethod]) + + // crypto withdrawals are plain on-chain transfers — fiat-rail minimums don't + // apply. selectedMethod is the routing source of truth; the URL param only + // covers the first render before the mount effect commits the crypto method. + const isCryptoWithdraw = selectedMethod ? selectedMethod.type === 'crypto' : isCryptoFromSend + + // fetch exchange rate for non-USD countries to convert local minimum to USD + const { exchangeRate } = useGetExchangeRate({ + accountType: rateAccountType, + enabled: !isCryptoWithdraw && rateAccountType !== AccountType.US && countryIso2 !== '', + }) + + // compute minimum withdrawal in USD using the exchange rate + const minUsdAmount = useMemo(() => { + // no amount-step minimum for crypto: same-chain (Arbitrum) withdrawals + // are direct transfers with no floor, matching send-via-link. Rhino's + // per-network bridge minimums are enforced chain-aware at review time + // (see withdraw/crypto), once the destination is known. + if (isCryptoWithdraw) return 0 + // shared with the submit-side re-check in useBridgeOfframpFlow (Chip + // round 5) — one conversion, two enforcement points + return bankWithdrawMinUsd(countryIso2, exchangeRate) + }, [isCryptoWithdraw, countryIso2, exchangeRate]) + + // validate against user's limits for bank withdrawals + // note: crypto withdrawals don't have fiat limits + const limitsValidation = useLimitsValidation({ + flowType: 'offramp', + amount: rawTokenAmount, + currency: 'USD', + }) + + const validateAmount = useCallback( + (amountStr: string): boolean => { + if (!amountStr) { + setError({ showError: false, errorMessage: '' }) + return true + } + + const amount = Number(amountStr) + if (!Number.isFinite(amount) || amount <= 0) { + setError({ showError: true, errorMessage: t('errors.invalidNumber') }) + return false + } + + // AmountInput is USD-pinned on this page (price: 1), so the typed + // value IS the USD value. + const usdEquivalent = amount + + // While the balance is still loading, maxDecimalAmount is 0 — skip the + // balance check so a pre-filled amount isn't false-blocked; the effect + // re-validates once it lands (validateAmount is in its deps). + const balanceLoaded = balance !== undefined + if (usdEquivalent >= minUsdAmount && (!balanceLoaded || amount <= maxDecimalAmount)) { + setError({ showError: false, errorMessage: '' }) + return true + } + + // determine message + let message = '' + if (usdEquivalent < minUsdAmount) { + const minDisplay = minUsdAmount % 1 === 0 ? `$${minUsdAmount}` : `$${minUsdAmount.toFixed(2)}` + message = isFromSendFlow + ? t('errors.minimumSend', { amount: minDisplay }) + : t('errors.minimumWithdrawal', { amount: minDisplay }) + } else if (balanceLoaded && amount > maxDecimalAmount) { + message = tErrors('notEnoughBalanceAddFunds') + } else { + message = t('errors.invalidAmount') + } + setError({ showError: true, errorMessage: message }) + return false + }, + [balance, maxDecimalAmount, setError, isFromSendFlow, minUsdAmount, t, tErrors] + ) + + const handleAmountChange = useCallback( + (value: string | undefined) => { + let newValue = value || '' + // treat leading "0" from initial AmountInput mount as empty + if (newValue === '0') { + newValue = '' + } + setRawTokenAmount(newValue) + + // ignore programmatically injected tiny residual amounts (<1) before user interaction + const numericVal = parseFloat(newValue) + if (!userTypedRef.current && numericVal > 0 && numericVal < 1) { + return // do not update state at all + } + + // mark that the user has interacted once they type anything >= 1 or delete everything + if (newValue === '' || numericVal >= 1) { + userTypedRef.current = true + } + + // the URL is the durable copy of the typed amount (survives refresh, + // shareable mid-flow) — nuqs throttles the actual history writes + void setUrlAmount(newValue === '' ? null : newValue) + + // clear any existing errors when user starts typing + if (error.showError) { + setError({ showError: false, errorMessage: '' }) + } + }, + [setUrlAmount, error.showError, setError] + ) + + // only validate when rawTokenAmount changes and we're on the amount step + useEffect(() => { + if (stepper.step !== 'amount') return undefined + if (rawTokenAmount === '') { + setError({ showError: false, errorMessage: '' }) + return undefined + } + // a small delay to avoid validating while the user is still typing + const timeoutId = setTimeout(() => { + validateAmount(rawTokenAmount) + }, 300) + return () => clearTimeout(timeoutId) + }, [rawTokenAmount, validateAmount, setError, stepper.step]) + + /** Build the query string for a downstream route: amount + preserved send marker. */ + const downstreamQuery = useCallback( + (extra?: Record) => { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(extra ?? {})) params.set(key, value) + if (isFromSendFlow && methodParam && !params.has('method')) params.set('method', methodParam) + if (rawTokenAmount) params.set('amount', rawTokenAmount) + const qs = params.toString() + return qs ? `?${qs}` : '' + }, + [isFromSendFlow, methodParam, rawTokenAmount] + ) + + const handleAmountContinue = useCallback(() => { + if (!validateAmount(rawTokenAmount) || !selectedMethod) return + + const usdVal = parseFloat(rawTokenAmount) + posthog.capture(ANALYTICS_EVENTS.WITHDRAW_AMOUNT_ENTERED, { + amount_usd: usdVal, + method_type: selectedMethod.type, + country: selectedMethod.countryPath, + from_send_flow: isFromSendFlow, + }) + + // Route based on selected method type (check method type first to avoid + // a stale bank account taking priority) + if (selectedMethod.type === 'crypto') { + router.push(`/withdraw/crypto${downstreamQuery()}`) + } else if (selectedMethod.type === 'manteca') { + // Manteca (AR/BR) accounts route to the Manteca flow. Checked BEFORE + // the generic saved-bank-account branch below — that branch targets + // the Bridge bank page via getCountryFromAccount and would both + // mis-route a Manteca account and throw when its country can't be + // resolved. The manteca flow honors ?amount= and skips its own + // amount entry (TASK-21664). + const mantecaMethod = selectedMethod.title?.toLowerCase().replace(/\s+/g, '-') || 'bank-transfer' + router.push( + `/withdraw/manteca${downstreamQuery({ method: mantecaMethod, country: selectedMethod.countryPath ?? '' })}` + ) + } else if (selectedBankAccount) { + const country = getCountryFromAccount(selectedBankAccount) + if (country) { + router.push(withdrawBankUrl(country.path, downstreamQuery())) + } else { + // Never throw inside the click handler: a synchronous throw aborts + // the router transition with no UI feedback, so the button silently + // dies ("press Continue, nothing happens"). Surface a recoverable + // error and log for observability instead. + console.error('[withdraw] could not resolve country from saved bank account', { + type: selectedBankAccount.type, + countryName: selectedBankAccount.details?.countryName, + countryCode: selectedBankAccount.details?.countryCode, + }) + setError({ showError: true, errorMessage: t('errors.countryUnresolved') }) + } + } else if (selectedMethod.countryPath) { + // Bridge (and any other) countries go to the country page for the + // bank-account form + router.push(withdrawCountryUrl(selectedMethod.countryPath, downstreamQuery())) + } else { + // No branch matched the selected method — surface an error rather + // than leaving the user with a silently-dead Continue button. + console.error('[withdraw] no route matched for selected method', { + type: selectedMethod.type, + countryPath: selectedMethod.countryPath, + hasBankAccount: !!selectedBankAccount, + }) + setError({ showError: true, errorMessage: t('errors.setupFailed') }) + } + }, [ + validateAmount, + rawTokenAmount, + selectedMethod, + selectedBankAccount, + isFromSendFlow, + router, + downstreamQuery, + setError, + t, + ]) + + const handleAmountBack = useCallback(() => { + if (isCryptoFromSend) { + // crypto from send: back leaves for /send (the method was implied) + setSelectedMethod(null) + router.push('/send') + return + } + // back to method selection — clear the amount so it doesn't carry over + // to a different method + setRawTokenAmount('') + void setUrlAmount(null) + setSelectedMethod(null) + setSelectedBankAccount(null) + void stepper.back() + }, [isCryptoFromSend, router, setSelectedMethod, setSelectedBankAccount, setUrlAmount, stepper]) + + // check if continue button should be disabled + const continueDisabled = useMemo(() => { + if (!rawTokenAmount) return true + + const numericAmount = parseFloat(rawTokenAmount) + if (!Number.isFinite(numericAmount) || numericAmount <= 0) return true + + if (numericAmount < minUsdAmount) return true // below the method's USD minimum + + // only apply the balance ceiling once it has loaded (maxDecimalAmount is 0 + // while spendableBalance is undefined) — else Continue is disabled during load + if ((balance !== undefined && numericAmount > maxDecimalAmount) || error.showError) return true + + // fiat limits gate — crypto has no fiat limits + return !isCryptoWithdraw && (limitsValidation.isLoading || limitsValidation.isBlocking) + }, [ + rawTokenAmount, + balance, + maxDecimalAmount, + error.showError, + minUsdAmount, + isCryptoWithdraw, + limitsValidation.isLoading, + limitsValidation.isBlocking, + ]) + + return { + stepper, + rawTokenAmount, + walletBalance, + error, + isCryptoWithdraw, + limitsValidation, + continueDisabled, + isFromSendFlow, + isCryptoFromSend, + isBankFromSend, + selectedMethod, + handleAmountChange, + handleAmountContinue, + handleAmountBack, + } +} diff --git a/src/components/Withdraw/views/Confirm.withdraw.view.tsx b/src/features/withdraw/views/ConfirmWithdrawView.tsx similarity index 100% rename from src/components/Withdraw/views/Confirm.withdraw.view.tsx rename to src/features/withdraw/views/ConfirmWithdrawView.tsx diff --git a/src/components/Withdraw/views/Initial.withdraw.view.tsx b/src/features/withdraw/views/InitialWithdrawView.tsx similarity index 98% rename from src/components/Withdraw/views/Initial.withdraw.view.tsx rename to src/features/withdraw/views/InitialWithdrawView.tsx index 55652493e4..1d2bffa5d1 100644 --- a/src/components/Withdraw/views/Initial.withdraw.view.tsx +++ b/src/features/withdraw/views/InitialWithdrawView.tsx @@ -6,7 +6,7 @@ import { Notification } from '@/components/0_Bruddle/Notification' import GeneralRecipientInput, { type GeneralRecipientUpdate } from '@/components/Global/GeneralRecipientInput' import NavHeader from '@/components/Global/NavHeader' import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard' -import { useWithdrawFlow } from '@/context/WithdrawFlowContext' +import { useWithdrawFlow } from '@/features/withdraw/WithdrawFlowContext' import { tokenSelectorContext } from '@/context/tokenSelector.context' import { type ITokenPriceData } from '@/interfaces/interfaces' import type { ChainWithTokens } from '@/interfaces/chain-meta' @@ -35,7 +35,7 @@ export default function InitialWithdrawView({ isProcessing, isFromSendFlow = false, }: InitialWithdrawViewProps) { - const { usdAmount, withdrawData } = useWithdrawFlow() + const { withdrawData } = useWithdrawFlow() const t = useTranslations('withdraw') const tNav = useTranslations('navigation') const router = useRouter() @@ -186,7 +186,7 @@ export default function InitialWithdrawView({ transactionType={'WITHDRAW'} recipientType="USERNAME" recipientName={''} - amount={`${formatAmount(parseFloat(usdAmount || amount))}`} + amount={`${formatAmount(parseFloat(amount))}`} tokenSymbol="USDC" isFromSendFlow={isFromSendFlow} /> diff --git a/src/components/Withdraw/views/PixKeySend.view.tsx b/src/features/withdraw/views/PixKeySendView.tsx similarity index 100% rename from src/components/Withdraw/views/PixKeySend.view.tsx rename to src/features/withdraw/views/PixKeySendView.tsx diff --git a/src/features/withdraw/views/WithdrawAmountView.tsx b/src/features/withdraw/views/WithdrawAmountView.tsx new file mode 100644 index 0000000000..4c42fbfdf2 --- /dev/null +++ b/src/features/withdraw/views/WithdrawAmountView.tsx @@ -0,0 +1,93 @@ +'use client' + +import { Button } from '@/components/0_Bruddle/Button' +import { Notification } from '@/components/0_Bruddle/Notification' +import { PageStack } from '@/components/0_Bruddle/PageStack' +import AmountInput from '@/components/Global/AmountInput' +import NavHeader from '@/components/Global/NavHeader' +import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard' +import { getLimitsWarningCardProps } from '@/features/limits/utils' +import { type useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation' +import { shouldShowAmountError } from '@/features/withdraw/amount-gating' +import { type FlowErrorState } from '@/features/withdraw/types' +import { type FC } from 'react' +import { useTranslations } from 'next-intl' + +interface WithdrawAmountViewProps { + pageTitle: string + heading: string + initialAmount: string + walletBalance: string + onAmountChange: (value: string | undefined) => void + onBack: () => void + onContinue: () => void + continueDisabled: boolean + error: FlowErrorState + isCryptoWithdraw: boolean + limitsValidation: ReturnType +} + +/** Amount step of the withdraw flow — dumb view, state lives in the flow hook + URL. */ +export const WithdrawAmountView: FC = ({ + pageTitle, + heading, + initialAmount, + walletBalance, + onAmountChange, + onBack, + onContinue, + continueDisabled, + error, + isCryptoWithdraw, + limitsValidation, +}) => { + const tCommon = useTranslations('common') + + // only show limits card for bank/manteca withdrawals, not crypto + const showLimitsCard = !isCryptoWithdraw && (limitsValidation.isBlocking || limitsValidation.isWarning) + const limitsCardProps = showLimitsCard + ? getLimitsWarningCardProps({ validation: limitsValidation, flowType: 'offramp', currency: 'USD' }) + : null + + return ( + + + +
{heading}
+ + + {limitsCardProps && } + + + {/* the banner yields to the limits card only when that card renders (TASK-21666) */} + {shouldShowAmountError({ + showError: error.showError && !!error.errorMessage, + isCryptoWithdraw, + limitsBlocking: limitsValidation.isBlocking, + }) && ( + + {error.errorMessage} + + )} +
+
+ ) +} diff --git a/src/features/withdraw/views/WithdrawBankReviewView.tsx b/src/features/withdraw/views/WithdrawBankReviewView.tsx new file mode 100644 index 0000000000..0134f44b3e --- /dev/null +++ b/src/features/withdraw/views/WithdrawBankReviewView.tsx @@ -0,0 +1,185 @@ +'use client' + +import { Button } from '@/components/0_Bruddle/Button' +import { Notification } from '@/components/0_Bruddle/Notification' +import { ALL_COUNTRIES_ALPHA3_TO_ALPHA2 } from '@/components/AddMoney/consts' +import Card from '@/components/Global/Card' +import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard' +import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' +import { PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants/zerodev.consts' +import ExchangeRate from '@/components/ExchangeRate' +import countryCurrencyMappings, { isNonEuroSepaCountry } from '@/constants/countryCurrencyMapping' +import { AccountType, type Account } from '@/interfaces/interfaces' +import { formatIban } from '@/utils/general.utils' +import { type FC } from 'react' +import { useAuth } from '@/context/authContext' +import { useTranslations } from 'next-intl' + +interface WithdrawBankReviewViewProps { + bankAccount: Account + amount: string + country: string + fromSendFlow: boolean + isLoading: boolean + /** false while the spendable balance or the rail-minimum FX rate loads — submit stays disabled (Chip rounds 3+5). */ + isSubmitReady: boolean + /** On-chain leg already fired — never offer Retry (double-pay). */ + submittedTxHash: string | null + error: { showError: boolean; errorMessage: string } + balanceErrorMessage: string | null + confirmPendingCopy: string + onSubmit: () => void + onDone: () => void +} + +/** Review step of the Bridge bank withdraw — dumb view, logic in useBridgeOfframpFlow. */ +export const WithdrawBankReviewView: FC = ({ + bankAccount, + amount, + country, + fromSendFlow, + isLoading, + isSubmitReady, + submittedTxHash, + error, + balanceErrorMessage, + confirmPendingCopy, + onSubmit, + onDone, +}) => { + const t = useTranslations('withdraw') + const tNav = useTranslations('navigation') + const tCommon = useTranslations('common') + const { user } = useAuth() + + const nonEuroCurrency = countryCurrencyMappings.find( + (currency) => + country.toLowerCase() === currency.country.toLowerCase() || + currency.path?.toLowerCase() === country.toLowerCase() + )?.currencyCode + + // non-eur sepa countries that are currently experiencing issues + const isNonEuroSepa = isNonEuroSepaCountry(nonEuroCurrency) + + const countryCodeForFlag = () => { + if (!bankAccount?.details?.countryCode) return '' + const code = + ALL_COUNTRIES_ALPHA3_TO_ALPHA2[bankAccount.details.countryCode ?? ''] ?? bankAccount.details.countryCode + return code.toLowerCase() + } + + const getBicAndRoutingNumber = () => { + if (bankAccount.type === AccountType.IBAN) { + return bankAccount.bic?.toUpperCase() ?? 'N/A' + } else if (bankAccount.type === AccountType.US) { + return bankAccount.routingNumber?.toUpperCase() ?? 'N/A' + } else if (bankAccount.type === AccountType.CLABE) { + return bankAccount.identifier?.toUpperCase() ?? 'N/A' + } else if (bankAccount.type === AccountType.GB) { + return bankAccount.sortCode ?? 'N/A' + } + return 'N/A' + } + + return ( +
+ + + {/* Warning for non-EUR SEPA countries (not UK — UK uses Faster Payments with GBP) */} + {isNonEuroSepa && bankAccount?.type !== AccountType.GB && ( + + {t('bank.eurDescription')} + + )} + + + + {bankAccount?.type === AccountType.IBAN ? ( + <> + + + + ) : bankAccount?.type === AccountType.CLABE ? ( + + ) : bankAccount?.type === AccountType.GB ? ( + <> + + + + ) : ( + <> + + + + )} + + + + + {submittedTxHash ? ( + // On-chain leg already fired. Even if confirmOfframp failed + // we must NOT offer Retry — it would re-run sendMoney() and + // double-pay (Sentry PEANUT-UI-QH9). Surface the in-progress + // state and a Done button that takes the user home. + + ) : error.showError ? ( + + ) : ( + + )} + {submittedTxHash ? ( + + {confirmPendingCopy} + + ) : ( + error.showError && {error.errorMessage} + )} + {balanceErrorMessage && {balanceErrorMessage}} +
+ ) +} diff --git a/src/features/withdraw/views/WithdrawMethodView.tsx b/src/features/withdraw/views/WithdrawMethodView.tsx new file mode 100644 index 0000000000..d3d740235c --- /dev/null +++ b/src/features/withdraw/views/WithdrawMethodView.tsx @@ -0,0 +1,243 @@ +'use client' + +import { Button } from '@/components/0_Bruddle/Button' +import { IconBubble } from '@/components/0_Bruddle/IconBubble' +import { type DepositMethod } from '@/components/AddMoney/components/DepositMethodList' +import Card from '@/components/Global/Card' +import NavHeader from '@/components/Global/NavHeader' +import Loading from '@/components/Global/Loading' +import { CountryList } from '@/components/Common/CountryList' +import SavedAccountsView from '@/components/Common/SavedAccountsView' +import { useGeoFilteredPaymentOptions } from '@/hooks/useGeoFilteredPaymentOptions' +import { useSendFlowOrigin } from '@/hooks/useSendFlowOrigin' +import { useUserStore } from '@/redux/hooks' +import { AccountType, type Account } from '@/interfaces/interfaces' +import { isMantecaCountry } from '@/constants/manteca.consts' +import { getFromLocalStorage } from '@/utils/general.utils' +import { withdrawCountryUrl } from '@/utils/native-routes' +import { mantecaWithdrawUrl } from '@/features/withdraw/routes' +import { useWithdrawFlow } from '@/features/withdraw/WithdrawFlowContext' +import { useRouter } from 'next/navigation' +import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs' +import { type FC, useMemo, useTransition } from 'react' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' +import { useTranslations } from 'next-intl' + +interface WithdrawMethodViewProps { + pageTitle: string + mainHeading: string + /** Leave the flow (back on the first screen). */ + onExit: () => void + /** A method was chosen and stored in the flow context — advance to the amount step. */ + onMethodChosen: () => void +} + +/** + * Method-select step of the withdraw flow. The "all methods vs saved accounts" + * toggle lives in the URL (`?showAll=true`) — it used to be a context boolean + * owned by two racing effects, which is the TASK-21198 list flicker. + * + * Withdraw-only: the former dual-flow AddWithdrawRouterView is gone (its + * `add` branches had no consumer — add-money renders AddWithdrawCountriesList). + */ +export const WithdrawMethodView: FC = ({ pageTitle, mainHeading, onExit, onMethodChosen }) => { + const router = useRouter() + const { user } = useUserStore() + const t = useTranslations('withdraw') + const { setSelectedBankAccount, setSelectedMethod } = useWithdrawFlow() + const [, startTransition] = useTransition() + const [showAllParam, setShowAll] = useQueryState('showAll', parseAsBoolean.withDefault(false)) + + const [methodParam] = useQueryState('method', parseAsString) + const [currencyCode] = useQueryState('currencyCode', parseAsString) + // if currencyCode is present, show all methods + const showAll = showAllParam || !!currencyCode + + const isBankFromSend = useSendFlowOrigin().isBankFromSend + // withdraw board 17832:80463: the Mercado Pago add-new-account row follows + // the same geo gate as the send method list (hidden in brazil). gate on + // !isLoading too — countryCode is null while geo resolves, and the filter + // only removes mercadopago once it knows the user is in BR + const { filteredMethods: geoMethods, isLoading: isGeoLoading } = useGeoFilteredPaymentOptions() + const isMercadoPagoAvailable = !isGeoLoading && geoMethods.some((m) => m.id === 'mercadopago') + + const savedAccounts = useMemo(() => { + const bankAccounts = + user?.accounts.filter( + (acc) => + acc.type === AccountType.IBAN || + acc.type === AccountType.US || + acc.type === AccountType.CLABE || + acc.type === AccountType.GB || + acc.type === AccountType.MANTECA + ) ?? [] + return bankAccounts as unknown as Account[] + }, [user]) + + // check if we're coming from request fulfillment or similar flow + const fromRequestFulfillment = typeof window !== 'undefined' && getFromLocalStorage('fromRequestFulfillment') + + const handleMethodSelected = (method: DepositMethod) => { + const methodType = method.type === 'crypto' ? 'crypto' : isMantecaCountry(method.path) ? 'manteca' : 'bridge' + + posthog.capture(ANALYTICS_EVENTS.WITHDRAW_METHOD_SELECTED, { + method_type: methodType, + country: method.path?.split('?')[0].split('/').filter(Boolean).at(-1), + }) + + setSelectedMethod({ + type: methodType, + countryPath: method.path, + currency: method.currency, + title: method.title, + }) + onMethodChosen() + } + + // The saved-accounts vs no-accounts split needs the user to have resolved — + // rendering the empty-state card off a still-null user flashed the wrong + // screen for signed-in users. + if (!user) { + return ( +
+ +
+ ) + } + + if (!showAll && savedAccounts.length === 0) { + return ( +
+ + +
+ +
+

{t('noAccountsTitle')}

+

+ {t.rich('noAccountsDescription', { br: () =>
})} +

+
+
+ +
+
+ ) + } + + if (!showAll && savedAccounts.length > 0) { + return ( + { + setSelectedBankAccount(account) + const countryPath = account.details?.countryName || path || '' + setSelectedMethod({ + type: account.type === AccountType.MANTECA ? 'manteca' : 'bridge', + countryPath, + title: 'To Bank', + }) + if (account.type === AccountType.MANTECA) { + // Manteca saved accounts skip the shared amount step — the + // manteca flow collects the amount in the local currency. + // preserve method param if coming from send flow + router.push( + mantecaWithdrawUrl({ + country: countryPath, + destination: account.identifier, + isSavedAccount: 'true', + method: isBankFromSend ? (methodParam ?? undefined) : undefined, + }) + ) + return + } + onMethodChosen() + }} + onSelectNewMethodClick={() => setShowAll(true)} + onCryptoClick={() => + handleMethodSelected({ id: 'crypto', type: 'crypto', title: 'Crypto', path: 'crypto' }) + } + onMercadoPagoClick={ + isMercadoPagoAvailable + ? () => { + posthog.capture(ANALYTICS_EVENTS.WITHDRAW_METHOD_SELECTED, { + method_type: 'manteca', + country: 'argentina', + }) + router.push(mantecaWithdrawUrl({ method: 'mercadopago', country: 'argentina' })) + } + : undefined + } + /> + ) + } + + // all-methods view + return ( +
+ { + // if coming from request fulfillment or similar external flow, go back immediately + if (fromRequestFulfillment) { + onExit() + return + } + // toggle back to saved accounts when the user navigated to "select new method" + if (showAllParam && savedAccounts.length > 0) { + void setShowAll(null) + } else { + onExit() + } + }} + /> + + { + posthog.capture(ANALYTICS_EVENTS.WITHDRAW_METHOD_SELECTED, { + method_type: isMantecaCountry(country.path) ? 'manteca' : 'bridge', + country: country.path, + }) + + // from send flow (bank): set method in context and stay on /withdraw?method=bank + if (isBankFromSend) { + if (isMantecaCountry(country.path)) { + startTransition(() => { + router.push(mantecaWithdrawUrl({ method: 'bank-transfer', country: country.path })) + }) + return + } + setSelectedMethod({ + type: 'bridge', + countryPath: country.path, + currency: country.currency, + title: country.title, + }) + onMethodChosen() + return + } + + // default behaviour: navigate to country page + // use transition for smoother navigation, keeps ui responsive during route change + startTransition(() => { + router.push(withdrawCountryUrl(country.path)) + }) + }} + onCryptoClick={() => + // set method in context, no navigation — the withdraw page owns + // the amount step and navigates to /withdraw/crypto after Continue + handleMethodSelected({ id: 'crypto', type: 'crypto', title: 'Crypto', path: 'crypto' }) + } + flow="withdraw" + /> +
+ ) +} diff --git a/src/components/Withdraw/views/__tests__/Initial.withdraw.view.test.tsx b/src/features/withdraw/views/__tests__/InitialWithdrawView.test.tsx similarity index 95% rename from src/components/Withdraw/views/__tests__/Initial.withdraw.view.test.tsx rename to src/features/withdraw/views/__tests__/InitialWithdrawView.test.tsx index b26e0c3554..bc0e05783f 100644 --- a/src/components/Withdraw/views/__tests__/Initial.withdraw.view.test.tsx +++ b/src/features/withdraw/views/__tests__/InitialWithdrawView.test.tsx @@ -1,9 +1,9 @@ import React, { useMemo, useState } from 'react' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { IntlWrapper } from '@/test-utils/intl' -import { WithdrawFlowContextProvider } from '@/context/WithdrawFlowContext' +import { WithdrawFlowProvider } from '@/features/withdraw/WithdrawFlowContext' import { tokenSelectorContext } from '@/context/tokenSelector.context' -import InitialWithdrawView from '../Initial.withdraw.view' +import InitialWithdrawView from '../../views/InitialWithdrawView' import { validateAndResolveRecipient } from '@/lib/validation/recipient' jest.mock('@/lib/validation/recipient', () => ({ @@ -100,12 +100,12 @@ function TestHarness() { ) return ( - + - + ) } diff --git a/src/features/withdraw/views/__tests__/WithdrawMethodView.test.tsx b/src/features/withdraw/views/__tests__/WithdrawMethodView.test.tsx new file mode 100644 index 0000000000..0454c7ec75 --- /dev/null +++ b/src/features/withdraw/views/__tests__/WithdrawMethodView.test.tsx @@ -0,0 +1,197 @@ +/** + * WithdrawMethodView — the method-select step that mutates the withdraw + * flow's shared destination state (Chip review round 7). Pins what the + * deleted AddWithdrawRouterView test used to cover: + * (a) a saved Manteca account forwards destination= and + * isSavedAccount=true into /withdraw/manteca (skipping the shared + * amount step); + * (b) a saved non-Manteca account sets selectedBankAccount and advances to + * the amount step WITHOUT navigating; + * (c) the crypto row sets selectedMethod and performs no router.push — a + * pre-amount push trips the crypto page's no-amount redirect guard, + * whose unmount cleanup resets the flow. + */ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { type Account } from '@/interfaces/interfaces' + +// ---------- module-level mocks ---------- + +const mockRouterPush = jest.fn() +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockRouterPush, back: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }), + usePathname: () => '/withdraw', +})) + +jest.mock('next-intl', () => ({ + useTranslations: (ns: string) => { + const t = (key: string) => `${ns}.${key}` + t.rich = (key: string) => `${ns}.${key}` + return t + }, +})) + +jest.mock('posthog-js', () => ({ + __esModule: true, + default: { capture: jest.fn(), init: jest.fn() }, +})) + +jest.mock('@/constants/analytics.consts', () => ({ + ANALYTICS_EVENTS: { WITHDRAW_METHOD_SELECTED: 'withdraw_method_selected' }, +})) + +jest.mock('@/components/0_Bruddle/Button', () => ({ + Button: (props: { onClick?: () => void; children?: React.ReactNode }) => ( + + ), +})) +jest.mock('@/components/0_Bruddle/IconBubble', () => ({ IconBubble: () => null })) +jest.mock('@/components/Global/Card', () => ({ + __esModule: true, + default: (props: { children?: React.ReactNode }) =>
{props.children}
, +})) +jest.mock('@/components/Global/NavHeader', () => ({ __esModule: true, default: () => null })) +jest.mock('@/components/Global/Loading', () => ({ __esModule: true, default: () =>
})) + +// SavedAccountsView: expose the callbacks the view wires up +jest.mock('@/components/Common/SavedAccountsView', () => ({ + __esModule: true, + default: (props: { + savedAccounts: Account[] + onAccountClick: (account: Account, path?: string) => void + onCryptoClick: () => void + }) => ( +
+ {props.savedAccounts.map((account) => ( + + ))} + +
+ ), +})) +jest.mock('@/components/Common/CountryList', () => ({ CountryList: () => null })) + +jest.mock('@/hooks/useGeoFilteredPaymentOptions', () => ({ + useGeoFilteredPaymentOptions: () => ({ filteredMethods: [], isLoading: false }), +})) +jest.mock('@/hooks/useSendFlowOrigin', () => ({ + useSendFlowOrigin: () => ({ isBankFromSend: false }), +})) +jest.mock('@/utils/general.utils', () => ({ + getFromLocalStorage: () => null, +})) +jest.mock('@/utils/native-routes', () => ({ + withdrawCountryUrl: (path: string) => `/withdraw/${path}`, +})) + +const MANTECA_ACCOUNT = { + type: 'manteca', + identifier: 'cbu-12345678901234567890', + details: { countryName: 'argentina' }, +} as unknown as Account +const IBAN_ACCOUNT = { + type: 'iban', + identifier: 'DE89370400440532013000', + details: { countryName: 'germany' }, +} as unknown as Account + +jest.mock('@/redux/hooks', () => ({ + useUserStore: () => ({ + user: { + accounts: [ + { type: 'manteca', identifier: 'cbu-12345678901234567890', details: { countryName: 'argentina' } }, + { type: 'iban', identifier: 'DE89370400440532013000', details: { countryName: 'germany' } }, + ], + }, + }), +})) + +const mockSetSelectedBankAccount = jest.fn() +const mockSetSelectedMethod = jest.fn() +jest.mock('@/features/withdraw/WithdrawFlowContext', () => ({ + useWithdrawFlow: () => ({ + setSelectedBankAccount: mockSetSelectedBankAccount, + setSelectedMethod: mockSetSelectedMethod, + }), +})) + +import { WithdrawMethodView } from '../WithdrawMethodView' + +// ---------- helpers ---------- + +const mockOnExit = jest.fn() +const mockOnMethodChosen = jest.fn() + +const renderView = (searchParams: Record = {}) => + render( + + + + ) + +beforeEach(() => { + jest.clearAllMocks() +}) + +// ---------- tests ---------- + +describe('WithdrawMethodView — destination state and routing (Chip review round 7)', () => { + it('a saved Manteca account forwards destination + isSavedAccount into /withdraw/manteca', () => { + renderView() + fireEvent.click(screen.getByTestId(`account-${MANTECA_ACCOUNT.identifier}`)) + + expect(mockSetSelectedBankAccount).toHaveBeenCalledWith( + expect.objectContaining({ identifier: MANTECA_ACCOUNT.identifier }) + ) + expect(mockSetSelectedMethod).toHaveBeenCalledWith( + expect.objectContaining({ type: 'manteca', countryPath: 'argentina' }) + ) + const pushed = mockRouterPush.mock.calls.at(-1)?.[0] as string + expect(pushed).toContain('/withdraw/manteca?') + expect(pushed).toContain('country=argentina') + expect(pushed).toContain(`destination=${MANTECA_ACCOUNT.identifier}`) + expect(pushed).toContain('isSavedAccount=true') + // Manteca collects its amount locally — the shared amount step is skipped + expect(mockOnMethodChosen).not.toHaveBeenCalled() + }) + + it('a saved non-Manteca account sets the flow state and advances WITHOUT navigating', () => { + renderView() + fireEvent.click(screen.getByTestId(`account-${IBAN_ACCOUNT.identifier}`)) + + expect(mockSetSelectedBankAccount).toHaveBeenCalledWith( + expect.objectContaining({ identifier: IBAN_ACCOUNT.identifier }) + ) + expect(mockSetSelectedMethod).toHaveBeenCalledWith( + expect.objectContaining({ type: 'bridge', countryPath: 'germany' }) + ) + expect(mockOnMethodChosen).toHaveBeenCalledTimes(1) + expect(mockRouterPush).not.toHaveBeenCalled() + }) + + it('the crypto row sets the method in context and does NOT navigate', () => { + // a pre-amount push trips the crypto page's no-amount redirect guard, + // whose unmount cleanup resets the flow (the deleted + // AddWithdrawRouterView test pinned this exact regression) + renderView() + fireEvent.click(screen.getByTestId('crypto-row')) + + expect(mockSetSelectedMethod).toHaveBeenCalledWith(expect.objectContaining({ type: 'crypto' })) + expect(mockOnMethodChosen).toHaveBeenCalledTimes(1) + expect(mockRouterPush).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/__tests__/useFlowStepper.test.tsx b/src/hooks/__tests__/useFlowStepper.test.tsx new file mode 100644 index 0000000000..9aa26c2d32 --- /dev/null +++ b/src/hooks/__tests__/useFlowStepper.test.tsx @@ -0,0 +1,140 @@ +import { act, renderHook } from '@testing-library/react' +import { NuqsTestingAdapter, type OnUrlUpdateFunction } from 'nuqs/adapters/testing' +import type { ReactNode } from 'react' +import { useFlowStepper } from '../useFlowStepper' +import type { FlowStepperOptions } from '../useFlowStepper.types' + +const STEPS = ['method', 'amount', 'review', 'success'] as const +type Step = (typeof STEPS)[number] + +const wrapperFor = (searchParams: Record, onUrlUpdate?: OnUrlUpdateFunction) => + function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ) + } + +const render = ( + searchParams: Record, + options: Partial> = {}, + onUrlUpdate?: OnUrlUpdateFunction +) => + renderHook(() => useFlowStepper({ steps: STEPS, ...options }), { + wrapper: wrapperFor(searchParams, onUrlUpdate), + }) + +describe('useFlowStepper', () => { + it('starts on the first step when the URL has no step param', () => { + const { result } = render({}) + expect(result.current.step).toBe('method') + expect(result.current.isFirst).toBe(true) + }) + + it('reads a named step id from the URL', () => { + const { result } = render({ step: 'review' }) + expect(result.current.step).toBe('review') + expect(result.current.isFirst).toBe(false) + }) + + it('falls back to the default step on an unknown step id (never an index)', () => { + const { result } = render({ step: '2' }) + expect(result.current.step).toBe('method') + }) + + it('goTo writes the step id into the URL', async () => { + const onUrlUpdate = jest.fn() + const { result } = render({}, {}, onUrlUpdate) + await act(async () => { + await result.current.goTo('amount') + }) + expect(result.current.step).toBe('amount') + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('step')).toBe('amount') + }) + + it('goTo(default step) clears the param instead of writing it', async () => { + const onUrlUpdate = jest.fn() + const { result } = render({ step: 'amount' }, {}, onUrlUpdate) + await act(async () => { + await result.current.goTo('method') + }) + expect(result.current.step).toBe('method') + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('step')).toBeNull() + }) + + it('back walks the step list in order', async () => { + const { result } = render({ step: 'review' }) + await act(async () => { + await result.current.back() + }) + expect(result.current.step).toBe('amount') + await act(async () => { + await result.current.back() + }) + expect(result.current.step).toBe('method') + }) + + it('back on the first step calls onExit', async () => { + const onExit = jest.fn() + const { result } = render({}, { onExit }) + await act(async () => { + await result.current.back() + }) + expect(onExit).toHaveBeenCalledTimes(1) + expect(result.current.step).toBe('method') + }) + + it('backMap overrides the linear back path', async () => { + const { result } = render({ step: 'review' }, { backMap: { review: 'method' } }) + await act(async () => { + await result.current.back() + }) + expect(result.current.step).toBe('method') + }) + + it('a failing guard resolves to its fallback without rendering the dead step', () => { + const { result } = render({ step: 'review' }, { guards: { review: { ok: false, fallback: 'amount' } } }) + expect(result.current.step).toBe('amount') + }) + + it('a failing guard with no fallback resolves to the default step', () => { + const { result } = render({ step: 'review' }, { guards: { review: { ok: false } } }) + expect(result.current.step).toBe('method') + }) + + it('a passing guard leaves the step alone', () => { + const { result } = render({ step: 'review' }, { guards: { review: { ok: true, fallback: 'amount' } } }) + expect(result.current.step).toBe('review') + }) + + it('the guard redirect rewrites the URL to the resolved step', async () => { + const onUrlUpdate = jest.fn() + const { result } = render( + { step: 'success' }, + { guards: { success: { ok: false, fallback: 'review' } } }, + onUrlUpdate + ) + expect(result.current.step).toBe('review') + // the corrective write is throttled by nuqs (~50ms) — wait it out + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 120)) + }) + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('step')).toBe('review') + }) + + it('reset clears the step param', async () => { + const onUrlUpdate = jest.fn() + const { result } = render({ step: 'review' }, {}, onUrlUpdate) + await act(async () => { + await result.current.reset() + }) + expect(result.current.step).toBe('method') + expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('step')).toBeNull() + }) + + it('supports a custom URL key', () => { + const { result } = render({ screen: 'amount' }, { urlKey: 'screen' }) + expect(result.current.step).toBe('amount') + }) +}) diff --git a/src/hooks/useFlowStepper.ts b/src/hooks/useFlowStepper.ts new file mode 100644 index 0000000000..9b55266ae7 --- /dev/null +++ b/src/hooks/useFlowStepper.ts @@ -0,0 +1,56 @@ +'use client' + +import { useCallback, useEffect } from 'react' +import { parseAsStringEnum, useQueryState } from 'nuqs' +import type { FlowStepper, FlowStepperOptions } from './useFlowStepper.types' + +/** + * URL-backed step cursor for multi-step flows (design.md "multi-step flow"). + * + * The step lives in the URL as a named screen id (`?step=review`), so it + * survives refresh and is shareable. nuqs keeps its default `replace` history: + * in-flow back is `NavHeader onPrev={stepper.back}`, browser back exits the + * whole flow — intended, per design.md. + * + * This is a cursor with guards, not a state machine: any step may move to any + * other step; guards only protect entry into a step whose prerequisites are + * missing (refresh mid-flow, hand-edited URL). + */ +export function useFlowStepper(options: FlowStepperOptions): FlowStepper { + const { steps, defaultStep = options.steps[0], urlKey = 'step', guards, backMap, onExit } = options + + const [rawStep, setStep] = useQueryState(urlKey, parseAsStringEnum([...steps]).withDefault(defaultStep)) + + // A guarded step never renders — resolve to its fallback synchronously so + // there is no one-frame flash of the dead screen. + const guard = guards?.[rawStep] + const step = guard && !guard.ok ? (guard.fallback ?? defaultStep) : rawStep + + // Keep the URL honest after a guard redirect (replace, no history entry). + // Strict-mode safe: setting the same value again is a no-op for nuqs. + useEffect(() => { + if (step !== rawStep) void setStep(step === defaultStep ? null : step) + }, [step, rawStep, defaultStep, setStep]) + + const goTo = useCallback( + // The default step is represented by a clean URL (no param). The + // returned promise resolves once the (throttled) URL write lands. + (next: Step) => setStep(next === defaultStep ? null : next), + [setStep, defaultStep] + ) + + const index = steps.indexOf(step) + const previous = backMap?.[step] ?? (index > 0 ? steps[index - 1] : undefined) + + const back = useCallback(() => { + if (previous === undefined) { + onExit?.() + return Promise.resolve() + } + return setStep(previous === defaultStep ? null : previous).then(() => undefined) + }, [previous, setStep, defaultStep, onExit]) + + const reset = useCallback(() => setStep(null), [setStep]) + + return { step, goTo, back, reset, isFirst: previous === undefined } +} diff --git a/src/hooks/useFlowStepper.types.ts b/src/hooks/useFlowStepper.types.ts new file mode 100644 index 0000000000..822fe164d4 --- /dev/null +++ b/src/hooks/useFlowStepper.types.ts @@ -0,0 +1,42 @@ +/** One entry guard: when `ok` is false the step cannot render and the stepper + * replaces it with `fallback` (or the flow's default step). */ +export interface FlowStepGuard { + ok: boolean + fallback?: Step +} + +export interface FlowStepperOptions { + /** + * Ordered list of the flow's named screen ids. The ids appear verbatim in + * the URL (`?step=review`) — never indexes. Order defines the default + * back path. + */ + steps: readonly Step[] + /** Step used when the URL carries no step param. Defaults to the first step. */ + defaultStep?: Step + /** URL param name. Defaults to `step`. */ + urlKey?: string + /** + * Per-step entry guards. A refresh or deep link can put the URL on a step + * whose prerequisites live in flow memory that did not survive — the guard + * redirects it instead of rendering a dead screen. + */ + guards?: Partial>> + /** Per-step back overrides, for flows whose back path is not linear. */ + backMap?: Partial> + /** Called when back() fires on the first step — leave the flow here. */ + onExit?: () => void +} + +export interface FlowStepper { + /** The step to render now (guards already applied). */ + step: Step + /** Jump to a step. Resolves once the URL write lands. */ + goTo: (step: Step) => Promise + /** Go to the previous step (backMap first, then list order); calls onExit on the first step. */ + back: () => Promise + /** Clear the step param — the flow returns to its default step. */ + reset: () => Promise + /** True when the current step has no previous step. */ + isFirst: boolean +} diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index 785fa8bde7..bedb774992 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -3000,7 +3000,7 @@ "crossChainUnavailable": "Cross-chain transactions are temporarily unavailable. You can use USDC on Arbitrum.", "selectANetwork": "Select a network", "moreNetworksButton": "more", - "searchTokenPlaceholder": "Search for a token or paste address", + "searchTokenPlaceholder": "Search for a token", "sponsoredHint": "Transactions using USDC on Arbitrum are sponsored", "availableToken": "Available token", "searchResults": "Search Results", diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index e5e346aa58..9e477ba971 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -3000,7 +3000,7 @@ "crossChainUnavailable": "Las transacciones entre cadenas no están disponibles temporalmente. Puedes usar USDC en Arbitrum.", "selectANetwork": "Elige una red", "moreNetworksButton": "más", - "searchTokenPlaceholder": "Busca un token o pega una dirección", + "searchTokenPlaceholder": "Busca un token", "sponsoredHint": "Las transacciones con USDC en Arbitrum son patrocinadas", "availableToken": "Token disponible", "searchResults": "Resultados de búsqueda", diff --git a/src/i18n/app/messages/es-AR.json b/src/i18n/app/messages/es-AR.json index a0ee93ef28..3adc74d88d 100644 --- a/src/i18n/app/messages/es-AR.json +++ b/src/i18n/app/messages/es-AR.json @@ -1299,7 +1299,7 @@ "drawerTitle": "Elegí token y red", "crossChainUnavailable": "Las transacciones entre cadenas no están disponibles temporalmente. Podés usar USDC en Arbitrum.", "selectANetwork": "Elegí una red", - "searchTokenPlaceholder": "Buscá un token o pegá una dirección", + "searchTokenPlaceholder": "Buscá un token", "noMatchingTokensDescription": "Intentá buscar otro token", "searchNetworkPlaceholder": "Buscá una red", "noNetworksFoundDescription": "Intentá buscar otra red" diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index 4dec3f16e6..2d4d4938e7 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -3000,7 +3000,7 @@ "crossChainUnavailable": "As transações entre redes estão temporariamente indisponíveis. Você pode usar USDC na Arbitrum.", "selectANetwork": "Escolha uma rede", "moreNetworksButton": "mais", - "searchTokenPlaceholder": "Busque um token ou cole um endereço", + "searchTokenPlaceholder": "Busque um token", "sponsoredHint": "As transações com USDC na Arbitrum são patrocinadas", "availableToken": "Token disponível", "searchResults": "Resultados da busca",