From d1735ff32076369e452387a65af5063b0b4d5bf7 Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Tue, 16 Jun 2026 15:58:02 +0000 Subject: [PATCH 01/52] =?UTF-8?q?chore(content):=20bump=20src/content=20?= =?UTF-8?q?=E2=80=94=20remove=20Red=20ATM=20from=20marketing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps src/content f204f50..5cbbe4f (peanut-content main). Headline change: scrub the Argentina cardless cash-withdrawal ("Red ATM") marketing across all 5 locales — it was promoted as a live feature but is being pulled. The RedATM withdrawal flow in the app is untouched; this is marketing copy only. Competitor/pain-point ATM copy is intentionally preserved (foreign-ATM fee comparisons, Western Union, Revolut/BLIK). Content-only: zero code paths touched. Supersedes the handrolled #2226 and the stale auto/update-content PRs, which were based off main and showed phantom code diffs against dev. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/content | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content b/src/content index f204f50bb..5cbbe4f5e 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit f204f50bbbf088465f577dd08ca9c4f1a0c51e7e +Subproject commit 5cbbe4f5e494ab9c9f1dce4657b31507cd0c2a72 From db86328fe8be9256f4a54ca53625fc8acc130c48 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 16 Jun 2026 17:50:15 -0700 Subject: [PATCH 02/52] fix(p2p): correct two request-pot display bugs (slider 49.98%, $1k+ progress bar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cosmetic-but-confusing display bugs in the bill-split / request-pot UI, both reported by Konrad: 1. Slider "snap to 50% → 49.98%": the slider snaps the thumb to a clean percentage on drag, but AmountInput re-derives the thumb position from a cent-floored amount ($33.37 pot → 50% = $16.685 → floored $16.68 → 49.98%), and the Slider's sync effect then clobbered the snapped value. The amount is correct (you can't pay half a cent); only the label drifted. The sync effect now keeps the thumb on a snap point through sub-cent drift. 2. Request-pot progress bar blank for pots that collected >= $1,000: the receipt passed the comma-grouped display string to Number() (Number("1,234.56") === NaN), blanking the bar. Use the raw numeric totalAmountCollected, matching how `goal` already uses transaction.amount. Display-only; no amounts or money paths change. Complements api-ts #1027 (the send-link $0 phantom) as part of the same P2P display-correctness sweep. --- .../Global/Slider/__tests__/index.test.tsx | 33 +++++++++++++++++++ src/components/Global/Slider/index.tsx | 14 +++++--- .../TransactionDetailsReceipt.tsx | 5 ++- 3 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 src/components/Global/Slider/__tests__/index.test.tsx diff --git a/src/components/Global/Slider/__tests__/index.test.tsx b/src/components/Global/Slider/__tests__/index.test.tsx new file mode 100644 index 000000000..74a727b2a --- /dev/null +++ b/src/components/Global/Slider/__tests__/index.test.tsx @@ -0,0 +1,33 @@ +// Locks the snap-stick fix: a bill-split slider snapped to 50% must keep +// showing 50% even though the parent re-derives the thumb position from a +// cent-floored amount (e.g. a $33.37 pot → $16.685 → floored $16.68 → 49.98%). +// Konrad: "Snap to 50% bugs out — click 50%, confirm, jumps to 49.98%." +import { render } from '@testing-library/react' +import { Slider } from '../index' + +// Radix Slider observes its track size; jsdom has no ResizeObserver. +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} +;(globalThis as unknown as { ResizeObserver: typeof ResizeObserverStub }).ResizeObserver = ResizeObserverStub + +describe('Slider snap-stick on sub-cent drift', () => { + test('keeps the thumb label at 50% when the controlled value drifts to 49.98%', () => { + const { container, rerender } = render( {}} />) + expect(container.textContent).toContain('50%') + + // Parent re-derives 49.98% from the cent-floored amount and feeds it back. + rerender( {}} />) + expect(container.textContent).toContain('50%') + expect(container.textContent).not.toContain('49.98%') + }) + + test('still syncs a genuine off-snap change (no over-sticking)', () => { + const { container, rerender } = render( {}} />) + rerender( {}} />) + expect(container.textContent).toContain('70%') + expect(container.textContent).not.toContain('50%') + }) +}) diff --git a/src/components/Global/Slider/index.tsx b/src/components/Global/Slider/index.tsx index 0dc48a0bc..ae44d1397 100644 --- a/src/components/Global/Slider/index.tsx +++ b/src/components/Global/Slider/index.tsx @@ -17,11 +17,17 @@ function Slider({ // Use internal state for the slider value to enable magnetic snapping const [internalValue, setInternalValue] = React.useState(defaultValue || controlledValue) - // Sync internal state when controlled value changes from external source + // Sync internal state when controlled value changes from external source. + // The parent derives the controlled value from a cent-rounded amount, so a + // percentage the user snapped to (e.g. 50%) comes back as 49.98% for pots + // whose half lands on a sub-cent ($33.37 → $16.685 → floored to $16.68). + // Don't let that sub-cent drift knock the thumb off a snap point it's + // already resting on (the amount stays correct; only the label was wrong). React.useEffect(() => { - if (controlledValue !== undefined && controlledValue[0] !== internalValue[0]) { - setInternalValue(controlledValue) - } + if (controlledValue === undefined || controlledValue[0] === internalValue[0]) return + const restingSnap = SNAP_POINTS.find((snapPoint) => Math.abs(internalValue[0] - snapPoint) < 0.5) + if (restingSnap !== undefined && Math.abs(controlledValue[0] - restingSnap) < 0.5) return + setInternalValue(controlledValue) }, [controlledValue]) // Check if current value is at a snap point (exact match) diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx index dc82087ed..fefb7c5e9 100644 --- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx +++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx @@ -316,7 +316,10 @@ export const TransactionDetailsReceipt = ({ isAvatarClickable={isAvatarClickable} showProgessBar={transaction.isRequestPotLink} goal={Number(transaction.amount)} - progress={Number(formattedTotalAmountCollected)} + // Use the raw numeric field, NOT formattedTotalAmountCollected — the + // latter is comma-grouped ("1,234.56"), so Number() → NaN for any pot + // that has collected ≥ $1,000, blanking the progress bar. + progress={Number(transaction.totalAmountCollected)} isRequestPotTransaction={transaction.isRequestPotLink} isTransactionClosed={transaction.status === 'closed'} convertedAmount={convertedAmount ?? undefined} From 9a45cff0165a43c83dd2447a2560b2ebd3ac24da Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 17 Jun 2026 10:07:31 -0700 Subject: [PATCH 03/52] feat(card): "Split this bill" CTA on card-spend receipts (TASK-19739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Card payments couldn't be split. The "Split this bill" CTA + /request prefill + auto-create already ship for QR pays (gated on isQRPayment); this extends the gate to card spends so a Peanut-card purchase can seed a bill-split request (amount = the spend's USD value, comment = "Bill split for {merchant}"). - isCardSpend predicate: CARD_SPEND_AUTH/CLEAR only — refunds + auth reversals excluded (you didn't pay those). - Threaded through useReceiptViewModel; CTA gate is now (isQRPayment || isCardSpend) and also excludes failed/declined spends. - Omit the merchant param when userName is the "Card payment" fallback so the comment isn't "Bill split for Card payment". FE-only — reuses the existing /request creation + POST /requests. --- .../TransactionDetailsReceipt.tsx | 32 ++++++++++++------- .../__tests__/transaction-predicates.test.ts | 11 +++++++ .../transaction-predicates.ts | 7 ++++ .../TransactionDetails/useReceiptViewModel.ts | 4 +++ 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx index fefb7c5e9..74bbb5ccc 100644 --- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx +++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx @@ -121,6 +121,7 @@ export const TransactionDetailsReceipt = ({ isPendingRequester, isPendingSentLink, isQRPayment, + isCardSpend, country, rowVisibilityConfig, shouldHideBorder, @@ -742,17 +743,26 @@ export const TransactionDetailsReceipt = ({ )} - {!isPublic && isQRPayment && transaction.status !== 'refunded' && ( - - )} + {!isPublic && + (isQRPayment || isCardSpend) && + transaction.status !== 'refunded' && + transaction.status !== 'failed' && ( + + )} {shouldShowShareReceipt && !!getReceiptUrl(transaction) && (
diff --git a/src/components/TransactionDetails/__tests__/transaction-predicates.test.ts b/src/components/TransactionDetails/__tests__/transaction-predicates.test.ts index e6900b3ea..2cd364b58 100644 --- a/src/components/TransactionDetails/__tests__/transaction-predicates.test.ts +++ b/src/components/TransactionDetails/__tests__/transaction-predicates.test.ts @@ -4,6 +4,7 @@ // `extraData.kind` pinned to a canonical TransactionIntentKind value. import { + isCardSpend, isDirectSendEntry, isFxBearingFlow, isMantecaOnrampEntry, @@ -79,6 +80,16 @@ describe('entry-kind predicates', () => { expect(hasShareableReceipt(tx('SEND_LINK'))).toBe(false) }) + // Gates the "Split this bill" CTA — must fire on real card spends only, + // never on refunds or auth reversals (you didn't pay those). + test('isCardSpend matches CARD_SPEND_AUTH + CARD_SPEND_CLEAR only', () => { + expect(isCardSpend(tx('CARD_SPEND_AUTH'))).toBe(true) + expect(isCardSpend(tx('CARD_SPEND_CLEAR'))).toBe(true) + expect(isCardSpend(tx('CARD_AUTH_REVERSAL'))).toBe(false) + expect(isCardSpend(tx('REFUND'))).toBe(false) + expect(isCardSpend(tx('QR_PAY'))).toBe(false) + }) + describe('isFxBearingFlow', () => { test.each(['ONRAMP', 'OFFRAMP', 'QR_PAY'])('matches fiat-rail kind=%s', (kind) => { expect(isFxBearingFlow(tx(kind))).toBe(true) diff --git a/src/components/TransactionDetails/transaction-predicates.ts b/src/components/TransactionDetails/transaction-predicates.ts index d93fe2af1..2eed62032 100644 --- a/src/components/TransactionDetails/transaction-predicates.ts +++ b/src/components/TransactionDetails/transaction-predicates.ts @@ -23,6 +23,13 @@ export function isQRPayment(transaction: TransactionDetails): boolean { return isKind(transaction, 'QR_PAY') } +/** A Rain card *spend* (not a refund or reversal) — the only card rows eligible + * for a "Split this bill" CTA. Kind-based so refunds (REFUND/OTHER) and auth + * reversals are excluded. */ +export function isCardSpend(transaction: TransactionDetails): boolean { + return isKind(transaction, 'CARD_SPEND_AUTH') || isKind(transaction, 'CARD_SPEND_CLEAR') +} + /** Kinds that move money across a fiat rail: bank on/off-ramps + QR pays. * The single anchor for the receipt-page whitelist (`getReceiptUrl`), the * share gate (`hasShareableReceipt`), and the FX predicate diff --git a/src/components/TransactionDetails/useReceiptViewModel.ts b/src/components/TransactionDetails/useReceiptViewModel.ts index 090bf5726..aba3b38f8 100644 --- a/src/components/TransactionDetails/useReceiptViewModel.ts +++ b/src/components/TransactionDetails/useReceiptViewModel.ts @@ -10,6 +10,7 @@ import { import { hasShareableReceipt, isCardPaymentEntry, + isCardSpend as isCardSpendTransaction, isFxBearingFlow, isDirectSendEntry, isMantecaOnrampEntry, @@ -46,6 +47,7 @@ export interface ReceiptViewModel { isPendingRequester: boolean isPendingSentLink: boolean isQRPayment: boolean + isCardSpend: boolean /** Country resolved from the transaction's currency code (used by the * Manteca deposit-info row for the country-specific address label). */ @@ -295,6 +297,7 @@ export function useReceiptViewModel( }, [transaction]) const isQRPayment = transaction ? isQRPaymentTransaction(transaction) : false + const isCardSpend = transaction ? isCardSpendTransaction(transaction) : false const formattedTotalAmountCollected = formatCurrency(transaction?.totalAmountCollected?.toString() ?? '0', 2, 0) return { @@ -305,6 +308,7 @@ export function useReceiptViewModel( isPendingRequester, isPendingSentLink, isQRPayment, + isCardSpend, country, rowVisibilityConfig, shouldHideBorder, From a3280d1867e7b9325cd5d26df32559f99542e077 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 17 Jun 2026 13:11:23 -0700 Subject: [PATCH 04/52] fix(split-bill): URL-encode merchant name in the split CTA query (CodeRabbit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A merchant name with reserved characters (e.g. "Tigers & Lions") would break the `/request?amount=…&merchant=…` query string and corrupt the request prefill. encodeURIComponent the merchant value. Addresses CodeRabbit's inline finding on #2235. --- src/components/TransactionDetails/TransactionDetailsReceipt.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx index 74bbb5ccc..69d3d5bec 100644 --- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx +++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx @@ -753,7 +753,7 @@ export const TransactionDetailsReceipt = ({ // the merchant param so the request comment isn't "Bill split for Card payment". const merchantParam = transaction.userName && transaction.userName !== 'Card payment' - ? `&merchant=${transaction.userName}` + ? `&merchant=${encodeURIComponent(transaction.userName)}` : '' router.push(`/request?amount=${transaction.amount}${merchantParam}`) }} From 82563a7651872d08e2e1064afa6d846a4cc9aecc Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 17 Jun 2026 13:36:42 -0700 Subject: [PATCH 05/52] fix(pix): canonicalize +55 phone keys that already carry a + prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizePixPhoneNumber returned the raw input untouched when the cleaned value already started with '+', so '+55-11-99999-9999' kept its separators and stayed non-canonical — conflicting with the helper's contract and risking PIX-key mismatches. Always return the separator-free +55 form. From release-PR #2236 CodeRabbit triage; adds the separator-with-+ regression cases the existing +-prefix tests never exercised. --- src/utils/__tests__/withdraw.utils.test.ts | 3 +++ src/utils/withdraw.utils.ts | 9 +++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/utils/__tests__/withdraw.utils.test.ts b/src/utils/__tests__/withdraw.utils.test.ts index 5da97f3f4..da08bd091 100644 --- a/src/utils/__tests__/withdraw.utils.test.ts +++ b/src/utils/__tests__/withdraw.utils.test.ts @@ -79,6 +79,9 @@ describe('Withdraw Utilities', () => { // Should keep existing + prefix ['+5511999999999', '+5511999999999'], ['+551199999999', '+551199999999'], + // Should strip separators even when the + prefix is already present + ['+55-11-99999-9999', '+5511999999999'], + ['+55 11 99999 9999', '+5511999999999'], // Should not modify non-phone formats ['12345678901', '12345678901'], // CPF ['12345678901234', '12345678901234'], // CNPJ diff --git a/src/utils/withdraw.utils.ts b/src/utils/withdraw.utils.ts index 9130416c6..bde51d4dc 100644 --- a/src/utils/withdraw.utils.ts +++ b/src/utils/withdraw.utils.ts @@ -212,10 +212,11 @@ export const isPixPhoneNumber = (pixKey: string): boolean => { */ export const normalizePixPhoneNumber = (pixKey: string): string => { const cleaned = pixKey.replace(/[\s-]/g, '') - if (isPixPhoneNumber(cleaned) && !cleaned.startsWith('+')) { - return '+' + cleaned - } - return pixKey + if (!isPixPhoneNumber(cleaned)) return pixKey + // Always return the separator-free +55 form. The old code returned the raw + // input untouched when it already started with '+', so inputs like + // '+55-11-99999-9999' kept their separators and stayed non-canonical. + return cleaned.startsWith('+') ? cleaned : '+' + cleaned } /** From c4b28faabdb4afa36d7ce844b0cdec3587958f2b Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 17 Jun 2026 14:06:45 -0700 Subject: [PATCH 06/52] test(split-bill): extract + unit-test the "Split this bill" CTA URL builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CTA's onClick inlined the /request URL build, including the two bug-prone bits — the "Card payment" merchant fallback and the URL-encode CodeRabbit caught on #2235. Extract to buildSplitBillRequestUrl and lock it with unit tests (encode reserved chars, omit fallback, omit empty merchant). Pure refactor + tests; no behavior change. --- .../TransactionDetailsReceipt.tsx | 11 ++------- .../__tests__/splitBill.utils.test.ts | 23 +++++++++++++++++++ .../TransactionDetails/splitBill.utils.ts | 16 +++++++++++++ 3 files changed, 41 insertions(+), 9 deletions(-) create mode 100644 src/components/TransactionDetails/__tests__/splitBill.utils.test.ts create mode 100644 src/components/TransactionDetails/splitBill.utils.ts diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx index 69d3d5bec..dcc21fa28 100644 --- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx +++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx @@ -52,6 +52,7 @@ import { usesCompletedTimestampLabel, } from './transaction-predicates' import { useReceiptViewModel } from './useReceiptViewModel' +import { buildSplitBillRequestUrl } from './splitBill.utils' import { CardPaymentRows } from './provider-rows/CardPaymentRows' import { LocalRailNudge } from './provider-rows/LocalRailNudge' import { MantecaDepositInfo } from './provider-rows/MantecaDepositInfo' @@ -748,15 +749,7 @@ export const TransactionDetailsReceipt = ({ transaction.status !== 'refunded' && transaction.status !== 'failed' && (
diff --git a/src/constants/rhino.consts.ts b/src/constants/rhino.consts.ts index f693a6dab..0927a1e04 100644 --- a/src/constants/rhino.consts.ts +++ b/src/constants/rhino.consts.ts @@ -26,6 +26,9 @@ export const TOKEN_LOGOS = { export type ChainName = keyof typeof CHAIN_LOGOS export type TokenName = keyof typeof TOKEN_LOGOS +// Mirrors Rhino's live SDA config (`depositAddresses.getSupportedConfigs()`). +// Scroll was removed 2026-06-11: Rhino's live config no longer returns an SDA +// entry for it, and a deposit on an unsupported chain is silently lost. export const SUPPORTED_EVM_CHAINS = [ 'ARBITRUM', 'ETHEREUM', @@ -34,7 +37,6 @@ export const SUPPORTED_EVM_CHAINS = [ 'BNB', 'POLYGON', 'KATANA', - 'SCROLL', 'GNOSIS', 'CELO', ] as const From 7a57b7056c486cdd2e6ad6d4e38325eb389f3965 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 18 Jun 2026 18:42:49 -0700 Subject: [PATCH 16/52] fix(claim): restore dynamic social-preview metadata for claim links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claim-link previews fell back to the generic Peanut image instead of "X sent you $Y via Peanut". The claim page's generateMetadata + force-dynamic were stripped for the native static-export build (d2049447f), but scripts/native-build.js already strips them at build time and restores the source afterwards — so the source-level removal was redundant and silently degraded only the web build. Restore generateMetadata, extracted into a unit-tested helper (buildClaimMetadata) so the title/OG-image logic can't silently regress again. Native build is unaffected: its P0_TRANSFORMS replaces this page by path. --- src/app/(mobile-ui)/claim/page.tsx | 28 ++++ .../__tests__/claim-metadata.utils.test.ts | 83 +++++++++++ src/utils/claim-metadata.utils.ts | 132 ++++++++++++++++++ 3 files changed, 243 insertions(+) create mode 100644 src/utils/__tests__/claim-metadata.utils.test.ts create mode 100644 src/utils/claim-metadata.utils.ts diff --git a/src/app/(mobile-ui)/claim/page.tsx b/src/app/(mobile-ui)/claim/page.tsx index 9bd47c0dd..92f8daa89 100644 --- a/src/app/(mobile-ui)/claim/page.tsx +++ b/src/app/(mobile-ui)/claim/page.tsx @@ -1,4 +1,32 @@ import { Claim } from '@/components' +import { BASE_URL } from '@/constants/general.consts' +import getOrigin from '@/lib/hosting/get-origin' +import { buildClaimMetadata, getClaimLinkData } from '@/utils/claim-metadata.utils' +import { type Metadata } from 'next' + +// Claim previews are resolved per-request from the link's query params, so the +// page must render dynamically on the web. The native (Capacitor static-export) +// build strips this export — scripts/native-build.js replaces this file with a +// metadata-free stub at build time, so SSR-only concerns never reach native. +export const dynamic = 'force-dynamic' + +export async function generateMetadata({ + searchParams, +}: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }> +}): Promise { + const resolvedSearchParams = await searchParams + + let siteUrl = BASE_URL + try { + siteUrl = (await getOrigin()) || BASE_URL + } catch { + // getOrigin throws on a missing/invalid host header — fall back to BASE_URL + } + + const claimData = await getClaimLinkData(resolvedSearchParams, siteUrl) + return buildClaimMetadata({ claimData, siteUrl }) +} export default function ClaimPage() { return diff --git a/src/utils/__tests__/claim-metadata.utils.test.ts b/src/utils/__tests__/claim-metadata.utils.test.ts new file mode 100644 index 000000000..527425c80 --- /dev/null +++ b/src/utils/__tests__/claim-metadata.utils.test.ts @@ -0,0 +1,83 @@ +import { buildClaimMetadata, getClaimLinkData, type ClaimLinkData } from '@/utils/claim-metadata.utils' + +const SITE_URL = 'https://peanut.me' + +const ogImageUrl = (metadata: ReturnType): string => { + const image = (metadata.openGraph?.images as Array<{ url: string }>)?.[0] + return image?.url ?? '' +} + +const unclaimed = ( + overrides: Partial = {}, + username: string | null = null +): ClaimLinkData => ({ + username, + linkDetails: { + senderAddress: '0x1111111111111111111111111111111111111111', + tokenAmount: '100', + tokenSymbol: 'USDC', + claimed: false, + ...overrides, + }, +}) + +describe('buildClaimMetadata', () => { + it('falls back to the generic Peanut preview when the link cannot be resolved', () => { + const metadata = buildClaimMetadata({ claimData: null, siteUrl: SITE_URL }) + + expect(metadata.title).toBe('Claim Payment | Peanut') + // Regression guard: the bug shipped exactly this generic image for real links. + expect(ogImageUrl(metadata)).toBe('/metadata-img.png') + expect(metadata.description).toBe('Tap the link to receive instantly and without fees.') + }) + + it('uses the sender username + amount when the link is unclaimed', () => { + const metadata = buildClaimMetadata({ claimData: unclaimed({}, 'kkonrad'), siteUrl: SITE_URL }) + + expect(metadata.title).toBe('kkonrad sent you $100 via Peanut') + + const url = new URL(ogImageUrl(metadata)) + expect(url.origin + url.pathname).toBe('https://peanut.me/api/og') + expect(url.searchParams.get('type')).toBe('send') + expect(url.searchParams.get('username')).toBe('kkonrad') + expect(url.searchParams.get('amount')).toBe('100') + expect(url.searchParams.get('token')).toBe('USDC') + expect(url.searchParams.get('isReceipt')).toBeNull() + }) + + it('falls back to the sender address + token when there is no username', () => { + const metadata = buildClaimMetadata({ claimData: unclaimed(), siteUrl: SITE_URL }) + + expect(metadata.title).toBe('You received 100 in USDC!') + expect(new URL(ogImageUrl(metadata)).searchParams.get('username')).toBe( + '0x1111111111111111111111111111111111111111' + ) + }) + + it('says "some" for dust amounts under a cent', () => { + const metadata = buildClaimMetadata({ claimData: unclaimed({ tokenAmount: '0.004' }), siteUrl: SITE_URL }) + expect(metadata.title).toBe('You received some USDC!') + }) + + it('renders the receipt variant for a claimed link', () => { + const metadata = buildClaimMetadata({ + claimData: unclaimed({ claimed: true }, 'kkonrad'), + siteUrl: SITE_URL, + }) + + expect(metadata.title).toBe('This link has been claimed') + expect(metadata.description).toBe('This payment link has already been claimed.') + + const url = new URL(ogImageUrl(metadata)) + expect(url.searchParams.get('isReceipt')).toBe('true') + expect(url.searchParams.get('amount')).toBeNull() + }) +}) + +describe('getClaimLinkData', () => { + it('returns null without hitting the API when chainId or depositIdx is missing', async () => { + await expect(getClaimLinkData({}, SITE_URL)).resolves.toBeNull() + await expect(getClaimLinkData({ c: '42161' }, SITE_URL)).resolves.toBeNull() + await expect(getClaimLinkData({ i: '159' }, SITE_URL)).resolves.toBeNull() + }) +}) diff --git a/src/utils/claim-metadata.utils.ts b/src/utils/claim-metadata.utils.ts new file mode 100644 index 000000000..ec64cd578 --- /dev/null +++ b/src/utils/claim-metadata.utils.ts @@ -0,0 +1,132 @@ +import { PEANUT_WALLET_TOKEN_DECIMALS, PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants/zerodev.consts' +import { sendLinksApi } from '@/services/sendLinks' +import { resolveAddressToUsername } from '@/utils/ens.utils' +import { formatAmount } from '@/utils/general.utils' +import { type Metadata } from 'next' +import { formatUnits } from 'viem' + +export type ClaimLinkDetails = { + senderAddress: string + tokenAmount: string + tokenSymbol: string + claimed: boolean +} + +export type ClaimLinkData = { + linkDetails: ClaimLinkDetails + username: string | null +} + +/** + * Fetches the data needed to render a claim link's social preview from its + * query params (`?c=&v=&i=`). + * + * Uses the password-less `/send-links` lookup (DB + on-chain fallback) so it + * can run during SSR metadata generation. Returns `null` when the link can't + * be resolved — the caller then falls back to the generic Peanut preview. + */ +export async function getClaimLinkData( + searchParams: { [key: string]: string | string[] | undefined }, + siteUrl: string +): Promise { + const chainId = typeof searchParams.c === 'string' ? searchParams.c : undefined + const depositIdx = typeof searchParams.i === 'string' ? searchParams.i : undefined + if (!chainId || !depositIdx) return null + + try { + const contractVersion = (typeof searchParams.v === 'string' && searchParams.v) || 'v4.3' + const sendLink = await sendLinksApi.getByParams({ chainId, depositIdx, contractVersion }) + + const tokenDecimals = sendLink.tokenDecimals ?? PEANUT_WALLET_TOKEN_DECIMALS + const tokenSymbol = sendLink.tokenSymbol ?? PEANUT_WALLET_TOKEN_SYMBOL + + const linkDetails: ClaimLinkDetails = { + senderAddress: sendLink.senderAddress, + tokenAmount: formatUnits(sendLink.amount, tokenDecimals), + tokenSymbol, + claimed: sendLink.status === 'CLAIMED' || sendLink.status === 'CANCELLED', + } + + let username = sendLink.sender?.username || null + + // Fall back to ENS reverse-resolution when the sender has no Peanut + // handle. Race a 3s timeout so a slow ENS lookup never stalls metadata. + if (!username && linkDetails.senderAddress) { + const timeout = new Promise((resolve) => setTimeout(() => resolve(null), 3000)) + const resolved = resolveAddressToUsername(linkDetails.senderAddress, siteUrl).catch(() => null) + username = await Promise.race([resolved, timeout]) + } + + return { linkDetails, username } + } catch { + return null + } +} + +/** + * Builds the Open Graph / Twitter metadata for a claim link from already + * fetched data. Pure + synchronous so the title and OG-image logic is unit + * testable — this is the exact behaviour that silently regressed when the + * claim page's `generateMetadata` was stripped for the native build. + */ +export function buildClaimMetadata({ + claimData, + siteUrl, +}: { + claimData: ClaimLinkData | null + siteUrl: string +}): Metadata { + let title = 'Claim Payment | Peanut' + let ogImageUrl = '/metadata-img.png' + + if (claimData) { + const { linkDetails, username } = claimData + const amount = Number(linkDetails.tokenAmount) + + if (linkDetails.claimed) { + title = 'This link has been claimed' + } else if (username) { + title = `${username} sent you $${formatAmount(amount)} via Peanut` + } else { + title = `You received ${amount < 0.01 ? 'some ' : `${formatAmount(amount)} in `}${linkDetails.tokenSymbol}!` + } + + const ogUrl = new URL('/api/og', siteUrl) + ogUrl.searchParams.set('type', 'send') + ogUrl.searchParams.set('username', username || linkDetails.senderAddress) + if (linkDetails.claimed) { + // claimed links show the "receipt" variant of the OG image + ogUrl.searchParams.set('isReceipt', 'true') + } else { + ogUrl.searchParams.set('amount', linkDetails.tokenAmount) + ogUrl.searchParams.set('token', linkDetails.tokenSymbol) + } + ogImageUrl = ogUrl.toString() + } + + const description = claimData?.linkDetails.claimed + ? 'This payment link has already been claimed.' + : 'Tap the link to receive instantly and without fees.' + + return { + title, + description, + metadataBase: new URL(siteUrl), + icons: { icon: '/favicon.ico' }, + openGraph: { + title, + description, + images: [{ url: ogImageUrl, width: 1200, height: 630 }], + type: 'website', + siteName: 'Peanut', + }, + twitter: { + card: 'summary_large_image', + site: '@PeanutProtocol', + creator: '@PeanutProtocol', + title, + description, + images: [{ url: ogImageUrl }], + }, + } +} From 2d700a799d399509aed25db2200470720013e7fe Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 18 Jun 2026 19:37:54 -0700 Subject: [PATCH 17/52] =?UTF-8?q?fix(claim):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20non-barrel=20import,=20clear=20ENS=20timeout,=20spy?= =?UTF-8?q?=20assertion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - import Claim from '@/components/Claim' (specific file) instead of the '@/components' barrel (eslint no-restricted-imports) - clear the ENS resolution timeout after Promise.race settles so no timer is left pending per request (CodeRabbit) - assert sendLinksApi.getByParams is never called on the early-return path (CodeRabbit) --- src/app/(mobile-ui)/claim/page.tsx | 2 +- src/utils/__tests__/claim-metadata.utils.test.ts | 6 ++++++ src/utils/claim-metadata.utils.ts | 11 +++++++++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/app/(mobile-ui)/claim/page.tsx b/src/app/(mobile-ui)/claim/page.tsx index 92f8daa89..43646bb0e 100644 --- a/src/app/(mobile-ui)/claim/page.tsx +++ b/src/app/(mobile-ui)/claim/page.tsx @@ -1,4 +1,4 @@ -import { Claim } from '@/components' +import { Claim } from '@/components/Claim' import { BASE_URL } from '@/constants/general.consts' import getOrigin from '@/lib/hosting/get-origin' import { buildClaimMetadata, getClaimLinkData } from '@/utils/claim-metadata.utils' diff --git a/src/utils/__tests__/claim-metadata.utils.test.ts b/src/utils/__tests__/claim-metadata.utils.test.ts index 527425c80..47f1b153b 100644 --- a/src/utils/__tests__/claim-metadata.utils.test.ts +++ b/src/utils/__tests__/claim-metadata.utils.test.ts @@ -1,4 +1,5 @@ import { buildClaimMetadata, getClaimLinkData, type ClaimLinkData } from '@/utils/claim-metadata.utils' +import { sendLinksApi } from '@/services/sendLinks' const SITE_URL = 'https://peanut.me' @@ -76,8 +77,13 @@ describe('buildClaimMetadata', () => { describe('getClaimLinkData', () => { it('returns null without hitting the API when chainId or depositIdx is missing', async () => { + const getByParamsSpy = jest.spyOn(sendLinksApi, 'getByParams') + await expect(getClaimLinkData({}, SITE_URL)).resolves.toBeNull() await expect(getClaimLinkData({ c: '42161' }, SITE_URL)).resolves.toBeNull() await expect(getClaimLinkData({ i: '159' }, SITE_URL)).resolves.toBeNull() + + expect(getByParamsSpy).not.toHaveBeenCalled() + getByParamsSpy.mockRestore() }) }) diff --git a/src/utils/claim-metadata.utils.ts b/src/utils/claim-metadata.utils.ts index ec64cd578..d95b97e87 100644 --- a/src/utils/claim-metadata.utils.ts +++ b/src/utils/claim-metadata.utils.ts @@ -52,9 +52,16 @@ export async function getClaimLinkData( // Fall back to ENS reverse-resolution when the sender has no Peanut // handle. Race a 3s timeout so a slow ENS lookup never stalls metadata. if (!username && linkDetails.senderAddress) { - const timeout = new Promise((resolve) => setTimeout(() => resolve(null), 3000)) + let timeoutId: ReturnType | undefined + const timeout = new Promise((resolve) => { + timeoutId = setTimeout(() => resolve(null), 3000) + }) const resolved = resolveAddressToUsername(linkDetails.senderAddress, siteUrl).catch(() => null) - username = await Promise.race([resolved, timeout]) + try { + username = await Promise.race([resolved, timeout]) + } finally { + clearTimeout(timeoutId) + } } return { linkDetails, username } From 08ec5affda47d6f3c604a3a74a9ead8db83e089b Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 18 Jun 2026 20:02:09 -0700 Subject: [PATCH 18/52] refactor(og): single buildOgImageUrl() helper for /api/og links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /api/og query-param contract (type/username/amount/token/isReceipt/ isPeanutUsername/isInvite) was hand-built in four places — claim, the [...recipient] payment/profile page, receipts, and invites — so a renamed or added param meant editing N sites with nothing catching a miss. Extract one typed, unit-tested buildOgImageUrl() and route all four through it. Claim + [...recipient] URLs are byte-identical to before; receipt + invite produce the same params (order differs, semantically identical). This also gives the previously-untested catch-all OG logic coverage via the shared helper's tests. Follow-up to #2249. --- src/app/[...recipient]/page.tsx | 38 +++++++---------- src/app/invite/page.tsx | 7 +--- src/app/receipt/[entryId]/page.tsx | 36 ++++++++-------- src/utils/__tests__/og.utils.test.ts | 61 ++++++++++++++++++++++++++++ src/utils/claim-metadata.utils.ts | 24 ++++++----- src/utils/og.utils.ts | 40 ++++++++++++++++++ 6 files changed, 148 insertions(+), 58 deletions(-) create mode 100644 src/utils/__tests__/og.utils.test.ts create mode 100644 src/utils/og.utils.ts diff --git a/src/app/[...recipient]/page.tsx b/src/app/[...recipient]/page.tsx index 70b4beb0e..af5c7de1a 100644 --- a/src/app/[...recipient]/page.tsx +++ b/src/app/[...recipient]/page.tsx @@ -7,6 +7,7 @@ import { isAddress } from 'viem' import { printableAddress, isStableCoin } from '@/utils/general.utils' import { chargesApi } from '@/services/charges' import { parseAmountAndToken } from '@/lib/url-parser/parser' +import { buildOgImageUrl } from '@/utils/og.utils' import { notFound } from 'next/navigation' import { couldBeRecipient, isReservedRoute } from '@/constants/routes' @@ -99,30 +100,19 @@ export async function generateMetadata({ params, searchParams }: any) { if (!siteUrl) { console.error('Error: Unable to determine site origin') } else { - const ogUrl = new URL(`${siteUrl}/api/og`) - ogUrl.searchParams.set('type', 'request') - ogUrl.searchParams.set('username', recipient) - - if (amount) { - ogUrl.searchParams.set('amount', String(amount)) - if (token) { - ogUrl.searchParams.set('token', token.toUpperCase()) - } - } else { - // For ETH addresses/ENS without amount, set to 0 to show "is requesting funds" - ogUrl.searchParams.set('amount', '0') - } - - // Only show as receipt if there's both a chargeId AND it's paid - if (chargeId && isPaid) { - ogUrl.searchParams.set('isReceipt', 'true') - } - - if (isPeanutUsername) { - ogUrl.searchParams.set('isPeanutUsername', 'true') - } - - ogImageUrl = ogUrl.toString() + ogImageUrl = buildOgImageUrl( + { + type: 'request', + username: recipient, + // ETH addresses/ENS without an amount use 0 to show "is requesting funds" + amount: amount ? String(amount) : '0', + token: amount && token ? token.toUpperCase() : undefined, + // only a receipt when there's a chargeId AND it's paid + isReceipt: Boolean(chargeId && isPaid), + isPeanutUsername, + }, + siteUrl + ) } } diff --git a/src/app/invite/page.tsx b/src/app/invite/page.tsx index ad6f00809..dbe935fb8 100644 --- a/src/app/invite/page.tsx +++ b/src/app/invite/page.tsx @@ -3,6 +3,7 @@ import getOrigin from '@/lib/hosting/get-origin' import { type Metadata } from 'next' import { validateInviteCode } from '../actions/invites' import { BASE_URL } from '@/constants/general.consts' +import { buildOgImageUrl } from '@/utils/og.utils' export const dynamic = 'force-dynamic' @@ -46,14 +47,10 @@ export async function generateMetadata({ description = 'Join Peanut to send and receive money instantly, shop with merchants, and move funds across borders.' - const ogUrl = new URL(`${siteUrl}/api/og`) - ogUrl.searchParams.set('isInvite', 'true') - ogUrl.searchParams.set('username', inviteCodeData.username) - if (!siteUrl) { console.error('Error: Unable to determine site origin') } else { - ogImageUrl = ogUrl.toString() + ogImageUrl = buildOgImageUrl({ username: inviteCodeData.username, isInvite: true }, siteUrl) } } diff --git a/src/app/receipt/[entryId]/page.tsx b/src/app/receipt/[entryId]/page.tsx index f55158c76..68a4131f3 100644 --- a/src/app/receipt/[entryId]/page.tsx +++ b/src/app/receipt/[entryId]/page.tsx @@ -13,6 +13,7 @@ import { generateMetadata as generateBaseMetadata } from '@/app/metadata' import { type Metadata } from 'next' import { BASE_URL } from '@/constants/general.consts' import { formatAmount, formatCurrency, isStableCoin } from '@/utils/general.utils' +import { buildOgImageUrl } from '@/utils/og.utils' import getOrigin from '@/lib/hosting/get-origin' import PageContainer from '@/components/0_Bruddle/PageContainer' @@ -138,32 +139,31 @@ export async function generateMetadata({ // Generate dynamic OG image URL const origin = (await getOrigin()) || BASE_URL - const ogUrl = new URL(`${origin}/api/og`) - - // Map transaction type for OG image const ogType = mapTransactionTypeToOGType(transactionDetails.extraDataForDrawer?.transactionCardType || 'send') - ogUrl.searchParams.set('type', ogType) - ogUrl.searchParams.set('isReceipt', 'true') - - // Add amount if available (always use USD amount) - if (transactionDetails.amount > 0) { - ogUrl.searchParams.set('amount', formatCurrency(Number(transactionDetails.amount).toString())) - ogUrl.searchParams.set('token', 'USDC') - } - - // Add username if available and not an address-like string - if ( + const hasAmount = transactionDetails.amount > 0 + // include username only when present and not an address-like string + const hasUsername = Boolean( transactionDetails.userName && transactionDetails.userName.length < 20 && !transactionDetails.userName.startsWith('0x') - ) { - ogUrl.searchParams.set('username', transactionDetails.userName) - } + ) + + const ogImageUrl = buildOgImageUrl( + { + type: ogType, + isReceipt: true, + username: hasUsername ? transactionDetails.userName : undefined, + // always denominate the receipt amount in USD + amount: hasAmount ? formatCurrency(Number(transactionDetails.amount).toString()) : undefined, + token: hasAmount ? 'USDC' : undefined, + }, + origin + ) return generateBaseMetadata({ title, description, - image: ogUrl.toString(), + image: ogImageUrl, keywords: 'crypto receipt, transaction receipt, payment receipt, Peanut Protocol', }) } diff --git a/src/utils/__tests__/og.utils.test.ts b/src/utils/__tests__/og.utils.test.ts new file mode 100644 index 000000000..d93e871c8 --- /dev/null +++ b/src/utils/__tests__/og.utils.test.ts @@ -0,0 +1,61 @@ +import { buildOgImageUrl } from '@/utils/og.utils' + +const SITE_URL = 'https://peanut.me' +const parse = (url: string) => new URL(url) + +describe('buildOgImageUrl', () => { + it('builds an absolute /api/og URL on the given origin', () => { + const url = parse(buildOgImageUrl({ type: 'send' }, SITE_URL)) + expect(url.origin + url.pathname).toBe('https://peanut.me/api/og') + expect(url.searchParams.get('type')).toBe('send') + }) + + it('send + amount (unclaimed claim link)', () => { + const p = parse( + buildOgImageUrl({ type: 'send', username: 'kkonrad', amount: '100', token: 'USDC' }, SITE_URL) + ).searchParams + expect(p.get('type')).toBe('send') + expect(p.get('username')).toBe('kkonrad') + expect(p.get('amount')).toBe('100') + expect(p.get('token')).toBe('USDC') + expect(p.get('isReceipt')).toBeNull() + }) + + it('send + isReceipt omits amount/token (claimed claim link)', () => { + const p = parse(buildOgImageUrl({ type: 'send', username: 'kkonrad', isReceipt: true }, SITE_URL)).searchParams + expect(p.get('isReceipt')).toBe('true') + expect(p.get('amount')).toBeNull() + expect(p.get('token')).toBeNull() + }) + + it('request + peanut username + amount 0 (address/profile request)', () => { + const p = parse( + buildOgImageUrl({ type: 'request', username: 'kkonrad', amount: '0', isPeanutUsername: true }, SITE_URL) + ).searchParams + expect(p.get('type')).toBe('request') + expect(p.get('amount')).toBe('0') + expect(p.get('isPeanutUsername')).toBe('true') + }) + + it('invite has no type param and sets isInvite', () => { + const p = parse(buildOgImageUrl({ username: 'kkonrad', isInvite: true }, SITE_URL)).searchParams + expect(p.get('type')).toBeNull() + expect(p.get('isInvite')).toBe('true') + expect(p.get('username')).toBe('kkonrad') + }) + + it('omits empty amount/token and false flags', () => { + const p = parse( + buildOgImageUrl( + { type: 'send', username: '', amount: '', token: undefined, isReceipt: false, isPeanutUsername: false }, + SITE_URL + ) + ).searchParams + expect([...p.keys()]).toEqual(['type']) + }) + + it('includes a numeric zero amount', () => { + const p = parse(buildOgImageUrl({ type: 'request', amount: 0 }, SITE_URL)).searchParams + expect(p.get('amount')).toBe('0') + }) +}) diff --git a/src/utils/claim-metadata.utils.ts b/src/utils/claim-metadata.utils.ts index d95b97e87..a6bea20a6 100644 --- a/src/utils/claim-metadata.utils.ts +++ b/src/utils/claim-metadata.utils.ts @@ -2,6 +2,7 @@ import { PEANUT_WALLET_TOKEN_DECIMALS, PEANUT_WALLET_TOKEN_SYMBOL } from '@/cons import { sendLinksApi } from '@/services/sendLinks' import { resolveAddressToUsername } from '@/utils/ens.utils' import { formatAmount } from '@/utils/general.utils' +import { buildOgImageUrl } from '@/utils/og.utils' import { type Metadata } from 'next' import { formatUnits } from 'viem' @@ -98,17 +99,18 @@ export function buildClaimMetadata({ title = `You received ${amount < 0.01 ? 'some ' : `${formatAmount(amount)} in `}${linkDetails.tokenSymbol}!` } - const ogUrl = new URL('/api/og', siteUrl) - ogUrl.searchParams.set('type', 'send') - ogUrl.searchParams.set('username', username || linkDetails.senderAddress) - if (linkDetails.claimed) { - // claimed links show the "receipt" variant of the OG image - ogUrl.searchParams.set('isReceipt', 'true') - } else { - ogUrl.searchParams.set('amount', linkDetails.tokenAmount) - ogUrl.searchParams.set('token', linkDetails.tokenSymbol) - } - ogImageUrl = ogUrl.toString() + // claimed links show the "receipt" variant; unclaimed show amount + token + ogImageUrl = buildOgImageUrl( + linkDetails.claimed + ? { type: 'send', username: username || linkDetails.senderAddress, isReceipt: true } + : { + type: 'send', + username: username || linkDetails.senderAddress, + amount: linkDetails.tokenAmount, + token: linkDetails.tokenSymbol, + }, + siteUrl + ) } const description = claimData?.linkDetails.claimed diff --git a/src/utils/og.utils.ts b/src/utils/og.utils.ts new file mode 100644 index 000000000..c9072668d --- /dev/null +++ b/src/utils/og.utils.ts @@ -0,0 +1,40 @@ +/** + * Single source of truth for building `/api/og` social-preview image URLs. + * + * The query-param contract lives here and in the image renderer at + * `src/app/api/og/route.tsx` — keep the param names in the two in sync. Every + * page that needs a dynamic OG image (claim, the `[...recipient]` + * payment/profile page, receipts, invites) builds its URL through this helper, + * so a change to the contract happens in one place instead of N. + */ +export type OgImageParams = { + type?: 'send' | 'request' | 'generic' + username?: string + /** Token amount as a display string/number; omitted from the URL when empty. */ + amount?: string | number + /** Token symbol, e.g. USDC. */ + token?: string + /** Render the already-paid / claimed "receipt" variant. */ + isReceipt?: boolean + /** Mark the recipient as a Peanut handle (profile styling). */ + isPeanutUsername?: boolean + /** Render the invite variant. */ + isInvite?: boolean +} + +/** Build an absolute `/api/og` image URL from the given params and site origin. */ +export function buildOgImageUrl(params: OgImageParams, siteUrl: string): string { + const url = new URL('/api/og', siteUrl) + + if (params.type) url.searchParams.set('type', params.type) + if (params.username) url.searchParams.set('username', params.username) + if (params.amount !== undefined && params.amount !== null && params.amount !== '') { + url.searchParams.set('amount', String(params.amount)) + } + if (params.token) url.searchParams.set('token', params.token) + if (params.isReceipt) url.searchParams.set('isReceipt', 'true') + if (params.isPeanutUsername) url.searchParams.set('isPeanutUsername', 'true') + if (params.isInvite) url.searchParams.set('isInvite', 'true') + + return url.toString() +} From 4abd233497c1a72c9fedc467572528a34aca05ad Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 18 Jun 2026 20:32:05 -0700 Subject: [PATCH 19/52] content: bump src/content to latest peanut-content main (f066775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries the Sumsub es-es correction into dev so the release ships fully-current content. Clean dev-based bump (the auto-PR #2247 branched off main and couldn't merge — that workflow bug is being fixed separately). --- src/content | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content b/src/content index 5eb8453d8..f06677504 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit 5eb8453d8cba84a8bc2d200880e36b129c238951 +Subproject commit f0667750492717ff48afed12cd15d6cfd6fa6a0f From 6bac289cdcb0375048a2fedadfeb38d5e0e3c8e0 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 18 Jun 2026 20:36:06 -0700 Subject: [PATCH 20/52] ci(content): base auto-bump on dev + content-only auto-merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The content auto-bump opened PRs but never merged them — 9 piled up since June 3, and they were branched off the default branch (main), which (a) let code drift pollute them (#2226) and (b) made them conflict with dev once dev's content moved (#2247). Fix: - Branch the bump off **dev** (the PR base) so the PR is content-only by construction and always cleanly mergeable. - Auto-merge the PR, guarded so it only fires when the diff is *solely* the src/content pointer (anything else → left for human review). - Create the PR with CONTENT_BOT_TOKEN (PAT) when present so CI triggers and auto-merge can gate on green checks; falls back to GITHUB_TOKEN (no regression). Requires (repo admin, one-time): set CONTENT_BOT_TOKEN secret (PAT w/ peanut-ui contents+PR write) + enable 'Allow auto-merge'. Takes effect once this reaches main (repository_dispatch runs the workflow from the default branch). --- .github/workflows/update-content.yml | 32 +++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/.github/workflows/update-content.yml b/.github/workflows/update-content.yml index ee1132ce3..528da2f03 100644 --- a/.github/workflows/update-content.yml +++ b/.github/workflows/update-content.yml @@ -13,7 +13,14 @@ jobs: update: runs-on: ubuntu-latest steps: + # Base the bump on `dev` (the PR target), NOT the repo default branch. + # Branching off a different branch than the base is what let unrelated + # code drift leak into the auto-PR (#2226) and made it conflict with dev + # once dev's content pointer moved (#2247). Off `dev`, the PR is + # content-only by construction and always cleanly mergeable. - uses: actions/checkout@v4 + with: + ref: dev - name: Init and update submodule env: @@ -34,14 +41,19 @@ jobs: echo "changed=true" >> "$GITHUB_OUTPUT" fi - - name: Create PR + - name: Create PR + auto-merge (content-only) if: steps.check.outputs.changed == 'true' + # Prefer a PAT (CONTENT_BOT_TOKEN) so the PR triggers CI and auto-merge + # can fire on green checks. Falls back to GITHUB_TOKEN — the PR still + # opens, but GITHUB_TOKEN-created PRs don't trigger CI, so auto-merge + # won't fire until the PAT is configured (no regression vs today). env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.CONTENT_BOT_TOKEN || secrets.GITHUB_TOKEN }} run: | + set -euo pipefail BRANCH="auto/update-content-$(date -u +%Y%m%d-%H%M%S)" SUBMODULE_SHA=$(git -C src/content rev-parse HEAD) - PARENT=$(git rev-parse HEAD) + PARENT=$(git rev-parse HEAD) # dev tip — bump is content-only vs dev BASE_TREE=$(gh api repos/${{ github.repository }}/git/commits/$PARENT --jq '.tree.sha') # Create tree via API with updated submodule pointer TREE=$(gh api repos/${{ github.repository }}/git/trees \ @@ -64,8 +76,18 @@ jobs: --method POST \ -f "ref=refs/heads/$BRANCH" \ -f "sha=$COMMIT_SHA" - gh pr create \ + PR_URL=$(gh pr create \ --head "$BRANCH" \ --base dev \ --title "Update content submodule" \ - --body "Auto-generated: updates content submodule to latest peanut-content main." + --body "Auto-generated: bumps the content submodule to latest peanut-content main. Based on dev, so it's content-only and auto-merges once checks pass.") + # Guard: only auto-merge when the PR touches NOTHING but the submodule + # pointer. If anything else slipped in, leave it for a human. + FILES=$(gh pr diff "$PR_URL" --name-only) + if [ "$FILES" = "src/content" ]; then + gh pr merge "$PR_URL" --auto --squash \ + || echo "::warning::auto-merge not enabled/permitted — left for manual merge ($PR_URL)" + else + echo "::warning::PR touches more than src/content — left for manual review:" + echo "$FILES" + fi From def5c6282384d98f98415f23162b52cc2f86c822 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 18 Jun 2026 21:02:06 -0700 Subject: [PATCH 21/52] fix(home): show "You're unlocked" modal once, off a milestone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The celebration was gated on the backend showKycCompletedModal flag, which was re-set on every KYC →approved transition — so re-approvals (e.g. the faster_payments endorsement backfill) re-showed it to long-time users, listing rails they'd had for months. Now it shows once: KYC-approved (a rail is enabled) AND activationCelebratedAt is null; dismissal stamps the milestone server-side (dismissActivationCelebration), so it can never resurface. Pairs with peanut-api-ts (activation_celebrated_at + idempotent approval side-effects). --- src/app/(mobile-ui)/home/page.tsx | 16 ++++++++++------ src/interfaces/interfaces.ts | 4 +++- src/types/api.generated.ts | 2 +- src/types/api.openapi.json | 2 +- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/app/(mobile-ui)/home/page.tsx b/src/app/(mobile-ui)/home/page.tsx index 41a33bd68..b25cadca6 100644 --- a/src/app/(mobile-ui)/home/page.tsx +++ b/src/app/(mobile-ui)/home/page.tsx @@ -75,7 +75,7 @@ export default function Home() { const [showBalanceWarningModal, setShowBalanceWarningModal] = useState(false) const [isPostSignupActionModalVisible, setIsPostSignupActionModalVisible] = useState(false) - const [showKycModal, setShowKycModal] = useState(user?.user.showKycCompletedModal ?? false) + const [showKycModal, setShowKycModal] = useState(false) // Track if this is a fresh signup session - captured once on mount so it persists // even after NoMoreJailModal clears the sessionStorage key @@ -89,12 +89,16 @@ export default function Home() { fetchUser() }, []) // eslint-disable-line react-hooks/exhaustive-deps - // sync modal state with user data when it changes + // Show the "You're unlocked" celebration exactly once: the user has a usable + // rail (isKycApproved) and has never dismissed it (activationCelebratedAt is + // null, stamped server-side on dismiss). A KYC re-approval can't resurface it + // — unlike the old showKycCompletedModal flag, which re-fired on every + // `→ approved` transition (e.g. the faster_payments endorsement backfill). useEffect(() => { - if (user?.user.showKycCompletedModal !== undefined) { - setShowKycModal(user.user.showKycCompletedModal) + if (isKycApproved && !user?.user.activationCelebratedAt) { + setShowKycModal(true) } - }, [user?.user.showKycCompletedModal]) + }, [isKycApproved, user?.user.activationCelebratedAt]) const userFullName = useMemo(() => { if (!user) return @@ -256,7 +260,7 @@ export default function Home() { if (user?.user.userId) { await updateUserById({ userId: user.user.userId, - showKycCompletedModal: false, + dismissActivationCelebration: true, }) // refetch user to ensure the modal doesn't reappear await fetchUser() diff --git a/src/interfaces/interfaces.ts b/src/interfaces/interfaces.ts index d90457722..abb970c7e 100644 --- a/src/interfaces/interfaces.ts +++ b/src/interfaces/interfaces.ts @@ -198,7 +198,9 @@ export interface User { activatedAt?: string | null activationMilestone?: 'registered' | 'verified' | 'funded' | 'activated' showFullName: boolean - showKycCompletedModal?: boolean + // Null until the user dismisses the "You're unlocked" celebration. The modal + // shows once, when KYC-approved (a rail is enabled) AND this is still null. + activationCelebratedAt?: string | null createdAt: string accounts: Account[] badges?: Array<{ diff --git a/src/types/api.generated.ts b/src/types/api.generated.ts index ac8ccb386..084bc7e4c 100644 --- a/src/types/api.generated.ts +++ b/src/types/api.generated.ts @@ -540,7 +540,7 @@ export interface paths { showFullName?: boolean; hasSeenEarlyUserModal?: boolean; bridgeKycStatus?: "not_started" | "incomplete" | "under_review" | "approved" | "rejected"; - showKycCompletedModal?: boolean; + dismissActivationCelebration?: boolean; }; }; }; diff --git a/src/types/api.openapi.json b/src/types/api.openapi.json index 14ead7852..94b307be4 100644 --- a/src/types/api.openapi.json +++ b/src/types/api.openapi.json @@ -739,7 +739,7 @@ } ] }, - "showKycCompletedModal": { + "dismissActivationCelebration": { "type": "boolean" } }, From 6842d34f13a7e741d89e4aa29fc2de7461e9138b Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 18 Jun 2026 21:05:16 -0700 Subject: [PATCH 22/52] fix(add-money): back button no longer dies on the amount screen Both add-money amount flows declared their nuqs URL state with { history: 'push' }. `amount` is rewritten on every keystroke, so each character pushed a new browser-history entry for the *same* screen. The NavHeader back button (useSafeBack -> router.back()) then only stepped back one of those stale same-screen entries, so the view never changed and the button looked dead. Reported for the MP (Manteca AR/BR) flow; the Bridge bank flow (SEPA/US/UK/MX) had the identical bug. Drop the option so both flows use the nuqs default ('replace'): the URL stays shareable / deep-linkable but no longer grows history, and one tap leaves the screen. Systemic guard: a no-restricted-syntax rule now bans { history: 'push' } in nuqs useQueryState(s). The existing back-button guard pack (#1965, #1997) caught the call-shape anti-patterns (router.back, onBack push, window.history.length) but not this config footgun that defeats useSafeBack from the other side. Plus data-testid="nav-back" on NavHeader and a Playwright regression test that types an amount and asserts a single back tap leaves /bank. --- e2e/flows/add-money.spec.ts | 34 +++++++++++++++++++ eslint.config.js | 10 ++++++ .../add-money/[country]/bank/page.tsx | 16 +++++---- .../AddMoney/components/MantecaAddMoney.tsx | 20 ++++++----- src/components/Global/NavHeader/index.tsx | 10 ++++-- 5 files changed, 72 insertions(+), 18 deletions(-) diff --git a/e2e/flows/add-money.spec.ts b/e2e/flows/add-money.spec.ts index 2e7ebdef5..7702ad349 100644 --- a/e2e/flows/add-money.spec.ts +++ b/e2e/flows/add-money.spec.ts @@ -81,4 +81,38 @@ test.describe('Add money flow', () => { consoleLogs.flush(testInfo, 'add-money-us-bank') await context.close() }) + + // Regression: the NavHeader back button must LEAVE the amount screen on the first tap. + // Before fix/addmoney-back-nuqs-replace the flow used nuqs { history: 'push' }, so every + // amount keystroke stacked a same-screen history entry and useSafeBack's router.back() + // only stepped through stale amounts — the back button looked dead (MP/bank reports). + test('add-money/AR/bank — back button leaves the amount screen after typing (verified-ar)', async ({ + browser, + }, testInfo) => { + const context = await browser.newContext({ ...devices['Pixel 7'] }) + await usePersona(context, 'verified-ar') + + const page = await context.newPage() + const consoleLogs = collectConsoleLogs(page) + await installApiMocks(page) + + await page.goto('/add-money/AR/bank') + + // amount step renders (verified persona skips the "country not found" gate) + const amountInput = page.locator('input[inputmode="decimal"]').first() + await amountInput.waitFor({ state: 'visible', timeout: 15000 }) + + // typing writes ?amount= — with the old { history: 'push' } this stacked back-stack + // entries; with the default 'replace' it does not. + await amountInput.fill('100') + await captureStep(page, testInfo, { name: '01-add-money-ar-bank-amount-typed' }) + + // one tap must exit to the country page, not linger on /bank with a stale amount + await page.locator('[data-testid="nav-back"]').first().click() + await expect(page).toHaveURL(/\/add-money\/AR(?:\?.*)?$/) + await captureStep(page, testInfo, { name: '02-add-money-ar-bank-back-left-screen' }) + + consoleLogs.flush(testInfo, 'add-money-ar-bank-back') + await context.close() + }) }) diff --git a/eslint.config.js b/eslint.config.js index 44dbeecb0..051b8f20b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -127,6 +127,16 @@ module.exports = [ message: "window.history.length is the pre-useSafeBack idiom (history.length > 1 ? back : push). It misfires on cold-load from external referrers — useSafeBack's pushState counter is more accurate. See PR #1965.", }, + { + // nuqs `history: 'push'` stacks a browser-history entry on every URL write. + // For per-keystroke params (e.g. `amount`) that poisons the back stack: + // useSafeBack → router.back() then steps through stale same-screen states + // and the back button looks dead (add-money MP/bank reports, June 2026). + selector: + "CallExpression[callee.name=/^useQueryStates?$/] Property[key.name='history'][value.value='push']", + message: + "Don't pass { history: 'push' } to nuqs useQueryState(s) — a history entry per URL write breaks the back button (useSafeBack steps through same-screen states instead of leaving). Use the default 'replace'; the URL stays shareable. If a flow genuinely needs push-per-step, add a scoped file exemption with a comment (see useNativePlugins).", + }, ], }, }, diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index a704a2db1..4dbb06990 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -49,13 +49,15 @@ export default function OnrampBankPage() { // URL state - persisted in query params // Example: /add-money/mexico/bank?step=inputAmount&amount=500 - const [urlState, setUrlState] = useQueryStates( - { - step: parseAsStringEnum(['inputAmount', 'showDetails']), - amount: parseAsString, - }, - { history: 'push' } - ) + // history stays at the nuqs default ('replace'): `amount` is rewritten on every + // keystroke, so 'push' would stack a browser-history entry per character and the + // NavHeader back button (useSafeBack → router.back()) would only step through stale + // amounts of this same screen instead of leaving it. The URL stays shareable either + // way. Enforced by the no-restricted-syntax guard in eslint.config.js. + const [urlState, setUrlState] = useQueryStates({ + step: parseAsStringEnum(['inputAmount', 'showDetails']), + amount: parseAsString, + }) // Amount from URL const rawTokenAmount = urlState.amount ?? '' diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index e402bf540..7084629f1 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -39,15 +39,17 @@ const MantecaAddMoney: FC = () => { // URL state - persisted in query params // Example: /add-money/argentina/manteca?step=inputAmount&amount=100¤cy=ARS - // The `amount` is stored in whatever denomination `currency` specifies - const [urlState, setUrlState] = useQueryStates( - { - step: parseAsStringEnum(['inputAmount', 'depositDetails']), - amount: parseAsString, - currency: parseAsStringEnum(['USD', 'ARS', 'BRL', 'MXN', 'EUR']), - }, - { history: 'push' } - ) + // The `amount` is stored in whatever denomination `currency` specifies. + // history stays at the nuqs default ('replace'): `amount` is rewritten on every + // keystroke, so 'push' would stack a browser-history entry per character and the + // NavHeader back button (useSafeBack → router.back()) would only step through stale + // amounts of this same screen instead of leaving it. The URL stays shareable either + // way. Enforced by the no-restricted-syntax guard in eslint.config.js. + const [urlState, setUrlState] = useQueryStates({ + step: parseAsStringEnum(['inputAmount', 'depositDetails']), + amount: parseAsString, + currency: parseAsStringEnum(['USD', 'ARS', 'BRL', 'MXN', 'EUR']), + }) // Derive state from URL (with defaults) const step: MantecaStep = urlState.step ?? 'inputAmount' diff --git a/src/components/Global/NavHeader/index.tsx b/src/components/Global/NavHeader/index.tsx index d1be84f07..9946fd6bc 100644 --- a/src/components/Global/NavHeader/index.tsx +++ b/src/components/Global/NavHeader/index.tsx @@ -32,7 +32,7 @@ const NavHeader = ({
{!onPrev ? ( -
) diff --git a/src/components/Kyc/AdvisoryPreemptModal.tsx b/src/components/Kyc/AdvisoryPreemptModal.tsx new file mode 100644 index 000000000..0287db45f --- /dev/null +++ b/src/components/Kyc/AdvisoryPreemptModal.tsx @@ -0,0 +1,63 @@ +import ActionModal from '@/components/Global/ActionModal' + +interface AdvisoryPreemptModalProps { + visible: boolean + /** ISO date the requirement becomes blocking; drives the deadline copy. */ + effectiveDate?: string + isLoading?: boolean + /** Launch the verification flow early. */ + onCompleteNow: () => void + /** Dismiss and continue with what the user was doing. */ + onSkip: () => void + onClose: () => void +} + +function formatEffectiveDate(iso?: string): string | null { + if (!iso) return null + const date = new Date(iso) + return Number.isNaN(date.getTime()) + ? null + : date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) +} + +/** + * Skippable pre-empt for a future-dated verification requirement on a rail that + * still works today (the gate's `ready` + `advisory`). "Complete now" launches + * the verification early; "Not now" lets the user carry on and resolve it later. + * Once the effective date passes the backend reclassifies the requirement to + * blocking and the non-skippable InitiateKycModal takes over — there is no FE + * cutover logic here. + */ +export default function AdvisoryPreemptModal({ + visible, + effectiveDate, + isLoading = false, + onCompleteNow, + onSkip, + onClose, +}: AdvisoryPreemptModalProps) { + const formatted = formatEffectiveDate(effectiveDate) + return ( + + ) +} diff --git a/src/hooks/useAdvisoryPreempt.test.ts b/src/hooks/useAdvisoryPreempt.test.ts new file mode 100644 index 000000000..f4404e332 --- /dev/null +++ b/src/hooks/useAdvisoryPreempt.test.ts @@ -0,0 +1,63 @@ +import { act, renderHook } from '@testing-library/react' +import { useAdvisoryPreempt } from './useAdvisoryPreempt' +import type { GateAdvisory } from '@/utils/capability-gate' + +const advisory: GateAdvisory = { effectiveDate: '2099-06-29', levelKey: 'eea_uplift' } + +describe('useAdvisoryPreempt', () => { + test('no advisory → intercept proceeds immediately, modal stays hidden', () => { + const proceed = jest.fn() + const onCompleteNow = jest.fn() + const { result } = renderHook(() => useAdvisoryPreempt({ advisory: undefined, onCompleteNow })) + + act(() => result.current.intercept(proceed)) + + expect(proceed).toHaveBeenCalledTimes(1) + expect(result.current.modalProps.visible).toBe(false) + }) + + test('advisory present → intercept opens the modal and defers proceed', () => { + const proceed = jest.fn() + const onCompleteNow = jest.fn() + const { result } = renderHook(() => useAdvisoryPreempt({ advisory, onCompleteNow })) + + act(() => result.current.intercept(proceed)) + + expect(proceed).not.toHaveBeenCalled() + expect(result.current.modalProps.visible).toBe(true) + expect(result.current.modalProps.effectiveDate).toBe('2099-06-29') + }) + + test('skip runs the deferred proceed; once dismissed, later intercepts pass straight through', () => { + const proceed = jest.fn() + const onCompleteNow = jest.fn() + const { result } = renderHook(() => useAdvisoryPreempt({ advisory, onCompleteNow })) + + act(() => result.current.intercept(proceed)) + act(() => result.current.modalProps.onSkip()) + + expect(proceed).toHaveBeenCalledTimes(1) + expect(result.current.modalProps.visible).toBe(false) + + // Dismissed for the session — a second proceed runs immediately, no re-prompt. + const proceed2 = jest.fn() + act(() => result.current.intercept(proceed2)) + expect(proceed2).toHaveBeenCalledTimes(1) + expect(result.current.modalProps.visible).toBe(false) + }) + + test('completeNow launches the verification and does NOT run the deferred proceed', async () => { + const proceed = jest.fn() + const onCompleteNow = jest.fn() + const { result } = renderHook(() => useAdvisoryPreempt({ advisory, onCompleteNow })) + + act(() => result.current.intercept(proceed)) + await act(async () => { + await result.current.modalProps.onCompleteNow() + }) + + expect(onCompleteNow).toHaveBeenCalledTimes(1) + expect(proceed).not.toHaveBeenCalled() + expect(result.current.modalProps.visible).toBe(false) + }) +}) diff --git a/src/hooks/useAdvisoryPreempt.ts b/src/hooks/useAdvisoryPreempt.ts new file mode 100644 index 000000000..58fafe93f --- /dev/null +++ b/src/hooks/useAdvisoryPreempt.ts @@ -0,0 +1,67 @@ +import { useCallback, useRef, useState } from 'react' +import type { GateAdvisory } from '@/utils/capability-gate' + +interface UseAdvisoryPreemptArgs { + /** The advisory from a `ready` gate (`gate.kind === 'ready' ? gate.advisory : undefined`). */ + advisory: GateAdvisory | undefined + /** Launch the verification flow early — e.g. `sumsubFlow.handleInitiateKyc(region, advisory.levelKey, …)`. */ + onCompleteNow: () => void | Promise + isLoading?: boolean +} + +/** + * Drives the skippable advisory pre-empt at the add/withdraw entry points. The + * rail is usable now, so we don't block — we intercept the "proceed" step ONCE + * per session with a skippable modal. "Complete now" launches the verification + * early; "Not now" dismisses and runs the original proceed action. Either choice + * marks it dismissed so the user isn't re-prompted mid-session. + * + * Returns `intercept(proceed)` to call in the gate's `ready` branch, and + * `modalProps` to spread onto {@link AdvisoryPreemptModal}. + */ +export function useAdvisoryPreempt({ advisory, onCompleteNow, isLoading = false }: UseAdvisoryPreemptArgs) { + const [dismissed, setDismissed] = useState(false) + const [visible, setVisible] = useState(false) + const pendingProceed = useRef<(() => void) | null>(null) + + const intercept = useCallback( + (proceed: () => void) => { + if (advisory && !dismissed) { + pendingProceed.current = proceed + setVisible(true) + return + } + proceed() + }, + [advisory, dismissed] + ) + + const completeNow = useCallback(async () => { + setDismissed(true) + setVisible(false) + pendingProceed.current = null + await onCompleteNow() + }, [onCompleteNow]) + + const skip = useCallback(() => { + setDismissed(true) + setVisible(false) + const proceed = pendingProceed.current + pendingProceed.current = null + proceed?.() + }, []) + + const close = useCallback(() => setVisible(false), []) + + return { + intercept, + modalProps: { + visible, + effectiveDate: advisory?.effectiveDate, + isLoading, + onCompleteNow: completeNow, + onSkip: skip, + onClose: close, + }, + } +} diff --git a/src/types/capabilities.ts b/src/types/capabilities.ts index 2793f0f48..5471d7a30 100644 --- a/src/types/capabilities.ts +++ b/src/types/capabilities.ts @@ -78,6 +78,13 @@ export interface RailCapability { operations?: Partial> /** keys into NextAction.key — actions that unlock currently-unavailable operations on this rail. */ blockingActions?: string[] + /** + * Non-blocking hints — actions the user CAN take on a rail that's otherwise + * working (the rail stays usable): the Bridge advisory pre-empt (a future-dated + * requirement whose NextAction carries `effectiveDate`) and the Manteca + * cap-nudge. Distinct from `blockingActions` so the FE never gates on them. + */ + hintActions?: string[] /** present for requires-info / blocked — normalized reason for uniform FE rendering. */ reason?: CapabilityReason } @@ -103,6 +110,14 @@ export interface NextAction { levelKey?: string /** for kind:'accept-tos' */ tosUrl?: string + /** + * Advisory (non-blocking) actions only — surfaced via RailCapability.hintActions. + * ISO date the requirement becomes blocking (Bridge future_requirements[].effective_date); + * absent on current/blocking actions. The FE renders a skippable "complete before {date}" pre-empt. + */ + effectiveDate?: string + /** Advisory actions only — the provider requirement key, for telemetry / FE branching. */ + requirementKey?: string } export interface CapabilityRestriction { diff --git a/src/utils/capability-gate.test.ts b/src/utils/capability-gate.test.ts index 3532eb3b5..f11f139bf 100644 --- a/src/utils/capability-gate.test.ts +++ b/src/utils/capability-gate.test.ts @@ -1,5 +1,6 @@ import { deriveGate, + getGateAdvisory, getGateUserMessage, getKycModalVariant, type CapabilityState, @@ -475,3 +476,105 @@ describe('deriveGate — country scoping (the Add Money dead-end class)', () => expect(gate.kind).toBe('needs-enrollment') }) }) + +describe('deriveGate — advisory pre-empt (future-dated requirement on a ready rail)', () => { + // A Bridge rail that's ENABLED now but carries a future-dated requirement, + // surfaced by the BE as a non-blocking hintAction whose NextAction has an + // `effectiveDate` (the 2026-06-29 sof_individual_primary_purpose cohort). + const advisoryAction: NextAction = { + key: 'sumsub:eea_uplift', + kind: 'sumsub', + purpose: 'unlock-bridge', + levelKey: 'eea_uplift', + effectiveDate: '2099-06-29', + requirementKey: 'sof_individual_primary_purpose', + } + + test('enabled rail with a future-dated hint → ready + advisory (rail stays usable)', () => { + const rail = bankRail({ + id: 'bridge.sepa_eu', + method: 'SEPA_EU', + country: 'EU', + currency: 'EUR', + status: 'enabled', + hintActions: ['sumsub:eea_uplift'], + }) + const gate = deriveGate(state([rail], [advisoryAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + if (gate.kind === 'ready') { + expect(gate.advisory).toEqual({ + effectiveDate: '2099-06-29', + levelKey: 'eea_uplift', + requirementKey: 'sof_individual_primary_purpose', + }) + } + expect(getGateAdvisory(gate)).toMatchObject({ effectiveDate: '2099-06-29', levelKey: 'eea_uplift' }) + }) + + test('enabled rail with no hint → ready, no advisory (back-compat)', () => { + const gate = deriveGate(state([bankRail({ status: 'enabled' })]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + if (gate.kind === 'ready') expect(gate.advisory).toBeUndefined() + expect(getGateAdvisory(gate)).toBeUndefined() + }) + + test('a hint WITHOUT effectiveDate (Manteca cap-nudge) is not an advisory pre-empt', () => { + const capNudge: NextAction = { + key: 'sumsub:source_of_funds', + kind: 'sumsub', + purpose: 'raise-manteca-limit', + levelKey: 'source_of_funds', + } + const rail = bankRail({ + id: 'manteca.pix_br', + provider: 'manteca', + method: 'PIX_BR', + country: 'BR', + currency: 'BRL', + status: 'enabled', + hintActions: ['sumsub:source_of_funds'], + }) + const gate = deriveGate(state([rail], [capNudge]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + if (gate.kind === 'ready') expect(gate.advisory).toBeUndefined() + }) + + test('earliest effectiveDate wins across multiple ready rails', () => { + const later: NextAction = { + key: 'sumsub:tax_identification_number', + kind: 'sumsub', + purpose: 'unlock-bridge', + levelKey: 'tax_identification_number', + effectiveDate: '2099-12-31', + } + const rails = [ + bankRail({ + id: 'bridge.sepa_eu', + method: 'SEPA_EU', + country: 'EU', + currency: 'EUR', + status: 'enabled', + hintActions: ['sumsub:tax_identification_number'], + }), + bankRail({ id: 'bridge.ach_us', status: 'enabled', hintActions: ['sumsub:eea_uplift'] }), + ] + const gate = deriveGate(state(rails, [advisoryAction, later]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + if (gate.kind === 'ready') expect(gate.advisory?.effectiveDate).toBe('2099-06-29') + }) + + test('a blocking sibling still wins — advisory only rides on an otherwise-ready scope', () => { + // If the same scope has a CURRENT blocker, that takes priority (ready + // requires an enabled rail); advisory is strictly a ready-state rider. + const blockedRail = bankRail({ + id: 'bridge.sepa_eu', + method: 'SEPA_EU', + country: 'EU', + currency: 'EUR', + status: 'requires-info', + blockingActions: ['sumsub:eea_uplift'], + }) + const gate = deriveGate(state([blockedRail], [advisoryAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('fixable-rejection') + }) +}) diff --git a/src/utils/capability-gate.ts b/src/utils/capability-gate.ts index 5e3804187..598a87572 100644 --- a/src/utils/capability-gate.ts +++ b/src/utils/capability-gate.ts @@ -18,6 +18,23 @@ import type { NextAction, RailCapability, RailOperation, CapabilityReason, RailChannel } from '@/types/capabilities' +/** + * A non-blocking advisory pre-empt riding on a `ready` gate. An ENABLED rail can + * carry a future-dated requirement (Bridge's `future_requirements[].effective_date`, + * surfaced as a `hintAction` whose NextAction has an `effectiveDate`). The rail + * stays usable; the FE offers a SKIPPABLE "complete before {date}" prompt. When + * the date passes the BE reclassifies it to blocking and the gate becomes + * `fixable-rejection` on its own — no FE date logic, no hardcoded cutover. + */ +export interface GateAdvisory { + /** ISO date the requirement becomes blocking. */ + effectiveDate: string + /** registry key for the Sumsub RFI to launch if the user completes it now (NextAction.levelKey). */ + levelKey?: string + /** which requirement — telemetry / FE branching. */ + requirementKey?: string +} + /** * Normalized gate state. Discriminated union — consumers branch on `kind`. * @@ -38,7 +55,7 @@ import type { NextAction, RailCapability, RailOperation, CapabilityReason, RailC */ export type GateState = | { kind: 'loading' } - | { kind: 'ready' } + | { kind: 'ready'; advisory?: GateAdvisory } | { kind: 'pending' } | { kind: 'waiting-on-provider'; userMessage: string | null; reason?: CapabilityReason } | { kind: 'accept-tos'; tosUrl?: string; userMessage: string | null; reason?: CapabilityReason } @@ -93,6 +110,30 @@ function railActions(rail: RailCapability, byKey: Map): Next .filter((action): action is NextAction => action !== undefined) } +/** Resolve a rail's HINT (non-blocking) actions to NextAction descriptors. */ +function railHintActions(rail: RailCapability, byKey: Map): NextAction[] { + return (rail.hintActions ?? []) + .map((key) => byKey.get(key)) + .filter((action): action is NextAction => action !== undefined) +} + +/** + * The most-urgent advisory pre-empt among the given (ready) rails — the + * hintAction whose NextAction carries the earliest `effectiveDate`. Returns + * undefined when no rail has a future-dated hint. + */ +function firstAdvisory(rails: RailCapability[], byKey: Map): GateAdvisory | undefined { + let best: NextAction | undefined + for (const rail of rails) { + for (const action of railHintActions(rail, byKey)) { + if (!action.effectiveDate) continue + if (!best || action.effectiveDate < best.effectiveDate!) best = action + } + } + if (!best) return undefined + return { effectiveDate: best.effectiveDate!, levelKey: best.levelKey, requirementKey: best.requirementKey } +} + /** * Input state for the pure derive. Held separately from the React hook so the * gate is independently testable (and re-usable from non-React callers). @@ -147,8 +188,14 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat // above blocked / accept-tos / fixable-rejection because the user has // a working path; a blocked sibling rail (different currency, KYC // remediation pending) is not the user's problem right now. - const hasReady = candidates.some((rail) => operationStatus(rail, op) === 'enabled') - if (hasReady) return { kind: 'ready' } + const readyRails = candidates.filter((rail) => operationStatus(rail, op) === 'enabled') + if (readyRails.length > 0) { + // A working rail can still carry a future-dated requirement as a + // non-blocking hint — surface it as a SKIPPABLE pre-empt without + // demoting `ready` (the rail is usable now). + const advisory = firstAdvisory(readyRails, actionByKey) + return advisory ? { kind: 'ready', advisory } : { kind: 'ready' } + } // 3. blocked — split: if the rail carries a `restart-identity` action the // user can self-fix by re-verifying with a different document; otherwise @@ -263,3 +310,8 @@ export function getGateUserMessage(gate: GateState): string | undefined { } return undefined } + +/** The advisory pre-empt riding on a `ready` gate, if present. */ +export function getGateAdvisory(gate: GateState): GateAdvisory | undefined { + return gate.kind === 'ready' ? gate.advisory : undefined +} From 59c7ee317b8804b16f55ebe7178642982446dbe6 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 18 Jun 2026 21:29:39 -0700 Subject: [PATCH 24/52] test(add-money): cover the reported Manteca back-button path in e2e #2254 fixed both add-money amount screens but the Playwright regression only drove the Bridge bank variant (/add-money/AR/bank). Add the same assertion for the originally-reported Manteca (MP) flow (/add-money/argentina/manteca): type an amount, one back tap must leave for /add-money/argentina. Test-only; mirrors the bank case. --- e2e/flows/add-money.spec.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/e2e/flows/add-money.spec.ts b/e2e/flows/add-money.spec.ts index 7702ad349..16c490f3d 100644 --- a/e2e/flows/add-money.spec.ts +++ b/e2e/flows/add-money.spec.ts @@ -115,4 +115,35 @@ test.describe('Add money flow', () => { consoleLogs.flush(testInfo, 'add-money-ar-bank-back') await context.close() }) + + // Same regression, the originally-reported flow: Manteca (MP) AR deposit at + // /add-money/argentina/manteca. Both add-money amount screens shared the + // { history: 'push' } bug; this covers the reported variant directly. + test('add-money/argentina/manteca — back button leaves the amount screen after typing (verified-ar)', async ({ + browser, + }, testInfo) => { + const context = await browser.newContext({ ...devices['Pixel 7'] }) + await usePersona(context, 'verified-ar') + + const page = await context.newPage() + const consoleLogs = collectConsoleLogs(page) + await installApiMocks(page) + + await page.goto('/add-money/argentina/manteca') + + // amount step renders (verified persona; currency rate is mocked) + const amountInput = page.locator('input[inputmode="decimal"]').first() + await amountInput.waitFor({ state: 'visible', timeout: 15000 }) + + await amountInput.fill('100') + await captureStep(page, testInfo, { name: '01-add-money-manteca-amount-typed' }) + + // one tap must exit to the country page, not linger on /manteca with a stale amount + await page.locator('[data-testid="nav-back"]').first().click() + await expect(page).toHaveURL(/\/add-money\/argentina(?:\?.*)?$/) + await captureStep(page, testInfo, { name: '02-add-money-manteca-back-left-screen' }) + + consoleLogs.flush(testInfo, 'add-money-manteca-back') + await context.close() + }) }) From 063ece783aec4b56a5f51fe57b79c1995d888a8d Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 18 Jun 2026 21:35:31 -0700 Subject: [PATCH 25/52] =?UTF-8?q?fix(kyc):=20advisory=20pre-empt=20?= =?UTF-8?q?=E2=80=94=20dispatch=20via=20start-action,=20safe=20dismiss,=20?= =?UTF-8?q?UTC=20date?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit fixes: - 'Complete now' dispatched via handleInitiateKyc -> /users/identity, which ignores the level and no-ops for already-approved users (the only cohort with an enabled advisory rail) -> SDK never opened. Add startKycAction + handleStartAction to POST the NextAction key to /users/kyc/start-action (the capability-native path that resolves key -> RFI level + mints a token). GateAdvisory now carries the action key (not levelKey). - onClose (X/backdrop/Escape) stranded the user + re-prompted every click: now dismisses for the session without auto-triggering the money action. - effectiveDate is date-only YYYY-MM-DD -> format in UTC, else Americas timezones show the day before the deadline. - move add-money's DEPOSIT_AMOUNT_ENTERED into the proceed callback so an X-then-reclick can't double-count it. --- .../add-money/[country]/bank/page.tsx | 26 ++++++------ .../withdraw/[country]/bank/page.tsx | 8 +--- src/app/actions/sumsub.ts | 41 +++++++++++++++++++ src/components/Kyc/AdvisoryPreemptModal.tsx | 4 +- src/hooks/useAdvisoryPreempt.test.ts | 20 ++++++++- src/hooks/useAdvisoryPreempt.ts | 9 +++- src/hooks/useMultiPhaseKycFlow.ts | 2 + src/hooks/useSumsubKycFlow.ts | 39 +++++++++++++++++- src/utils/capability-gate.test.ts | 4 +- src/utils/capability-gate.ts | 6 +-- 10 files changed, 129 insertions(+), 30 deletions(-) diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index 62eacd57a..641e55e8d 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -119,13 +119,7 @@ export default function OnrampBankPage() { const { intercept: advisoryIntercept, modalProps: advisoryModalProps } = useAdvisoryPreempt({ advisory, isLoading: sumsubFlow.isLoading, - onCompleteNow: () => - sumsubFlow.handleInitiateKyc( - getRegionIntent(selectedCountry?.region ?? 'rest-of-the-world'), - advisory?.levelKey, - undefined, - selectedCountry?.id - ), + onCompleteNow: () => (advisory ? sumsubFlow.handleStartAction(advisory.actionKey) : Promise.resolve()), }) const { guardWithTos, showBridgeTos, hideTos } = useTosGuard() const { setIsSupportModalOpen } = useModalsContext() @@ -247,14 +241,18 @@ export default function OnrampBankPage() { return } - posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, { - amount_usd: usdEquivalent, - method_type: 'bank', - country: selectedCountryPath, + // ready — offer the skippable advisory pre-empt once; on proceed (now, or + // after "Not now") record the amount-entered event and open the + // confirmation modal. Firing inside the proceed avoids double-counting if + // the user dismisses the advisory and re-clicks. + advisoryIntercept(() => { + posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, { + amount_usd: usdEquivalent, + method_type: 'bank', + country: selectedCountryPath, + }) + setShowWarningModal(true) }) - // ready — but offer the skippable advisory pre-empt once before the - // confirmation modal (no-op when there's nothing future-dated pending). - advisoryIntercept(() => setShowWarningModal(true)) } const handleWarningConfirm = async () => { diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 3ceb2a593..82c2f6c7b 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -100,13 +100,7 @@ export default function WithdrawBankPage() { const { intercept: advisoryIntercept, modalProps: advisoryModalProps } = useAdvisoryPreempt({ advisory, isLoading: sumsubFlow.isLoading, - onCompleteNow: () => - sumsubFlow.handleInitiateKyc( - getRegionIntent(getCountryFromPath(country)?.region ?? 'rest-of-the-world'), - advisory?.levelKey, - undefined, - getCountryFromPath(country)?.id - ), + onCompleteNow: () => (advisory ? sumsubFlow.handleStartAction(advisory.actionKey) : Promise.resolve()), }) const [showKycModal, setShowKycModal] = useState(false) const { setIsSupportModalOpen } = useModalsContext() diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts index a3f72895b..8edfc794e 100644 --- a/src/app/actions/sumsub.ts +++ b/src/app/actions/sumsub.ts @@ -112,3 +112,44 @@ export const initiateSelfHealResubmission = async ( return { error: message } } } + +export interface StartKycActionResponse { + token: string + levelName: string + externalActionId?: string +} + +/** + * Mint a Sumsub WebSDK token for a capability nextAction by its `key` + * (POST /users/kyc/start-action). The capability model returns action + * descriptors (a stable key + a registry levelKey) and never carries a token; + * the FE posts the key here to get an unexpired token bound to the right RFI + * level. Used by the advisory pre-empt — an already-approved user starting a + * future-dated RFI early, where /users/identity would short-circuit on + * "already approved" and never mint a token. + */ +export const startKycAction = async (key: string): Promise<{ data?: StartKycActionResponse; error?: string }> => { + try { + const response = await serverFetch('/users/kyc/start-action', { + method: 'POST', + body: JSON.stringify({ key }), + }) + const responseJson = await response.json() + if (!response.ok) { + return { error: responseJson.userMessage || responseJson.error || 'Failed to start verification' } + } + if (!responseJson.sumsubAccessToken) { + return { error: 'Invalid response from server' } + } + return { + data: { + token: responseJson.sumsubAccessToken, + levelName: responseJson.levelName, + externalActionId: responseJson.externalActionId, + }, + } + } catch (e: unknown) { + const message = e instanceof Error ? e.message : 'An unexpected error occurred' + return { error: message } + } +} diff --git a/src/components/Kyc/AdvisoryPreemptModal.tsx b/src/components/Kyc/AdvisoryPreemptModal.tsx index 0287db45f..bf0a62a11 100644 --- a/src/components/Kyc/AdvisoryPreemptModal.tsx +++ b/src/components/Kyc/AdvisoryPreemptModal.tsx @@ -15,9 +15,11 @@ interface AdvisoryPreemptModalProps { function formatEffectiveDate(iso?: string): string | null { if (!iso) return null const date = new Date(iso) + // `iso` is a date-only YYYY-MM-DD, so `new Date()` parses it at UTC midnight. + // Format in UTC too, or Americas timezones render the day before the deadline. return Number.isNaN(date.getTime()) ? null - : date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) + : date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }) } /** diff --git a/src/hooks/useAdvisoryPreempt.test.ts b/src/hooks/useAdvisoryPreempt.test.ts index f4404e332..d8991d560 100644 --- a/src/hooks/useAdvisoryPreempt.test.ts +++ b/src/hooks/useAdvisoryPreempt.test.ts @@ -2,7 +2,7 @@ import { act, renderHook } from '@testing-library/react' import { useAdvisoryPreempt } from './useAdvisoryPreempt' import type { GateAdvisory } from '@/utils/capability-gate' -const advisory: GateAdvisory = { effectiveDate: '2099-06-29', levelKey: 'eea_uplift' } +const advisory: GateAdvisory = { effectiveDate: '2099-06-29', actionKey: 'sumsub:eea_uplift' } describe('useAdvisoryPreempt', () => { test('no advisory → intercept proceeds immediately, modal stays hidden', () => { @@ -46,6 +46,24 @@ describe('useAdvisoryPreempt', () => { expect(result.current.modalProps.visible).toBe(false) }) + test('onClose dismisses without running the deferred proceed (X must not trigger the money action)', () => { + const proceed = jest.fn() + const onCompleteNow = jest.fn() + const { result } = renderHook(() => useAdvisoryPreempt({ advisory, onCompleteNow })) + + act(() => result.current.intercept(proceed)) + act(() => result.current.modalProps.onClose()) + + expect(proceed).not.toHaveBeenCalled() + expect(result.current.modalProps.visible).toBe(false) + + // ...but it dismisses for the session — the next click passes through, no re-prompt. + const proceed2 = jest.fn() + act(() => result.current.intercept(proceed2)) + expect(proceed2).toHaveBeenCalledTimes(1) + expect(result.current.modalProps.visible).toBe(false) + }) + test('completeNow launches the verification and does NOT run the deferred proceed', async () => { const proceed = jest.fn() const onCompleteNow = jest.fn() diff --git a/src/hooks/useAdvisoryPreempt.ts b/src/hooks/useAdvisoryPreempt.ts index 58fafe93f..a9c01b6bc 100644 --- a/src/hooks/useAdvisoryPreempt.ts +++ b/src/hooks/useAdvisoryPreempt.ts @@ -51,7 +51,14 @@ export function useAdvisoryPreempt({ advisory, onCompleteNow, isLoading = false proceed?.() }, []) - const close = useCallback(() => setVisible(false), []) + // X / backdrop / Escape: dismiss for the session WITHOUT running the deferred + // proceed — closing the dialog must not auto-trigger the add/withdraw action. + // The user's next add/withdraw click then passes straight through (dismissed). + const close = useCallback(() => { + setDismissed(true) + setVisible(false) + pendingProceed.current = null + }, []) return { intercept, diff --git a/src/hooks/useMultiPhaseKycFlow.ts b/src/hooks/useMultiPhaseKycFlow.ts index 3405320e5..2e4f83638 100644 --- a/src/hooks/useMultiPhaseKycFlow.ts +++ b/src/hooks/useMultiPhaseKycFlow.ts @@ -203,6 +203,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent handleInitiateKyc: originalHandleInitiateKyc, handleRestartIdentity, handleSelfHealResubmit, + handleStartAction, handleSdkComplete: originalHandleSdkComplete, handleClose, refreshToken, @@ -396,6 +397,7 @@ export const useMultiPhaseKycFlow = ({ onKycSuccess, onManualClose, regionIntent handleInitiateKyc, handleRestartIdentity, handleSelfHealResubmit, + handleStartAction, isLoading, error, liveKycStatus, diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 85a928081..32e5d8e87 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -2,7 +2,12 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { useRouter } from 'next/navigation' import { useWebSocket } from '@/hooks/useWebSocket' import { useUserStore } from '@/redux/hooks' -import { initiateSumsubKyc, initiateSelfHealResubmission, restartIdentityVerification } from '@/app/actions/sumsub' +import { + initiateSumsubKyc, + initiateSelfHealResubmission, + restartIdentityVerification, + startKycAction, +} from '@/app/actions/sumsub' import { type KYCRegionIntent, type SumsubKycStatus } from '@/app/actions/types/sumsub.types' import { isMantecaSupportedCountryCode } from '@/constants/manteca.consts' import { isCapacitor } from '@/utils/capacitor' @@ -437,6 +442,37 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: } }, []) + // Start a capability nextAction by key (POST /users/kyc/start-action) and + // open the WebSDK with the returned token. Unlike handleInitiateKyc (which + // resolves the level from region and no-ops for an already-approved user), + // this mints a token for the specific RFI level the key maps to — the path + // the advisory pre-empt needs to start a future-dated requirement early. + const handleStartAction = useCallback(async (key: string) => { + setIsLoading(true) + setError(null) + userInitiatedRef.current = true + selfHealProviderRef.current = null + + try { + const response = await startKycAction(key) + if (response.error || !response.data?.token) { + userInitiatedRef.current = false + setError(response.error || 'Could not start verification. Please try again.') + return + } + levelNameRef.current = response.data.levelName + setAccessToken(response.data.token) + setIsActionFlow(true) + setShowWrapper(true) + } catch (e: unknown) { + userInitiatedRef.current = false + const message = e instanceof Error ? e.message : 'An unexpected error occurred' + setError(message) + } finally { + setIsLoading(false) + } + }, []) + return { isLoading, error, @@ -447,6 +483,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: handleInitiateKyc, handleRestartIdentity, handleSelfHealResubmit, + handleStartAction, handleSdkComplete, handleClose, refreshToken, diff --git a/src/utils/capability-gate.test.ts b/src/utils/capability-gate.test.ts index f11f139bf..72f86ac69 100644 --- a/src/utils/capability-gate.test.ts +++ b/src/utils/capability-gate.test.ts @@ -504,11 +504,11 @@ describe('deriveGate — advisory pre-empt (future-dated requirement on a ready if (gate.kind === 'ready') { expect(gate.advisory).toEqual({ effectiveDate: '2099-06-29', - levelKey: 'eea_uplift', + actionKey: 'sumsub:eea_uplift', requirementKey: 'sof_individual_primary_purpose', }) } - expect(getGateAdvisory(gate)).toMatchObject({ effectiveDate: '2099-06-29', levelKey: 'eea_uplift' }) + expect(getGateAdvisory(gate)).toMatchObject({ effectiveDate: '2099-06-29', actionKey: 'sumsub:eea_uplift' }) }) test('enabled rail with no hint → ready, no advisory (back-compat)', () => { diff --git a/src/utils/capability-gate.ts b/src/utils/capability-gate.ts index 598a87572..dbc183fe2 100644 --- a/src/utils/capability-gate.ts +++ b/src/utils/capability-gate.ts @@ -29,8 +29,8 @@ import type { NextAction, RailCapability, RailOperation, CapabilityReason, RailC export interface GateAdvisory { /** ISO date the requirement becomes blocking. */ effectiveDate: string - /** registry key for the Sumsub RFI to launch if the user completes it now (NextAction.levelKey). */ - levelKey?: string + /** the NextAction `key` to start (POST /users/kyc/start-action) if the user completes it now. */ + actionKey: string /** which requirement — telemetry / FE branching. */ requirementKey?: string } @@ -131,7 +131,7 @@ function firstAdvisory(rails: RailCapability[], byKey: Map): } } if (!best) return undefined - return { effectiveDate: best.effectiveDate!, levelKey: best.levelKey, requirementKey: best.requirementKey } + return { effectiveDate: best.effectiveDate!, actionKey: best.key, requirementKey: best.requirementKey } } /** From f85760dc44dcd65a7ff5118a4e2a17896099e8a7 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 18 Jun 2026 21:46:29 -0700 Subject: [PATCH 26/52] fix(split-bill): hide "Split this bill" only on charges that didn't stick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CTA gate fired on any QR pay or card spend that wasn't refunded/failed. A reversed/expired card hold (status `cancelled` — "Hold released, funds back on your card") still passed, so a user could ask friends to chip in for a charge that never actually cost them. Extract eligibility into a tested isSplittable() predicate. The CTA is an in-the-moment action right after paying, so a freshly-authorized hold (`pending`) stays splittable — card settlement takes days and we don't make users wait. Only charges that didn't stick are excluded: refunded, failed, and `cancelled`. Also strip a leading sign in buildSplitBillRequestUrl so a stray negative amount can't seed /request?amount=-15. Found in the dev→main release review. --- .../TransactionDetailsReceipt.tsx | 23 +++++----- .../__tests__/splitBill.utils.test.ts | 6 +++ .../__tests__/transaction-predicates.test.ts | 43 +++++++++++++++++++ .../TransactionDetails/splitBill.utils.ts | 7 ++- .../transaction-predicates.ts | 21 +++++++-- 5 files changed, 83 insertions(+), 17 deletions(-) diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx index dcc21fa28..4ead262e3 100644 --- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx +++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx @@ -49,6 +49,7 @@ import { isPerkReward as isPerkRewardTransaction, isRequestEntry, isSendLinkEntry, + isSplittable, usesCompletedTimestampLabel, } from './transaction-predicates' import { useReceiptViewModel } from './useReceiptViewModel' @@ -122,7 +123,6 @@ export const TransactionDetailsReceipt = ({ isPendingRequester, isPendingSentLink, isQRPayment, - isCardSpend, country, rowVisibilityConfig, shouldHideBorder, @@ -744,18 +744,15 @@ export const TransactionDetailsReceipt = ({ )} - {!isPublic && - (isQRPayment || isCardSpend) && - transaction.status !== 'refunded' && - transaction.status !== 'failed' && ( - - )} + {!isPublic && isSplittable(transaction) && ( + + )} {shouldShowShareReceipt && !!getReceiptUrl(transaction) && (
diff --git a/src/components/TransactionDetails/__tests__/splitBill.utils.test.ts b/src/components/TransactionDetails/__tests__/splitBill.utils.test.ts index ec75210fe..9f776edb3 100644 --- a/src/components/TransactionDetails/__tests__/splitBill.utils.test.ts +++ b/src/components/TransactionDetails/__tests__/splitBill.utils.test.ts @@ -20,4 +20,10 @@ describe('buildSplitBillRequestUrl', () => { expect(buildSplitBillRequestUrl(8, null)).toBe('/request?amount=8') expect(buildSplitBillRequestUrl(8, '')).toBe('/request?amount=8') }) + + test('strips a leading minus so the prefill amount is never negative', () => { + expect(buildSplitBillRequestUrl(-15, 'Cafe Tortoni')).toBe('/request?amount=15&merchant=Cafe%20Tortoni') + expect(buildSplitBillRequestUrl('-12.5')).toBe('/request?amount=12.5') + expect(buildSplitBillRequestUrl(-20n)).toBe('/request?amount=20') + }) }) diff --git a/src/components/TransactionDetails/__tests__/transaction-predicates.test.ts b/src/components/TransactionDetails/__tests__/transaction-predicates.test.ts index 2cd364b58..d6fe48af5 100644 --- a/src/components/TransactionDetails/__tests__/transaction-predicates.test.ts +++ b/src/components/TransactionDetails/__tests__/transaction-predicates.test.ts @@ -12,6 +12,7 @@ import { isQRPayment, isRequestEntry, isSendLinkEntry, + isSplittable, hasShareableReceipt, } from '../transaction-predicates' import type { TransactionDetails } from '../transactionTransformer' @@ -109,3 +110,45 @@ describe('entry-kind predicates', () => { }) }) }) + +// Gates the "Split this bill" CTA: a QR payment, or a card spend that went +// through. It's an in-the-moment action right after paying, so a freshly- +// authorized (`pending`) card hold IS splittable — settlement takes days. Only +// charges that didn't stick (refunded/failed/cancelled) are excluded. +describe('isSplittable', () => { + const txWithStatus = (kind: string, status?: string): TransactionDetails => + ({ + status, + extraDataForDrawer: { originalType: 'TRANSACTION_INTENT', kind }, + }) as unknown as TransactionDetails + + test('QR payments are splittable unless refunded/failed (behaviour unchanged)', () => { + expect(isSplittable(txWithStatus('QR_PAY', 'completed'))).toBe(true) + expect(isSplittable(txWithStatus('QR_PAY', 'pending'))).toBe(true) + expect(isSplittable(txWithStatus('QR_PAY', 'refunded'))).toBe(false) + expect(isSplittable(txWithStatus('QR_PAY', 'failed'))).toBe(false) + }) + + test('a freshly-authorized (pending) card hold IS splittable — split in the moment, settlement takes days', () => { + expect(isSplittable(txWithStatus('CARD_SPEND_AUTH', 'pending'))).toBe(true) + }) + + test('settled card spends are splittable', () => { + expect(isSplittable(txWithStatus('CARD_SPEND_CLEAR', 'completed'))).toBe(true) + expect(isSplittable(txWithStatus('CARD_SPEND_AUTH', 'completed'))).toBe(true) + }) + + test('a cancelled (reversed/expired) card hold is NOT splittable — the charge never stuck', () => { + expect(isSplittable(txWithStatus('CARD_SPEND_AUTH', 'cancelled'))).toBe(false) + }) + + test('refunded/failed card spends are NOT splittable', () => { + expect(isSplittable(txWithStatus('CARD_SPEND_CLEAR', 'refunded'))).toBe(false) + expect(isSplittable(txWithStatus('CARD_SPEND_CLEAR', 'failed'))).toBe(false) + }) + + test('non-QR / non-card kinds are never splittable', () => { + expect(isSplittable(txWithStatus('DIRECT_TRANSFER', 'completed'))).toBe(false) + expect(isSplittable(txWithStatus('SEND_LINK', 'completed'))).toBe(false) + }) +}) diff --git a/src/components/TransactionDetails/splitBill.utils.ts b/src/components/TransactionDetails/splitBill.utils.ts index c99af1473..b8567aade 100644 --- a/src/components/TransactionDetails/splitBill.utils.ts +++ b/src/components/TransactionDetails/splitBill.utils.ts @@ -8,9 +8,14 @@ * merchant name) so the request comment isn't "Bill split for Card payment". * - URL-encode the merchant so reserved chars (e.g. "Tigers & Lions") can't * break the query string. + * - Strip a leading sign so the prefill can never be negative. */ export function buildSplitBillRequestUrl(amount: number | bigint | string, merchantName?: string | null): string { const merchantParam = merchantName && merchantName !== 'Card payment' ? `&merchant=${encodeURIComponent(merchantName)}` : '' - return `/request?amount=${encodeURIComponent(String(amount))}${merchantParam}` + // A split request must never carry a negative amount — strip a leading sign + // so a stray "-15" can't seed `/request?amount=-15`. Done on the string form + // to stay exact for bigint/string inputs. + const positiveAmount = String(amount).replace(/^-/, '') + return `/request?amount=${encodeURIComponent(positiveAmount)}${merchantParam}` } diff --git a/src/components/TransactionDetails/transaction-predicates.ts b/src/components/TransactionDetails/transaction-predicates.ts index 2eed62032..bed019ba0 100644 --- a/src/components/TransactionDetails/transaction-predicates.ts +++ b/src/components/TransactionDetails/transaction-predicates.ts @@ -23,13 +23,28 @@ export function isQRPayment(transaction: TransactionDetails): boolean { return isKind(transaction, 'QR_PAY') } -/** A Rain card *spend* (not a refund or reversal) — the only card rows eligible - * for a "Split this bill" CTA. Kind-based so refunds (REFUND/OTHER) and auth - * reversals are excluded. */ +/** A Rain card *spend* (not a refund or reversal). Kind-based so refunds + * (REFUND/OTHER) and auth reversals are excluded. Card-spend eligibility for + * the "Split this bill" CTA layers a settled-status check on top — see + * {@link isSplittable}. */ export function isCardSpend(transaction: TransactionDetails): boolean { return isKind(transaction, 'CARD_SPEND_AUTH') || isKind(transaction, 'CARD_SPEND_CLEAR') } +/** Eligible for the "Split this bill" CTA: a QR payment, or a card spend that + * actually went through. This is an in-the-moment action right after paying, + * so a freshly-authorized card hold (`pending`) IS splittable — settlement can + * take days and we don't make users wait. The only card spends excluded are the + * ones that didn't stick: refunded, failed, and `cancelled` (the auth was + * reversed or expired — "Hold released, funds back on your card"). QR pays keep + * their prior behaviour (splittable unless refunded/failed). */ +export function isSplittable(transaction: TransactionDetails): boolean { + if (transaction.status === 'refunded' || transaction.status === 'failed') return false + if (isQRPayment(transaction)) return true + if (isCardSpend(transaction)) return transaction.status !== 'cancelled' + return false +} + /** Kinds that move money across a fiat rail: bank on/off-ramps + QR pays. * The single anchor for the receipt-page whitelist (`getReceiptUrl`), the * share gate (`hasShareableReceipt`), and the FX predicate From 938e71781868f62c8b38b10c52f2a1e70fc39046 Mon Sep 17 00:00:00 2001 From: peanut Date: Fri, 19 Jun 2026 15:18:38 +0200 Subject: [PATCH 27/52] feat(card): show registered cardholder name on reveal Render the Rain-registered cardholder name on the card face, only in the revealed state (alongside PAN/CVV/expiry) and ph-no-capture since it's PII. Companion to peanut-api-ts card-registered-name, which returns the name as an optional cardholderName on GET /rain/cards/:cardId/details. The field is optional, so the FE degrades gracefully when it's absent. --- src/components/Card/CardFace.tsx | 10 +++++ .../Card/__tests__/CardFace.test.tsx | 39 +++++++++++++++++++ src/services/rain.ts | 3 ++ 3 files changed, 52 insertions(+) create mode 100644 src/components/Card/__tests__/CardFace.test.tsx diff --git a/src/components/Card/CardFace.tsx b/src/components/Card/CardFace.tsx index 663660de0..9e16efff2 100644 --- a/src/components/Card/CardFace.tsx +++ b/src/components/Card/CardFace.tsx @@ -11,6 +11,9 @@ export interface RevealedCardDetails { cvv: string expiryMonth: number expiryYear: number + /** Registered cardholder name from Rain. Optional — the backend resolves it + * best-effort, so a Rain hiccup leaves it absent and the field is hidden. */ + cardholderName?: string } interface Props { @@ -125,6 +128,13 @@ const CardFace: FC = ({ )}
+ {/* Registered cardholder name — PII, kept out of session + * recordings like the other revealed fields. */} + {revealed.cardholderName && ( + + {revealed.cardholderName} + + )}
diff --git a/src/components/Card/__tests__/CardFace.test.tsx b/src/components/Card/__tests__/CardFace.test.tsx new file mode 100644 index 000000000..784ebc767 --- /dev/null +++ b/src/components/Card/__tests__/CardFace.test.tsx @@ -0,0 +1,39 @@ +/** + * CardFace — registered cardholder name. + * + * The name comes from Rain (best-effort) and is shown ONLY in the revealed + * state, alongside PAN/CVV/expiry. It must never appear on the masked card, and + * the card must still render when the reveal payload omits the name (backend + * degraded the Rain lookup). + */ +import React from 'react' +import { render, screen } from '@testing-library/react' +import CardFace, { type RevealedCardDetails } from '@/components/Card/CardFace' + +const revealed: RevealedCardDetails = { + pan: '4111111111111234', + cvv: '123', + expiryMonth: 12, + expiryYear: 2030, + cardholderName: 'Jane Doe', +} + +describe('CardFace cardholder name', () => { + it('shows the registered name when the card is revealed', () => { + render() + expect(screen.getByText('Jane Doe')).toBeInTheDocument() + }) + + it('hides the name when the card is masked (not revealed)', () => { + render() + expect(screen.queryByText('Jane Doe')).not.toBeInTheDocument() + }) + + it('still renders the revealed card when the name is absent', () => { + const { cardholderName: _omitted, ...withoutName } = revealed + render() + expect(screen.queryByText('Jane Doe')).not.toBeInTheDocument() + // PAN still renders, proving reveal works without the name. + expect(screen.getByText('4111 1111 1111 1234')).toBeInTheDocument() + }) +}) diff --git a/src/services/rain.ts b/src/services/rain.ts index 0115fc4f2..73463e25b 100644 --- a/src/services/rain.ts +++ b/src/services/rain.ts @@ -174,6 +174,9 @@ export interface RainCardDetailsResponse { expiryYear: number last4: string network: string + /** Registered cardholder name from Rain. Best-effort on the backend, so it + * may be absent if the Rain user lookup failed. */ + cardholderName?: string } export type RainLimitFrequency = 'perAuthorization' | 'per24HourPeriod' | 'per30DayPeriod' | 'perAllTime' From 23abae6cbed58dcd930efe142b671e7d31ed964f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Fri, 19 Jun 2026 18:00:11 -0300 Subject: [PATCH 28/52] =?UTF-8?q?fix(kyc):=20correct=20DUPLICATE=5FEMAIL?= =?UTF-8?q?=20copy=20=E2=80=94=20sign=20in=20/=20contact=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Verify again with a different email" was wrong advice: the collision is almost always the user's own earlier account, now auto-resolved server-side. The cases that still surface this message are genuine conflicts, where the right action is to sign into the other account or contact support. --- src/constants/sumsub-reject-labels.consts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/constants/sumsub-reject-labels.consts.ts b/src/constants/sumsub-reject-labels.consts.ts index 9df35b914..2f9fb85c2 100644 --- a/src/constants/sumsub-reject-labels.consts.ts +++ b/src/constants/sumsub-reject-labels.consts.ts @@ -275,7 +275,7 @@ const REJECT_LABEL_MAP: Record = { DUPLICATE_EMAIL: { title: 'Email already in use', description: - 'The email you entered is already associated with another account. Please verify again with a different email.', + 'This email is already linked to another Peanut account. If it’s yours, sign in to that account to continue — otherwise contact support.', }, } From 784912b8a1b05a6eccc31959ef3bb9cb46322253 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Fri, 19 Jun 2026 17:22:02 -0700 Subject: [PATCH 29/52] fix(sentry): don't report qr-payment/init 422 as a client error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit peanut-api-ts #1041 made /manteca/qr-payment/init return 422 (instead of 500) for a QR the provider can't decode — a user-input outcome, not a server bug. Add 422 to the skip-list alongside the existing 400 so these stop generating Sentry warning noise, matching how the 400 (open QR awaiting merchant amount) is already handled. --- src/utils/sentry.utils.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/utils/sentry.utils.ts b/src/utils/sentry.utils.ts index 11b4547ee..5fba47d54 100644 --- a/src/utils/sentry.utils.ts +++ b/src/utils/sentry.utils.ts @@ -12,7 +12,10 @@ const SKIP_REPORTING: Array<{ pattern: string | RegExp; statuses: number[] }> = { pattern: /\/get-user(?:\b|$)/, statuses: [400, 401, 403, 404] }, { pattern: /users/, statuses: [400, 401, 403, 404] }, { pattern: /perks/, statuses: [400, 401, 403, 404] }, - { pattern: /qr-payment\/init/, statuses: [400] }, + // qr-payment/init: 400 = open QR awaiting merchant amount; 422 = a QR the + // provider can't decode (bad/expired/unsupported) — both are user-input + // outcomes shown to the user, not server bugs. (BE peanut-api-ts #1041.) + { pattern: /qr-payment\/init/, statuses: [400, 422] }, // Rain card secrets endpoints are intentionally rate-limited (5/min) — a // 429 here is an expected outcome surfaced to the user, not a server bug. { pattern: /\/rain\/cards\/[^/]+\/details/, statuses: [429] }, From c19f6287ac769215fa9927dc80daaf59455772e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Sat, 20 Jun 2026 15:02:18 -0300 Subject: [PATCH 30/52] fix(kyc): route advisory Complete now through self-heal relay Advisory Complete now called handleStartAction (start-action), whose submission never round-trips to Bridge. Switch both add-money and withdraw bank pages to handleSelfHealResubmit('BRIDGE', advisory.requirementKey), the path whose webhook completion relays answers to Bridge. Thread an optional requirementKey through initiateSelfHealResubmission and handleSelfHealResubmit to target the future-dated advisory requirement. handleStartAction/startKycAction are now unused (kept for a focused follow-up cleanup to avoid the isActionFlow cascade in this diff). --- src/app/(mobile-ui)/add-money/[country]/bank/page.tsx | 6 +++++- src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx | 6 +++++- src/app/actions/sumsub.ts | 7 +++++-- src/hooks/useSumsubKycFlow.ts | 8 +++++--- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index 886996c6f..e94ae231a 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -121,7 +121,11 @@ export default function OnrampBankPage() { const { intercept: advisoryIntercept, modalProps: advisoryModalProps } = useAdvisoryPreempt({ advisory, isLoading: sumsubFlow.isLoading, - onCompleteNow: () => (advisory ? sumsubFlow.handleStartAction(advisory.actionKey) : Promise.resolve()), + // 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: () => + advisory ? sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey) : Promise.resolve(), }) const { guardWithTos, showBridgeTos, hideTos } = useTosGuard() const { setIsSupportModalOpen } = useModalsContext() diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 82c2f6c7b..6a7c89f04 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -100,7 +100,11 @@ export default function WithdrawBankPage() { const { intercept: advisoryIntercept, modalProps: advisoryModalProps } = useAdvisoryPreempt({ advisory, isLoading: sumsubFlow.isLoading, - onCompleteNow: () => (advisory ? sumsubFlow.handleStartAction(advisory.actionKey) : Promise.resolve()), + // 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: () => + advisory ? sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey) : Promise.resolve(), }) const [showKycModal, setShowKycModal] = useState(false) const { setIsSupportModalOpen } = useModalsContext() diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts index 8edfc794e..519e8f43c 100644 --- a/src/app/actions/sumsub.ts +++ b/src/app/actions/sumsub.ts @@ -86,12 +86,15 @@ export const restartIdentityVerification = async (): Promise<{ // initiate self-heal document resubmission for a provider-rejected user export const initiateSelfHealResubmission = async ( - provider: 'BRIDGE' | 'MANTECA' + provider: 'BRIDGE' | 'MANTECA', + // Optional — target a specific (e.g. future-dated advisory) Bridge requirement + // by key. Omitted for the legacy blocking flow (current nextAction). + requirementKey?: string ): Promise<{ data?: SelfHealResubmissionResponse; error?: string }> => { try { const response = await serverFetch('/users/identity/resubmit', { method: 'POST', - body: JSON.stringify({ provider }), + body: JSON.stringify({ provider, ...(requirementKey ? { requirementKey } : {}) }), }) const responseJson = await response.json() diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 32e5d8e87..1c77a60c5 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -407,15 +407,17 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: }, []) // initiate self-heal document resubmission: calls the resubmit API - // and opens the sumsub SDK with the action token - const handleSelfHealResubmit = useCallback(async (provider: 'BRIDGE' | 'MANTECA') => { + // and opens the sumsub SDK with the action token. `requirementKey` targets a + // specific (e.g. future-dated advisory) Bridge requirement; omitted for the + // legacy blocking flow. + const handleSelfHealResubmit = useCallback(async (provider: 'BRIDGE' | 'MANTECA', requirementKey?: string) => { setIsLoading(true) setError(null) userInitiatedRef.current = true selfHealProviderRef.current = provider try { - const response = await initiateSelfHealResubmission(provider) + const response = await initiateSelfHealResubmission(provider, requirementKey) if (response.error) { userInitiatedRef.current = false From 832ec52f90a54745b824153a070ea226b04059a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Sat, 20 Jun 2026 18:00:38 -0300 Subject: [PATCH 31/52] fix(kyc): guard against double-submit in advisory completeNow (CodeRabbit) onCompleteNow now fires a real network call (self-heal resubmit), so rapid clicks before isLoading disables the CTA could launch duplicate requests. Add a completingRef in-flight guard. --- src/hooks/useAdvisoryPreempt.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/hooks/useAdvisoryPreempt.ts b/src/hooks/useAdvisoryPreempt.ts index a9c01b6bc..78f29eebb 100644 --- a/src/hooks/useAdvisoryPreempt.ts +++ b/src/hooks/useAdvisoryPreempt.ts @@ -23,6 +23,10 @@ export function useAdvisoryPreempt({ advisory, onCompleteNow, isLoading = false const [dismissed, setDismissed] = useState(false) const [visible, setVisible] = useState(false) const pendingProceed = useRef<(() => void) | null>(null) + // Guards against double-submit: onCompleteNow now fires a real network call + // (self-heal resubmit), so rapid clicks before isLoading disables the CTA + // would otherwise launch duplicate requests. + const completingRef = useRef(false) const intercept = useCallback( (proceed: () => void) => { @@ -37,10 +41,16 @@ export function useAdvisoryPreempt({ advisory, onCompleteNow, isLoading = false ) const completeNow = useCallback(async () => { + if (completingRef.current) return + completingRef.current = true setDismissed(true) setVisible(false) pendingProceed.current = null - await onCompleteNow() + try { + await onCompleteNow() + } finally { + completingRef.current = false + } }, [onCompleteNow]) const skip = useCallback(() => { From 7ea2b35ece91c8dad7a89c201cb1ecd56066ab09 Mon Sep 17 00:00:00 2001 From: peanut Date: Sun, 21 Jun 2026 17:57:02 +0200 Subject: [PATCH 32/52] fix(request): show spendable balance incl. card collateral The /request create-link and direct-request views showed smart-account-only `balance`, excluding Rain card collateral, so the "Balance:" affordance read lower than /home and /send for users whose funds are split into card collateral. Switch to spendableBalance (smart + collateral), matching the Send view and useWallet's documented intent (useWallet.ts:257-263). --- .../direct-request/views/Initial.direct.request.view.tsx | 2 +- src/components/Request/link/views/Create.request.link.view.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Request/direct-request/views/Initial.direct.request.view.tsx b/src/components/Request/direct-request/views/Initial.direct.request.view.tsx index dfad01ffc..8766a4604 100644 --- a/src/components/Request/direct-request/views/Initial.direct.request.view.tsx +++ b/src/components/Request/direct-request/views/Initial.direct.request.view.tsx @@ -29,7 +29,7 @@ interface DirectRequestInitialViewProps { const DirectRequestInitialView = ({ username }: DirectRequestInitialViewProps) => { const onBack = useSafeBack('/home') const { user: authUser } = useUserStore() - const { balance, address } = useWallet() + const { spendableBalance: balance, address } = useWallet() const [attachmentOptions, setAttachmentOptions] = useState({ message: undefined, fileUrl: undefined, diff --git a/src/components/Request/link/views/Create.request.link.view.tsx b/src/components/Request/link/views/Create.request.link.view.tsx index b43724bc7..75e723c9a 100644 --- a/src/components/Request/link/views/Create.request.link.view.tsx +++ b/src/components/Request/link/views/Create.request.link.view.tsx @@ -34,7 +34,7 @@ import { useSafeBack } from '@/hooks/useSafeBack' export const CreateRequestLinkView = () => { const toast = useToast() const onBack = useSafeBack('/home') - const { address, isConnected, balance } = useWallet() + const { address, isConnected, spendableBalance: balance } = useWallet() const { user } = useAuth() const { selectedChainID, setSelectedChainID, selectedTokenAddress, setSelectedTokenAddress, selectedTokenData } = useContext(tokenSelectorContext) From 66f5505d1728daeeaf5447989614d2b12426904e Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:51:25 +0530 Subject: [PATCH 33/52] feat(bridge): pass claimer details for travel rule in guest claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forward claimer's name and address from the bank account form to the guest offramp creation request. Backend uses these as beneficiary details for Bridge's travel rule compliance (is_self=false). Address block is only included when street, city, and country are all present — avoids sending empty strings to Bridge compliance fields. --- .../Claim/Link/views/BankFlowManager.view.tsx | 17 +++++++++++++++++ src/services/services.types.ts | 9 +++++++++ 2 files changed, 26 insertions(+) diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index 975ab3a9b..8810c70a5 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -257,6 +257,23 @@ export const BankFlowManager = (props: IClaimScreenProps) => { externalAccountId, }, features: { allowAnyFromAddress: true }, + // travel rule: pass claimer details for third-party guest claims + ...(isGuestFlow && + account.firstName && + account.lastName && { + beneficiaryName: `${account.firstName} ${account.lastName}`, + ...(account.street && + account.city && + account.country && { + beneficiaryAddress: { + street: account.street, + city: account.city, + country: account.country, + state: account.state || undefined, + postalCode: account.postalCode || undefined, + }, + }), + }), } const offrampResponse = isGuestFlow diff --git a/src/services/services.types.ts b/src/services/services.types.ts index 06ed0311c..2a6a5d944 100644 --- a/src/services/services.types.ts +++ b/src/services/services.types.ts @@ -273,6 +273,15 @@ export interface TCreateOfframpRequest { features?: { allowAnyFromAddress?: boolean } + // travel rule: claimer (beneficiary) details for third-party guest claims + beneficiaryName?: string + beneficiaryAddress?: { + street: string + city: string + country: string + state?: string + postalCode?: string + } } export interface TCreateOfframpResponse { From b25f3c8238dd9782dc202e3a55897b4882f70418 Mon Sep 17 00:00:00 2001 From: peanut Date: Tue, 23 Jun 2026 13:49:23 +0200 Subject: [PATCH 34/52] fix(card): drop "Expiry"/"CVV" labels so PAN + name clear the artwork The registered-name line added height to the bottom-pinned (mt-auto) value block, pushing the PAN up into the hand artwork. Removing the two-line Expiry/CVV labels (revealed + loading skeleton) reclaims that height; flexbox shifts PAN + name back down. Values and ph-no-capture PII guards untouched. --- src/components/Card/CardFace.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Card/CardFace.tsx b/src/components/Card/CardFace.tsx index 9e16efff2..14bff2756 100644 --- a/src/components/Card/CardFace.tsx +++ b/src/components/Card/CardFace.tsx @@ -138,7 +138,7 @@ const CardFace: FC = ({
-
Expiry
+ {/* "Expiry" label dropped — value row stays one line so PAN/name clear the artwork */} {/* ph-no-capture: expiry digits out of recordings. */}
{String(revealed.expiryMonth).padStart(2, '0')}/ @@ -147,7 +147,7 @@ const CardFace: FC = ({
-
CVV
+ {/* "CVV" label dropped — value only */} {/* ph-no-capture: CVV out of recordings. */}
{revealed.cvv}
@@ -180,11 +180,11 @@ const CardFace: FC = ({
-
Expiry
+ {/* label dropped to match the revealed layout — no height jump on reveal */}
-
CVV
+ {/* label dropped to match the revealed layout */}
From 53aeeb643890a004583786587a832e6219a5c045 Mon Sep 17 00:00:00 2001 From: peanut Date: Tue, 23 Jun 2026 14:08:31 +0200 Subject: [PATCH 35/52] test(card): assert registered name stays inside ph-no-capture wrapper Addresses CodeRabbit nit. The name is PII; asserting only the text would let a future refactor move it outside ph-no-capture without failing a test. Lock the class so the session-recording guard is regression-proof. --- src/components/Card/__tests__/CardFace.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/Card/__tests__/CardFace.test.tsx b/src/components/Card/__tests__/CardFace.test.tsx index 784ebc767..2c50a501b 100644 --- a/src/components/Card/__tests__/CardFace.test.tsx +++ b/src/components/Card/__tests__/CardFace.test.tsx @@ -21,7 +21,11 @@ const revealed: RevealedCardDetails = { describe('CardFace cardholder name', () => { it('shows the registered name when the card is revealed', () => { render() - expect(screen.getByText('Jane Doe')).toBeInTheDocument() + const name = screen.getByText('Jane Doe') + expect(name).toBeInTheDocument() + // PII guard: the name must stay inside the ph-no-capture wrapper so it's + // kept out of session recordings — assert the class, not just the text. + expect(name).toHaveClass('ph-no-capture') }) it('hides the name when the card is masked (not revealed)', () => { From d2cfdfedafd26d4cc888b18b5193518ce4a970fb Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 23 Jun 2026 14:09:39 -0700 Subject: [PATCH 36/52] fix(balance): gate money-flows on available-now spendable, not display balance The displayed spendable balance includes in-transit card-collateral top-ups so it doesn't crater during the ~10-45s smart->collateral handoff. Several legacy flows hand-rolled their affordability gate against that DISPLAY number, so during a top-up they could green-light a spend whose funds aren't routable yet - failing at execution instead of being blocked at input. Route Send-link, qr-pay and bank/manteca withdraw gates through the shared useWallet.hasSufficientSpendableBalance() predicate (smart + LANDED collateral), matching the features/payments flows. Withdraw's amount ceiling now derives from a newly-exposed availableSpendableBalance. Displayed balances are unchanged. Outside the top-up window available == display, so behaviour is identical; the change only tightens the rare in-transit window. Revives the qr-pay insufficient-balance test (skipped on mock drift) and adds a request-view regression for the spendable-balance display fix. --- .../qr-pay/__tests__/qr-pay-states.test.tsx | 15 +++++++----- src/app/(mobile-ui)/qr-pay/page.tsx | 8 ++++--- .../withdraw/[country]/bank/page.tsx | 10 ++++---- .../__tests__/withdraw-states.test.tsx | 14 ++++++++--- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 8 ++++--- src/app/(mobile-ui)/withdraw/page.tsx | 11 ++++++--- .../Request/__tests__/request-states.test.tsx | 24 +++++++++++++++++++ .../link/views/Initial.link.send.view.tsx | 20 +++++++++++----- src/hooks/wallet/useWallet.ts | 5 ++++ 9 files changed, 86 insertions(+), 29 deletions(-) diff --git a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx index cdcfc5213..b9db87546 100644 --- a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx +++ b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx @@ -560,6 +560,8 @@ function applyDefaults() { mockUseWallet.mockReturnValue({ balance: parseUnits('100', 6), // $100 USDC + spendableBalance: parseUnits('100', 6), + hasSufficientSpendableBalance: () => true, sendMoney: jest.fn(), }) @@ -780,14 +782,15 @@ describe('GROUP 2: Payment Form States', () => { expect(screen.getByRole('button', { name: 'Pay' })).toBeInTheDocument() }) - test.skip('Insufficient balance shows pay button disabled + error', async () => { - // SKIP 2026-04-24: feat/card-ui merge surfaced post-merge balance - // path mismatch in qr-pay state tests. Mock signature for useWallet - // drifted vs new spendable-balance shape. FOLLOW-UP: rewrite or delete - // these state tests after the card-ui apply flow stabilises. - // Set balance to $5 but payment needs $18.4 + test('Insufficient balance shows pay button disabled + error', async () => { + // Payment needs ~$18.4 but the available-now spendable is only $5, so the + // affordability gate (hasSufficientSpendableBalance) blocks it. Revived from + // skip once the gate moved off the raw display balance onto the shared hook + // predicate (the original mock-shape drift this test hit). mockUseWallet.mockReturnValue({ balance: parseUnits('5', 6), + spendableBalance: parseUnits('5', 6), + hasSufficientSpendableBalance: () => false, sendMoney: jest.fn(), }) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index c2012710f..9bbce3479 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -98,7 +98,7 @@ export default function QRPayPage() { const qrCode = decodeURIComponent(searchParams.get('qrCode') || '') const timestamp = searchParams.get('t') const qrType = searchParams.get('type') - const { spendableBalance: balance, sendMoney } = useWallet() + const { spendableBalance: balance, hasSufficientSpendableBalance, sendMoney } = useWallet() const { signSpend } = useSignSpendBundle() const handleStaleSession = useStaleSessionGuard() const { overview: rainCardOverview } = useRainCardOverview() @@ -937,12 +937,14 @@ export default function QRPayPage() { setBalanceErrorMessage(`QR payment amount exceeds maximum limit of $${MAX_QR_PAYMENT_AMOUNT}`) } else if (paymentAmount < parseUnits(MIN_QR_PAYMENT_AMOUNT, PEANUT_WALLET_TOKEN_DECIMALS)) { setBalanceErrorMessage(`QR payment amount must be at least $${MIN_QR_PAYMENT_AMOUNT}`) - } else if (paymentAmount > balance) { + } else if (!hasSufficientSpendableBalance(usdAmount)) { + // available-now gate (excludes in-transit collateral) — the displayed + // `balance` can briefly read higher during a card top-up. setBalanceErrorMessage('Not enough balance to complete payment. Add funds!') } else { setBalanceErrorMessage(null) } - }, [usdAmount, balance, paymentProcessor, currency?.code, currencyAmount]) + }, [usdAmount, balance, hasSufficientSpendableBalance, paymentProcessor, currency?.code, currencyAmount]) // Use points confetti hook for animation - must be called unconditionally usePointsConfetti(isSuccess && pointsData?.estimatedPoints ? pointsData.estimatedPoints : undefined, pointsDivRef) diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 4f91076d9..ee531426b 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -41,7 +41,6 @@ import countryCurrencyMappings, { isNonEuroSepaCountry } from '@/constants/count import { isBridgeSupportedCountry, getRegionIntent } from '@/utils/regions.utils' import { PointsAction } from '@/services/services.types' import { usePointsCalculation } from '@/hooks/usePointsCalculation' -import { parseUnits } from 'viem' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { withdrawCountryUrl } from '@/utils/native-routes' @@ -68,7 +67,7 @@ export default function WithdrawBankPage() { setSelectedMethod, } = useWithdrawFlow() const { user, fetchUser } = useAuth() - const { address, sendMoney, spendableBalance: balance } = useWallet() + const { address, sendMoney, spendableBalance: balance, hasSufficientSpendableBalance } = useWallet() const { guardWithTos, showBridgeTos, hideTos } = useTosGuard() const queryClient = useQueryClient() const router = useRouter() @@ -351,13 +350,14 @@ export default function WithdrawBankPage() { return } - const withdrawAmount = parseUnits(amountToWithdraw, PEANUT_WALLET_TOKEN_DECIMALS) - if (withdrawAmount > balance) { + // available-now gate (excludes in-transit collateral); `balance` above is + // the displayed total and can read higher mid-top-up. + if (!hasSufficientSpendableBalance(amountToWithdraw)) { setBalanceErrorMessage('Not enough balance to complete withdrawal.') } else { setBalanceErrorMessage(null) } - }, [amountToWithdraw, balance, hasPendingTransactions, isLoading]) + }, [amountToWithdraw, balance, hasSufficientSpendableBalance, hasPendingTransactions, isLoading]) if (!bankAccount) { return null diff --git a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx index a6602a68c..6193f91e5 100644 --- a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx +++ b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx @@ -248,8 +248,10 @@ function applyDefaults() { mockWithdrawFlow.selectedBankAccount = null mockUseWallet.mockReturnValue({ - // component destructures `spendableBalance` (not `balance`) — CodeRabbit nit + // component destructures `spendableBalance` (display) + `availableSpendableBalance` + // (the spend ceiling that backs maxDecimalAmount). Same value here: no in-transit. spendableBalance: parseUnits('100', 6), + availableSpendableBalance: parseUnits('100', 6), }) mockUseGetExchangeRate.mockReturnValue({ @@ -492,7 +494,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) }) + mockUseWallet.mockReturnValue({ + spendableBalance: parseUnits('100', 6), + availableSpendableBalance: parseUnits('100', 6), + }) mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } mockWithdrawFlow.selectedBankAccount = { type: 'iban', details: { countryName: '', countryCode: '' } } mockWithdrawFlow.amountToWithdraw = '50' @@ -513,7 +518,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) }) + mockUseWallet.mockReturnValue({ + spendableBalance: parseUnits('100', 6), + availableSpendableBalance: parseUnits('100', 6), + }) mockWithdrawFlow.selectedMethod = { type: 'manteca', countryPath: 'argentina', title: 'Bank Transfer' } mockWithdrawFlow.selectedBankAccount = { type: 'manteca', details: { countryName: 'argentina' } } mockWithdrawFlow.amountToWithdraw = '50' diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 121e8fc99..077a3e37d 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -105,7 +105,7 @@ function MantecaBankWithdrawFlow() { const [priceLock, setPriceLock] = useState(null) const [isLockingPrice, setIsLockingPrice] = useState(false) const router = useRouter() - const { spendableBalance: balance } = useWallet() + const { spendableBalance: balance, hasSufficientSpendableBalance } = useWallet() const { signSpend } = useSignSpendBundle() const handleStaleSession = useStaleSessionGuard() const { overview: rainCardOverview } = useRainCardOverview() @@ -496,12 +496,14 @@ function MantecaBankWithdrawFlow() { // only check min amount and balance here - max amount is handled by limits validation if (paymentAmount < parseUnits(MIN_MANTECA_WITHDRAW_AMOUNT.toString(), PEANUT_WALLET_TOKEN_DECIMALS)) { setBalanceErrorMessage(`Withdraw amount must be at least $${MIN_MANTECA_WITHDRAW_AMOUNT}`) - } else if (paymentAmount > balance) { + } else if (!hasSufficientSpendableBalance(usdAmount)) { + // available-now gate (excludes in-transit collateral); displayed `balance` + // can read higher during a card top-up. setBalanceErrorMessage('Not enough balance to complete withdrawal.') } else { setBalanceErrorMessage(null) } - }, [usdAmount, balance, hasPendingTransactions, isLoading]) + }, [usdAmount, balance, hasSufficientSpendableBalance, hasPendingTransactions, isLoading]) // Fetch points early to avoid latency penalty - fetch as soon as we have usdAmount // Use flowId as uniqueId to prevent cache collisions between different withdrawal flows diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index fb84273e0..c8344300d 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -81,11 +81,16 @@ export default function WithdrawPage() { // raw amount currently typed in the input const [rawTokenAmount, setRawTokenAmount] = useState(amountFromContext || '') - const { spendableBalance: balance } = useWallet() + const { spendableBalance: balance, availableSpendableBalance } = useWallet() + // Spend CEILING — gates the entered amount. Use available-now (smart + LANDED + // collateral); the displayed balance below includes in-transit top-ups that + // can't be withdrawn until they land. const maxDecimalAmount = useMemo(() => { - return balance !== undefined ? Number(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : 0 - }, [balance]) + return availableSpendableBalance !== undefined + ? Number(formatUnits(availableSpendableBalance, PEANUT_WALLET_TOKEN_DECIMALS)) + : 0 + }, [availableSpendableBalance]) const peanutWalletBalance = useMemo(() => { return balance !== undefined ? formatAmount(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : '' diff --git a/src/components/Request/__tests__/request-states.test.tsx b/src/components/Request/__tests__/request-states.test.tsx index 3cadcf7dc..ece0c17fd 100644 --- a/src/components/Request/__tests__/request-states.test.tsx +++ b/src/components/Request/__tests__/request-states.test.tsx @@ -285,6 +285,7 @@ jest.mock('@/context/loadingStates.context', () => { // ---------- import components under test AFTER all mocks ---------- import { CreateRequestLinkView } from '../link/views/Create.request.link.view' import { PayRequestLink } from '../Pay/Pay' +import { printableUsdc } from '@/utils/balance.utils' // the jest.fn mock above // ---------- helpers ---------- @@ -411,6 +412,29 @@ beforeEach(() => { applyDefaults() }) +// ============================================================ +// GROUP 0: Balance affordance — spendable (smart + card collateral) +// ============================================================ +describe('GROUP 0: Balance affordance', () => { + test('formats spendableBalance (smart + collateral), not smart-only balance', () => { + // Regression for the report where /request read lower than /home: the view + // must format `spendableBalance` (smart + card collateral), not the smart-only + // `balance`. printableUsdc is mocked to a constant, so we assert the VALUE it + // was handed (the field choice) rather than the rendered text. + mockUseWallet.mockReturnValue({ + address: '0x1234567890abcdef1234567890abcdef12345678', + isConnected: true, + balance: BigInt(100_000_000), // smart-only: $100 + spendableBalance: BigInt(250_000_000), // smart + collateral: $250 + }) + + renderCreateRequest() + + expect(jest.mocked(printableUsdc)).toHaveBeenCalledWith(BigInt(250_000_000)) + expect(jest.mocked(printableUsdc)).not.toHaveBeenCalledWith(BigInt(100_000_000)) + }) +}) + // ============================================================ // GROUP 1: CreateRequestLinkView — Initial Form States // ============================================================ diff --git a/src/components/Send/link/views/Initial.link.send.view.tsx b/src/components/Send/link/views/Initial.link.send.view.tsx index 570c8fbf8..e25358724 100644 --- a/src/components/Send/link/views/Initial.link.send.view.tsx +++ b/src/components/Send/link/views/Initial.link.send.view.tsx @@ -38,7 +38,7 @@ const LinkSendInitialView = () => { const { setLoadingState, isLoading } = useContext(loadingStateContext) - const { fetchBalance, spendableBalance: balance } = useWallet() + const { fetchBalance, spendableBalance: balance, hasSufficientSpendableBalance } = useWallet() const queryClient = useQueryClient() const { hasPendingTransactions } = usePendingTransactions() @@ -133,15 +133,23 @@ const LinkSendInitialView = () => { setErrorState({ showError: false, errorMessage: '' }) return } - if ( - parseUnits(peanutWalletBalance, PEANUT_WALLET_TOKEN_DECIMALS) < - parseUnits(tokenValue, PEANUT_WALLET_TOKEN_DECIMALS) - ) { + // Gate on available-now (smart + LANDED collateral), NOT the displayed + // `peanutWalletBalance` which includes in-transit top-ups that can't be + // routed yet — otherwise the ~10–45s post-top-up window green-lights a + // create-link that fails at execution. Matches the features/payments flows. + if (!hasSufficientSpendableBalance(tokenValue)) { setErrorState({ showError: true, errorMessage: 'Insufficient balance' }) } else { setErrorState({ showError: false, errorMessage: '' }) } - }, [peanutWalletBalance, tokenValue, setErrorState, hasPendingTransactions, isLoading]) + }, [ + peanutWalletBalance, + tokenValue, + setErrorState, + hasPendingTransactions, + isLoading, + hasSufficientSpendableBalance, + ]) return (
diff --git a/src/hooks/wallet/useWallet.ts b/src/hooks/wallet/useWallet.ts index 7c3db5f7b..24ec10bb2 100644 --- a/src/hooks/wallet/useWallet.ts +++ b/src/hooks/wallet/useWallet.ts @@ -288,6 +288,11 @@ export const useWallet = () => { address: isAddressReady ? address : undefined, // populate address only if it is validated and matches the user's wallet address balance, spendableBalance, + // available-now spendable (smart + LANDED collateral), excluding in-transit + // top-ups. This is the spend CEILING — what `hasSufficientSpendableBalance` + // gates on. Use it (not `spendableBalance`) anywhere you cap or validate an + // amount the user is about to move, so the in-transit window can't over-permit. + availableSpendableBalance, formattedBalance, formattedSpendableBalance, hasSufficientSpendableBalance, From 54605960ef2aa17b76e58140e5477d2bfeb55f73 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 23 Jun 2026 14:51:27 -0700 Subject: [PATCH 37/52] fix(balance): address CodeRabbit review on the gate commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - qr-pay: drop the now-unused `sendMoney` destructuring (was pre-existing dead binding on the line I touched; flagged as a no-unused-vars lint failure). - request tests: mirror the spendable-balance regression for the direct-request entry view (the report + fix covered both /request views, only one was locked). Renders in the loading state — printableUsdc runs in a useMemo before the early return, so the field-choice assertion fires without the full form harness. --- src/app/(mobile-ui)/qr-pay/page.tsx | 2 +- .../Request/__tests__/request-states.test.tsx | 51 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 9bbce3479..151fdd80c 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -98,7 +98,7 @@ export default function QRPayPage() { const qrCode = decodeURIComponent(searchParams.get('qrCode') || '') const timestamp = searchParams.get('t') const qrType = searchParams.get('type') - const { spendableBalance: balance, hasSufficientSpendableBalance, sendMoney } = useWallet() + const { spendableBalance: balance, hasSufficientSpendableBalance } = useWallet() const { signSpend } = useSignSpendBundle() const handleStaleSession = useStaleSessionGuard() const { overview: rainCardOverview } = useRainCardOverview() diff --git a/src/components/Request/__tests__/request-states.test.tsx b/src/components/Request/__tests__/request-states.test.tsx index ece0c17fd..ee95f8eeb 100644 --- a/src/components/Request/__tests__/request-states.test.tsx +++ b/src/components/Request/__tests__/request-states.test.tsx @@ -282,9 +282,34 @@ jest.mock('@/context/loadingStates.context', () => { return { loadingStateContext } }) +// DirectRequestInitialView deps — only this view uses them (PayRequestLink does +// not), so stubbing them globally is safe. We render the view in its loading +// state below; printableUsdc runs in a useMemo BEFORE the early return, so the +// balance-field assertion still fires without rendering the full form. +const mockUseUserStore = jest.fn(() => ({ user: undefined })) +jest.mock('@/redux/hooks', () => ({ + useUserStore: () => mockUseUserStore(), +})) + +const mockUseUserByUsername = jest.fn(() => ({ user: undefined, isLoading: true, error: undefined })) +jest.mock('@/hooks/useUserByUsername', () => ({ + useUserByUsername: () => mockUseUserByUsername(), +})) + +const mockUseUserInteractions = jest.fn(() => ({ interactions: {} })) +jest.mock('@/hooks/useUserInteractions', () => ({ + useUserInteractions: () => mockUseUserInteractions(), +})) + +jest.mock('@/components/Global/PeanutLoading', () => ({ + __esModule: true, + default: () =>
, +})) + // ---------- import components under test AFTER all mocks ---------- import { CreateRequestLinkView } from '../link/views/Create.request.link.view' import { PayRequestLink } from '../Pay/Pay' +import DirectRequestInitialView from '../direct-request/views/Initial.direct.request.view' import { printableUsdc } from '@/utils/balance.utils' // the jest.fn mock above // ---------- helpers ---------- @@ -324,6 +349,16 @@ function renderPayRequest(params: Record = {}) { ) } +function renderDirectRequest() { + const queryClient = createQueryClient() + + return render( + + + + ) +} + // ---------- default mock values ---------- function applyDefaults() { @@ -433,6 +468,22 @@ describe('GROUP 0: Balance affordance', () => { expect(jest.mocked(printableUsdc)).toHaveBeenCalledWith(BigInt(250_000_000)) expect(jest.mocked(printableUsdc)).not.toHaveBeenCalledWith(BigInt(100_000_000)) }) + + test('direct-request formats spendableBalance (smart + collateral), not smart-only balance', () => { + // Mirror of the above for the second entry view both the report and the fix + // covered. Renders in the loading state (default mocks) — printableUsdc runs + // in a useMemo before the early return, so the field choice is still asserted. + mockUseWallet.mockReturnValue({ + address: '0x1234567890abcdef1234567890abcdef12345678', + balance: BigInt(100_000_000), // smart-only: $100 + spendableBalance: BigInt(250_000_000), // smart + collateral: $250 + }) + + renderDirectRequest() + + expect(jest.mocked(printableUsdc)).toHaveBeenCalledWith(BigInt(250_000_000)) + expect(jest.mocked(printableUsdc)).not.toHaveBeenCalledWith(BigInt(100_000_000)) + }) }) // ============================================================ From 85413404b16fb64c4e54f304a2fd1fcf5185d873 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 23 Jun 2026 15:27:28 -0700 Subject: [PATCH 38/52] refactor(balance): unify gate messaging (settling vs insufficient) + display formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two DRY follow-ups to the gate fix, both single-sourced in the wallet hook: 1. Messaging — useWallet.spendBlockReason(amount) classifies a blocked spend as 'settling' (covered by the displayed balance but part is still mid-rebalance: <= display, > available-now) vs 'insufficient' (exceeds the displayed total). The five legacy gates (send-link, qr-pay, withdraw page/bank/manteca) map that to one shared SPEND_BLOCK_MESSAGE instead of four bespoke strings. The 'settling' copy is deliberately generic ("Your balance is updating...") so it never exposes the card-collateral mechanic, and it only shows in the rare ~10-45s in-transit window: in the 99% case display == available so only the normal "insufficient" path is reachable. 2. Display formatting — send-link, withdraw page/manteca and both request views render the hook's formattedSpendableBalance instead of re-deriving locally (printableUsdc vs formatAmount diverged: commas vs none). One formatter, consistent across screens; orphaned imports removed. Displayed balance is unchanged: always the full spendable total (smart + collateral, incl. in-transit). Only the spend gate is strict, and only briefly. hasSufficientSpendableBalance stays for the features/payments flows; both share a parseUsdToBaseUnits helper. Tests revived/extended; full unit suite green. --- .../qr-pay/__tests__/qr-pay-states.test.tsx | 14 ++-- src/app/(mobile-ui)/qr-pay/page.tsx | 15 ++--- .../withdraw/[country]/bank/page.tsx | 16 ++--- .../__tests__/withdraw-states.test.tsx | 11 +++- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 23 +++---- src/app/(mobile-ui)/withdraw/page.tsx | 25 ++++--- .../Request/__tests__/request-states.test.tsx | 65 ++++++++++--------- .../views/Initial.direct.request.view.tsx | 9 +-- .../link/views/Create.request.link.view.tsx | 11 ++-- .../link/views/Initial.link.send.view.tsx | 31 ++++----- src/hooks/wallet/useWallet.ts | 42 ++++++++++-- src/utils/balance.utils.ts | 23 +++++++ 12 files changed, 178 insertions(+), 107 deletions(-) diff --git a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx index b9db87546..2e5c06d3f 100644 --- a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx +++ b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx @@ -138,6 +138,10 @@ jest.mock('@/hooks/useRainCardOverview', () => ({ jest.mock('@/utils/balance.utils', () => ({ rainCentsToUsdcUnits: jest.fn(() => 0n), + SPEND_BLOCK_MESSAGE: { + settling: 'Your balance is updating. Try again in a few seconds.', + insufficient: 'Not enough balance. Add funds to continue.', + }, })) const mockUseTransactionDetailsDrawer = jest.fn() @@ -561,7 +565,7 @@ function applyDefaults() { mockUseWallet.mockReturnValue({ balance: parseUnits('100', 6), // $100 USDC spendableBalance: parseUnits('100', 6), - hasSufficientSpendableBalance: () => true, + spendBlockReason: () => null, // affordable by default sendMoney: jest.fn(), }) @@ -784,13 +788,13 @@ describe('GROUP 2: Payment Form States', () => { test('Insufficient balance shows pay button disabled + error', async () => { // Payment needs ~$18.4 but the available-now spendable is only $5, so the - // affordability gate (hasSufficientSpendableBalance) blocks it. Revived from - // skip once the gate moved off the raw display balance onto the shared hook - // predicate (the original mock-shape drift this test hit). + // gate (spendBlockReason) returns 'insufficient'. Revived from skip once the + // gate moved off the raw display balance onto the shared hook classifier + // (the original mock-shape drift this test hit). mockUseWallet.mockReturnValue({ balance: parseUnits('5', 6), spendableBalance: parseUnits('5', 6), - hasSufficientSpendableBalance: () => false, + spendBlockReason: () => 'insufficient', sendMoney: jest.fn(), }) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 151fdd80c..1e472d296 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -22,7 +22,7 @@ import { useStaleSessionGuard } from '@/hooks/wallet/useStaleSessionGuard' import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle' import { rainCollateralErrorMessage } from '@/utils/friendly-error.utils' import { useRainCardOverview } from '@/hooks/useRainCardOverview' -import { rainCentsToUsdcUnits } from '@/utils/balance.utils' +import { rainCentsToUsdcUnits, SPEND_BLOCK_MESSAGE } from '@/utils/balance.utils' import { isTxReverted, saveRedirectUrl, formatNumberForDisplay } from '@/utils/general.utils' import { getShakeClass, type ShakeIntensity } from '@/utils/perk.utils' import { @@ -98,7 +98,7 @@ export default function QRPayPage() { const qrCode = decodeURIComponent(searchParams.get('qrCode') || '') const timestamp = searchParams.get('t') const qrType = searchParams.get('type') - const { spendableBalance: balance, hasSufficientSpendableBalance } = useWallet() + const { spendableBalance: balance, spendBlockReason } = useWallet() const { signSpend } = useSignSpendBundle() const handleStaleSession = useStaleSessionGuard() const { overview: rainCardOverview } = useRainCardOverview() @@ -937,14 +937,13 @@ export default function QRPayPage() { setBalanceErrorMessage(`QR payment amount exceeds maximum limit of $${MAX_QR_PAYMENT_AMOUNT}`) } else if (paymentAmount < parseUnits(MIN_QR_PAYMENT_AMOUNT, PEANUT_WALLET_TOKEN_DECIMALS)) { setBalanceErrorMessage(`QR payment amount must be at least $${MIN_QR_PAYMENT_AMOUNT}`) - } else if (!hasSufficientSpendableBalance(usdAmount)) { - // available-now gate (excludes in-transit collateral) — the displayed - // `balance` can briefly read higher during a card top-up. - setBalanceErrorMessage('Not enough balance to complete payment. Add funds!') } else { - setBalanceErrorMessage(null) + // available-now gate; 'settling' covers the brief card top-up window + // where the displayed balance reads higher than what's routable. + const block = spendBlockReason(usdAmount) + setBalanceErrorMessage(block ? SPEND_BLOCK_MESSAGE[block] : null) } - }, [usdAmount, balance, hasSufficientSpendableBalance, paymentProcessor, currency?.code, currencyAmount]) + }, [usdAmount, balance, spendBlockReason, paymentProcessor, currency?.code, currencyAmount]) // Use points confetti hook for animation - must be called unconditionally usePointsConfetti(isSuccess && pointsData?.estimatedPoints ? pointsData.estimatedPoints : undefined, pointsDivRef) diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index ee531426b..c685b7446 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -24,6 +24,7 @@ import { useQueryClient } from '@tanstack/react-query' import { TRANSACTIONS } from '@/constants/query.consts' import PaymentSuccessView from '@/features/payments/shared/components/PaymentSuccessView' import { ErrorHandler } from '@/utils/friendly-error.utils' +import { SPEND_BLOCK_MESSAGE } from '@/utils/balance.utils' import { getBridgeChainName } from '@/utils/bridge-accounts.utils' import { getOfframpCurrencyConfig, getCountryFromPath, railJurisdictionForBank } from '@/utils/bridge.utils' import { createOfframp, confirmOfframp } from '@/app/actions/offramp' @@ -67,7 +68,7 @@ export default function WithdrawBankPage() { setSelectedMethod, } = useWithdrawFlow() const { user, fetchUser } = useAuth() - const { address, sendMoney, spendableBalance: balance, hasSufficientSpendableBalance } = useWallet() + const { address, sendMoney, spendableBalance: balance, spendBlockReason } = useWallet() const { guardWithTos, showBridgeTos, hideTos } = useTosGuard() const queryClient = useQueryClient() const router = useRouter() @@ -350,14 +351,11 @@ export default function WithdrawBankPage() { return } - // available-now gate (excludes in-transit collateral); `balance` above is - // the displayed total and can read higher mid-top-up. - if (!hasSufficientSpendableBalance(amountToWithdraw)) { - setBalanceErrorMessage('Not enough balance to complete withdrawal.') - } else { - setBalanceErrorMessage(null) - } - }, [amountToWithdraw, balance, hasSufficientSpendableBalance, hasPendingTransactions, isLoading]) + // available-now gate; 'settling' covers the brief card top-up window where + // the displayed balance reads higher than what's routable. + const block = spendBlockReason(amountToWithdraw) + setBalanceErrorMessage(block ? SPEND_BLOCK_MESSAGE[block] : null) + }, [amountToWithdraw, balance, spendBlockReason, hasPendingTransactions, isLoading]) if (!bankAccount) { return null diff --git a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx index 6193f91e5..086771291 100644 --- a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx +++ b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx @@ -252,6 +252,9 @@ function applyDefaults() { // (the spend ceiling that backs maxDecimalAmount). Same value here: no in-transit. spendableBalance: parseUnits('100', 6), availableSpendableBalance: parseUnits('100', 6), + formattedSpendableBalance: '100.00', + // amount-aware so over-$100 entries classify as a real shortfall + spendBlockReason: (amt: string | number) => (Number(amt) > 100 ? 'insufficient' : null), }) mockUseGetExchangeRate.mockReturnValue({ @@ -366,10 +369,10 @@ describe('GROUP 3: Amount Validation', () => { test('Error state shows ErrorAlert', () => { mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } - mockWithdrawFlow.error = { showError: true, errorMessage: 'Amount exceeds your wallet balance.' } + mockWithdrawFlow.error = { showError: true, errorMessage: 'Not enough balance. Add funds to continue.' } renderWithdraw() - expect(screen.getByTestId('error-alert')).toHaveTextContent('Amount exceeds your wallet balance.') + expect(screen.getByTestId('error-alert')).toHaveTextContent('Not enough balance. Add funds to continue.') }) test('Error hidden when limits blocking card is displayed', () => { @@ -497,6 +500,8 @@ describe('GROUP 6: Continue never dead-buttons', () => { mockUseWallet.mockReturnValue({ spendableBalance: parseUnits('100', 6), availableSpendableBalance: parseUnits('100', 6), + formattedSpendableBalance: '100.00', + spendBlockReason: (amt: string | number) => (Number(amt) > 100 ? 'insufficient' : null), }) mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } mockWithdrawFlow.selectedBankAccount = { type: 'iban', details: { countryName: '', countryCode: '' } } @@ -521,6 +526,8 @@ describe('GROUP 6: Continue never dead-buttons', () => { mockUseWallet.mockReturnValue({ spendableBalance: parseUnits('100', 6), availableSpendableBalance: parseUnits('100', 6), + formattedSpendableBalance: '100.00', + spendBlockReason: (amt: string | number) => (Number(amt) > 100 ? 'insufficient' : null), }) mockWithdrawFlow.selectedMethod = { type: 'manteca', countryPath: 'argentina', title: 'Bank Transfer' } mockWithdrawFlow.selectedBankAccount = { type: 'manteca', details: { countryName: 'argentina' } } diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 077a3e37d..a83ee3fa2 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -5,7 +5,7 @@ import { useSignSpendBundle } from '@/hooks/wallet/useSignSpendBundle' import { useStaleSessionGuard } from '@/hooks/wallet/useStaleSessionGuard' import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle' import { rainCollateralErrorMessage } from '@/utils/friendly-error.utils' -import { rainCentsToUsdcUnits } from '@/utils/balance.utils' +import { rainCentsToUsdcUnits, SPEND_BLOCK_MESSAGE } from '@/utils/balance.utils' import { useRainCardOverview } from '@/hooks/useRainCardOverview' import { useState, useMemo, useContext, useEffect, useCallback, useId } from 'react' import { useRouter, useSearchParams } from 'next/navigation' @@ -22,11 +22,11 @@ import { loadingStateContext } from '@/context/loadingStates.context' import { countryData } from '@/components/AddMoney/consts' import { getFlagUrl } from '@/constants/countryCurrencyMapping' import Image from 'next/image' -import { formatAmount, formatNumberForDisplay } from '@/utils/general.utils' +import { formatNumberForDisplay } from '@/utils/general.utils' import { validateCbuCvuAlias, validatePixKey, normalizePixInput, isPixEmvcoQr } from '@/utils/withdraw.utils' import ValidatedInput from '@/components/Global/ValidatedInput' import AmountInput from '@/components/Global/AmountInput' -import { formatUnits, parseUnits } from 'viem' +import { parseUnits } from 'viem' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import { useModalsContext } from '@/context/ModalsContext' import Select from '@/components/Global/Select' @@ -105,7 +105,7 @@ function MantecaBankWithdrawFlow() { const [priceLock, setPriceLock] = useState(null) const [isLockingPrice, setIsLockingPrice] = useState(false) const router = useRouter() - const { spendableBalance: balance, hasSufficientSpendableBalance } = useWallet() + const { spendableBalance: balance, formattedSpendableBalance, spendBlockReason } = useWallet() const { signSpend } = useSignSpendBundle() const handleStaleSession = useStaleSessionGuard() const { overview: rainCardOverview } = useRainCardOverview() @@ -496,14 +496,13 @@ function MantecaBankWithdrawFlow() { // only check min amount and balance here - max amount is handled by limits validation if (paymentAmount < parseUnits(MIN_MANTECA_WITHDRAW_AMOUNT.toString(), PEANUT_WALLET_TOKEN_DECIMALS)) { setBalanceErrorMessage(`Withdraw amount must be at least $${MIN_MANTECA_WITHDRAW_AMOUNT}`) - } else if (!hasSufficientSpendableBalance(usdAmount)) { - // available-now gate (excludes in-transit collateral); displayed `balance` - // can read higher during a card top-up. - setBalanceErrorMessage('Not enough balance to complete withdrawal.') } else { - setBalanceErrorMessage(null) + // available-now gate; 'settling' covers the brief card top-up window + // where the displayed balance reads higher than what's routable. + const block = spendBlockReason(usdAmount) + setBalanceErrorMessage(block ? SPEND_BLOCK_MESSAGE[block] : null) } - }, [usdAmount, balance, hasSufficientSpendableBalance, hasPendingTransactions, isLoading]) + }, [usdAmount, balance, spendBlockReason, hasPendingTransactions, isLoading]) // Fetch points early to avoid latency penalty - fetch as soon as we have usdAmount // Use flowId as uniqueId to prevent cache collisions between different withdrawal flows @@ -689,9 +688,7 @@ function MantecaBankWithdrawFlow() { price: 1, decimals: 2, }} - walletBalance={ - balance ? formatAmount(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : undefined - } + walletBalance={balance ? formattedSpendableBalance : undefined} /> {/* limits warning/error card - uses centralized helper for props */} diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index c8344300d..631b2a2f7 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -9,7 +9,7 @@ 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 { formatAmount } from '@/utils/general.utils' +import { SPEND_BLOCK_MESSAGE } from '@/utils/balance.utils' import { getCountryFromAccount, getCountryFromPath, getMinimumAmount } from '@/utils/bridge.utils' import useGetExchangeRate from '@/hooks/useGetExchangeRate' import { AccountType } from '@/interfaces' @@ -81,7 +81,12 @@ export default function WithdrawPage() { // raw amount currently typed in the input const [rawTokenAmount, setRawTokenAmount] = useState(amountFromContext || '') - const { spendableBalance: balance, availableSpendableBalance } = useWallet() + const { + spendableBalance: balance, + availableSpendableBalance, + formattedSpendableBalance, + spendBlockReason, + } = useWallet() // Spend CEILING — gates the entered amount. Use available-now (smart + LANDED // collateral); the displayed balance below includes in-transit top-ups that @@ -92,9 +97,11 @@ export default function WithdrawPage() { : 0 }, [availableSpendableBalance]) + // 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 ? formatAmount(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : '' - }, [balance]) + return balance === undefined ? '' : formattedSpendableBalance + }, [balance, formattedSpendableBalance]) // derive country and account type for minimum amount validation const { countryIso2, rateAccountType } = useMemo(() => { @@ -203,15 +210,17 @@ export default function WithdrawPage() { message = isFromSendFlow ? `Minimum send amount is ${minDisplay}.` : `Minimum withdrawal is ${minDisplay}.` - } else if (amount > maxDecimalAmount) { - message = 'Amount exceeds your wallet balance.' } else { - message = 'Please enter a valid amount.' + // amount > available-now: distinguish "settling" (funds briefly + // mid-rebalance) from a true shortfall; generic prompt is a + // defensive fallback that shouldn't normally be reached. + const block = spendBlockReason(usdEquivalent) + message = block ? SPEND_BLOCK_MESSAGE[block] : 'Please enter a valid amount.' } setError({ showError: true, errorMessage: message }) return false }, - [maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount] + [maxDecimalAmount, spendBlockReason, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount] ) const handleTokenAmountChange = useCallback( diff --git a/src/components/Request/__tests__/request-states.test.tsx b/src/components/Request/__tests__/request-states.test.tsx index ee95f8eeb..00e5c5c97 100644 --- a/src/components/Request/__tests__/request-states.test.tsx +++ b/src/components/Request/__tests__/request-states.test.tsx @@ -138,7 +138,7 @@ jest.mock('@/components/0_Bruddle/Toast', () => ({ jest.mock('@/components/Global/AmountInput', () => ({ __esModule: true, default: (props: any) => ( -
+
{ }) // DirectRequestInitialView deps — only this view uses them (PayRequestLink does -// not), so stubbing them globally is safe. We render the view in its loading -// state below; printableUsdc runs in a useMemo BEFORE the early return, so the -// balance-field assertion still fires without rendering the full form. -const mockUseUserStore = jest.fn(() => ({ user: undefined })) +// not), so stubbing them globally is safe. Defaults resolve to a logged-in user +// viewing a valid recipient, so the main form (incl. AmountInput) renders. +const mockUseUserStore = jest.fn(() => ({ user: { user: { userId: 'user-1', username: 'me' } } })) jest.mock('@/redux/hooks', () => ({ useUserStore: () => mockUseUserStore(), })) -const mockUseUserByUsername = jest.fn(() => ({ user: undefined, isLoading: true, error: undefined })) +const mockUseUserByUsername = jest.fn(() => ({ + user: { userId: 'recip-1', username: 'test-user', fullName: 'Test User', isVerified: false }, + isLoading: false, + error: undefined, +})) jest.mock('@/hooks/useUserByUsername', () => ({ useUserByUsername: () => mockUseUserByUsername(), })) @@ -306,11 +309,15 @@ jest.mock('@/components/Global/PeanutLoading', () => ({ default: () =>
, })) +jest.mock('@/components/User/UserCard', () => ({ + __esModule: true, + default: () =>
, +})) + // ---------- import components under test AFTER all mocks ---------- import { CreateRequestLinkView } from '../link/views/Create.request.link.view' import { PayRequestLink } from '../Pay/Pay' import DirectRequestInitialView from '../direct-request/views/Initial.direct.request.view' -import { printableUsdc } from '@/utils/balance.utils' // the jest.fn mock above // ---------- helpers ---------- @@ -451,38 +458,34 @@ beforeEach(() => { // GROUP 0: Balance affordance — spendable (smart + card collateral) // ============================================================ describe('GROUP 0: Balance affordance', () => { - test('formats spendableBalance (smart + collateral), not smart-only balance', () => { - // Regression for the report where /request read lower than /home: the view - // must format `spendableBalance` (smart + card collateral), not the smart-only - // `balance`. printableUsdc is mocked to a constant, so we assert the VALUE it - // was handed (the field choice) rather than the rendered text. - mockUseWallet.mockReturnValue({ - address: '0x1234567890abcdef1234567890abcdef12345678', - isConnected: true, - balance: BigInt(100_000_000), // smart-only: $100 - spendableBalance: BigInt(250_000_000), // smart + collateral: $250 - }) + // Regression for the report where /request read lower than /home: both entry + // views must show the spendable total (smart + card collateral), sourced from + // the hook's `formattedSpendableBalance` — NOT the smart-only `formattedBalance`. + // Distinct sentinels prove which field reaches the AmountInput's walletBalance. + const SPENDABLE = '250.00 (spendable)' + const SMART_ONLY = '100.00 (smart-only)' + const walletWithSplit = { + address: '0x1234567890abcdef1234567890abcdef12345678', + isConnected: true, + spendableBalance: BigInt(250_000_000), // defined → not the loading branch + formattedSpendableBalance: SPENDABLE, + formattedBalance: SMART_ONLY, + } + + test('create-request shows the spendable balance, not smart-only', () => { + mockUseWallet.mockReturnValue(walletWithSplit) renderCreateRequest() - expect(jest.mocked(printableUsdc)).toHaveBeenCalledWith(BigInt(250_000_000)) - expect(jest.mocked(printableUsdc)).not.toHaveBeenCalledWith(BigInt(100_000_000)) + expect(screen.getByTestId('amount-input')).toHaveAttribute('data-wallet-balance', SPENDABLE) }) - test('direct-request formats spendableBalance (smart + collateral), not smart-only balance', () => { - // Mirror of the above for the second entry view both the report and the fix - // covered. Renders in the loading state (default mocks) — printableUsdc runs - // in a useMemo before the early return, so the field choice is still asserted. - mockUseWallet.mockReturnValue({ - address: '0x1234567890abcdef1234567890abcdef12345678', - balance: BigInt(100_000_000), // smart-only: $100 - spendableBalance: BigInt(250_000_000), // smart + collateral: $250 - }) + test('direct-request shows the spendable balance, not smart-only', () => { + mockUseWallet.mockReturnValue(walletWithSplit) renderDirectRequest() - expect(jest.mocked(printableUsdc)).toHaveBeenCalledWith(BigInt(250_000_000)) - expect(jest.mocked(printableUsdc)).not.toHaveBeenCalledWith(BigInt(100_000_000)) + expect(screen.getByTestId('amount-input')).toHaveAttribute('data-wallet-balance', SPENDABLE) }) }) diff --git a/src/components/Request/direct-request/views/Initial.direct.request.view.tsx b/src/components/Request/direct-request/views/Initial.direct.request.view.tsx index 8766a4604..33b8b2b36 100644 --- a/src/components/Request/direct-request/views/Initial.direct.request.view.tsx +++ b/src/components/Request/direct-request/views/Initial.direct.request.view.tsx @@ -15,7 +15,6 @@ import { useUserStore } from '@/redux/hooks' import { type IAttachmentOptions } from '@/interfaces/attachment' import { usersApi } from '@/services/users' import { formatAmount } from '@/utils/general.utils' -import { printableUsdc } from '@/utils/balance.utils' import { captureException } from '@sentry/nextjs' import { useCallback, useContext, useEffect, useMemo, useState } from 'react' import { useUserInteractions } from '@/hooks/useUserInteractions' @@ -29,7 +28,7 @@ interface DirectRequestInitialViewProps { const DirectRequestInitialView = ({ username }: DirectRequestInitialViewProps) => { const onBack = useSafeBack('/home') const { user: authUser } = useUserStore() - const { spendableBalance: balance, address } = useWallet() + const { spendableBalance: balance, formattedSpendableBalance, address } = useWallet() const [attachmentOptions, setAttachmentOptions] = useState({ message: undefined, fileUrl: undefined, @@ -66,9 +65,11 @@ const DirectRequestInitialView = ({ username }: DirectRequestInitialViewProps) = }) } + // Displayed total spendable, single-sourced + formatted by the hook; empty + // while loading so we don't flash "$0.00". const peanutWalletBalance = useMemo(() => { - return balance !== undefined ? printableUsdc(balance) : '' - }, [balance]) + return balance === undefined ? '' : formattedSpendableBalance + }, [balance, formattedSpendableBalance]) const handleTokenValueChange = (value: string | undefined) => { setCurrentInputValue(value || '') diff --git a/src/components/Request/link/views/Create.request.link.view.tsx b/src/components/Request/link/views/Create.request.link.view.tsx index 75e723c9a..09d730837 100644 --- a/src/components/Request/link/views/Create.request.link.view.tsx +++ b/src/components/Request/link/views/Create.request.link.view.tsx @@ -22,7 +22,6 @@ import { type IToken } from '@/interfaces' import { type IAttachmentOptions } from '@/interfaces/attachment' import { requestsApi } from '@/services/requests' import { fetchTokenSymbol, formatTokenAmount, getRequestLink, isNativeCurrency } from '@/utils/general.utils' -import { printableUsdc } from '@/utils/balance.utils' import * as Sentry from '@sentry/nextjs' import * as peanutInterfaces from '@/interfaces/peanut-sdk-types' import { useQueryClient } from '@tanstack/react-query' @@ -34,7 +33,7 @@ import { useSafeBack } from '@/hooks/useSafeBack' export const CreateRequestLinkView = () => { const toast = useToast() const onBack = useSafeBack('/home') - const { address, isConnected, spendableBalance: balance } = useWallet() + const { address, isConnected, spendableBalance: balance, formattedSpendableBalance } = useWallet() const { user } = useAuth() const { selectedChainID, setSelectedChainID, selectedTokenAddress, setSelectedTokenAddress, selectedTokenData } = useContext(tokenSelectorContext) @@ -79,8 +78,12 @@ export const CreateRequestLinkView = () => { // Refs for cleanup const createLinkAbortRef = useRef(null) - // Derived state - const peanutWalletBalance = useMemo(() => (balance !== undefined ? printableUsdc(balance) : ''), [balance]) + // Derived state — displayed total spendable, single-sourced + formatted by the + // hook; empty while loading so we don't flash "$0.00". + const peanutWalletBalance = useMemo( + () => (balance === undefined ? '' : formattedSpendableBalance), + [balance, formattedSpendableBalance] + ) const usdValue = useMemo(() => { if (!selectedTokenData?.price || !tokenValue) return '' diff --git a/src/components/Send/link/views/Initial.link.send.view.tsx b/src/components/Send/link/views/Initial.link.send.view.tsx index e25358724..61d6d70de 100644 --- a/src/components/Send/link/views/Initial.link.send.view.tsx +++ b/src/components/Send/link/views/Initial.link.send.view.tsx @@ -10,7 +10,7 @@ import { useLinkSendFlow } from '@/context/LinkSendFlowContext' import { useWallet } from '@/hooks/wallet/useWallet' import { sendLinksApi } from '@/services/sendLinks' import { ErrorHandler } from '@/utils/friendly-error.utils' -import { printableUsdc } from '@/utils/balance.utils' +import { SPEND_BLOCK_MESSAGE } from '@/utils/balance.utils' import { captureException } from '@sentry/nextjs' import { useQueryClient } from '@tanstack/react-query' import { useCallback, useContext, useEffect, useMemo } from 'react' @@ -38,13 +38,15 @@ const LinkSendInitialView = () => { const { setLoadingState, isLoading } = useContext(loadingStateContext) - const { fetchBalance, spendableBalance: balance, hasSufficientSpendableBalance } = useWallet() + const { fetchBalance, spendableBalance: balance, formattedSpendableBalance, spendBlockReason } = useWallet() const queryClient = useQueryClient() const { hasPendingTransactions } = usePendingTransactions() + // 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 ? printableUsdc(balance) : '' - }, [balance]) + return balance === undefined ? '' : formattedSpendableBalance + }, [balance, formattedSpendableBalance]) const handleOnNext = useCallback(async () => { try { @@ -133,23 +135,16 @@ const LinkSendInitialView = () => { setErrorState({ showError: false, errorMessage: '' }) return } - // Gate on available-now (smart + LANDED collateral), NOT the displayed - // `peanutWalletBalance` which includes in-transit top-ups that can't be - // routed yet — otherwise the ~10–45s post-top-up window green-lights a - // create-link that fails at execution. Matches the features/payments flows. - if (!hasSufficientSpendableBalance(tokenValue)) { - setErrorState({ showError: true, errorMessage: 'Insufficient balance' }) + // Classify against available-now vs the displayed total: the ~10–45s + // post-top-up window shows the generic "updating" copy instead of a misleading + // "insufficient" that would contradict the balance on screen. + const block = spendBlockReason(tokenValue) + if (block) { + setErrorState({ showError: true, errorMessage: SPEND_BLOCK_MESSAGE[block] }) } else { setErrorState({ showError: false, errorMessage: '' }) } - }, [ - peanutWalletBalance, - tokenValue, - setErrorState, - hasPendingTransactions, - isLoading, - hasSufficientSpendableBalance, - ]) + }, [peanutWalletBalance, tokenValue, setErrorState, hasPendingTransactions, isLoading, spendBlockReason]) return (
diff --git a/src/hooks/wallet/useWallet.ts b/src/hooks/wallet/useWallet.ts index 24ec10bb2..cdf6c63c9 100644 --- a/src/hooks/wallet/useWallet.ts +++ b/src/hooks/wallet/useWallet.ts @@ -14,7 +14,12 @@ import { useBalance } from './useBalance' import { useSendMoney as useSendMoneyMutation } from './useSendMoney' import { formatCurrency } from '@/utils/general.utils' import { useRainCardOverview, RAIN_CARD_OVERVIEW_QUERY_KEY } from '../useRainCardOverview' -import { computeAvailableSpendable, computeDisplaySpendable, rainCentsToUsdcUnits } from '@/utils/balance.utils' +import { + computeAvailableSpendable, + computeDisplaySpendable, + rainCentsToUsdcUnits, + type SpendBlockReason, +} from '@/utils/balance.utils' import { useSpendBundle, type SpendStrategy } from './useSpendBundle' import type { RainCollateralKind } from '@/services/rain' @@ -268,6 +273,14 @@ export const useWallet = () => { return formatCurrency(formatUnits(spendableBalance, PEANUT_WALLET_TOKEN_DECIMALS)) }, [spendableBalance]) + // Parse a USD amount to token base units; null for invalid/negative input. + // Shared by the gate + shortfall classifier so they parse identically. + const parseUsdToBaseUnits = useCallback((amountUsd: string | number): bigint | null => { + const amount = typeof amountUsd === 'string' ? parseFloat(amountUsd) : amountUsd + if (isNaN(amount) || amount < 0) return null + return BigInt(Math.floor(amount * 10 ** PEANUT_WALLET_TOKEN_DECIMALS)) + }, []) + // Check if the user has enough spendable to cover a USD amount. Gates on // available-now (smart + LANDED collateral) — NOT the displayed total, which // includes in-transit top-ups that can't be routed until they land. Using the @@ -276,12 +289,30 @@ export const useWallet = () => { const hasSufficientSpendableBalance = useCallback( (amountUsd: string | number): boolean => { if (availableSpendableBalance === undefined) return false - const amount = typeof amountUsd === 'string' ? parseFloat(amountUsd) : amountUsd - if (isNaN(amount) || amount < 0) return false - const amountInBaseUnits = BigInt(Math.floor(amount * 10 ** PEANUT_WALLET_TOKEN_DECIMALS)) + const amountInBaseUnits = parseUsdToBaseUnits(amountUsd) + if (amountInBaseUnits === null) return false return availableSpendableBalance >= amountInBaseUnits }, - [availableSpendableBalance] + [availableSpendableBalance, parseUsdToBaseUnits] + ) + + // Classify why a would-be spend of `amountUsd` can't go through, so callers + // can show the right message instead of a blanket "insufficient": + // • null — fully spendable now (≤ available-now) + // • 'settling' — covered by the displayed total but part is in-transit + // collateral not yet landed (≤ display, > available-now) + // • 'insufficient'— exceeds the displayed total too + // `null` while either figure is still loading (callers also guard on that). + const spendBlockReason = useCallback( + (amountUsd: string | number): SpendBlockReason | null => { + if (availableSpendableBalance === undefined || spendableBalance === undefined) return null + const amountInBaseUnits = parseUsdToBaseUnits(amountUsd) + if (amountInBaseUnits === null) return null + if (amountInBaseUnits <= availableSpendableBalance) return null + if (amountInBaseUnits <= spendableBalance) return 'settling' + return 'insufficient' + }, + [availableSpendableBalance, spendableBalance, parseUsdToBaseUnits] ) return { @@ -296,6 +327,7 @@ export const useWallet = () => { formattedBalance, formattedSpendableBalance, hasSufficientSpendableBalance, + spendBlockReason, isConnected: isKernelClientReady, sendTransactions, sendMoney, diff --git a/src/utils/balance.utils.ts b/src/utils/balance.utils.ts index da3d4b280..87b318f06 100644 --- a/src/utils/balance.utils.ts +++ b/src/utils/balance.utils.ts @@ -10,6 +10,29 @@ export const printableUsdc = (balance: bigint): string => { return Number(formatted).toFixed(2) } +/** + * Why a would-be spend can't go through right now: + * • 'settling' — covered by the displayed balance, but part is in-transit + * card collateral that hasn't landed yet (≤ display, > available-now). + * • 'insufficient' — exceeds the displayed balance too (a real shortfall). + * Returned by `useWallet().spendBlockReason(amount)`; `null` means it's fine. + */ +export type SpendBlockReason = 'settling' | 'insufficient' + +/** + * Single source of truth for affordability-gate copy across every money-flow + * (send, pay, withdraw). The 'settling' string explains the ~10–45s window after + * a card top-up where the displayed balance briefly exceeds what's routable — + * so the user isn't told "insufficient" while the screen shows enough. + */ +export const SPEND_BLOCK_MESSAGE: Record = { + // Deliberately generic — it must NOT expose the card-collateral mechanic. + // Only seen in the rare ~10-45s window where funds are mid-rebalance; the + // full balance is already on screen, this just says "give it a second". + settling: 'Your balance is updating. Try again in a few seconds.', + insufficient: 'Not enough balance. Add funds to continue.', +} + /** * Widen a Rain balance figure from integer cents (2 decimals) to a USDC * bigint (matching PEANUT_WALLET_TOKEN_DECIMALS, typically 6) so it can be From d466312396241cb16c69a91be458796d9e40816a Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 23 Jun 2026 18:54:07 -0700 Subject: [PATCH 39/52] fix(balance): enforce send gate at submit + show $0 balance on manteca CodeRabbit review on the messaging/format commit: - Send-link: the Retry button isn't disabled on a balance error (unlike the other flows, which disable their submit), so handleOnNext could reach createLink with a blocked amount and fail at execution. Re-check spendBlockReason at the top of handleOnNext so the block holds at submit too. - withdraw/manteca: use an explicit `balance !== undefined` guard for the displayed balance so a real $0 balance shows "$0.00" instead of being hidden, matching the other consolidated flows (the old truthy guard dropped 0n). --- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 2 +- .../Send/link/views/Initial.link.send.view.tsx | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index a83ee3fa2..53bea0343 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -688,7 +688,7 @@ function MantecaBankWithdrawFlow() { price: 1, decimals: 2, }} - walletBalance={balance ? formattedSpendableBalance : undefined} + walletBalance={balance !== undefined ? formattedSpendableBalance : undefined} /> {/* limits warning/error card - uses centralized helper for props */} diff --git a/src/components/Send/link/views/Initial.link.send.view.tsx b/src/components/Send/link/views/Initial.link.send.view.tsx index 61d6d70de..a66fb8bc0 100644 --- a/src/components/Send/link/views/Initial.link.send.view.tsx +++ b/src/components/Send/link/views/Initial.link.send.view.tsx @@ -52,6 +52,15 @@ const LinkSendInitialView = () => { try { if (isLoading || !tokenValue) return + // Re-check affordability at submit too: the Retry button isn't disabled + // on a balance error (unlike the other flows), so without this a blocked + // amount could reach createLink and fail at execution instead of here. + const block = spendBlockReason(tokenValue) + if (block) { + setErrorState({ showError: true, errorMessage: SPEND_BLOCK_MESSAGE[block] }) + return + } + setLoadingState('Loading') // clear any previous errors @@ -120,6 +129,7 @@ const LinkSendInitialView = () => { setLink, setView, setErrorState, + spendBlockReason, ]) useEffect(() => { From bc3d2bbf7bb2d83eadf67a65f052e5b7fd985597 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 2 Jun 2026 23:29:07 +0100 Subject: [PATCH 40/52] fix(offramp): derive destination from account type, not country MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-06-02 21:24 UTC, PEANUT-API-5P/5M/5N. A user with a UK GBP bank account tried to offramp USDC → EUR via SEPA. Bridge rejected with "country is not supported for SEPA" — SEPA only credits EUR-denominated Eurozone accounts, not GBP/UK. Root cause was in BankFlowManager.handleCreateOfframpAndClaim: const destination = getOfframpCurrencyConfig( account.country ?? selectedCountry!.id ) When `account.country` is missing, the picker falls back to `selectedCountry`. If `selectedCountry` doesn't match the saved-account's actual type (e.g. user picks a random country, or country resolution is buggy), the helper's "everything unknown → EUR/SEPA" default fires — even for a clearly GBP/UK account. The account's own `type` already carries the right answer for every Bridge destination we support (us/gb/clabe/iban). Adding a tiny helper `getOfframpConfigFromAccount` that derives currency+rail directly from account.type closes this class of bug. - `gb` → gbp/faster_payments - `us` → usd/ach - `clabe` → mxn/spei - `iban` → eur/sepa - `manteca` → throws (must use the Manteca offramp path, not Bridge) - type missing → falls back to country-based picking (prior behavior) The helper also accepts BE Prisma-shape suffixes (`BANK_IBAN`, `BANK_ACCOUNT_GB`, etc.) so it doesn't matter which side of the API the account row came from. 7 new unit tests in bridge.utils.test.ts cover: - The GB regression (was EUR/SEPA, now GBP/faster_payments) - All 4 supported account types map to the right rail - BE Prisma-suffix shapes (BANK_IBAN, BANK_ACCOUNT_GB) - Manteca throws to surface a wrong-path call early - Missing-type fallback to country picking still works All 71 bridge.utils tests passing. Companion PR: peanut-api-ts #964 — surfaces Bridge's actual error message in Sentry + stops Discord-paging on user-input 4xx errors. Even after this FE fix, other user-input mistakes (e.g. a Bridge customer status change mid-flow) shouldn't page on-call. --- .../Claim/Link/views/BankFlowManager.view.tsx | 10 ++- src/utils/__tests__/bridge.utils.test.ts | 65 +++++++++++++++++++ src/utils/bridge.utils.ts | 31 +++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index f66aacdef..0c6821f14 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -15,7 +15,7 @@ import useClaimLink from '../../useClaimLink' import { type AddBankAccountPayload } from '@/app/actions/types/users.types' import { useAuth } from '@/context/authContext' import { type TCreateOfframpRequest, type TCreateOfframpResponse } from '@/services/services.types' -import { getOfframpCurrencyConfig } from '@/utils/bridge.utils' +import { getOfframpConfigFromAccount } from '@/utils/bridge.utils' import { getBridgeChainName, getBridgeTokenName } from '@/utils/bridge-accounts.utils' import { generateKeysFromString, getParamsFromLink } from '@/utils/peanut-link.utils' import { getContractAddress } from '@/utils/peanut-claim.utils' @@ -239,7 +239,13 @@ export const BankFlowManager = (props: IClaimScreenProps) => { const externalAccountId = (account.bridgeAccountId ?? account.id) as string - const destination = getOfframpCurrencyConfig(account.country ?? selectedCountry!.id) + // Derive destination currency + rail from the SELECTED ACCOUNT's + // type, not from `selectedCountry`. Pairing a GB/GBP account with + // a SEPA destination is semantically impossible — Bridge rejects + // with "country is not supported for SEPA" (PEANUT-API-5P/5M/5N + // on 2026-06-02). The account's `type` already carries the right + // answer for every Bridge destination we support. + const destination = getOfframpConfigFromAccount(account) // handle offramp request creation const offrampRequestParams: TCreateOfframpRequest = { diff --git a/src/utils/__tests__/bridge.utils.test.ts b/src/utils/__tests__/bridge.utils.test.ts index 326894405..ec128da0e 100644 --- a/src/utils/__tests__/bridge.utils.test.ts +++ b/src/utils/__tests__/bridge.utils.test.ts @@ -2,6 +2,7 @@ import { BRIDGE_DEVELOPER_FEE_RATE } from '@/constants/payment.consts' import { applyBridgeCrossCurrencyFee, getCurrencyConfig, + getOfframpConfigFromAccount, getOfframpCurrencyConfig, getPaymentRailDisplayName, getMinimumAmount, @@ -142,6 +143,70 @@ describe('bridge.utils', () => { paymentRail: 'sepa', }) }) + }) + + describe('getOfframpConfigFromAccount (PEANUT-API-5P/5M/5N regression)', () => { + // The 2026-06-02 21:24 incident: a GB/GBP account got paired with + // EUR/SEPA because the picker used `account.country ?? selectedCountry` + // and the user-selected country fell through the "everything else → + // EUR/SEPA" default. These cases lock in the behavior that the account + // type alone is enough to pick the right rail. + it('GB account → GBP / faster_payments (was EUR/SEPA in the incident)', () => { + expect(getOfframpConfigFromAccount({ type: 'gb' })).toEqual({ + currency: 'gbp', + paymentRail: 'faster_payments', + }) + }) + + it('US account → USD / ach', () => { + expect(getOfframpConfigFromAccount({ type: 'us' })).toEqual({ + currency: 'usd', + paymentRail: 'ach', + }) + }) + + it('CLABE account → MXN / spei', () => { + expect(getOfframpConfigFromAccount({ type: 'clabe' })).toEqual({ + currency: 'mxn', + paymentRail: 'spei', + }) + }) + + it('IBAN account → EUR / sepa', () => { + expect(getOfframpConfigFromAccount({ type: 'iban' })).toEqual({ + currency: 'eur', + paymentRail: 'sepa', + }) + }) + + it('accepts BE Prisma-shape suffixes like BANK_IBAN / BANK_ACH_GB', () => { + expect(getOfframpConfigFromAccount({ type: 'BANK_IBAN' })).toEqual({ + currency: 'eur', + paymentRail: 'sepa', + }) + expect(getOfframpConfigFromAccount({ type: 'bank_account_gb' })).toEqual({ + currency: 'gbp', + paymentRail: 'faster_payments', + }) + }) + + it('throws on Manteca account type — must use the Manteca offramp path', () => { + expect(() => getOfframpConfigFromAccount({ type: 'manteca' })).toThrow( + 'Manteca accounts route through a separate offramp path' + ) + }) + + it('falls back to country-based picking when type is missing', () => { + expect(getOfframpConfigFromAccount({ country: 'US' })).toEqual({ + currency: 'usd', + paymentRail: 'ach', + }) + // Unknown country → EU/SEPA default, mirrors prior behavior + expect(getOfframpConfigFromAccount({ country: 'XYZ' })).toEqual({ + currency: 'eur', + paymentRail: 'sepa', + }) + }) describe('getMinimumAmount', () => { it('should return 50 for Mexico', () => { diff --git a/src/utils/bridge.utils.ts b/src/utils/bridge.utils.ts index 588e80a2e..aaa3d3655 100644 --- a/src/utils/bridge.utils.ts +++ b/src/utils/bridge.utils.ts @@ -81,6 +81,37 @@ export const getOfframpCurrencyConfig = (countryId: string): CurrencyConfig => { return getCurrencyConfig(countryId, 'offramp') } +/** + * Derive the offramp destination currency + payment rail from the bank + * account's actual `type`, falling back to country only when the type is + * unknown. + * + * Why: `getOfframpCurrencyConfig(country)` defaults *any* unknown country to + * EUR+SEPA. Pairing that default with a GBP/UK account caused Bridge to 400 + * with "country is not supported for SEPA" (PEANUT-API-5P/5M/5N, 2026-06-02 + * 21:24 UTC) — Bridge can't SEPA-credit a GBP account. The bank account's + * `type` already carries the right answer for every Bridge destination we + * support (`us`/`gb`/`clabe`/`iban`), so derive from it directly. + * + * Manteca-type accounts use a non-Bridge rail; the caller must NOT route + * those through this helper. We throw rather than silently misclassify. + */ +export const getOfframpConfigFromAccount = (account: { + type?: string | AccountType | null + country?: string | null +}): CurrencyConfig => { + const t = account.type?.toString().toLowerCase() + if (t === AccountType.US || t?.endsWith('ach') || t?.endsWith('us')) return getCurrencyConfig('US', 'offramp') + if (t === AccountType.GB || t?.endsWith('gb')) return getCurrencyConfig('GB', 'offramp') + if (t === AccountType.CLABE || t?.endsWith('clabe')) return getCurrencyConfig('MX', 'offramp') + if (t === AccountType.IBAN || t?.endsWith('iban')) return getCurrencyConfig('EU', 'offramp') + if (t === AccountType.MANTECA || t?.endsWith('manteca')) { + throw new Error('Manteca accounts route through a separate offramp path, not Bridge.') + } + // type missing / unknown — fall back to country, preserving prior behavior. + return getOfframpCurrencyConfig(account.country ?? 'EU') +} + /** * Map ISO fiat currency → Bridge bank AccountType. Used by onramp quote * + exchange-rate callers that only have the currency string in hand. From 7fdb7925a8981d36fc40c5756c0f563b731920ef Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Fri, 19 Jun 2026 17:12:06 -0700 Subject: [PATCH 41/52] fix(withdraw): stop false-rejecting IBANs whose country != the dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bank-details form rejected any IBAN whose country didn't equal the country selected on the previous screen ("IBAN does not match the selected country"). SEPA routes by IBAN, so the dropdown is cosmetic for a EUR payout — this gate blocked two legit cases: a German IBAN with Spain selected, and a UK user withdrawing EUR to a GB IBAN (Ibrahima / bobbyfresco). Gate on actual support instead: validateBankAccount() hits the BE allowedCountries check (SEPA/US/CA), so structurally-valid-but-unsupported IBANs are still rejected — just with an honest "not supported" message rather than a false country-mismatch. Drops the now-orphaned getCountryFromIban import. --- .../AddWithdraw/DynamicBankAccountForm.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx index 0b27f89ff..78026a57f 100644 --- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx +++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx @@ -10,6 +10,7 @@ import { BRIDGE_ALPHA3_TO_ALPHA2, ALL_COUNTRIES_ALPHA3_TO_ALPHA2 } from '@/compo import { useParams, useRouter } from 'next/navigation' import { validateIban, + validateBankAccount, validateBic, isValidRoutingNumber, isValidSortCode, @@ -19,7 +20,7 @@ import ErrorAlert from '@/components/Global/ErrorAlert' import { getBicFromIban } from '@/app/actions/ibanToBic' import PeanutActionDetailsCard, { type PeanutActionDetailsCardProps } from '../Global/PeanutActionDetailsCard' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' -import { getCountryFromIban, validateMXCLabeAccount, validateUSBankAccount } from '@/utils/withdraw.utils' +import { validateMXCLabeAccount, validateUSBankAccount } from '@/utils/withdraw.utils' import useSavedAccounts from '@/hooks/useSavedAccounts' import { useAppDispatch, useAppSelector } from '@/redux/hooks' import { bankFormActions } from '@/redux/slices/bank-form-slice' @@ -453,9 +454,14 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D const isValidIban = await validateIban(val) if (!isValidIban) return 'Invalid IBAN' - if (getCountryFromIban(val)?.toLowerCase() !== selectedCountry) { - return 'IBAN does not match the selected country' - } + // SEPA routes by IBAN — the country picked on the + // previous screen is cosmetic for a EUR payout. Don't + // force the IBAN's country to equal the dropdown: that + // false-rejected a German IBAN with Spain selected, and + // blocked UK users withdrawing EUR to a GB IBAN. Gate on + // actual support instead (BE allowedCountries: SEPA/US/CA). + const isSupported = await validateBankAccount(val) + if (!isSupported) return 'This IBAN isn’t supported for withdrawals' return true }, From 116627a6dcab36935d568cb6a4b1581a5030b26a Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Fri, 19 Jun 2026 17:26:37 -0700 Subject: [PATCH 42/52] fix(withdraw): source the Bridge countryCode from the IBAN, not the dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-on to dropping the IBAN-country gate: the add-bank-account payload set countryCode/countryName/address.country from the country picked on the previous screen. With the gate gone, a German IBAN entered under "Spain" would reach Bridge with countryCode=ESP and 400. SEPA routes by IBAN, so derive all three country fields from the IBAN itself for IBAN accounts — keeping the payload internally consistent exactly as the old equality gate guaranteed, just sourced from the IBAN. Unblocks the GB-IBAN-EUR case (GB IBAN -> GBR) too. Adds unit coverage for getCountryCodeForWithdraw (GB->GBR/DE->DEU/ES->ESP/US->USA, idempotent on ISO-3) and getCountryFromIban, the derivation the fix relies on. --- .../AddWithdraw/DynamicBankAccountForm.tsx | 26 +++++++++++--- src/utils/__tests__/withdraw.utils.test.ts | 34 +++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx index 78026a57f..65da18b27 100644 --- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx +++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx @@ -20,7 +20,12 @@ import ErrorAlert from '@/components/Global/ErrorAlert' import { getBicFromIban } from '@/app/actions/ibanToBic' import PeanutActionDetailsCard, { type PeanutActionDetailsCardProps } from '../Global/PeanutActionDetailsCard' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' -import { validateMXCLabeAccount, validateUSBankAccount } from '@/utils/withdraw.utils' +import { + getCountryFromIban, + getCountryCodeForWithdraw, + validateMXCLabeAccount, + validateUSBankAccount, +} from '@/utils/withdraw.utils' import useSavedAccounts from '@/hooks/useSavedAccounts' import { useAppDispatch, useAppSelector } from '@/redux/hooks' import { bankFormActions } from '@/redux/slices/bank-form-slice' @@ -214,11 +219,24 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D ? accountNumber.replace(/\s/g, '').padStart(8, '0') : accountNumber.replace(/\s/g, '') + // SEPA routes by IBAN, so the destination country must come from the + // IBAN itself — not the country picked on the previous screen. We + // relaxed the "IBAN must match the selected country" gate, so without + // this a German IBAN entered under "Spain" would reach Bridge with + // countryCode=ESP and 400. Derive all three country fields from the + // IBAN to keep the Bridge payload internally consistent (exactly what + // the old equality gate guaranteed, just sourced from the IBAN). + const ibanCountryCode = isIban + ? getCountryCodeForWithdraw(cleanedAccountNumber.slice(0, 2).toUpperCase()) + : '' + const ibanCountryName = isIban ? (getCountryFromIban(cleanedAccountNumber) ?? selectedCountry) : '' + const resolvedCountryCode = isUs ? 'USA' : isIban ? ibanCountryCode : country.toUpperCase() + const payload: Partial = { accountType, accountNumber: cleanedAccountNumber, - countryCode: isUs ? 'USA' : country.toUpperCase(), - countryName: selectedCountry, + countryCode: resolvedCountryCode, + countryName: isIban ? ibanCountryName : selectedCountry, accountOwnerType: BridgeAccountOwnerType.INDIVIDUAL, accountOwnerName: { firstName: firstName.trim(), @@ -229,7 +247,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D city: data.city ?? '', state: data.state ?? '', postalCode: data.postalCode ?? '', - country: isUs ? 'USA' : country.toUpperCase(), + country: resolvedCountryCode, }, ...(bic && { bic }), } diff --git a/src/utils/__tests__/withdraw.utils.test.ts b/src/utils/__tests__/withdraw.utils.test.ts index da08bd091..1335453c0 100644 --- a/src/utils/__tests__/withdraw.utils.test.ts +++ b/src/utils/__tests__/withdraw.utils.test.ts @@ -5,11 +5,45 @@ import { isPixEmvcoQr, normalizePixInput, validatePixKey, + getCountryCodeForWithdraw, + getCountryFromIban, } from '@/utils/withdraw.utils' jest.mock('@/assets', () => ({})) describe('Withdraw Utilities', () => { + // Locks in the IBAN→country derivation the bank-details form relies on after + // dropping the "IBAN must match the selected country" gate: the Bridge payload + // countryCode is now sourced from the IBAN, so a German IBAN entered under any + // SEPA country must resolve to DEU (not the dropdown's country). + describe('getCountryCodeForWithdraw (ISO-2 → ISO-3 for the Bridge payload)', () => { + it.each([ + ['GB', 'GBR'], + ['DE', 'DEU'], + ['ES', 'ESP'], + ['US', 'USA'], + ])('maps %s → %s', (iso2, iso3) => { + expect(getCountryCodeForWithdraw(iso2)).toBe(iso3) + }) + + it('is idempotent on an already ISO-3 code', () => { + expect(getCountryCodeForWithdraw('DEU')).toBe('DEU') + }) + }) + + describe('getCountryFromIban', () => { + it('resolves the country from the IBAN prefix (GB/DE) and is whitespace-tolerant', () => { + // GB IBAN (Revolut-style) — the GB-IBAN-EUR case (Ibrahima). + expect(getCountryFromIban('GB33BUKB20201555555555')).not.toBeNull() + // DE IBAN with spaces — the intra-SEPA mismatch case. + expect(getCountryFromIban('DE89 3704 0044 0532 0130 00')).not.toBeNull() + }) + + it('returns null for an unknown country prefix', () => { + expect(getCountryFromIban('ZZ00')).toBeNull() + }) + }) + describe('validateCbuCvuAlias', () => { it.each([ { value: '', valid: false, message: 'Invalid length' }, From c38e2b8aceebe4e22c8cc462aef66817b7eb3bc0 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Fri, 19 Jun 2026 17:45:56 -0700 Subject: [PATCH 43/52] fix(offramp): preserve account type through bank details + normalize IBAN source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit (2 major): - BankFlowManager: the bankDetails objects (new-account, saved-account, guest) dropped `type`, so getOfframpConfigFromAccount() fell back to country routing — defeating the derive-from-type fix. Carry `type` through all three (guest path derives it from the response shape: iban/clabe/sort_code→gb/account→us). - DynamicBankAccountForm: derive the IBAN country from a single normalized source (cleanedAccountNumber, falling back to the `iban` form value) so countryCode is never derived off an empty string → no empty countryCode to Bridge. --- .../AddWithdraw/DynamicBankAccountForm.tsx | 9 +++++++-- .../Claim/Link/views/BankFlowManager.view.tsx | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx index 65da18b27..3b65d8488 100644 --- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx +++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx @@ -226,10 +226,15 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D // countryCode=ESP and 400. Derive all three country fields from the // IBAN to keep the Bridge payload internally consistent (exactly what // the old equality gate guaranteed, just sourced from the IBAN). + // Single normalized IBAN source: the IBAN is entered in the + // accountNumber field (→ cleanedAccountNumber), but fall back to the + // separate `iban` form value so the country never derives off an empty + // string (which would send an empty countryCode to Bridge → 400). + const normalizedIban = isIban ? cleanedAccountNumber || (iban || '').replace(/\s/g, '') : '' const ibanCountryCode = isIban - ? getCountryCodeForWithdraw(cleanedAccountNumber.slice(0, 2).toUpperCase()) + ? getCountryCodeForWithdraw(normalizedIban.slice(0, 2).toUpperCase()) : '' - const ibanCountryName = isIban ? (getCountryFromIban(cleanedAccountNumber) ?? selectedCountry) : '' + const ibanCountryName = isIban ? (getCountryFromIban(normalizedIban) ?? selectedCountry) : '' const resolvedCountryCode = isUs ? 'USA' : isIban ? ibanCountryCode : country.toUpperCase() const payload: Partial = { diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index 0c6821f14..4fbfb4b24 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -327,6 +327,9 @@ export const BankFlowManager = (props: IClaimScreenProps) => { } if (addBankAccountResponse.data?.id) { const bankDetails = { + // carry the account type so getOfframpConfigFromAccount() derives + // the rail from it (GB→GBP) instead of falling back to country + type: addBankAccountResponse.data.type, name: addBankAccountResponse.data.details.accountOwnerName || user?.user.fullName || '', iban: addBankAccountResponse.data.type === 'iban' @@ -416,6 +419,18 @@ export const BankFlowManager = (props: IClaimScreenProps) => { // merge the external account details with the user's details const finalBankDetails = { + // derive the account type from the response shape so + // getOfframpConfigFromAccount() routes by rail (GB sort_code → gb) + // instead of falling back to country + type: externalAccountResponse?.iban + ? 'iban' + : externalAccountResponse?.clabe + ? 'clabe' + : externalAccountResponse?.account?.sort_code + ? 'gb' + : externalAccountResponse?.account + ? 'us' + : undefined, id: externalAccountResponse.id, bridgeAccountId: externalAccountResponse.id, name: externalAccountResponse.bank_name ?? rawData.name, @@ -465,6 +480,9 @@ export const BankFlowManager = (props: IClaimScreenProps) => { const lastName = lastNameParts.join(' ') const bankDetails = { + // carry the account type so getOfframpConfigFromAccount() + // derives the rail from it instead of falling back to country + type: account.type, name: account.details?.accountOwnerName || user?.user.fullName || '', iban: account.type === 'iban' ? account.identifier || '' : '', clabe: account.type === 'clabe' ? account.identifier || '' : '', From 48e749d80be8e5c339088ee8ef395b6986829baf Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 23 Jun 2026 13:00:20 -0700 Subject: [PATCH 44/52] fix(withdraw): derive offramp config from account + guard fresh account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T1.4: destinationDetails derived the currency/rail from a country switch whose default returned an empty rail, so a UK account typed anything but GB (pre-BANK_GB mistype, or a Prisma-shaped 'BANK_GB') dead-ended on 'External account ID is missing.'. Derive from the account via getOfframpConfigFromAccount (GB->GBP), consistent with the claim flow. T1.2: harden the freshly-added-account fallback — if the add-bank-account response lacks bridgeAccountId, surface a retryable error instead of navigating to a confirm screen that dead-ends on 'Bank account is missing.'. --- .../withdraw/[country]/bank/page.tsx | 39 ++++++------------- .../AddWithdraw/AddWithdrawCountriesList.tsx | 8 ++++ src/utils/__tests__/bridge.utils.test.ts | 13 +++++++ 3 files changed, 33 insertions(+), 27 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 6a7c89f04..30b14df4d 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -25,7 +25,7 @@ import { TRANSACTIONS } from '@/constants/query.consts' import PaymentSuccessView from '@/features/payments/shared/components/PaymentSuccessView' import { ErrorHandler } from '@/utils/friendly-error.utils' import { getBridgeChainName } from '@/utils/bridge-accounts.utils' -import { getOfframpCurrencyConfig, getCountryFromPath, railJurisdictionForBank } from '@/utils/bridge.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' @@ -160,32 +160,17 @@ export default function WithdrawBankPage() { }, [bankAccount, router, amountToWithdraw, country, view]) const destinationDetails = (account: Account) => { - let countryId: string - - switch (account.type) { - case AccountType.US: - countryId = 'US' - break - case AccountType.IBAN: - // Default to a European country that uses EUR/SEPA - countryId = 'DE' // Germany as default EU country - break - case AccountType.CLABE: - countryId = 'MX' - break - case AccountType.GB: - countryId = 'GB' - break - default: - return { - currency: '', - paymentRail: '', - externalAccountId: null, - } - } - - const { currency, paymentRail } = getOfframpCurrencyConfig(countryId) - + // 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, diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 6698a3044..c00fc73de 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -209,6 +209,14 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { // fallback to the previous method if we can't find the new account // this can happen if the user object is not updated immediately const newAccountFromResponse = result.data as Account + // The freshly-added account hasn't surfaced in the user refetch yet. + // The add-bank-account response is the projected wire shape, so it + // already carries bridgeAccountId + the legacy `type`. Guard: without + // a bridgeAccountId the confirm step dead-ends on "Bank account is + // missing", so surface a retryable error rather than navigating. + if (!newAccountFromResponse?.bridgeAccountId) { + return { error: 'Your bank account is still being set up. Please try again in a moment.' } + } // ensure details has accountOwnerName for confirmation page display newAccountFromResponse.details = { ...(newAccountFromResponse.details || {}), diff --git a/src/utils/__tests__/bridge.utils.test.ts b/src/utils/__tests__/bridge.utils.test.ts index ec128da0e..0439e8e0c 100644 --- a/src/utils/__tests__/bridge.utils.test.ts +++ b/src/utils/__tests__/bridge.utils.test.ts @@ -190,6 +190,19 @@ describe('bridge.utils', () => { }) }) + it('accepts the exact Prisma enum BANK_GB → GBP / faster_payments (uk-gbp-withdraw-flow)', () => { + // The withdraw bank page derives the offramp payload from the account + // via destinationDetails → getOfframpConfigFromAccount. A UK account + // whose `type` arrives as the raw Prisma enum 'BANK_GB' (not the + // projected 'gb') must still map to GBP/faster_payments; the previous + // switch's `default` returned an empty rail → "External account ID is + // missing.". + expect(getOfframpConfigFromAccount({ type: 'BANK_GB' })).toEqual({ + currency: 'gbp', + paymentRail: 'faster_payments', + }) + }) + it('throws on Manteca account type — must use the Manteca offramp path', () => { expect(() => getOfframpConfigFromAccount({ type: 'manteca' })).toThrow( 'Manteca accounts route through a separate offramp path' From ec95c9d1ca8123d42392d050aef7d1053e73ac85 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 23 Jun 2026 23:02:01 -0700 Subject: [PATCH 45/52] refactor(balance): fail-late on display gate + refetch on failure (drop settling-at-input) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gating money-flows at input on an "available-now" subset was wrong: the FE balance is only ~30s-polled while the spend routing reads the chain live at submit (#2234), so an input-time available-now gate blocks spends that would actually succeed — and the ~15s flow naturally lets the ~10-45s collateral rebalance settle. So gate on the DISPLAYED balance (block only a true shortfall) and let in-transit spends fail late on live execution. - useWallet: hasSufficientSpendableBalance now gates on the displayed spendableBalance. Deletes spendBlockReason / availableSpendableBalance / SPEND_BLOCK_MESSAGE and the whole "settling at input" apparatus (−55 net). - Refetch on failure (TanStack): on InsufficientSpendableError the routing already re-read live smart balance, so the two bundle hooks (useSpendBundle / useSignSpendBundle) invalidate [RAIN_CARD_OVERVIEW_QUERY_KEY] before throwing — the displayed balance + a retry de-stale immediately instead of waiting out the 30s poll. - Informative failure copy: the post-gate in-transit failure now says "Your balance isn't fully available yet — try again in a few seconds" (one shared BALANCE_SETTLING_MESSAGE) across send (ErrorHandler), qr-pay, manteca, useSendMoney — instead of a misleading "add funds". - Keeps the display-field fix + the formattedSpendableBalance formatting consolidation. Net -39 lines. --- .../qr-pay/__tests__/qr-pay-states.test.tsx | 17 ++-- src/app/(mobile-ui)/qr-pay/page.tsx | 17 ++-- .../withdraw/[country]/bank/page.tsx | 13 ++- .../__tests__/withdraw-states.test.tsx | 14 ++-- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 17 ++-- src/app/(mobile-ui)/withdraw/page.tsx | 33 +++----- .../link/views/Initial.link.send.view.tsx | 39 +++++---- src/hooks/wallet/useSendMoney.ts | 6 +- src/hooks/wallet/useSignSpendBundle.ts | 6 +- src/hooks/wallet/useSpendBundle.ts | 6 +- src/hooks/wallet/useWallet.ts | 81 +++++-------------- src/utils/balance.utils.ts | 33 +++----- src/utils/friendly-error.utils.tsx | 5 ++ 13 files changed, 124 insertions(+), 163 deletions(-) diff --git a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx index 2e5c06d3f..7217b4a8e 100644 --- a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx +++ b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx @@ -138,10 +138,8 @@ jest.mock('@/hooks/useRainCardOverview', () => ({ jest.mock('@/utils/balance.utils', () => ({ rainCentsToUsdcUnits: jest.fn(() => 0n), - SPEND_BLOCK_MESSAGE: { - settling: 'Your balance is updating. Try again in a few seconds.', - insufficient: 'Not enough balance. Add funds to continue.', - }, + INSUFFICIENT_BALANCE_MESSAGE: 'Not enough balance. Add funds to continue.', + BALANCE_SETTLING_MESSAGE: "Your balance isn't fully available yet. Please try again in a few seconds.", })) const mockUseTransactionDetailsDrawer = jest.fn() @@ -565,7 +563,7 @@ function applyDefaults() { mockUseWallet.mockReturnValue({ balance: parseUnits('100', 6), // $100 USDC spendableBalance: parseUnits('100', 6), - spendBlockReason: () => null, // affordable by default + hasSufficientSpendableBalance: () => true, // affordable by default sendMoney: jest.fn(), }) @@ -787,14 +785,13 @@ describe('GROUP 2: Payment Form States', () => { }) test('Insufficient balance shows pay button disabled + error', async () => { - // Payment needs ~$18.4 but the available-now spendable is only $5, so the - // gate (spendBlockReason) returns 'insufficient'. Revived from skip once the - // gate moved off the raw display balance onto the shared hook classifier - // (the original mock-shape drift this test hit). + // Payment needs ~$18.4 but the displayed spendable is only $5, so the gate + // (hasSufficientSpendableBalance) returns false. Revived from skip once the + // gate moved onto the shared hook predicate (the original mock-shape drift). mockUseWallet.mockReturnValue({ balance: parseUnits('5', 6), spendableBalance: parseUnits('5', 6), - spendBlockReason: () => 'insufficient', + hasSufficientSpendableBalance: () => false, sendMoney: jest.fn(), }) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 1e472d296..bdfedab86 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -22,7 +22,7 @@ import { useStaleSessionGuard } from '@/hooks/wallet/useStaleSessionGuard' import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle' import { rainCollateralErrorMessage } from '@/utils/friendly-error.utils' import { useRainCardOverview } from '@/hooks/useRainCardOverview' -import { rainCentsToUsdcUnits, SPEND_BLOCK_MESSAGE } from '@/utils/balance.utils' +import { rainCentsToUsdcUnits, INSUFFICIENT_BALANCE_MESSAGE, BALANCE_SETTLING_MESSAGE } from '@/utils/balance.utils' import { isTxReverted, saveRedirectUrl, formatNumberForDisplay } from '@/utils/general.utils' import { getShakeClass, type ShakeIntensity } from '@/utils/perk.utils' import { @@ -98,7 +98,7 @@ export default function QRPayPage() { const qrCode = decodeURIComponent(searchParams.get('qrCode') || '') const timestamp = searchParams.get('t') const qrType = searchParams.get('type') - const { spendableBalance: balance, spendBlockReason } = useWallet() + const { spendableBalance: balance, hasSufficientSpendableBalance } = useWallet() const { signSpend } = useSignSpendBundle() const handleStaleSession = useStaleSessionGuard() const { overview: rainCardOverview } = useRainCardOverview() @@ -628,7 +628,7 @@ export default function QRPayPage() { } catch (error) { const rainMsg = rainCollateralErrorMessage(error) if (error instanceof InsufficientSpendableError) { - setErrorMessage('Not enough USDC in your wallet or card to cover this payment.') + setErrorMessage(BALANCE_SETTLING_MESSAGE) } else if (error instanceof SessionKeyGrantRequiredError) { setErrorMessage("One-time card authorization needed. You'll be asked to confirm once.") } else if (rainMsg) { @@ -937,13 +937,14 @@ export default function QRPayPage() { setBalanceErrorMessage(`QR payment amount exceeds maximum limit of $${MAX_QR_PAYMENT_AMOUNT}`) } else if (paymentAmount < parseUnits(MIN_QR_PAYMENT_AMOUNT, PEANUT_WALLET_TOKEN_DECIMALS)) { setBalanceErrorMessage(`QR payment amount must be at least $${MIN_QR_PAYMENT_AMOUNT}`) + } else if (!hasSufficientSpendableBalance(usdAmount)) { + // gate on the displayed total; an in-transit shortfall passes here and + // fails late with the settling message at execution. + setBalanceErrorMessage(INSUFFICIENT_BALANCE_MESSAGE) } else { - // available-now gate; 'settling' covers the brief card top-up window - // where the displayed balance reads higher than what's routable. - const block = spendBlockReason(usdAmount) - setBalanceErrorMessage(block ? SPEND_BLOCK_MESSAGE[block] : null) + setBalanceErrorMessage(null) } - }, [usdAmount, balance, spendBlockReason, paymentProcessor, currency?.code, currencyAmount]) + }, [usdAmount, balance, hasSufficientSpendableBalance, paymentProcessor, currency?.code, currencyAmount]) // Use points confetti hook for animation - must be called unconditionally usePointsConfetti(isSuccess && pointsData?.estimatedPoints ? pointsData.estimatedPoints : undefined, pointsDivRef) diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index c685b7446..fa13d05b8 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -24,7 +24,7 @@ import { useQueryClient } from '@tanstack/react-query' import { TRANSACTIONS } from '@/constants/query.consts' import PaymentSuccessView from '@/features/payments/shared/components/PaymentSuccessView' import { ErrorHandler } from '@/utils/friendly-error.utils' -import { SPEND_BLOCK_MESSAGE } from '@/utils/balance.utils' +import { INSUFFICIENT_BALANCE_MESSAGE } from '@/utils/balance.utils' import { getBridgeChainName } from '@/utils/bridge-accounts.utils' import { getOfframpCurrencyConfig, getCountryFromPath, railJurisdictionForBank } from '@/utils/bridge.utils' import { createOfframp, confirmOfframp } from '@/app/actions/offramp' @@ -68,7 +68,7 @@ export default function WithdrawBankPage() { setSelectedMethod, } = useWithdrawFlow() const { user, fetchUser } = useAuth() - const { address, sendMoney, spendableBalance: balance, spendBlockReason } = useWallet() + const { address, sendMoney, spendableBalance: balance, hasSufficientSpendableBalance } = useWallet() const { guardWithTos, showBridgeTos, hideTos } = useTosGuard() const queryClient = useQueryClient() const router = useRouter() @@ -351,11 +351,10 @@ export default function WithdrawBankPage() { return } - // available-now gate; 'settling' covers the brief card top-up window where - // the displayed balance reads higher than what's routable. - const block = spendBlockReason(amountToWithdraw) - setBalanceErrorMessage(block ? SPEND_BLOCK_MESSAGE[block] : null) - }, [amountToWithdraw, balance, spendBlockReason, hasPendingTransactions, isLoading]) + // gate on the displayed total; an in-transit shortfall passes here and + // fails late with the settling message at execution. + setBalanceErrorMessage(hasSufficientSpendableBalance(amountToWithdraw) ? null : INSUFFICIENT_BALANCE_MESSAGE) + }, [amountToWithdraw, balance, hasSufficientSpendableBalance, hasPendingTransactions, isLoading]) if (!bankAccount) { return null diff --git a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx index 086771291..bb51cd3c8 100644 --- a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx +++ b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx @@ -248,13 +248,11 @@ function applyDefaults() { mockWithdrawFlow.selectedBankAccount = null mockUseWallet.mockReturnValue({ - // component destructures `spendableBalance` (display) + `availableSpendableBalance` - // (the spend ceiling that backs maxDecimalAmount). Same value here: no in-transit. + // component gates on the displayed `spendableBalance` (= maxDecimalAmount). spendableBalance: parseUnits('100', 6), - availableSpendableBalance: parseUnits('100', 6), formattedSpendableBalance: '100.00', - // amount-aware so over-$100 entries classify as a real shortfall - spendBlockReason: (amt: string | number) => (Number(amt) > 100 ? 'insufficient' : null), + // amount-aware: over-$100 entries are a true shortfall + hasSufficientSpendableBalance: (amt: string | number) => Number(amt) <= 100, }) mockUseGetExchangeRate.mockReturnValue({ @@ -499,9 +497,8 @@ describe('GROUP 6: Continue never dead-buttons', () => { mockUseWallet.mockReturnValue({ spendableBalance: parseUnits('100', 6), - availableSpendableBalance: parseUnits('100', 6), formattedSpendableBalance: '100.00', - spendBlockReason: (amt: string | number) => (Number(amt) > 100 ? 'insufficient' : null), + hasSufficientSpendableBalance: (amt: string | number) => Number(amt) <= 100, }) mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } mockWithdrawFlow.selectedBankAccount = { type: 'iban', details: { countryName: '', countryCode: '' } } @@ -525,9 +522,8 @@ describe('GROUP 6: Continue never dead-buttons', () => { // /withdraw/manteca rather than the Bridge bank page (or the throw). mockUseWallet.mockReturnValue({ spendableBalance: parseUnits('100', 6), - availableSpendableBalance: parseUnits('100', 6), formattedSpendableBalance: '100.00', - spendBlockReason: (amt: string | number) => (Number(amt) > 100 ? 'insufficient' : null), + hasSufficientSpendableBalance: (amt: string | number) => Number(amt) <= 100, }) mockWithdrawFlow.selectedMethod = { type: 'manteca', countryPath: 'argentina', title: 'Bank Transfer' } mockWithdrawFlow.selectedBankAccount = { type: 'manteca', details: { countryName: 'argentina' } } diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 53bea0343..2c6a704d9 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -5,7 +5,7 @@ import { useSignSpendBundle } from '@/hooks/wallet/useSignSpendBundle' import { useStaleSessionGuard } from '@/hooks/wallet/useStaleSessionGuard' import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle' import { rainCollateralErrorMessage } from '@/utils/friendly-error.utils' -import { rainCentsToUsdcUnits, SPEND_BLOCK_MESSAGE } from '@/utils/balance.utils' +import { rainCentsToUsdcUnits, INSUFFICIENT_BALANCE_MESSAGE, BALANCE_SETTLING_MESSAGE } from '@/utils/balance.utils' import { useRainCardOverview } from '@/hooks/useRainCardOverview' import { useState, useMemo, useContext, useEffect, useCallback, useId } from 'react' import { useRouter, useSearchParams } from 'next/navigation' @@ -105,7 +105,7 @@ function MantecaBankWithdrawFlow() { const [priceLock, setPriceLock] = useState(null) const [isLockingPrice, setIsLockingPrice] = useState(false) const router = useRouter() - const { spendableBalance: balance, formattedSpendableBalance, spendBlockReason } = useWallet() + const { spendableBalance: balance, formattedSpendableBalance, hasSufficientSpendableBalance } = useWallet() const { signSpend } = useSignSpendBundle() const handleStaleSession = useStaleSessionGuard() const { overview: rainCardOverview } = useRainCardOverview() @@ -358,7 +358,7 @@ function MantecaBankWithdrawFlow() { } catch (error) { const rainMsg = rainCollateralErrorMessage(error) if (error instanceof InsufficientSpendableError) { - setErrorMessage('Not enough USDC in your wallet or card to cover this withdrawal.') + setErrorMessage(BALANCE_SETTLING_MESSAGE) } else if (error instanceof SessionKeyGrantRequiredError) { // Grant prompt was attempted inside signSpend and failed. // Telling the user "you'll be asked" is misleading — they @@ -496,13 +496,14 @@ function MantecaBankWithdrawFlow() { // only check min amount and balance here - max amount is handled by limits validation if (paymentAmount < parseUnits(MIN_MANTECA_WITHDRAW_AMOUNT.toString(), PEANUT_WALLET_TOKEN_DECIMALS)) { setBalanceErrorMessage(`Withdraw amount must be at least $${MIN_MANTECA_WITHDRAW_AMOUNT}`) + } else if (!hasSufficientSpendableBalance(usdAmount)) { + // gate on the displayed total; an in-transit shortfall passes here and + // fails late with the settling message at execution. + setBalanceErrorMessage(INSUFFICIENT_BALANCE_MESSAGE) } else { - // available-now gate; 'settling' covers the brief card top-up window - // where the displayed balance reads higher than what's routable. - const block = spendBlockReason(usdAmount) - setBalanceErrorMessage(block ? SPEND_BLOCK_MESSAGE[block] : null) + setBalanceErrorMessage(null) } - }, [usdAmount, balance, spendBlockReason, hasPendingTransactions, isLoading]) + }, [usdAmount, balance, hasSufficientSpendableBalance, hasPendingTransactions, isLoading]) // Fetch points early to avoid latency penalty - fetch as soon as we have usdAmount // Use flowId as uniqueId to prevent cache collisions between different withdrawal flows diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index 631b2a2f7..67f2c358b 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -9,7 +9,7 @@ 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 { SPEND_BLOCK_MESSAGE } from '@/utils/balance.utils' +import { INSUFFICIENT_BALANCE_MESSAGE } from '@/utils/balance.utils' import { getCountryFromAccount, getCountryFromPath, getMinimumAmount } from '@/utils/bridge.utils' import useGetExchangeRate from '@/hooks/useGetExchangeRate' import { AccountType } from '@/interfaces' @@ -81,21 +81,14 @@ export default function WithdrawPage() { // raw amount currently typed in the input const [rawTokenAmount, setRawTokenAmount] = useState(amountFromContext || '') - const { - spendableBalance: balance, - availableSpendableBalance, - formattedSpendableBalance, - spendBlockReason, - } = useWallet() - - // Spend CEILING — gates the entered amount. Use available-now (smart + LANDED - // collateral); the displayed balance below includes in-transit top-ups that - // can't be withdrawn until they land. + 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 availableSpendableBalance !== undefined - ? Number(formatUnits(availableSpendableBalance, PEANUT_WALLET_TOKEN_DECIMALS)) - : 0 - }, [availableSpendableBalance]) + 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". @@ -210,17 +203,15 @@ export default function WithdrawPage() { message = isFromSendFlow ? `Minimum send amount is ${minDisplay}.` : `Minimum withdrawal is ${minDisplay}.` + } else if (amount > maxDecimalAmount) { + message = INSUFFICIENT_BALANCE_MESSAGE } else { - // amount > available-now: distinguish "settling" (funds briefly - // mid-rebalance) from a true shortfall; generic prompt is a - // defensive fallback that shouldn't normally be reached. - const block = spendBlockReason(usdEquivalent) - message = block ? SPEND_BLOCK_MESSAGE[block] : 'Please enter a valid amount.' + message = 'Please enter a valid amount.' } setError({ showError: true, errorMessage: message }) return false }, - [maxDecimalAmount, spendBlockReason, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount] + [maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount] ) const handleTokenAmountChange = useCallback( diff --git a/src/components/Send/link/views/Initial.link.send.view.tsx b/src/components/Send/link/views/Initial.link.send.view.tsx index a66fb8bc0..da8d16b83 100644 --- a/src/components/Send/link/views/Initial.link.send.view.tsx +++ b/src/components/Send/link/views/Initial.link.send.view.tsx @@ -10,7 +10,7 @@ import { useLinkSendFlow } from '@/context/LinkSendFlowContext' import { useWallet } from '@/hooks/wallet/useWallet' import { sendLinksApi } from '@/services/sendLinks' import { ErrorHandler } from '@/utils/friendly-error.utils' -import { SPEND_BLOCK_MESSAGE } from '@/utils/balance.utils' +import { INSUFFICIENT_BALANCE_MESSAGE } from '@/utils/balance.utils' import { captureException } from '@sentry/nextjs' import { useQueryClient } from '@tanstack/react-query' import { useCallback, useContext, useEffect, useMemo } from 'react' @@ -38,7 +38,12 @@ const LinkSendInitialView = () => { const { setLoadingState, isLoading } = useContext(loadingStateContext) - const { fetchBalance, spendableBalance: balance, formattedSpendableBalance, spendBlockReason } = useWallet() + const { + fetchBalance, + spendableBalance: balance, + formattedSpendableBalance, + hasSufficientSpendableBalance, + } = useWallet() const queryClient = useQueryClient() const { hasPendingTransactions } = usePendingTransactions() @@ -54,10 +59,10 @@ const LinkSendInitialView = () => { // Re-check affordability at submit too: the Retry button isn't disabled // on a balance error (unlike the other flows), so without this a blocked - // amount could reach createLink and fail at execution instead of here. - const block = spendBlockReason(tokenValue) - if (block) { - setErrorState({ showError: true, errorMessage: SPEND_BLOCK_MESSAGE[block] }) + // amount could reach createLink. Gates on the displayed total — an + // in-transit shortfall passes here and fails late with the settling copy. + if (!hasSufficientSpendableBalance(tokenValue)) { + setErrorState({ showError: true, errorMessage: INSUFFICIENT_BALANCE_MESSAGE }) return } @@ -129,7 +134,7 @@ const LinkSendInitialView = () => { setLink, setView, setErrorState, - spendBlockReason, + hasSufficientSpendableBalance, ]) useEffect(() => { @@ -145,16 +150,22 @@ const LinkSendInitialView = () => { setErrorState({ showError: false, errorMessage: '' }) return } - // Classify against available-now vs the displayed total: the ~10–45s - // post-top-up window shows the generic "updating" copy instead of a misleading - // "insufficient" that would contradict the balance on screen. - const block = spendBlockReason(tokenValue) - if (block) { - setErrorState({ showError: true, errorMessage: SPEND_BLOCK_MESSAGE[block] }) + // Gate on the displayed total: block only a true shortfall. An in-transit + // amount passes and fails late (settling message + refetch) — the FE balance + // is ~30s-polled, so blocking it here would over-reject routable funds. + if (!hasSufficientSpendableBalance(tokenValue)) { + setErrorState({ showError: true, errorMessage: INSUFFICIENT_BALANCE_MESSAGE }) } else { setErrorState({ showError: false, errorMessage: '' }) } - }, [peanutWalletBalance, tokenValue, setErrorState, hasPendingTransactions, isLoading, spendBlockReason]) + }, [ + peanutWalletBalance, + tokenValue, + setErrorState, + hasPendingTransactions, + isLoading, + hasSufficientSpendableBalance, + ]) return (
diff --git a/src/hooks/wallet/useSendMoney.ts b/src/hooks/wallet/useSendMoney.ts index 21e7c5288..aa358ce02 100644 --- a/src/hooks/wallet/useSendMoney.ts +++ b/src/hooks/wallet/useSendMoney.ts @@ -6,7 +6,7 @@ import { TRANSACTIONS, BALANCE_DECREASE, SEND_MONEY } from '@/constants/query.co import { useToast } from '@/components/0_Bruddle/Toast' import { useBalance } from './useBalance' import { useRainCardOverview, RAIN_CARD_OVERVIEW_QUERY_KEY } from '../useRainCardOverview' -import { rainCentsToUsdcUnits } from '@/utils/balance.utils' +import { rainCentsToUsdcUnits, BALANCE_SETTLING_MESSAGE } from '@/utils/balance.utils' import type { RainCollateralKind } from '@/services/rain' import { InsufficientSpendableError, @@ -120,7 +120,9 @@ export const useSendMoney = ({ address }: UseSendMoneyOptions) => { console.error('[useSendMoney] Transaction failed, rolled back balance:', error) if (error instanceof InsufficientSpendableError) { - toast.error('Insufficient balance for this transfer.') + // Passed the display gate but couldn't route yet — useSpendBundle has + // already refetched the Rain overview; nudge a retry. + toast.error(BALANCE_SETTLING_MESSAGE) return } if (error instanceof SessionKeyGrantRequiredError) { diff --git a/src/hooks/wallet/useSignSpendBundle.ts b/src/hooks/wallet/useSignSpendBundle.ts index ba8ea1a44..a6206642a 100644 --- a/src/hooks/wallet/useSignSpendBundle.ts +++ b/src/hooks/wallet/useSignSpendBundle.ts @@ -15,7 +15,7 @@ import { rainWithdrawEip712Types, } from '@/constants/rain.consts' import { rainApi, type RainCollateralKind } from '@/services/rain' -import { useRainCardOverview } from '@/hooks/useRainCardOverview' +import { useRainCardOverview, RAIN_CARD_OVERVIEW_QUERY_KEY } from '@/hooks/useRainCardOverview' import { useGrantSessionKey, type GrantSessionKeyError } from './useGrantSessionKey' import { useSignUserOp, type SignedUserOpData } from './useSignUserOp' import { @@ -142,6 +142,10 @@ export const useSignSpendBundle = () => { error_kind: 'insufficient', flow: 'sign-only', }) + // Passed the FE display gate but the live balance can't cover it yet + // (in-transit collateral not landed / ~30s-stale FE). Refresh the Rain + // overview so the displayed balance + a retry reflect reality. + queryClient.invalidateQueries({ queryKey: [RAIN_CARD_OVERVIEW_QUERY_KEY] }) throw new InsufficientSpendableError() } diff --git a/src/hooks/wallet/useSpendBundle.ts b/src/hooks/wallet/useSpendBundle.ts index 2f42c122a..ef12a6d5c 100644 --- a/src/hooks/wallet/useSpendBundle.ts +++ b/src/hooks/wallet/useSpendBundle.ts @@ -18,7 +18,7 @@ import { } from '@/constants/rain.consts' import { rainApi, type RainCollateralKind } from '@/services/rain' import { useZeroDev } from '@/hooks/useZeroDev' -import { useRainCardOverview } from '@/hooks/useRainCardOverview' +import { useRainCardOverview, RAIN_CARD_OVERVIEW_QUERY_KEY } from '@/hooks/useRainCardOverview' import { useGrantSessionKey, type GrantSessionKeyError } from './useGrantSessionKey' import { usdcUnitsToRainCents } from '@/utils/balance.utils' import { useModalsContextOptional } from '@/context/ModalsContext' @@ -202,6 +202,10 @@ export const useSpendBundle = () => { strategy: 'insufficient', error_kind: 'insufficient', }) + // Passed the FE display gate but the live balance can't cover it yet + // (in-transit collateral not landed / ~30s-stale FE). Refresh the Rain + // overview so the displayed balance + a retry reflect reality. + queryClient.invalidateQueries({ queryKey: [RAIN_CARD_OVERVIEW_QUERY_KEY] }) throw new InsufficientSpendableError() } diff --git a/src/hooks/wallet/useWallet.ts b/src/hooks/wallet/useWallet.ts index cdf6c63c9..a56c504d2 100644 --- a/src/hooks/wallet/useWallet.ts +++ b/src/hooks/wallet/useWallet.ts @@ -14,12 +14,7 @@ import { useBalance } from './useBalance' import { useSendMoney as useSendMoneyMutation } from './useSendMoney' import { formatCurrency } from '@/utils/general.utils' import { useRainCardOverview, RAIN_CARD_OVERVIEW_QUERY_KEY } from '../useRainCardOverview' -import { - computeAvailableSpendable, - computeDisplaySpendable, - rainCentsToUsdcUnits, - type SpendBlockReason, -} from '@/utils/balance.utils' +import { computeDisplaySpendable, rainCentsToUsdcUnits } from '@/utils/balance.utils' import { useSpendBundle, type SpendStrategy } from './useSpendBundle' import type { RainCollateralKind } from '@/services/rain' @@ -205,19 +200,14 @@ export const useWallet = () => { // consider balance as fetching until: address is validated and query has resolved const isBalanceLoading = !isAddressReady || isFetchingBalance - // Two flavours of "spendable", both summing the smart-account balance with - // Rain collateral. See docs §4.5 and §6 in peanut-api-ts/docs/rain-card-test-summary.md - // and the card design spec. `rainOverview` is declared above so `sendTransactions` - // can consult it too. - // • availableSpendableBalance — smart + LANDED collateral. What can actually - // be spent right now; backs the affordability gate + spend routing. - // • rawSpendableBalance (display) — also adds collateral top-ups still in - // transit, so the unified balance doesn't crater to 0 during the ~10–45s - // smart→collateral auto-balance handoff. - const availableSpendableBalance = useMemo(() => { - if (balance === undefined) return undefined - return computeAvailableSpendable(balance, rainOverview?.balance?.spendingPower) - }, [balance, rainOverview?.balance?.spendingPower]) + // Total spendable balance: smart-account balance + Rain collateral (landed + + // in-transit). Display AND the affordability gate both run on THIS number. + // Gating on the displayed total (rather than an "available-now" subset) is + // deliberate: the FE balance is only ~30s-polled while the live spend routing + // reads the chain at submit, so an input-time available-now gate would block + // spends that would actually succeed. A spend that can't be routed yet fails + // late with a "settling, try again" message + a balance refetch instead. + // See docs §4.5/§6 in peanut-api-ts/docs/rain-card-test-summary.md. const rawSpendableBalance = useMemo(() => { if (balance === undefined) return undefined return computeDisplaySpendable( @@ -273,61 +263,28 @@ export const useWallet = () => { return formatCurrency(formatUnits(spendableBalance, PEANUT_WALLET_TOKEN_DECIMALS)) }, [spendableBalance]) - // Parse a USD amount to token base units; null for invalid/negative input. - // Shared by the gate + shortfall classifier so they parse identically. - const parseUsdToBaseUnits = useCallback((amountUsd: string | number): bigint | null => { - const amount = typeof amountUsd === 'string' ? parseFloat(amountUsd) : amountUsd - if (isNaN(amount) || amount < 0) return null - return BigInt(Math.floor(amount * 10 ** PEANUT_WALLET_TOKEN_DECIMALS)) - }, []) - - // Check if the user has enough spendable to cover a USD amount. Gates on - // available-now (smart + LANDED collateral) — NOT the displayed total, which - // includes in-transit top-ups that can't be routed until they land. Using the - // real figure avoids green-lighting a send that would fail at execution during - // the brief top-up window. + // Whether the user can cover a USD amount. Gates on the DISPLAYED spendable + // total (smart + all collateral, incl. in-transit) — see the rawSpendableBalance + // note above for why we gate on display, not available-now. A spend that passes + // here but can't be routed yet fails late with the settling message + a refetch. const hasSufficientSpendableBalance = useCallback( (amountUsd: string | number): boolean => { - if (availableSpendableBalance === undefined) return false - const amountInBaseUnits = parseUsdToBaseUnits(amountUsd) - if (amountInBaseUnits === null) return false - return availableSpendableBalance >= amountInBaseUnits - }, - [availableSpendableBalance, parseUsdToBaseUnits] - ) - - // Classify why a would-be spend of `amountUsd` can't go through, so callers - // can show the right message instead of a blanket "insufficient": - // • null — fully spendable now (≤ available-now) - // • 'settling' — covered by the displayed total but part is in-transit - // collateral not yet landed (≤ display, > available-now) - // • 'insufficient'— exceeds the displayed total too - // `null` while either figure is still loading (callers also guard on that). - const spendBlockReason = useCallback( - (amountUsd: string | number): SpendBlockReason | null => { - if (availableSpendableBalance === undefined || spendableBalance === undefined) return null - const amountInBaseUnits = parseUsdToBaseUnits(amountUsd) - if (amountInBaseUnits === null) return null - if (amountInBaseUnits <= availableSpendableBalance) return null - if (amountInBaseUnits <= spendableBalance) return 'settling' - return 'insufficient' + if (spendableBalance === undefined) return false + const amount = typeof amountUsd === 'string' ? parseFloat(amountUsd) : amountUsd + if (isNaN(amount) || amount < 0) return false + const amountInBaseUnits = BigInt(Math.floor(amount * 10 ** PEANUT_WALLET_TOKEN_DECIMALS)) + return spendableBalance >= amountInBaseUnits }, - [availableSpendableBalance, spendableBalance, parseUsdToBaseUnits] + [spendableBalance] ) return { address: isAddressReady ? address : undefined, // populate address only if it is validated and matches the user's wallet address balance, spendableBalance, - // available-now spendable (smart + LANDED collateral), excluding in-transit - // top-ups. This is the spend CEILING — what `hasSufficientSpendableBalance` - // gates on. Use it (not `spendableBalance`) anywhere you cap or validate an - // amount the user is about to move, so the in-transit window can't over-permit. - availableSpendableBalance, formattedBalance, formattedSpendableBalance, hasSufficientSpendableBalance, - spendBlockReason, isConnected: isKernelClientReady, sendTransactions, sendMoney, diff --git a/src/utils/balance.utils.ts b/src/utils/balance.utils.ts index 87b318f06..06161c4e5 100644 --- a/src/utils/balance.utils.ts +++ b/src/utils/balance.utils.ts @@ -11,27 +11,20 @@ export const printableUsdc = (balance: bigint): string => { } /** - * Why a would-be spend can't go through right now: - * • 'settling' — covered by the displayed balance, but part is in-transit - * card collateral that hasn't landed yet (≤ display, > available-now). - * • 'insufficient' — exceeds the displayed balance too (a real shortfall). - * Returned by `useWallet().spendBlockReason(amount)`; `null` means it's fine. - */ -export type SpendBlockReason = 'settling' | 'insufficient' - -/** - * Single source of truth for affordability-gate copy across every money-flow - * (send, pay, withdraw). The 'settling' string explains the ~10–45s window after - * a card top-up where the displayed balance briefly exceeds what's routable — - * so the user isn't told "insufficient" while the screen shows enough. + * Single source of truth for money-flow balance copy across send / pay / withdraw. + * + * Two distinct moments: + * - INSUFFICIENT — input-time gate, when the entered amount exceeds the full + * displayed balance (a real shortfall). Gates run on the DISPLAYED balance so + * we never block funds the live spend could actually route — see `useWallet`. + * - SETTLING — failure-time, when a spend that passed the gate can't be routed + * yet (the smart→collateral rebalance hasn't landed, or the ~30s-polled FE + * balance was momentarily ahead of chain). Deliberately generic — it must NOT + * expose the card-collateral mechanic — and it nudges a retry, since the FE + * balance is refetched on this failure and the spend usually succeeds shortly. */ -export const SPEND_BLOCK_MESSAGE: Record = { - // Deliberately generic — it must NOT expose the card-collateral mechanic. - // Only seen in the rare ~10-45s window where funds are mid-rebalance; the - // full balance is already on screen, this just says "give it a second". - settling: 'Your balance is updating. Try again in a few seconds.', - insufficient: 'Not enough balance. Add funds to continue.', -} +export const INSUFFICIENT_BALANCE_MESSAGE = 'Not enough balance. Add funds to continue.' +export const BALANCE_SETTLING_MESSAGE = "Your balance isn't fully available yet. Please try again in a few seconds." /** * Widen a Rain balance figure from integer cents (2 decimals) to a USDC diff --git a/src/utils/friendly-error.utils.tsx b/src/utils/friendly-error.utils.tsx index 54914bfb6..955f4e423 100644 --- a/src/utils/friendly-error.utils.tsx +++ b/src/utils/friendly-error.utils.tsx @@ -1,3 +1,5 @@ +import { BALANCE_SETTLING_MESSAGE } from '@/utils/balance.utils' + /** Safely extract a string-form of an unknown error + its `.message` if any. * Lets the matchers below use `string` methods without unsafe property access * while still accepting whatever shape callers throw (Error, string, object). */ @@ -45,6 +47,9 @@ export const ErrorHandler = (error: unknown): string => { // on the cooldown case). Covers every spend path that touches Rain. const rainMsg = rainCollateralErrorMessage(error) if (rainMsg) return rainMsg + // Spend passed the displayed-balance gate but couldn't be routed yet + // (in-transit collateral not landed) — nudge a retry rather than "add funds". + if (text.includes('Insufficient spendable balance')) return BALANCE_SETTLING_MESSAGE if (text.includes('insufficient funds')) return "You don't have enough funds." if (text.includes('user rejected transaction')) return 'Please confirm the transaction in your wallet.' if (text.includes('not deployed on chain')) return 'Bulk is not able on this chain, please try another chain.' From 9e6e63e36250321463ab3edbb977ab45420903cb Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 23 Jun 2026 23:20:18 -0700 Subject: [PATCH 46/52] fix(balance): address /code-review findings (loading guards, refetch, docs, test) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified findings from the high-effort review pass: - Send handleOnNext: gate only once balance has loaded (`balance !== undefined`), else a tap before the query resolves false-rejected with "not enough balance" (hasSufficientSpendableBalance returns false on undefined). - useSendMoney.onError: invalidate ['balance', address] after the optimistic rollback — the rollback was discarding the fresh live balance useSpendBundle fetched mid-flight, leaving the smart portion stale until the next 30s poll. - contribute-pot RequestPotActionList: gate the loading-flash on isFetchingSpendableBalance (smart + Rain overview), not isFetchingBalance (smart only) — the latter flashed a false "insufficient" for split-funds users while the overview loaded. - Extracted the gate to a pure, exported `isDisplayBalanceSufficient` and unit- tested the gate-on-display contract (CONTRIBUTING: hooks that gate need a test). - Fixed stale JSDoc that still claimed the gate runs on available-now (formattedSpendableBalance note, computeAvailableSpendable/DisplaySpendable), and scoped the "shared copy" comment to send/pay/withdraw. Not changed (by design / accepted): gate widened to fail-late for the features/payments flows (intended); manteca float-rounding at the boundary fails-safe; the cosmetic toast+inline double-surface on a sendMoney settling failure (follow-up). --- .../link/views/Initial.link.send.view.tsx | 8 ++-- .../components/RequestPotActionList.tsx | 9 ++-- src/hooks/wallet/useSendMoney.ts | 6 +++ src/hooks/wallet/useWallet.ts | 19 +++----- src/utils/__tests__/balance.utils.test.ts | 43 +++++++++++++++++++ src/utils/balance.utils.ts | 39 +++++++++++++---- 6 files changed, 97 insertions(+), 27 deletions(-) diff --git a/src/components/Send/link/views/Initial.link.send.view.tsx b/src/components/Send/link/views/Initial.link.send.view.tsx index da8d16b83..f0f3e3260 100644 --- a/src/components/Send/link/views/Initial.link.send.view.tsx +++ b/src/components/Send/link/views/Initial.link.send.view.tsx @@ -59,9 +59,10 @@ const LinkSendInitialView = () => { // Re-check affordability at submit too: the Retry button isn't disabled // on a balance error (unlike the other flows), so without this a blocked - // amount could reach createLink. Gates on the displayed total — an - // in-transit shortfall passes here and fails late with the settling copy. - if (!hasSufficientSpendableBalance(tokenValue)) { + // amount could reach createLink. Only when the balance has loaded — else + // a tap before the query resolves would false-reject. Gates on the + // displayed total; an in-transit shortfall fails late with the settling copy. + if (balance !== undefined && !hasSufficientSpendableBalance(tokenValue)) { setErrorState({ showError: true, errorMessage: INSUFFICIENT_BALANCE_MESSAGE }) return } @@ -134,6 +135,7 @@ const LinkSendInitialView = () => { setLink, setView, setErrorState, + balance, hasSufficientSpendableBalance, ]) diff --git a/src/features/payments/flows/contribute-pot/components/RequestPotActionList.tsx b/src/features/payments/flows/contribute-pot/components/RequestPotActionList.tsx index 29517405d..20f4e9b86 100644 --- a/src/features/payments/flows/contribute-pot/components/RequestPotActionList.tsx +++ b/src/features/payments/flows/contribute-pot/components/RequestPotActionList.tsx @@ -58,7 +58,7 @@ export function RequestPotActionList({ const router = useRouter() const dispatch = useAppDispatch() const { user } = useAuth() - const { hasSufficientSpendableBalance: hasSufficientBalance, isFetchingBalance } = useWallet() + const { hasSufficientSpendableBalance: hasSufficientBalance, isFetchingSpendableBalance } = useWallet() // MIGRATION-REVIEW: mercadopago/pix are QR `pay` methods over Manteca. Old gate was // `isUserMantecaKycApproved`; mapped to canDo('pay', { provider: 'manteca' }) (operation-specific). const isMantecaPayEnabled = useCapabilities().canDo('pay', { provider: 'manteca' }) @@ -80,9 +80,12 @@ export function RequestPotActionList({ // only show insufficient balance after balance has loaded to avoid flash const userHasSufficientPeanutBalance = useMemo(() => { if (!user || !usdAmount) return false - if (isFetchingBalance) return true // assume sufficient while loading to avoid flash + // wait on BOTH smart + Rain overview (spendable) — using the smart-only + // flag would gate on a partial balance and flash a false "insufficient" + // for split-funds users during the Rain-overview load window. + if (isFetchingSpendableBalance) return true // assume sufficient while loading to avoid flash return hasSufficientBalance(usdAmount) - }, [user, usdAmount, hasSufficientBalance, isFetchingBalance]) + }, [user, usdAmount, hasSufficientBalance, isFetchingSpendableBalance]) // filter and sort payment methods const { filteredMethods: sortedMethods, isLoading: isGeoLoading } = useGeoFilteredPaymentOptions({ diff --git a/src/hooks/wallet/useSendMoney.ts b/src/hooks/wallet/useSendMoney.ts index aa358ce02..b5f82f3d4 100644 --- a/src/hooks/wallet/useSendMoney.ts +++ b/src/hooks/wallet/useSendMoney.ts @@ -117,6 +117,12 @@ export const useSendMoney = ({ address }: UseSendMoneyOptions) => { queryClient.setQueryData(['balance', address], context.previousBalance) } + // The spend may have read a fresh live balance mid-flight (useSpendBundle's + // fetchLiveSmartUsdcBalance) that the rollback above just discarded — + // refetch so the displayed balance settles on on-chain truth, not the + // pre-tap cached value. + queryClient.invalidateQueries({ queryKey: ['balance', address] }) + console.error('[useSendMoney] Transaction failed, rolled back balance:', error) if (error instanceof InsufficientSpendableError) { diff --git a/src/hooks/wallet/useWallet.ts b/src/hooks/wallet/useWallet.ts index a56c504d2..c61887907 100644 --- a/src/hooks/wallet/useWallet.ts +++ b/src/hooks/wallet/useWallet.ts @@ -14,7 +14,7 @@ import { useBalance } from './useBalance' import { useSendMoney as useSendMoneyMutation } from './useSendMoney' import { formatCurrency } from '@/utils/general.utils' import { useRainCardOverview, RAIN_CARD_OVERVIEW_QUERY_KEY } from '../useRainCardOverview' -import { computeDisplaySpendable, rainCentsToUsdcUnits } from '@/utils/balance.utils' +import { computeDisplaySpendable, rainCentsToUsdcUnits, isDisplayBalanceSufficient } from '@/utils/balance.utils' import { useSpendBundle, type SpendStrategy } from './useSpendBundle' import type { RainCollateralKind } from '@/services/rain' @@ -253,11 +253,9 @@ export const useWallet = () => { // funds split across smart and collateral sees a smaller balance than // they actually have and the "insufficient balance" gate trips even // though useSpendBundle would route through collateral just fine - // (2026-05-08 jotest097 report TASK-19573). - // NOTE: derived from `spendableBalance`, which includes in-transit collateral - // top-ups, so during the ~10–45s smart→collateral handoff this can read - // higher than `hasSufficientSpendableBalance` allows (gate is on available-now, - // by design — those funds aren't routable until they land). Self-heals in seconds. + // (2026-05-08 jotest097 report TASK-19573). The input gate runs on this same + // displayed total, so display and gate agree; a spend that isn't routable yet + // fails late rather than being blocked at input. const formattedSpendableBalance = useMemo(() => { if (spendableBalance === undefined) return '0.00' return formatCurrency(formatUnits(spendableBalance, PEANUT_WALLET_TOKEN_DECIMALS)) @@ -267,14 +265,9 @@ export const useWallet = () => { // total (smart + all collateral, incl. in-transit) — see the rawSpendableBalance // note above for why we gate on display, not available-now. A spend that passes // here but can't be routed yet fails late with the settling message + a refetch. + // Logic lives in the pure, unit-tested `isDisplayBalanceSufficient`. const hasSufficientSpendableBalance = useCallback( - (amountUsd: string | number): boolean => { - if (spendableBalance === undefined) return false - const amount = typeof amountUsd === 'string' ? parseFloat(amountUsd) : amountUsd - if (isNaN(amount) || amount < 0) return false - const amountInBaseUnits = BigInt(Math.floor(amount * 10 ** PEANUT_WALLET_TOKEN_DECIMALS)) - return spendableBalance >= amountInBaseUnits - }, + (amountUsd: string | number): boolean => isDisplayBalanceSufficient(amountUsd, spendableBalance), [spendableBalance] ) diff --git a/src/utils/__tests__/balance.utils.test.ts b/src/utils/__tests__/balance.utils.test.ts index 06eaf2269..8ab31a8a3 100644 --- a/src/utils/__tests__/balance.utils.test.ts +++ b/src/utils/__tests__/balance.utils.test.ts @@ -1,6 +1,7 @@ import { computeAvailableSpendable, computeDisplaySpendable, + isDisplayBalanceSufficient, printableUsdc, rainCentsToUsdcUnits, } from '../balance.utils' @@ -105,4 +106,46 @@ describe('balance utils', () => { } ) }) + + describe('isDisplayBalanceSufficient (input affordability gate)', () => { + const balance = 100_000_000n // $100 displayed spendable (6dp) + + it.each([ + ['0', true], + ['50', true], + ['99.99', true], + ['100', true], // exact balance is affordable + ['100.00', true], + ['100.01', false], // a cent over + ['250', false], + ])('gates amount %s against $100 → %s', (amount, expected) => { + expect(isDisplayBalanceSufficient(amount, balance)).toBe(expected) + }) + + it('accepts a numeric amount as well as a string', () => { + expect(isDisplayBalanceSufficient(100, balance)).toBe(true) + expect(isDisplayBalanceSufficient(100.01, balance)).toBe(false) + }) + + it('returns false while the balance is still loading (undefined) — never a false-positive', () => { + expect(isDisplayBalanceSufficient('1', undefined)).toBe(false) + }) + + it.each([['abc'], ['-5'], [Number.NaN], [-1]])('returns false for invalid/negative amount (%s)', (amount) => { + expect(isDisplayBalanceSufficient(amount, balance)).toBe(false) + }) + + it('a zero balance covers only a zero amount', () => { + expect(isDisplayBalanceSufficient('0', 0n)).toBe(true) + expect(isDisplayBalanceSufficient('0.01', 0n)).toBe(false) + }) + + it('gates on the DISPLAYED total incl. in-transit (the contract this PR locks in)', () => { + // smart 0, no landed collateral, $500 in transit → display $500 + const display = computeDisplaySpendable(0n, 0, 50_000) + expect(isDisplayBalanceSufficient('500', display)).toBe(true) + // available-now is $0 here, but the gate must NOT block — it fails late instead + expect(computeAvailableSpendable(0n, 0)).toBe(0n) + }) + }) }) diff --git a/src/utils/balance.utils.ts b/src/utils/balance.utils.ts index 06161c4e5..322179c3d 100644 --- a/src/utils/balance.utils.ts +++ b/src/utils/balance.utils.ts @@ -11,7 +11,9 @@ export const printableUsdc = (balance: bigint): string => { } /** - * Single source of truth for money-flow balance copy across send / pay / withdraw. + * Shared balance copy for the send-link, qr-pay and withdraw flows. (The + * features/payments flows — direct-send/semantic-request/contribute-pot — keep + * their own in-context wording.) * * Two distinct moments: * - INSUFFICIENT — input-time gate, when the entered amount exceeds the full @@ -26,6 +28,25 @@ export const printableUsdc = (balance: bigint): string => { export const INSUFFICIENT_BALANCE_MESSAGE = 'Not enough balance. Add funds to continue.' export const BALANCE_SETTLING_MESSAGE = "Your balance isn't fully available yet. Please try again in a few seconds." +/** + * Affordability gate for money-flows: can `amountUsd` be spent against the + * DISPLAYED spendable balance (smart + all collateral, incl. in-transit)? + * Gating on the displayed total — not an available-now subset — is deliberate: + * the live spend routing reads the chain at submit, so a too-strict input gate + * would block routable funds; a pass that can't be routed yet fails late. + * Returns false while the balance is still loading (undefined). Pure + exported + * so the gate contract is unit-tested independent of the `useWallet` hook. + */ +export const isDisplayBalanceSufficient = ( + amountUsd: string | number, + spendableBalance: bigint | undefined +): boolean => { + if (spendableBalance === undefined) return false + const amount = typeof amountUsd === 'string' ? parseFloat(amountUsd) : amountUsd + if (isNaN(amount) || amount < 0) return false + return spendableBalance >= BigInt(Math.floor(amount * 10 ** PEANUT_WALLET_TOKEN_DECIMALS)) +} + /** * Widen a Rain balance figure from integer cents (2 decimals) to a USDC * bigint (matching PEANUT_WALLET_TOKEN_DECIMALS, typically 6) so it can be @@ -46,9 +67,10 @@ export const rainCentsToUsdcUnits = (spendingPowerCents: number | null | undefin /** * Available-now spendable balance, as a USDC base-unit bigint (6dp) — the * smart-account balance plus landed Rain collateral `spendingPower`. This is - * what the user can actually spend right now (`useSpendBundle` routes through - * the smart account and landed collateral), so it backs the affordability gate - * and spend routing. + * what `useSpendBundle` can actually route through right now. It is the base of + * `computeDisplaySpendable` (which adds in-transit on top); it does NOT back the + * input affordability gate — that gates on the displayed total via + * `isDisplayBalanceSufficient` (see `useWallet`). */ export const computeAvailableSpendable = ( smartBalance: bigint, @@ -65,10 +87,11 @@ export const computeAvailableSpendable = ( * (from the backend's `inTransitToCollateralCents`) keeps the unified balance * steady through the handoff. * - * In-transit funds aren't spendable until they land, so they are deliberately - * EXCLUDED from `computeAvailableSpendable` (gate + routing). During the window - * the displayed total therefore exceeds spendable-now by the in-flight amount — - * by design — and reconciles within seconds once collateral lands. + * In-transit funds aren't routable until they land, so they are excluded from + * `computeAvailableSpendable` (what spend routing can actually use). The input + * gate, by contrast, runs on THIS displayed total — a spend that can't be + * routed yet fails late rather than being blocked at input. The displayed total + * reconciles within seconds once collateral lands. */ export const computeDisplaySpendable = ( smartBalance: bigint, From 10a7398ecc17f95c50a7eab36f96202b52c558e8 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 23 Jun 2026 23:24:28 -0700 Subject: [PATCH 47/52] fix(balance): guard isDisplayBalanceSufficient against non-finite amounts BigInt(Math.floor(Infinity * 1e6)) throws a RangeError; a pasted/oversized amount (parseFloat -> Infinity) must fail the gate, not crash the render. isNaN didn't catch Infinity; use Number.isFinite. + tests. --- src/utils/__tests__/balance.utils.test.ts | 8 ++++++++ src/utils/balance.utils.ts | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/utils/__tests__/balance.utils.test.ts b/src/utils/__tests__/balance.utils.test.ts index 8ab31a8a3..b43d528df 100644 --- a/src/utils/__tests__/balance.utils.test.ts +++ b/src/utils/__tests__/balance.utils.test.ts @@ -135,6 +135,14 @@ describe('balance utils', () => { expect(isDisplayBalanceSufficient(amount, balance)).toBe(false) }) + it.each([[Number.POSITIVE_INFINITY], [Number.NEGATIVE_INFINITY], ['1e999'], ['Infinity']])( + 'never throws on non-finite / overflowing amount (%s) — returns false, not a RangeError', + (amount) => { + expect(() => isDisplayBalanceSufficient(amount, balance)).not.toThrow() + expect(isDisplayBalanceSufficient(amount, balance)).toBe(false) + } + ) + it('a zero balance covers only a zero amount', () => { expect(isDisplayBalanceSufficient('0', 0n)).toBe(true) expect(isDisplayBalanceSufficient('0.01', 0n)).toBe(false) diff --git a/src/utils/balance.utils.ts b/src/utils/balance.utils.ts index 322179c3d..d20413356 100644 --- a/src/utils/balance.utils.ts +++ b/src/utils/balance.utils.ts @@ -43,7 +43,10 @@ export const isDisplayBalanceSufficient = ( ): boolean => { if (spendableBalance === undefined) return false const amount = typeof amountUsd === 'string' ? parseFloat(amountUsd) : amountUsd - if (isNaN(amount) || amount < 0) return false + // `Number.isFinite` rejects NaN, Infinity and -Infinity — the last is critical: + // `BigInt(Math.floor(Infinity * 1e6))` is `BigInt(Infinity)`, which THROWS a + // RangeError. A pasted/oversized amount must fail the gate, never crash it. + if (!Number.isFinite(amount) || amount < 0) return false return spendableBalance >= BigInt(Math.floor(amount * 10 ** PEANUT_WALLET_TOKEN_DECIMALS)) } From 71da9f5e735d31dc62d5fc7b0460c32ac9a5fafd Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 24 Jun 2026 00:05:19 -0700 Subject: [PATCH 48/52] fix(balance): adversarial-review fixes (orphan charges, button dead-end, precision) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Max-effort worst-case review found two real bugs I'd introduced plus robustness gaps: - [money] Orphan charges: the features/payments flows (direct-send, contribute-pot, semantic-request) createCharge BEFORE sendMoney, so widening their gate to the displayed total let an in-transit amount pass, create a backend charge, then fail late — an unpaid charge per retry. Restore the meaningful split: hasSufficientSpendableBalance now gates on AVAILABLE-NOW (those charge-first flows), while the no-pre-charge flows (send-link, qr-pay, withdraw) gate on the DISPLAYED total via the renamed pure helper isAmountWithinBalance(amount, balance). - [stuck] qr-pay Pay + manteca Withdraw buttons dead-ended after a settling failure: the settling message made isBlockingError / disabled true with no way to clear, while the copy says "try again". Exempt BALANCE_SETTLING_MESSAGE so the retry button stays live. - [precision/crash] Gate now parses the amount with parseUnits (exactly what the spend uses) instead of float Math.floor(amount*1e6): kills the boundary divergence AND fails closed (returns false, never a BigInt(Infinity) RangeError) on adversarial input. - [load] withdraw page no longer false-blocks ("insufficient") during the balance-load window (maxDecimalAmount=0); guarded on a loaded balance, re-validates when it lands. - [robustness] ErrorHandler matches the typed error name, not just the message string. - [cleanup] removed the orphaned PEANUT_WALLET_TOKEN_DECIMALS import in bank/page. Tests: pure isAmountWithinBalance (incl. Infinity/overflow), useSendMoney onError refetch. Full unit suite green. --- .../qr-pay/__tests__/qr-pay-states.test.tsx | 5 +- src/app/(mobile-ui)/qr-pay/page.tsx | 21 +++++-- .../withdraw/[country]/bank/page.tsx | 14 ++--- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 16 +++-- src/app/(mobile-ui)/withdraw/page.tsx | 10 ++- .../link/views/Initial.link.send.view.tsx | 23 ++----- .../wallet/__tests__/useSendMoney.test.tsx | 26 ++++++++ src/hooks/wallet/useWallet.ts | 61 ++++++++++++------- src/utils/__tests__/balance.utils.test.ts | 24 ++++---- src/utils/balance.utils.ts | 57 +++++++++++------ src/utils/friendly-error.utils.tsx | 17 +++--- 11 files changed, 170 insertions(+), 104 deletions(-) diff --git a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx index 7217b4a8e..83e367ace 100644 --- a/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx +++ b/src/app/(mobile-ui)/qr-pay/__tests__/qr-pay-states.test.tsx @@ -137,9 +137,10 @@ jest.mock('@/hooks/useRainCardOverview', () => ({ })) jest.mock('@/utils/balance.utils', () => ({ + // keep the real isAmountWithinBalance / messages so the gate is genuinely + // exercised; only stub the Rain widening helper. + ...jest.requireActual('@/utils/balance.utils'), rainCentsToUsdcUnits: jest.fn(() => 0n), - INSUFFICIENT_BALANCE_MESSAGE: 'Not enough balance. Add funds to continue.', - BALANCE_SETTLING_MESSAGE: "Your balance isn't fully available yet. Please try again in a few seconds.", })) const mockUseTransactionDetailsDrawer = jest.fn() diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index bdfedab86..0759ae013 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -22,7 +22,12 @@ import { useStaleSessionGuard } from '@/hooks/wallet/useStaleSessionGuard' import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle' import { rainCollateralErrorMessage } from '@/utils/friendly-error.utils' import { useRainCardOverview } from '@/hooks/useRainCardOverview' -import { rainCentsToUsdcUnits, INSUFFICIENT_BALANCE_MESSAGE, BALANCE_SETTLING_MESSAGE } from '@/utils/balance.utils' +import { + rainCentsToUsdcUnits, + INSUFFICIENT_BALANCE_MESSAGE, + BALANCE_SETTLING_MESSAGE, + isAmountWithinBalance, +} from '@/utils/balance.utils' import { isTxReverted, saveRedirectUrl, formatNumberForDisplay } from '@/utils/general.utils' import { getShakeClass, type ShakeIntensity } from '@/utils/perk.utils' import { @@ -98,7 +103,7 @@ export default function QRPayPage() { const qrCode = decodeURIComponent(searchParams.get('qrCode') || '') const timestamp = searchParams.get('t') const qrType = searchParams.get('type') - const { spendableBalance: balance, hasSufficientSpendableBalance } = useWallet() + const { spendableBalance: balance } = useWallet() const { signSpend } = useSignSpendBundle() const handleStaleSession = useStaleSessionGuard() const { overview: rainCardOverview } = useRainCardOverview() @@ -406,7 +411,13 @@ export default function QRPayPage() { }, [paymentLock?.code, paymentProcessor]) const isBlockingError = useMemo(() => { - return !!errorMessage && errorMessage !== 'Please confirm the transaction.' + // The settling failure says "try again in a few seconds" — keep the Pay + // button enabled so the user can retry, don't dead-end it like a hard error. + return ( + !!errorMessage && + errorMessage !== 'Please confirm the transaction.' && + errorMessage !== BALANCE_SETTLING_MESSAGE + ) }, [errorMessage]) const usdAmount = useMemo(() => { @@ -937,14 +948,14 @@ export default function QRPayPage() { setBalanceErrorMessage(`QR payment amount exceeds maximum limit of $${MAX_QR_PAYMENT_AMOUNT}`) } else if (paymentAmount < parseUnits(MIN_QR_PAYMENT_AMOUNT, PEANUT_WALLET_TOKEN_DECIMALS)) { setBalanceErrorMessage(`QR payment amount must be at least $${MIN_QR_PAYMENT_AMOUNT}`) - } else if (!hasSufficientSpendableBalance(usdAmount)) { + } else if (!isAmountWithinBalance(usdAmount, balance)) { // gate on the displayed total; an in-transit shortfall passes here and // fails late with the settling message at execution. setBalanceErrorMessage(INSUFFICIENT_BALANCE_MESSAGE) } else { setBalanceErrorMessage(null) } - }, [usdAmount, balance, hasSufficientSpendableBalance, paymentProcessor, currency?.code, currencyAmount]) + }, [usdAmount, balance, paymentProcessor, currency?.code, currencyAmount]) // Use points confetti hook for animation - must be called unconditionally usePointsConfetti(isSuccess && pointsData?.estimatedPoints ? pointsData.estimatedPoints : undefined, pointsDivRef) diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index fa13d05b8..04797e7a5 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -8,11 +8,7 @@ import InfoCard from '@/components/Global/InfoCard' 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, - PEANUT_WALLET_TOKEN_DECIMALS, -} from '@/constants/zerodev.consts' +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' @@ -24,7 +20,7 @@ import { useQueryClient } from '@tanstack/react-query' import { TRANSACTIONS } from '@/constants/query.consts' import PaymentSuccessView from '@/features/payments/shared/components/PaymentSuccessView' import { ErrorHandler } from '@/utils/friendly-error.utils' -import { INSUFFICIENT_BALANCE_MESSAGE } from '@/utils/balance.utils' +import { INSUFFICIENT_BALANCE_MESSAGE, isAmountWithinBalance } from '@/utils/balance.utils' import { getBridgeChainName } from '@/utils/bridge-accounts.utils' import { getOfframpCurrencyConfig, getCountryFromPath, railJurisdictionForBank } from '@/utils/bridge.utils' import { createOfframp, confirmOfframp } from '@/app/actions/offramp' @@ -68,7 +64,7 @@ export default function WithdrawBankPage() { setSelectedMethod, } = useWithdrawFlow() const { user, fetchUser } = useAuth() - const { address, sendMoney, spendableBalance: balance, hasSufficientSpendableBalance } = useWallet() + const { address, sendMoney, spendableBalance: balance } = useWallet() const { guardWithTos, showBridgeTos, hideTos } = useTosGuard() const queryClient = useQueryClient() const router = useRouter() @@ -353,8 +349,8 @@ export default function WithdrawBankPage() { // gate on the displayed total; an in-transit shortfall passes here and // fails late with the settling message at execution. - setBalanceErrorMessage(hasSufficientSpendableBalance(amountToWithdraw) ? null : INSUFFICIENT_BALANCE_MESSAGE) - }, [amountToWithdraw, balance, hasSufficientSpendableBalance, hasPendingTransactions, isLoading]) + setBalanceErrorMessage(isAmountWithinBalance(amountToWithdraw, balance) ? null : INSUFFICIENT_BALANCE_MESSAGE) + }, [amountToWithdraw, balance, hasPendingTransactions, isLoading]) if (!bankAccount) { return null diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 2c6a704d9..4dd2b4abf 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -5,7 +5,12 @@ import { useSignSpendBundle } from '@/hooks/wallet/useSignSpendBundle' import { useStaleSessionGuard } from '@/hooks/wallet/useStaleSessionGuard' import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle' import { rainCollateralErrorMessage } from '@/utils/friendly-error.utils' -import { rainCentsToUsdcUnits, INSUFFICIENT_BALANCE_MESSAGE, BALANCE_SETTLING_MESSAGE } from '@/utils/balance.utils' +import { + rainCentsToUsdcUnits, + INSUFFICIENT_BALANCE_MESSAGE, + BALANCE_SETTLING_MESSAGE, + isAmountWithinBalance, +} from '@/utils/balance.utils' import { useRainCardOverview } from '@/hooks/useRainCardOverview' import { useState, useMemo, useContext, useEffect, useCallback, useId } from 'react' import { useRouter, useSearchParams } from 'next/navigation' @@ -105,7 +110,7 @@ function MantecaBankWithdrawFlow() { const [priceLock, setPriceLock] = useState(null) const [isLockingPrice, setIsLockingPrice] = useState(false) const router = useRouter() - const { spendableBalance: balance, formattedSpendableBalance, hasSufficientSpendableBalance } = useWallet() + const { spendableBalance: balance, formattedSpendableBalance } = useWallet() const { signSpend } = useSignSpendBundle() const handleStaleSession = useStaleSessionGuard() const { overview: rainCardOverview } = useRainCardOverview() @@ -496,14 +501,14 @@ function MantecaBankWithdrawFlow() { // only check min amount and balance here - max amount is handled by limits validation if (paymentAmount < parseUnits(MIN_MANTECA_WITHDRAW_AMOUNT.toString(), PEANUT_WALLET_TOKEN_DECIMALS)) { setBalanceErrorMessage(`Withdraw amount must be at least $${MIN_MANTECA_WITHDRAW_AMOUNT}`) - } else if (!hasSufficientSpendableBalance(usdAmount)) { + } else if (!isAmountWithinBalance(usdAmount, balance)) { // gate on the displayed total; an in-transit shortfall passes here and // fails late with the settling message at execution. setBalanceErrorMessage(INSUFFICIENT_BALANCE_MESSAGE) } else { setBalanceErrorMessage(null) } - }, [usdAmount, balance, hasSufficientSpendableBalance, hasPendingTransactions, isLoading]) + }, [usdAmount, balance, hasPendingTransactions, isLoading]) // Fetch points early to avoid latency penalty - fetch as soon as we have usdAmount // Use flowId as uniqueId to prevent cache collisions between different withdrawal flows @@ -895,7 +900,8 @@ function MantecaBankWithdrawFlow() { icon="arrow-up" onClick={handleWithdraw} loading={isLoading} - disabled={!!errorMessage || isLoading} + // settling failure is retryable — don't dead-end the button on it + disabled={(!!errorMessage && errorMessage !== BALANCE_SETTLING_MESSAGE) || isLoading} shadowSize="4" > {isLoading ? loadingState : 'Withdraw'} diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index 67f2c358b..55ecdd445 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -191,7 +191,11 @@ export default function WithdrawPage() { const price = selectedTokenData?.price ?? 0 // 0 for safety; will fail below const usdEquivalent = price ? amount * price : amount // if no price assume token pegged 1 USD - if (usdEquivalent >= minUsdAmount && amount <= maxDecimalAmount) { + // 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 } @@ -203,7 +207,7 @@ export default function WithdrawPage() { message = isFromSendFlow ? `Minimum send amount is ${minDisplay}.` : `Minimum withdrawal is ${minDisplay}.` - } else if (amount > maxDecimalAmount) { + } else if (balanceLoaded && amount > maxDecimalAmount) { message = INSUFFICIENT_BALANCE_MESSAGE } else { message = 'Please enter a valid amount.' @@ -211,7 +215,7 @@ export default function WithdrawPage() { setError({ showError: true, errorMessage: message }) return false }, - [maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount] + [balance, maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount] ) const handleTokenAmountChange = useCallback( diff --git a/src/components/Send/link/views/Initial.link.send.view.tsx b/src/components/Send/link/views/Initial.link.send.view.tsx index f0f3e3260..94d95baf3 100644 --- a/src/components/Send/link/views/Initial.link.send.view.tsx +++ b/src/components/Send/link/views/Initial.link.send.view.tsx @@ -10,7 +10,7 @@ import { useLinkSendFlow } from '@/context/LinkSendFlowContext' import { useWallet } from '@/hooks/wallet/useWallet' import { sendLinksApi } from '@/services/sendLinks' import { ErrorHandler } from '@/utils/friendly-error.utils' -import { INSUFFICIENT_BALANCE_MESSAGE } from '@/utils/balance.utils' +import { INSUFFICIENT_BALANCE_MESSAGE, isAmountWithinBalance } from '@/utils/balance.utils' import { captureException } from '@sentry/nextjs' import { useQueryClient } from '@tanstack/react-query' import { useCallback, useContext, useEffect, useMemo } from 'react' @@ -38,12 +38,7 @@ const LinkSendInitialView = () => { const { setLoadingState, isLoading } = useContext(loadingStateContext) - const { - fetchBalance, - spendableBalance: balance, - formattedSpendableBalance, - hasSufficientSpendableBalance, - } = useWallet() + const { fetchBalance, spendableBalance: balance, formattedSpendableBalance } = useWallet() const queryClient = useQueryClient() const { hasPendingTransactions } = usePendingTransactions() @@ -62,7 +57,7 @@ const LinkSendInitialView = () => { // amount could reach createLink. Only when the balance has loaded — else // a tap before the query resolves would false-reject. Gates on the // displayed total; an in-transit shortfall fails late with the settling copy. - if (balance !== undefined && !hasSufficientSpendableBalance(tokenValue)) { + if (balance !== undefined && !isAmountWithinBalance(tokenValue, balance)) { setErrorState({ showError: true, errorMessage: INSUFFICIENT_BALANCE_MESSAGE }) return } @@ -136,7 +131,6 @@ const LinkSendInitialView = () => { setView, setErrorState, balance, - hasSufficientSpendableBalance, ]) useEffect(() => { @@ -155,19 +149,12 @@ const LinkSendInitialView = () => { // Gate on the displayed total: block only a true shortfall. An in-transit // amount passes and fails late (settling message + refetch) — the FE balance // is ~30s-polled, so blocking it here would over-reject routable funds. - if (!hasSufficientSpendableBalance(tokenValue)) { + if (!isAmountWithinBalance(tokenValue, balance)) { setErrorState({ showError: true, errorMessage: INSUFFICIENT_BALANCE_MESSAGE }) } else { setErrorState({ showError: false, errorMessage: '' }) } - }, [ - peanutWalletBalance, - tokenValue, - setErrorState, - hasPendingTransactions, - isLoading, - hasSufficientSpendableBalance, - ]) + }, [peanutWalletBalance, balance, tokenValue, setErrorState, hasPendingTransactions, isLoading]) return (
diff --git a/src/hooks/wallet/__tests__/useSendMoney.test.tsx b/src/hooks/wallet/__tests__/useSendMoney.test.tsx index ebd421c38..501bd5d56 100644 --- a/src/hooks/wallet/__tests__/useSendMoney.test.tsx +++ b/src/hooks/wallet/__tests__/useSendMoney.test.tsx @@ -179,6 +179,32 @@ describe('useSendMoney', () => { expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: [TRANSACTIONS] }) }) }) + + it('should refetch balance on failure so the rolled-back stale value cannot linger', async () => { + // The rollback restores the pre-tap snapshot, which can discard a fresh + // live balance the spend read mid-flight — onError must invalidate to refetch. + const initialBalance = parseUnits('100', PEANUT_WALLET_TOKEN_DECIMALS) + queryClient.setQueryData(['balance', mockAddress], initialBalance) + mockSmartBalance = initialBalance + mockSpend.mockRejectedValue(new Error('Transaction failed')) + + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries') + + const { result } = renderHook(() => useSendMoney({ address: mockAddress }), { wrapper }) + + try { + await result.current.mutateAsync({ + toAddress: '0x9999999999999999999999999999999999999999' as `0x${string}`, + amountInUsd: '10', + }) + } catch { + // expected + } + + await waitFor(() => { + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['balance', mockAddress] }) + }) + }) }) describe('Edge Cases', () => { diff --git a/src/hooks/wallet/useWallet.ts b/src/hooks/wallet/useWallet.ts index c61887907..8dd9cb92d 100644 --- a/src/hooks/wallet/useWallet.ts +++ b/src/hooks/wallet/useWallet.ts @@ -14,7 +14,12 @@ import { useBalance } from './useBalance' import { useSendMoney as useSendMoneyMutation } from './useSendMoney' import { formatCurrency } from '@/utils/general.utils' import { useRainCardOverview, RAIN_CARD_OVERVIEW_QUERY_KEY } from '../useRainCardOverview' -import { computeDisplaySpendable, rainCentsToUsdcUnits, isDisplayBalanceSufficient } from '@/utils/balance.utils' +import { + computeAvailableSpendable, + computeDisplaySpendable, + rainCentsToUsdcUnits, + isAmountWithinBalance, +} from '@/utils/balance.utils' import { useSpendBundle, type SpendStrategy } from './useSpendBundle' import type { RainCollateralKind } from '@/services/rain' @@ -202,12 +207,12 @@ export const useWallet = () => { // Total spendable balance: smart-account balance + Rain collateral (landed + // in-transit). Display AND the affordability gate both run on THIS number. - // Gating on the displayed total (rather than an "available-now" subset) is - // deliberate: the FE balance is only ~30s-polled while the live spend routing - // reads the chain at submit, so an input-time available-now gate would block - // spends that would actually succeed. A spend that can't be routed yet fails - // late with a "settling, try again" message + a balance refetch instead. - // See docs §4.5/§6 in peanut-api-ts/docs/rain-card-test-summary.md. + // DISPLAY spendable (smart + landed + in-transit collateral). What we show, + // and what the fail-late flows (send-link, qr-pay, withdraw) gate on directly + // via isAmountWithinBalance: the FE balance is only ~30s-polled while the live + // spend routing reads the chain at submit, so blocking an in-transit amount at + // input would reject funds that would actually succeed — it fails late with a + // "settling, try again" message + a refetch instead. const rawSpendableBalance = useMemo(() => { if (balance === undefined) return undefined return computeDisplaySpendable( @@ -217,6 +222,16 @@ export const useWallet = () => { ) }, [balance, rainOverview?.balance?.spendingPower, rainOverview?.balance?.inTransitToCollateralCents]) + // AVAILABLE-NOW spendable (smart + LANDED collateral, NO in-transit). What + // useSpendBundle can route this instant, and what hasSufficientSpendableBalance + // gates on — for flows that take an irreversible step BEFORE the spend (the + // features/payments flows createCharge first), so an in-transit amount is + // blocked at input rather than leaving an orphan charge when it fails late. + const availableSpendableBalance = useMemo(() => { + if (balance === undefined) return undefined + return computeAvailableSpendable(balance, rainOverview?.balance?.spendingPower) + }, [balance, rainOverview?.balance?.spendingPower]) + // The two inputs (smart-account + rain overview) refresh independently. // When both change at once (e.g. auto-balancer deposit: smart goes down, // collateral goes up by the same amount), the queries settle at slightly @@ -247,28 +262,28 @@ export const useWallet = () => { return formatCurrency(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) }, [balance]) - // Total spendable (smart + Rain collateral) formatted for display. - // Payment-input forms (request-pay, direct-send, contribute-pot) should - // show THIS rather than the smart-only number — otherwise a user with - // funds split across smart and collateral sees a smaller balance than - // they actually have and the "insufficient balance" gate trips even - // though useSpendBundle would route through collateral just fine - // (2026-05-08 jotest097 report TASK-19573). The input gate runs on this same - // displayed total, so display and gate agree; a spend that isn't routable yet - // fails late rather than being blocked at input. + // Total spendable (smart + Rain collateral) formatted for display. All + // payment-input forms show THIS rather than the smart-only number — otherwise + // a user with funds split across smart and collateral sees a smaller balance + // than they actually have (2026-05-08 jotest097 report TASK-19573). Note the + // gate may be stricter than the display: the features/payments flows gate on + // available-now (see hasSufficientSpendableBalance) while still showing this + // full total, so during the brief in-transit window display can exceed what + // they can spend — by design, it reconciles in seconds. const formattedSpendableBalance = useMemo(() => { if (spendableBalance === undefined) return '0.00' return formatCurrency(formatUnits(spendableBalance, PEANUT_WALLET_TOKEN_DECIMALS)) }, [spendableBalance]) - // Whether the user can cover a USD amount. Gates on the DISPLAYED spendable - // total (smart + all collateral, incl. in-transit) — see the rawSpendableBalance - // note above for why we gate on display, not available-now. A spend that passes - // here but can't be routed yet fails late with the settling message + a refetch. - // Logic lives in the pure, unit-tested `isDisplayBalanceSufficient`. + // STRICT affordability gate on AVAILABLE-NOW (excludes in-transit). Used by + // the features/payments flows, which createCharge before spending — an + // in-transit amount must be blocked here, not green-lit into an orphan charge. + // Fail-late flows (send-link, qr-pay, withdraw) instead gate on the displayed + // `spendableBalance` directly via isAmountWithinBalance. Logic is the pure, + // unit-tested isAmountWithinBalance. const hasSufficientSpendableBalance = useCallback( - (amountUsd: string | number): boolean => isDisplayBalanceSufficient(amountUsd, spendableBalance), - [spendableBalance] + (amountUsd: string | number): boolean => isAmountWithinBalance(amountUsd, availableSpendableBalance), + [availableSpendableBalance] ) return { diff --git a/src/utils/__tests__/balance.utils.test.ts b/src/utils/__tests__/balance.utils.test.ts index b43d528df..1ac08289b 100644 --- a/src/utils/__tests__/balance.utils.test.ts +++ b/src/utils/__tests__/balance.utils.test.ts @@ -1,7 +1,7 @@ import { computeAvailableSpendable, computeDisplaySpendable, - isDisplayBalanceSufficient, + isAmountWithinBalance, printableUsdc, rainCentsToUsdcUnits, } from '../balance.utils' @@ -107,7 +107,7 @@ describe('balance utils', () => { ) }) - describe('isDisplayBalanceSufficient (input affordability gate)', () => { + describe('isAmountWithinBalance (input affordability gate)', () => { const balance = 100_000_000n // $100 displayed spendable (6dp) it.each([ @@ -119,39 +119,39 @@ describe('balance utils', () => { ['100.01', false], // a cent over ['250', false], ])('gates amount %s against $100 → %s', (amount, expected) => { - expect(isDisplayBalanceSufficient(amount, balance)).toBe(expected) + expect(isAmountWithinBalance(amount, balance)).toBe(expected) }) it('accepts a numeric amount as well as a string', () => { - expect(isDisplayBalanceSufficient(100, balance)).toBe(true) - expect(isDisplayBalanceSufficient(100.01, balance)).toBe(false) + expect(isAmountWithinBalance(100, balance)).toBe(true) + expect(isAmountWithinBalance(100.01, balance)).toBe(false) }) it('returns false while the balance is still loading (undefined) — never a false-positive', () => { - expect(isDisplayBalanceSufficient('1', undefined)).toBe(false) + expect(isAmountWithinBalance('1', undefined)).toBe(false) }) it.each([['abc'], ['-5'], [Number.NaN], [-1]])('returns false for invalid/negative amount (%s)', (amount) => { - expect(isDisplayBalanceSufficient(amount, balance)).toBe(false) + expect(isAmountWithinBalance(amount, balance)).toBe(false) }) it.each([[Number.POSITIVE_INFINITY], [Number.NEGATIVE_INFINITY], ['1e999'], ['Infinity']])( 'never throws on non-finite / overflowing amount (%s) — returns false, not a RangeError', (amount) => { - expect(() => isDisplayBalanceSufficient(amount, balance)).not.toThrow() - expect(isDisplayBalanceSufficient(amount, balance)).toBe(false) + expect(() => isAmountWithinBalance(amount, balance)).not.toThrow() + expect(isAmountWithinBalance(amount, balance)).toBe(false) } ) it('a zero balance covers only a zero amount', () => { - expect(isDisplayBalanceSufficient('0', 0n)).toBe(true) - expect(isDisplayBalanceSufficient('0.01', 0n)).toBe(false) + expect(isAmountWithinBalance('0', 0n)).toBe(true) + expect(isAmountWithinBalance('0.01', 0n)).toBe(false) }) it('gates on the DISPLAYED total incl. in-transit (the contract this PR locks in)', () => { // smart 0, no landed collateral, $500 in transit → display $500 const display = computeDisplaySpendable(0n, 0, 50_000) - expect(isDisplayBalanceSufficient('500', display)).toBe(true) + expect(isAmountWithinBalance('500', display)).toBe(true) // available-now is $0 here, but the gate must NOT block — it fails late instead expect(computeAvailableSpendable(0n, 0)).toBe(0n) }) diff --git a/src/utils/balance.utils.ts b/src/utils/balance.utils.ts index d20413356..7aa9be3ed 100644 --- a/src/utils/balance.utils.ts +++ b/src/utils/balance.utils.ts @@ -1,5 +1,23 @@ import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' -import { formatUnits } from 'viem' +import { formatUnits, parseUnits } from 'viem' + +/** + * Parse a USD amount (string or number) to token base units PRECISELY — the same + * `parseUnits` the spend itself uses, so the gate verifies exactly what execution + * will require (no float `Math.floor` divergence at the boundary). Returns null + * for anything invalid — empty, NaN, negative, locale comma, >decimals fraction, + * scientific/overflow/Infinity — so the gate fails closed and NEVER throws. + */ +export const parseUsdAmountToUnits = (amountUsd: string | number): bigint | null => { + try { + const s = (typeof amountUsd === 'number' ? amountUsd.toString() : amountUsd).trim() + if (!s) return null + const units = parseUnits(s, PEANUT_WALLET_TOKEN_DECIMALS) + return units < 0n ? null : units + } catch { + return null + } +} export const printableUsdc = (balance: bigint): string => { // For 6 decimals, we want 2 decimal places in output @@ -29,25 +47,24 @@ export const INSUFFICIENT_BALANCE_MESSAGE = 'Not enough balance. Add funds to co export const BALANCE_SETTLING_MESSAGE = "Your balance isn't fully available yet. Please try again in a few seconds." /** - * Affordability gate for money-flows: can `amountUsd` be spent against the - * DISPLAYED spendable balance (smart + all collateral, incl. in-transit)? - * Gating on the displayed total — not an available-now subset — is deliberate: - * the live spend routing reads the chain at submit, so a too-strict input gate - * would block routable funds; a pass that can't be routed yet fails late. - * Returns false while the balance is still loading (undefined). Pure + exported - * so the gate contract is unit-tested independent of the `useWallet` hook. + * Pure affordability check: does `balanceUnits` cover `amountUsd`? Parses the + * amount the same way the spend does (parseUnits — precise, no float drift) and + * fails closed on invalid/loading input (returns false, never throws). + * + * The CALLER chooses which balance to pass, and that choice is the gate policy: + * - DISPLAYED total (smart + landed + in-transit) for fail-late flows that take + * no irreversible step before spending (send-link, qr-pay, withdraw) — an + * in-transit amount passes and, if not yet routable, fails late. + * - AVAILABLE-NOW (smart + landed) for flows that do something irreversible + * BEFORE the spend — the features/payments flows `createCharge` first, so an + * in-transit amount must be blocked at input or it leaves an orphan charge. + * Exported so the gate contract is unit-tested independent of `useWallet`. */ -export const isDisplayBalanceSufficient = ( - amountUsd: string | number, - spendableBalance: bigint | undefined -): boolean => { - if (spendableBalance === undefined) return false - const amount = typeof amountUsd === 'string' ? parseFloat(amountUsd) : amountUsd - // `Number.isFinite` rejects NaN, Infinity and -Infinity — the last is critical: - // `BigInt(Math.floor(Infinity * 1e6))` is `BigInt(Infinity)`, which THROWS a - // RangeError. A pasted/oversized amount must fail the gate, never crash it. - if (!Number.isFinite(amount) || amount < 0) return false - return spendableBalance >= BigInt(Math.floor(amount * 10 ** PEANUT_WALLET_TOKEN_DECIMALS)) +export const isAmountWithinBalance = (amountUsd: string | number, balanceUnits: bigint | undefined): boolean => { + if (balanceUnits === undefined) return false + const units = parseUsdAmountToUnits(amountUsd) + if (units === null) return false + return balanceUnits >= units } /** @@ -73,7 +90,7 @@ export const rainCentsToUsdcUnits = (spendingPowerCents: number | null | undefin * what `useSpendBundle` can actually route through right now. It is the base of * `computeDisplaySpendable` (which adds in-transit on top); it does NOT back the * input affordability gate — that gates on the displayed total via - * `isDisplayBalanceSufficient` (see `useWallet`). + * `isAmountWithinBalance` (see `useWallet`). */ export const computeAvailableSpendable = ( smartBalance: bigint, diff --git a/src/utils/friendly-error.utils.tsx b/src/utils/friendly-error.utils.tsx index 955f4e423..6f586f29a 100644 --- a/src/utils/friendly-error.utils.tsx +++ b/src/utils/friendly-error.utils.tsx @@ -3,16 +3,17 @@ import { BALANCE_SETTLING_MESSAGE } from '@/utils/balance.utils' /** Safely extract a string-form of an unknown error + its `.message` if any. * Lets the matchers below use `string` methods without unsafe property access * while still accepting whatever shape callers throw (Error, string, object). */ -function extractErrorParts(error: unknown): { text: string; message: string | undefined } { - if (typeof error === 'string') return { text: error, message: error } +function extractErrorParts(error: unknown): { text: string; message: string | undefined; name: string | undefined } { + if (typeof error === 'string') return { text: error, message: error, name: undefined } if (error && typeof error === 'object') { - const obj = error as { toString?: () => unknown; message?: unknown } + const obj = error as { toString?: () => unknown; message?: unknown; name?: unknown } const rawText = typeof obj.toString === 'function' ? obj.toString() : '' const text = typeof rawText === 'string' ? rawText : '' const message = typeof obj.message === 'string' ? obj.message : undefined - return { text, message } + const name = typeof obj.name === 'string' ? obj.name : undefined + return { text, message, name } } - return { text: '', message: undefined } + return { text: '', message: undefined, name: undefined } } /** @@ -41,7 +42,7 @@ export const rainCollateralErrorMessage = (error: unknown): string | null => { /** UI-friendly error message extractor. Matches substrings on common * wallet / viem / Peanut API error messages and returns user-facing copy. */ export const ErrorHandler = (error: unknown): string => { - const { text, message } = extractErrorParts(error) + const { text, message, name } = extractErrorParts(error) // Rain card-collateral errors — surface the backend's already user- // friendly copy verbatim (includes the "Try again in about M min." hint // on the cooldown case). Covers every spend path that touches Rain. @@ -49,7 +50,9 @@ export const ErrorHandler = (error: unknown): string => { if (rainMsg) return rainMsg // Spend passed the displayed-balance gate but couldn't be routed yet // (in-transit collateral not landed) — nudge a retry rather than "add funds". - if (text.includes('Insufficient spendable balance')) return BALANCE_SETTLING_MESSAGE + // Match the typed error's name first (stable) and fall back to the message. + if (name === 'InsufficientSpendableError' || text.includes('Insufficient spendable balance')) + return BALANCE_SETTLING_MESSAGE if (text.includes('insufficient funds')) return "You don't have enough funds." if (text.includes('user rejected transaction')) return 'Please confirm the transaction in your wallet.' if (text.includes('not deployed on chain')) return 'Bulk is not able on this chain, please try another chain.' From 4661a2b3845c7c9c9c07a5d934e0d0dac1774169 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 24 Jun 2026 07:28:27 -0700 Subject: [PATCH 49/52] fix(balance): preserve submit-time errors + don't disable Continue during load CodeRabbit on the adversarial-fix commit: - Send-link (Major): the balance effect cleared errorState on every sufficient- balance pass, wiping a submit-time failure message (e.g. the settling copy) the moment loading returned to idle. Now it only clears OUR balance-gate error (INSUFFICIENT_BALANCE_MESSAGE), never a handleOnNext failure message. - withdraw page (Minor): isContinueDisabled used maxDecimalAmount (=0 while the balance loads), disabling Continue during the load window. Guard it on a loaded balance, matching the validateAmount fix. --- src/app/(mobile-ui)/withdraw/page.tsx | 6 ++++-- .../Send/link/views/Initial.link.send.view.tsx | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index 55ecdd445..6eb1a7f91 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -347,8 +347,10 @@ export default function WithdrawPage() { const usdEq = (selectedTokenData?.price ?? 1) * numericAmount if (usdEq < minUsdAmount) return true // below country-specific minimum - return numericAmount > maxDecimalAmount || error.showError - }, [rawTokenAmount, maxDecimalAmount, error.showError, selectedTokenData?.price, minUsdAmount]) + // 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, selectedTokenData?.price, minUsdAmount]) // native app: render country-specific views when ?country= is present const viewFromQuery = searchParams.get('view') diff --git a/src/components/Send/link/views/Initial.link.send.view.tsx b/src/components/Send/link/views/Initial.link.send.view.tsx index 94d95baf3..2e9bf40d1 100644 --- a/src/components/Send/link/views/Initial.link.send.view.tsx +++ b/src/components/Send/link/views/Initial.link.send.view.tsx @@ -151,10 +151,20 @@ const LinkSendInitialView = () => { // is ~30s-polled, so blocking it here would over-reject routable funds. if (!isAmountWithinBalance(tokenValue, balance)) { setErrorState({ showError: true, errorMessage: INSUFFICIENT_BALANCE_MESSAGE }) - } else { + } else if (errorState?.errorMessage === INSUFFICIENT_BALANCE_MESSAGE) { + // only clear OUR balance-gate error — never wipe a submit-time failure + // message (e.g. the settling copy) that handleOnNext set on a late failure. setErrorState({ showError: false, errorMessage: '' }) } - }, [peanutWalletBalance, balance, tokenValue, setErrorState, hasPendingTransactions, isLoading]) + }, [ + peanutWalletBalance, + balance, + tokenValue, + setErrorState, + hasPendingTransactions, + isLoading, + errorState?.errorMessage, + ]) return (
From bac6b0907ea1b6b0864e8e1063577a41082b25fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Wed, 24 Jun 2026 15:03:19 -0300 Subject: [PATCH 50/52] fix(add-money): skip the redundant second bank/method selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add-money made the user pick the method twice: 'Bank Transfer' at the entry (AddMoneyMethodSelection), then again on /add-money/[country] (which renders AddWithdrawCountriesList, the per-country method picker). The withdraw flow already avoids this via withdrawBankUrl (country click → bank step directly); add-money had no equivalent and used addMoneyCountryUrl → method picker. Add addMoneyBankUrl (mirrors withdrawBankUrl) and use it in handleCountryClick (only renders in the bank branch, so method is already 'bank'). Country click now goes straight to the bank step on web and native. Tests for both modes. --- src/app/(mobile-ui)/add-money/page.tsx | 7 +++++-- src/utils/__tests__/native-routes.test.ts | 13 +++++++++++++ src/utils/native-routes.ts | 9 +++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/app/(mobile-ui)/add-money/page.tsx b/src/app/(mobile-ui)/add-money/page.tsx index 495b65140..656c5c56e 100644 --- a/src/app/(mobile-ui)/add-money/page.tsx +++ b/src/app/(mobile-ui)/add-money/page.tsx @@ -17,7 +17,7 @@ import { useQueryState, parseAsStringEnum } from 'nuqs' import { checkIfInternalNavigation, getRedirectUrl, clearRedirectUrl, getFromLocalStorage } from '@/utils/general.utils' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import { addMoneyCountryUrl } from '@/utils/native-routes' +import { addMoneyBankUrl } from '@/utils/native-routes' export default function AddMoneyPage() { const router = useRouter() @@ -68,7 +68,10 @@ export default function AddMoneyPage() { method_type: 'bank', country: country.path, }) - router.push(addMoneyCountryUrl(country.path)) + // User already chose Bank Transfer (this handler only renders in the bank + // branch), so go straight to the bank step — don't re-show the method + // picker on /add-money/[country] (that was the double "select bank twice"). + router.push(addMoneyBankUrl(country.path)) } // native app: render sub-views based on query params diff --git a/src/utils/__tests__/native-routes.test.ts b/src/utils/__tests__/native-routes.test.ts index 2f995266f..22ad6a654 100644 --- a/src/utils/__tests__/native-routes.test.ts +++ b/src/utils/__tests__/native-routes.test.ts @@ -17,6 +17,7 @@ import { chargePayUrl, requestPotUrl, addMoneyCountryUrl, + addMoneyBankUrl, withdrawCountryUrl, withdrawBankUrl, rewriteMethodPath, @@ -68,6 +69,12 @@ describe('native-routes', () => { }) }) + describe('addMoneyBankUrl', () => { + it('should return /add-money with country + view=bank (skips the method picker)', () => { + expect(addMoneyBankUrl('belgium')).toBe('/add-money?country=belgium&view=bank') + }) + }) + describe('withdrawCountryUrl', () => { it('should return /withdraw with country query param', () => { expect(withdrawCountryUrl('be')).toBe('/withdraw?country=be') @@ -199,6 +206,12 @@ describe('native-routes', () => { }) }) + describe('addMoneyBankUrl', () => { + it('should return /add-money/{country}/bank path (skips the method picker)', () => { + expect(addMoneyBankUrl('belgium')).toBe('/add-money/belgium/bank') + }) + }) + describe('addMoneyCountryUrl', () => { it('should return /add-money/{country} path', () => { expect(addMoneyCountryUrl('belgium')).toBe('/add-money/belgium') diff --git a/src/utils/native-routes.ts b/src/utils/native-routes.ts index 11a2708ac..bb099d64a 100644 --- a/src/utils/native-routes.ts +++ b/src/utils/native-routes.ts @@ -39,6 +39,15 @@ export function addMoneyCountryUrl(countryPath: string): string { return isCapacitor() ? `/add-money?country=${encodeURIComponent(countryPath)}` : `/add-money/${countryPath}` } +// Straight to the bank step, skipping the redundant per-country method picker — +// mirrors withdrawBankUrl. Used when the user already chose "Bank Transfer" up +// front, so re-showing the method list on the country page is a double-select. +export function addMoneyBankUrl(countryPath: string): string { + return isCapacitor() + ? `/add-money?country=${encodeURIComponent(countryPath)}&view=bank` + : `/add-money/${countryPath}/bank` +} + export function withdrawCountryUrl(countryPath: string, queryParams?: string): string { if (isCapacitor()) { const qs = queryParams ? `&${queryParams.replace('?', '')}` : '' From f934eb42b282ae1be66b7073470175989380014a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Wed, 24 Jun 2026 15:09:11 -0300 Subject: [PATCH 51/52] test(add-money): country click goes straight to bank step Update the assertion that codified the old double-select behavior (/add-money/[country]) to the new direct-to-bank navigation. --- .../add-money/__tests__/add-money-states.test.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx index d749e8a0e..70c931d76 100644 --- a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx +++ b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx @@ -989,12 +989,14 @@ describe('GROUP 1: Landing / Method Selection', () => { expect(screen.getByText('Select your country')).toBeInTheDocument() }) - test('selecting a country from list navigates to country page', () => { + test('selecting a country (already in bank flow) navigates straight to the bank step', () => { resetQueryState({ method: 'bank' }) renderWithProviders() fireEvent.click(screen.getByTestId('country-argentina')) - expect(mockRouterPush).toHaveBeenCalledWith('/add-money/argentina') + // Method was already chosen ('bank'), so skip the redundant per-country + // method picker and go straight to the bank step. + expect(mockRouterPush).toHaveBeenCalledWith('/add-money/argentina/bank') }) test('back from method selection navigates to /home', () => { From ea33c3f2664d69586fcdd4969c7edda82a1a7a00 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 24 Jun 2026 14:53:39 -0700 Subject: [PATCH 52/52] fix(amount-input): reliably autofocus the amount field on desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The amount field stopped auto-focusing when entering some flows (reported on add money): it used React's `autoFocus` prop, which only fires at the exact moment the input mounts and silently no-ops when the input mounts after a client-side navigation / step transition. Replace it with an explicit inputRef.focus() in a useEffect gated on the same desktop-only condition (shouldAutoFocus = deviceType === WEB), so focus lands regardless of mount timing. Mobile is unchanged (autofocus stays off by design). Shared component — restores focus across send / withdraw / qr-pay / add-money without altering the mobile path. --- src/components/Global/AmountInput/index.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/Global/AmountInput/index.tsx b/src/components/Global/AmountInput/index.tsx index d2f256fbe..d3bcf3788 100644 --- a/src/components/Global/AmountInput/index.tsx +++ b/src/components/Global/AmountInput/index.tsx @@ -216,6 +216,14 @@ const AmountInput = ({ } }, [displayValue]) + // Autofocus the amount field on mount (desktop only). Done explicitly via the + // ref instead of React's `autoFocus` prop, which only fires at the exact moment + // of mount and silently no-ops when the input mounts after a client-side + // navigation/step transition (the add-money amount screen regressed this way). + useEffect(() => { + if (shouldAutoFocus) inputRef.current?.focus() + }, [shouldAutoFocus]) + return (
{