From 95b07e046ad76414236ef3e981049b4ff86ce0ba Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Fri, 24 Jul 2026 10:26:11 +0000 Subject: [PATCH 01/19] feat: chain-aware ENS resolution + visible resolved address in withdraw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ENS names can hold a different address per chain (ENSIP-11). The withdraw flow resolved every name to its mainnet record and sent on the selected chain — funds loss when the name points elsewhere on that chain (reported by an external ENS-savvy tester with test.ses.eth: mainnet and Arbitrum records differ). - resolveEns/validateAndResolveRecipient accept the destination chainId and forward it to /ens/:name?chainId= (api-ts #1236). - Switching chains after typing a name re-resolves it for the new chain instead of silently keeping the old address. - The resolved address is now shown under the input as soon as the name validates, and again in the compatibility warning modal — the user sees where funds go BEFORE any warning/confirm step. (cherry picked from commit f2c0aacdb6a73733134d6405c51568140c95c45d) --- src/app/(mobile-ui)/withdraw/crypto/page.tsx | 22 ++++++++- src/app/actions/ens.ts | 9 +++- .../Global/GeneralRecipientInput/index.tsx | 13 +++++- .../Withdraw/views/Initial.withdraw.view.tsx | 46 ++++++++++++++++++- src/lib/validation/recipient.test.ts | 31 +++++++++---- src/lib/validation/recipient.ts | 7 ++- 6 files changed, 110 insertions(+), 18 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index 64f5b64648..2bacb331c6 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -34,7 +34,7 @@ import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN, PEANUT_WALLET_TOKEN_DECIMALS import { ROUTE_NOT_FOUND_ERROR } from '@/constants/general.consts' import { useCrossChainTransfer } from '@/features/payments/shared/hooks/useCrossChainTransfer' import { usePaymentRecorder } from '@/features/payments/shared/hooks/usePaymentRecorder' -import { isTxReverted } from '@/utils/general.utils' +import { isTxReverted, printableAddress } from '@/utils/general.utils' import { appBaseUrl } from '@/utils/url.utils' import { ErrorHandler } from '@/utils/friendly-error.utils' import posthog from 'posthog-js' @@ -621,7 +621,25 @@ export default function WithdrawCryptoPage() { }} preventClose={isPreparingReview} title="Is this address compatible?" - description="Only send to address that support the selected network and token. Incorrect transfers may be lost." + description={ +
+

+ Only send to an address that supports the selected network and token. Incorrect transfers + may be lost. +

+ {/* Show the concrete destination so the user confirms a real + address, not an abstract warning — for ENS recipients this + is the first time the resolved address is visible. */} + {!!withdrawData?.address && ( +

+ You're sending to{' '} + + {printableAddress(withdrawData.address)} + +

+ )} +
+ } icon="alert" footer={
diff --git a/src/app/actions/ens.ts b/src/app/actions/ens.ts index edffc3afa0..4271e7d51e 100644 --- a/src/app/actions/ens.ts +++ b/src/app/actions/ens.ts @@ -2,8 +2,13 @@ import { unstable_cache } from '@/utils/no-cache' import { serverFetch } from '@/utils/api-fetch' export const resolveEns = unstable_cache( - async (ensName: string): Promise => { - const response = await serverFetch(`/ens/${encodeURIComponent(ensName)}`, { + async (ensName: string, chainId?: string): Promise => { + // ENSIP-11: names can hold a distinct address per chain — resolve for + // the destination chain, not just mainnet. Backend falls back to the + // ETH (coinType 60) record when no chain-specific record exists. + const numericChainId = chainId ? Number(chainId) : undefined + const query = numericChainId && Number.isInteger(numericChainId) ? `?chainId=${numericChainId}` : '' + const response = await serverFetch(`/ens/${encodeURIComponent(ensName)}${query}`, { method: 'GET', }) if (response.status === 404) return undefined diff --git a/src/components/Global/GeneralRecipientInput/index.tsx b/src/components/Global/GeneralRecipientInput/index.tsx index 1a4b3c9e37..c20e5911c1 100644 --- a/src/components/Global/GeneralRecipientInput/index.tsx +++ b/src/components/Global/GeneralRecipientInput/index.tsx @@ -22,6 +22,9 @@ type GeneralRecipientInputProps = { * Solana/Tron short-circuit the IBAN/US-routing/ENS branches — a base58 * address is the only valid input for them. */ addressFamily?: WithdrawAddressFamily + /** Destination chain id — ENS names resolve to their ENSIP-11 per-chain + * address record for this chain (mainnet record when omitted). */ + chainId?: string } export type GeneralRecipientUpdate = { @@ -41,6 +44,7 @@ const GeneralRecipientInput = ({ showInfoText = true, isWithdrawal = false, addressFamily = 'evm', + chainId, }: GeneralRecipientInputProps) => { const recipientType = useRef('address') const errorMessage = useRef('') @@ -78,7 +82,12 @@ const GeneralRecipientInput = ({ isValid = true } else { try { - const validation = await validateAndResolveRecipient(trimmedInput, isWithdrawal) + const validation = await validateAndResolveRecipient( + trimmedInput, + isWithdrawal, + addressFamily, + chainId + ) isValid = true resolvedAddress.current = validation.resolvedAddress @@ -101,7 +110,7 @@ const GeneralRecipientInput = ({ return false } }, - [isWithdrawal, addressFamily] + [isWithdrawal, addressFamily, chainId] ) const onInputUpdate = useCallback( diff --git a/src/components/Withdraw/views/Initial.withdraw.view.tsx b/src/components/Withdraw/views/Initial.withdraw.view.tsx index fc4ff47e82..6a72518a58 100644 --- a/src/components/Withdraw/views/Initial.withdraw.view.tsx +++ b/src/components/Withdraw/views/Initial.withdraw.view.tsx @@ -9,12 +9,13 @@ import { useWithdrawFlow } from '@/context/WithdrawFlowContext' import { tokenSelectorContext } from '@/context/tokenSelector.context' import { type ITokenPriceData } from '@/interfaces' import type { ChainWithTokens } from '@/interfaces/chain-meta' -import { formatAmount } from '@/utils/general.utils' +import { formatAmount, printableAddress } from '@/utils/general.utils' import { useRouter } from 'next/navigation' import { useContext, useEffect, useMemo, useRef } from 'react' import TokenSelector from '@/components/Global/TokenSelector/TokenSelector' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts' import { addressFamilyForChainId } from '@/lib/validation/addressFamily' +import { validateAndResolveRecipient } from '@/lib/validation/recipient' interface InitialWithdrawViewProps { amount: string @@ -70,6 +71,38 @@ export default function InitialWithdrawView({ amount, onReview, onBack, isProces } }, [selectedChainID, error.showError, setError]) + // ENS names resolve per destination chain (ENSIP-11) — a validated name + // must re-resolve when the user switches chains after typing it, or the + // withdraw would go to the previous chain's address. Kept separate from the + // error-clearing effect above: its error.showError dep would abort an + // in-flight resolution via this cleanup. + const prevResolvedChainRef = useRef(selectedChainID) + useEffect(() => { + if (prevResolvedChainRef.current === selectedChainID) return + prevResolvedChainRef.current = selectedChainID + if (addressFamily !== 'evm' || !recipient.name) return + + const name = recipient.name + let stale = false + validateAndResolveRecipient(name, true, 'evm', selectedChainID) + .then((validation) => { + if (stale) return + setRecipient({ name, address: validation.resolvedAddress }) + }) + .catch(() => { + if (stale) return + setRecipient({ name: undefined, address: '' }) + setIsValidRecipient(false) + setError({ + showError: true, + errorMessage: 'Could not resolve the ENS name for the selected network', + }) + }) + return () => { + stale = true + } + }, [selectedChainID, addressFamily, recipient.name, setRecipient, setIsValidRecipient, setError]) + const handleReview = () => { // Context record already includes the synthetic non-EVM withdraw // destinations (merged once in tokenSelector.context). @@ -146,6 +179,7 @@ export default function InitialWithdrawView({ amount, onReview, onBack, isProces { setRecipient(update.recipient) @@ -160,6 +194,16 @@ export default function InitialWithdrawView({ amount, onReview, onBack, isProces isWithdrawal /> + {/* Surface the resolved address as soon as an ENS name validates — + the user must see where funds will actually go before any + review/warning step (external tester feedback). */} + {!!recipient.name && !!recipient.address && isValidRecipient && !inputChanging && ( +

+ {recipient.name} resolves to{' '} + {printableAddress(recipient.address)} +

+ )} + + ))} +
+ ) + }, +})) + +jest.mock('@/components/Global/DocsLink', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => {children}, +})) + +jest.mock('posthog-js', () => ({ capture: jest.fn() })) + +const mockCaptureException = jest.fn() +jest.mock('@sentry/nextjs', () => ({ + captureException: (...args: unknown[]) => mockCaptureException(...args), +})) + +import ReConsentModal from '../index' + +const statusDoc = (slug: string) => ({ + slug, + currentVersion: '2026-07-15', + acceptedVersion: null, + acceptedAt: null, + needsAcceptance: true, +}) + +const flush = () => act(async () => {}) + +beforeEach(() => { + jest.clearAllMocks() + mockUser = { user: { userId: 'user-1' } } +}) + +describe('ReConsentModal', () => { + it('fails open: a failed status check never shows (or locks) the modal', async () => { + mockGetStatus.mockRejectedValue(new Error('api down')) + render() + await flush() + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + // ...but prod is told about it + expect(mockCaptureException).toHaveBeenCalledTimes(1) + }) + + it('stays hidden when no re-consent is needed', async () => { + mockGetStatus.mockResolvedValue({ needsReConsent: false, documents: [] }) + render() + await flush() + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + }) + + it('stays hidden when the only outdated docs are ones this client cannot display', async () => { + mockGetStatus.mockResolvedValue({ + needsReConsent: true, + documents: [statusDoc('some-future-doc-this-build-does-not-know')], + }) + render() + await flush() + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + }) + + it('shows outdated docs and records acceptance of exactly what was displayed', async () => { + mockGetStatus.mockResolvedValue({ + needsReConsent: true, + documents: [statusDoc('terms'), statusDoc('privacy')], + }) + mockAccept.mockResolvedValue({ recorded: 2 }) + render() + await flush() + + expect(screen.getByTestId('modal')).toBeInTheDocument() + const cta = screen.getByText('Accept & continue') + expect(cta).toBeDisabled() + + fireEvent.click(screen.getByTestId('consent-checkbox')) + await act(async () => { + fireEvent.click(screen.getByText('Accept & continue')) + }) + + expect(mockAccept).toHaveBeenCalledWith([ + expect.objectContaining({ slug: 'terms' }), + expect.objectContaining({ slug: 'privacy' }), + ]) + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + }) + + it('a failed accept keeps the modal, shows the error, and leaves retry enabled', async () => { + mockGetStatus.mockResolvedValue({ needsReConsent: true, documents: [statusDoc('terms')] }) + mockAccept.mockRejectedValue(new Error('500')) + render() + await flush() + + fireEvent.click(screen.getByTestId('consent-checkbox')) + await act(async () => { + fireEvent.click(screen.getByText('Accept & continue')) + }) + + expect(screen.getByTestId('modal')).toBeInTheDocument() + expect(screen.getByText(/could not save your acceptance/i)).toBeInTheDocument() + expect(screen.getByText('Accept & continue')).not.toBeDisabled() + expect(mockCaptureException).toHaveBeenCalledTimes(1) + }) + + it('discards a slow status response from the previous account after a switch', async () => { + let resolvePreviousUser: (v: unknown) => void = () => undefined + mockGetStatus.mockImplementationOnce(() => new Promise((resolve) => (resolvePreviousUser = resolve))) + const { rerender } = render() + + mockGetStatus.mockResolvedValueOnce({ needsReConsent: false, documents: [] }) + mockUser = { user: { userId: 'user-2' } } + rerender() + await flush() + + // user-1's response arrives late — it must not populate user-2's modal + await act(async () => { + resolvePreviousUser({ needsReConsent: true, documents: [statusDoc('terms')] }) + }) + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + }) + + it('re-checks (and resets state) for each distinct user, once per session', async () => { + mockGetStatus.mockResolvedValue({ needsReConsent: false, documents: [] }) + const { rerender } = render() + await flush() + rerender() + await flush() + // same user re-rendering → still one check + expect(mockGetStatus).toHaveBeenCalledTimes(1) + + mockUser = { user: { userId: 'user-2' } } + rerender() + await flush() + expect(mockGetStatus).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/components/Global/ReConsentModal/index.tsx b/src/components/Global/ReConsentModal/index.tsx index d7161c1e39..974aef1fe3 100644 --- a/src/components/Global/ReConsentModal/index.tsx +++ b/src/components/Global/ReConsentModal/index.tsx @@ -1,5 +1,6 @@ 'use client' import { useEffect, useRef, useState } from 'react' +import * as Sentry from '@sentry/nextjs' import posthog from 'posthog-js' import ActionModal from '../ActionModal' import DocsLink from '@/components/Global/DocsLink' @@ -22,7 +23,8 @@ const DOC_LABELS: Record = { * Re-consent click-through (tos-v1 phase 2, ToS §17): when a legal document's * published version moves past what the user last provably accepted, this * blocking modal lists the updated documents and appends fresh consent-ledger - * rows on acceptance. Backed by GET/POST /users/consent — see peanut-api. + * rows on acceptance. Backed by GET /users/consent/status and + * POST /users/consent/accept — see peanut-api. */ const ReConsentModal = () => { const { user } = useAuth() @@ -60,8 +62,10 @@ const ReConsentModal = () => { }) }) .catch((e) => { - // a failed status check must never lock the app — retry next session - console.error('[re-consent] failed to load consent status', e) + // a failed status check must never lock the app — retry next + // session. Sentry (not console): a systematic failure here means + // re-consent silently stops rolling out, and prod must say so. + Sentry.captureException(e, { tags: { feature: 're-consent', action: 'status' } }) }) }, [user]) @@ -80,7 +84,9 @@ const ReConsentModal = () => { // must start with an unticked box setChecked(false) } catch (e) { - console.error('[re-consent] failed to record acceptance', e) + // Sentry (not console): if /accept fails systematically, every user + // sits behind this undismissable modal — that must be visible in prod + Sentry.captureException(e, { tags: { feature: 're-consent', action: 'accept' } }) setError('Could not save your acceptance — please try again.') } finally { setSubmitting(false) diff --git a/src/services/__tests__/consent.test.ts b/src/services/__tests__/consent.test.ts new file mode 100644 index 0000000000..5bda12cd07 --- /dev/null +++ b/src/services/__tests__/consent.test.ts @@ -0,0 +1,43 @@ +/** + * Consent echo helpers — the doc sets each acceptance surface ledgers. + * + * Silent drift here mis-records legal consent (a user shown the international + * card terms must never be ledgered as accepting the US ones), so the exact + * slug sets are pinned, and every entry must carry the generated version+hash + * for its own slug. + */ +jest.mock('@/utils/api-fetch', () => ({ apiFetch: jest.fn() })) + +import { signupConsentDocuments, cardConsentDocuments } from '../consent' +import { LEGAL_DOCUMENT_VERSIONS, type LegalDocumentSlug } from '@/constants/legal-versions.generated' + +const expectEntriesMatchGenerated = (docs: { slug: string; version: string; hash: string }[]) => { + for (const doc of docs) { + const generated = LEGAL_DOCUMENT_VERSIONS[doc.slug as LegalDocumentSlug] + expect(generated).toBeDefined() + expect(doc.version).toBe(generated.version) + expect(doc.hash).toBe(generated.hash) + } +} + +describe('signupConsentDocuments', () => { + it('echoes exactly terms + privacy, with the generated version and hash', () => { + const docs = signupConsentDocuments() + expect(docs.map((d) => d.slug)).toEqual(['terms', 'privacy']) + expectEntriesMatchGenerated(docs) + }) +}) + +describe('cardConsentDocuments', () => { + it('US residents: card-terms-us + card-esign + card-privacy', () => { + const docs = cardConsentDocuments(true) + expect(docs.map((d) => d.slug)).toEqual(['card-terms-us', 'card-esign', 'card-privacy']) + expectEntriesMatchGenerated(docs) + }) + + it('international residents: card-terms-international + card-esign — and never the US terms', () => { + const docs = cardConsentDocuments(false) + expect(docs.map((d) => d.slug)).toEqual(['card-terms-international', 'card-esign']) + expectEntriesMatchGenerated(docs) + }) +}) From a3c14978cc109eb4c76ce3530bf6b3dd9b47706f Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Wed, 29 Jul 2026 16:09:48 +0200 Subject: [PATCH 09/19] fix(card): force collateral-only routing on lock/cancel Lock/cancel sign a withdrawal whose purpose is draining Rain collateral back to the wallet, but since cb302d35a removed the smartBalance:0n input, routing fell through to live-balance strategy selection: any user whose wallet USDC covered their spending power routed smart-only, tripped the modals' own strategy check, and could neither lock nor cancel ('Unexpected withdrawal strategy', prod, 3 users affected). Also fail closed when the card overview hasn't loaded: undefined read as zero spending power, silently skipping the withdrawal and getting the action rejected server-side ('Withdrawal signature required'). Port of the dev-based #2570 (closed in favor of this main-based hotfix; that branch holds the i18n-ified variant for the dev back-merge). --- src/components/Card/CancelCardModal.tsx | 7 + src/components/Card/LockCardModal.tsx | 16 +- .../Card/__tests__/LockCardModal.test.tsx | 158 ++++++++++++++++++ 3 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 src/components/Card/__tests__/LockCardModal.test.tsx diff --git a/src/components/Card/CancelCardModal.tsx b/src/components/Card/CancelCardModal.tsx index 57602ce962..6b8a1ce4ed 100644 --- a/src/components/Card/CancelCardModal.tsx +++ b/src/components/Card/CancelCardModal.tsx @@ -57,6 +57,12 @@ const CancelCardModal: FC = ({ cardId, isOpen, onClose }) => { setPhase('canceling') setError(null) try { + // An unloaded overview reads as zero spending power below, which + // would skip the withdrawal and get the cancel rejected by the + // backend ("Withdrawal signature required"). Fail closed instead. + if (!overview) { + throw new Error('Card details still loading — please retry in a moment') + } // Cancel can be terminal on Rain's side (collateral contract may // become unreachable), so we MUST drain it BEFORE the cancel. // Backend enforces order — this just delivers the signed body. @@ -72,6 +78,7 @@ const CancelCardModal: FC = ({ cardId, isOpen, onClose }) => { recipient: smartWalletAddress as `0x${string}`, rainSpendingPower: spendingPowerUnits, kind: 'CRYPTO_WITHDRAW', + forceStrategy: 'collateral-only', }) if (artifact.strategy !== 'collateral-only') { throw new Error('Unexpected withdrawal strategy — please contact support') diff --git a/src/components/Card/LockCardModal.tsx b/src/components/Card/LockCardModal.tsx index b7dda32168..4748f5eed5 100644 --- a/src/components/Card/LockCardModal.tsx +++ b/src/components/Card/LockCardModal.tsx @@ -63,6 +63,12 @@ const LockCardModal: FC = ({ cardId, mode, isOpen, onClose }) => { setError(null) try { if (mode === 'lock') { + // An unloaded overview reads as zero spending power below, which + // would skip the withdrawal and get the lock rejected by the + // backend ("Withdrawal signature required"). Fail closed instead. + if (!overview) { + throw new Error('Card details still loading — please retry in a moment') + } // If the user has spending power, return collateral to their // smart wallet BEFORE locking so funds stay liquid. The // backend gates the lock on a successful withdrawal — order @@ -73,15 +79,17 @@ const LockCardModal: FC = ({ cardId, mode, isOpen, onClose }) => { if (!smartWalletAddress) { throw new Error('Wallet not ready — please retry in a moment') } - // Force collateral-only routing: smart=0n eliminates the - // smart-only and mixed branches, so the strategy resolver - // picks 'collateral-only' and signs a Rain withdrawal - // straight to the user's smart wallet (1 passkey tap). + // Routing MUST NOT pick smart-only here: the point of this + // spend is to drain Rain collateral back to the wallet, so a + // smart-account transfer would be a self-transfer no-op that + // leaves the collateral behind. (The `smartBalance: 0n` that + // used to force this was removed in cb302d35a.) const artifact = await signSpend({ requiredUsdcAmount: spendingPowerUnits, recipient: smartWalletAddress as `0x${string}`, rainSpendingPower: spendingPowerUnits, kind: 'CRYPTO_WITHDRAW', + forceStrategy: 'collateral-only', }) if (artifact.strategy !== 'collateral-only') { throw new Error('Unexpected withdrawal strategy — please contact support') diff --git a/src/components/Card/__tests__/LockCardModal.test.tsx b/src/components/Card/__tests__/LockCardModal.test.tsx new file mode 100644 index 0000000000..baa13923e6 --- /dev/null +++ b/src/components/Card/__tests__/LockCardModal.test.tsx @@ -0,0 +1,158 @@ +/** + * Regression tests for the lock/cancel collateral withdrawal (prod incident + * 2026-07-24): both modals MUST force collateral-only routing when returning + * spending power. Without `forceStrategy`, live routing picks smart-only + * whenever the smart wallet covers the amount (spendPreflight), and the + * modals then reject their own artifact ("Unexpected withdrawal strategy"), + * so users with wallet balance ≥ card balance could neither lock nor cancel. + * + * Contracts locked down here, for BOTH modals: + * 1. spendingPower > 0 → signSpend is called WITH forceStrategy: + * 'collateral-only' (the assertion that catches the regression) and the + * signed withdrawal is delivered to the backend call, + * 2. an unloaded overview fails closed BEFORE signing — undefined reads as + * zero spending power, which would silently skip the withdrawal and get + * the action rejected server-side, + * 3. a loaded overview with zero spending power proceeds without signing + * (no passkey prompt when there is nothing to return). + */ +import React, { type ReactNode } from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import LockCardModal from '@/components/Card/LockCardModal' +import CancelCardModal from '@/components/Card/CancelCardModal' +import { useRainCardOverview } from '@/hooks/useRainCardOverview' +import { useWallet } from '@/hooks/wallet/useWallet' +import { useSignSpendBundle } from '@/hooks/wallet/useSignSpendBundle' +import { rainApi } from '@/services/rain' + +const WALLET = '0xafbea1a6a6036d7d827e08072cd4315248b77352' + +jest.mock('posthog-js', () => ({ __esModule: true, default: { capture: jest.fn() } })) +jest.mock('@/hooks/useRainCardOverview', () => ({ + useRainCardOverview: jest.fn(), + RAIN_CARD_OVERVIEW_QUERY_KEY: 'rain-card-overview', +})) +jest.mock('@/hooks/wallet/useWallet', () => ({ useWallet: jest.fn() })) +jest.mock('@/hooks/wallet/useSignSpendBundle', () => ({ useSignSpendBundle: jest.fn() })) +jest.mock('@/services/rain', () => ({ + rainApi: { + lockCard: jest.fn(), + activateCard: jest.fn(), + cancelCard: jest.fn(), + submitCancellationFeedback: jest.fn(), + }, +})) +// Modal chrome and the slide gesture are not under test — render passthroughs. +jest.mock('@/components/Global/Modal', () => ({ + __esModule: true, + default: ({ visible, children }: { visible: boolean; children: React.ReactNode }) => + visible ?
{children}
: null, +})) +jest.mock('@/components/Card/SlideToAction', () => ({ + __esModule: true, + default: ({ label, onComplete, disabled }: { label: string; onComplete: () => void; disabled?: boolean }) => ( + + ), +})) + +const mockOverview = useRainCardOverview as jest.Mock +const mockUseWallet = useWallet as jest.Mock +const mockUseSignSpendBundle = useSignSpendBundle as jest.Mock +const mockLockCard = rainApi.lockCard as jest.Mock +const mockCancelCard = rainApi.cancelCard as jest.Mock +const mockSignSpend = jest.fn() + +const RAIN_WITHDRAWAL = { preparationId: 'prep-1', amount: '10060000' } +// $10.06 spending power — the reporting user's exact state. +const OVERVIEW = { balance: { spendingPower: 1006 } } +const FORCED_SIGN_ARGS = { + requiredUsdcAmount: 10_060_000n, // 1006 cents → 6dp USDC units + recipient: WALLET, + rainSpendingPower: 10_060_000n, + kind: 'CRYPTO_WITHDRAW', + forceStrategy: 'collateral-only', +} + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +) + +const setup = (overview?: { balance: { spendingPower: number } }) => { + mockOverview.mockReturnValue({ overview }) + mockUseWallet.mockReturnValue({ address: WALLET }) + mockUseSignSpendBundle.mockReturnValue({ signSpend: mockSignSpend }) +} + +const renderLock = () => + render(, { wrapper: Wrapper }) +const renderCancel = () => render(, { wrapper: Wrapper }) + +beforeEach(() => { + jest.clearAllMocks() + mockSignSpend.mockResolvedValue({ strategy: 'collateral-only', rainWithdrawal: RAIN_WITHDRAWAL }) + mockLockCard.mockResolvedValue({}) + mockCancelCard.mockResolvedValue({}) +}) + +describe('LockCardModal — lock with spending power', () => { + it('forces collateral-only routing and delivers the withdrawal to the lock call', async () => { + setup(OVERVIEW) + renderLock() + fireEvent.click(screen.getByText('Slide to Lock')) + expect(await screen.findByText('Card locked')).toBeInTheDocument() + expect(mockSignSpend).toHaveBeenCalledWith(FORCED_SIGN_ARGS) + expect(mockLockCard).toHaveBeenCalledWith('card-1', RAIN_WITHDRAWAL) + }) + + it('fails closed before signing when the overview has not loaded', async () => { + setup(undefined) + renderLock() + fireEvent.click(screen.getByText('Slide to Lock')) + expect(await screen.findByText(/still loading/)).toBeInTheDocument() + expect(mockSignSpend).not.toHaveBeenCalled() + expect(mockLockCard).not.toHaveBeenCalled() + }) + + it('locks without signing when there is no spending power to return', async () => { + setup({ balance: { spendingPower: 0 } }) + renderLock() + fireEvent.click(screen.getByText('Slide to Lock')) + expect(await screen.findByText('Card locked')).toBeInTheDocument() + expect(mockSignSpend).not.toHaveBeenCalled() + expect(mockLockCard).toHaveBeenCalledWith('card-1', undefined) + }) +}) + +describe('CancelCardModal', () => { + it('forces collateral-only routing and delivers the withdrawal to the cancel call', async () => { + setup(OVERVIEW) + renderCancel() + fireEvent.click(screen.getByText('Slide to Cancel')) + expect(await screen.findByText('Card canceled')).toBeInTheDocument() + expect(mockSignSpend).toHaveBeenCalledWith(FORCED_SIGN_ARGS) + expect(mockCancelCard).toHaveBeenCalledWith('card-1', { verifiedWithdrawal: RAIN_WITHDRAWAL }) + }) + + it('fails closed before signing when the overview has not loaded', async () => { + setup(undefined) + renderCancel() + fireEvent.click(screen.getByText('Slide to Cancel')) + expect(await screen.findByText(/still loading/)).toBeInTheDocument() + expect(mockSignSpend).not.toHaveBeenCalled() + expect(mockCancelCard).not.toHaveBeenCalled() + }) + + it('cancels without signing when there is no spending power to return', async () => { + setup({ balance: { spendingPower: 0 } }) + renderCancel() + fireEvent.click(screen.getByText('Slide to Cancel')) + expect(await screen.findByText('Card canceled')).toBeInTheDocument() + expect(mockSignSpend).not.toHaveBeenCalled() + expect(mockCancelCard).toHaveBeenCalledWith('card-1', { verifiedWithdrawal: undefined }) + }) +}) From 27e8eae11f82c29f18fd4319b70c73393ee82898 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:49:58 +0530 Subject: [PATCH 10/19] fix(withdraw): stop success receipt showing 'Sent to undefined' external-wallet withdrawals have no recipient username or parsed identifier, so the drawer's userName was undefined and the header rendered the literal string. fall back to the recipient address (shortened by printableUserHandle) like recipientName already does. --- .../payments/shared/components/PaymentSuccessView.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/features/payments/shared/components/PaymentSuccessView.tsx b/src/features/payments/shared/components/PaymentSuccessView.tsx index ef825ffb29..92e6e0fbb4 100644 --- a/src/features/payments/shared/components/PaymentSuccessView.tsx +++ b/src/features/payments/shared/components/PaymentSuccessView.tsx @@ -170,7 +170,13 @@ const PaymentSuccessView = ({ kind: 'DIRECT_TRANSFER', link: receiptLink, }, - userName: user?.username || parsedPaymentData?.recipient?.identifier, + // external-wallet withdrawals have no username/identifier — fall back to + // the recipient address so the receipt never renders "Sent to undefined" + userName: + user?.username || + parsedPaymentData?.recipient?.identifier || + chargeDetails.requestLink?.recipientAddress || + recipientName, sourceView: 'status', memo: chargeDetails.requestLink?.reference || undefined, attachmentUrl: chargeDetails.requestLink?.attachmentUrl || undefined, From fdbde988466578c443ca0956e7f723eba344543f Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:08:38 +0530 Subject: [PATCH 11/19] fix(ens): warm-cache resolved names in localstorage to stop address flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a cold mount (mobile reopening the pwa reloads the page) showed the raw address for a beat while the lookup ran — pay the 404, engage the client fallback, resolve, flip. persist resolved names for 24h and paint them immediately on mount while the lookup revalidates. an authoritative server "no name" evicts the entry so stale names can't mask reality. --- .../__tests__/usePrimaryNameServer.test.tsx | 26 ++++++++ src/hooks/usePrimaryNameServer.ts | 60 ++++++++++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/hooks/__tests__/usePrimaryNameServer.test.tsx b/src/hooks/__tests__/usePrimaryNameServer.test.tsx index ce148a5f5d..8e4bcefecb 100644 --- a/src/hooks/__tests__/usePrimaryNameServer.test.tsx +++ b/src/hooks/__tests__/usePrimaryNameServer.test.tsx @@ -22,6 +22,7 @@ const jsonResponse = (body: unknown, ok = true) => ({ ok, json: async () => body beforeEach(() => { jest.clearAllMocks() + window.localStorage.clear() mockUsePrimaryName.mockReturnValue({ primaryName: undefined, isLoading: false, error: null }) }) @@ -63,6 +64,31 @@ describe('usePrimaryNameServer', () => { expect(result.current.primaryName).toBeUndefined() }) + it('paints the cached name immediately on mount while the lookup is pending', async () => { + // seed the warm cache via a first mount that resolves + mockServerFetch.mockResolvedValue(jsonResponse({ name: 'alice.eth' })) + const first = renderHook(() => usePrimaryNameServer(ADDRESS), { wrapper }) + await waitFor(() => expect(first.result.current.primaryName).toBe('alice.eth')) + first.unmount() + + // fresh mount with a never-resolving lookup — cached name shows at once + mockServerFetch.mockImplementation(() => new Promise(() => {})) + const { result } = renderHook(() => usePrimaryNameServer(ADDRESS), { wrapper }) + expect(result.current.primaryName).toBe('alice.eth') + }) + + it('does not show a stale cached name once the server settles with no name', async () => { + window.localStorage.setItem( + 'ens-primary-name-cache', + JSON.stringify({ [ADDRESS.toLowerCase()]: { name: 'stale.eth', ts: Date.now() } }) + ) + mockServerFetch.mockResolvedValue(jsonResponse({ name: null })) + const { result } = renderHook(() => usePrimaryNameServer(ADDRESS), { wrapper }) + await waitFor(() => expect(result.current.primaryName).toBeUndefined()) + // authoritative "no name" also evicts the cache entry + await waitFor(() => expect(window.localStorage.getItem('ens-primary-name-cache')).not.toContain('stale.eth')) + }) + it('does not query for a non-address input', () => { renderHook(() => usePrimaryNameServer('not-an-address'), { wrapper }) expect(mockServerFetch).not.toHaveBeenCalled() diff --git a/src/hooks/usePrimaryNameServer.ts b/src/hooks/usePrimaryNameServer.ts index ec93fec9b6..960490e180 100644 --- a/src/hooks/usePrimaryNameServer.ts +++ b/src/hooks/usePrimaryNameServer.ts @@ -2,6 +2,7 @@ import { usePrimaryName } from '@justaname.id/react' import { useQuery } from '@tanstack/react-query' +import { useEffect, useMemo } from 'react' import { isAddress } from 'viem' import { serverFetch } from '@/utils/api-fetch' @@ -25,6 +26,44 @@ const PRIMARY_NAME_TTL_MS = 24 * 60 * 60 * 1000 // 1 day * `{ primaryName }` shape. If both paths fail, callers degrade to the raw * address exactly as before. */ +const CACHE_KEY = 'ens-primary-name-cache' + +type NameCache = Record + +// localstorage warm cache so a fresh page load (mobile reopening the pwa) +// paints the last-known name immediately instead of flashing the raw +// address while the async lookup runs. lookups still revalidate on mount. +function readNameCache(): NameCache { + if (typeof window === 'undefined') return {} + try { + return JSON.parse(window.localStorage.getItem(CACHE_KEY) ?? '{}') as NameCache + } catch { + return {} + } +} + +function getCachedName(address?: string): string | undefined { + if (!address) return undefined + const entry = readNameCache()[address.toLowerCase()] + return entry && Date.now() - entry.ts < PRIMARY_NAME_TTL_MS ? entry.name : undefined +} + +function writeCachedName(address: string, name: string | undefined) { + if (typeof window === 'undefined') return + try { + const cache = readNameCache() + const key = address.toLowerCase() + if (name) { + cache[key] = { name, ts: Date.now() } + } else { + delete cache[key] + } + window.localStorage.setItem(CACHE_KEY, JSON.stringify(cache)) + } catch { + // storage full/blocked — warm cache is best-effort + } +} + export function usePrimaryNameServer(address?: string): { primaryName: string | undefined } { const enabled = !!address && isAddress(address) @@ -52,5 +91,24 @@ export function usePrimaryNameServer(address?: string): { primaryName: string | priority: 'onChain', }) - return { primaryName: data ?? (isError ? clientName : undefined) } + // resolved: authoritative answer from either path. `data === null` means the + // server positively said "no name" — don't mask it with a stale cached name. + const settledNoName = data === null + const resolved = data ?? (isError ? normalizeToUndefined(clientName) : undefined) + + useEffect(() => { + if (!enabled || !address) return + if (resolved) writeCachedName(address, resolved) + else if (settledNoName) writeCachedName(address, undefined) + }, [enabled, address, resolved, settledNoName]) + + // read once per address, not on every render — history feeds mount many rows + const cachedName = useMemo(() => (enabled ? getCachedName(address) : undefined), [enabled, address]) + + return { primaryName: resolved ?? (settledNoName ? undefined : cachedName) } +} + +// justaname resolves "not found" as '' — treat it as undefined +function normalizeToUndefined(name: string | undefined): string | undefined { + return name || undefined } From 3645d598b4c8a29182d91416076f74becd1644c7 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:19:37 +0530 Subject: [PATCH 12/19] fix(ens): guard warm-cache root shape against malformed localstorage coderabbit: JSON.parse('null') passes the try/catch but null[address] throws during render. treat any non-object root as an empty cache. --- src/hooks/usePrimaryNameServer.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/usePrimaryNameServer.ts b/src/hooks/usePrimaryNameServer.ts index 960490e180..578bd8df72 100644 --- a/src/hooks/usePrimaryNameServer.ts +++ b/src/hooks/usePrimaryNameServer.ts @@ -36,7 +36,9 @@ type NameCache = Record function readNameCache(): NameCache { if (typeof window === 'undefined') return {} try { - return JSON.parse(window.localStorage.getItem(CACHE_KEY) ?? '{}') as NameCache + // guard the root shape — JSON.parse("null") etc. passes but would blow up on lookup + const cache: unknown = JSON.parse(window.localStorage.getItem(CACHE_KEY) ?? '{}') + return cache && typeof cache === 'object' && !Array.isArray(cache) ? (cache as NameCache) : {} } catch { return {} } From aa5da016ba424a3ccc50e90a6fb36b47ea1b62e2 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:25:44 +0530 Subject: [PATCH 13/19] fix(ens): evict warm cache on client-settled no-name; keep AddressLink href in sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit code-review findings: (1) with the server route erroring (today's prod state) the only eviction path was server null, which never fires — a client lookup settling '' (justaname's not-found) now also masks and evicts, so a released/transferred ens name can't keep painting from cache. (2) AddressLink's effect never reset urlAddress on the name→address downgrade, leaving the href pointing at a name the address may no longer own — reachable now that cached names can be evicted mid-mount. (3) server '' responses treated same as null. --- src/components/Global/AddressLink/index.tsx | 3 +++ src/hooks/__tests__/usePrimaryNameServer.test.tsx | 13 +++++++++++++ src/hooks/usePrimaryNameServer.ts | 15 ++++++--------- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/components/Global/AddressLink/index.tsx b/src/components/Global/AddressLink/index.tsx index bb9d86504a..e8ae85b795 100644 --- a/src/components/Global/AddressLink/index.tsx +++ b/src/components/Global/AddressLink/index.tsx @@ -29,6 +29,9 @@ const AddressLink = ({ address, className = '', isLink = true }: AddressLinkProp setUrlAddress(normalizedEnsName) } else { setDisplayAddress(isCryptoAddress(address) ? printableAddress(address) : address) + // keep the link target in sync — a cached/evicted ens name must not + // leave the href pointing at a name this address may no longer own + setUrlAddress(address) } }, [address, ensName]) diff --git a/src/hooks/__tests__/usePrimaryNameServer.test.tsx b/src/hooks/__tests__/usePrimaryNameServer.test.tsx index 8e4bcefecb..3bb6da99a5 100644 --- a/src/hooks/__tests__/usePrimaryNameServer.test.tsx +++ b/src/hooks/__tests__/usePrimaryNameServer.test.tsx @@ -89,6 +89,19 @@ describe('usePrimaryNameServer', () => { await waitFor(() => expect(window.localStorage.getItem('ens-primary-name-cache')).not.toContain('stale.eth')) }) + it('evicts the cached name when the client fallback settles with no name', async () => { + window.localStorage.setItem( + 'ens-primary-name-cache', + JSON.stringify({ [ADDRESS.toLowerCase()]: { name: 'stale.eth', ts: Date.now() } }) + ) + mockServerFetch.mockResolvedValue(jsonResponse({}, false)) + // justaname settles "not found" as '' (undefined would mean still loading) + mockUsePrimaryName.mockReturnValue({ primaryName: '', isLoading: false, error: null }) + const { result } = renderHook(() => usePrimaryNameServer(ADDRESS), { wrapper }) + await waitFor(() => expect(window.localStorage.getItem('ens-primary-name-cache')).not.toContain('stale.eth')) + expect(result.current.primaryName).toBeUndefined() + }) + it('does not query for a non-address input', () => { renderHook(() => usePrimaryNameServer('not-an-address'), { wrapper }) expect(mockServerFetch).not.toHaveBeenCalled() diff --git a/src/hooks/usePrimaryNameServer.ts b/src/hooks/usePrimaryNameServer.ts index 578bd8df72..ff07a79fd1 100644 --- a/src/hooks/usePrimaryNameServer.ts +++ b/src/hooks/usePrimaryNameServer.ts @@ -93,10 +93,12 @@ export function usePrimaryNameServer(address?: string): { primaryName: string | priority: 'onChain', }) - // resolved: authoritative answer from either path. `data === null` means the - // server positively said "no name" — don't mask it with a stale cached name. - const settledNoName = data === null - const resolved = data ?? (isError ? normalizeToUndefined(clientName) : undefined) + // authoritative "no name" from either path: server settles null/'' or, when + // the server call failed, the client lookup settles '' (justaname's not-found + // value — undefined means still loading). don't mask any of these with a + // stale cached name, and evict below so it can't repaint next mount. + const settledNoName = data === null || data === '' || (isError && clientName === '') + const resolved = (data || undefined) ?? (isError ? clientName || undefined : undefined) useEffect(() => { if (!enabled || !address) return @@ -109,8 +111,3 @@ export function usePrimaryNameServer(address?: string): { primaryName: string | return { primaryName: resolved ?? (settledNoName ? undefined : cachedName) } } - -// justaname resolves "not found" as '' — treat it as undefined -function normalizeToUndefined(name: string | undefined): string | undefined { - return name || undefined -} From b10ece9932bd0aa3f6de0c6701ddd8436324768b Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 29 Jul 2026 15:58:59 +0000 Subject: [PATCH 14/19] Update content submodule to latest main --- src/content | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content b/src/content index 6ad0006192..fc31f0776e 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit 6ad00061928298ea34cf0a76f5a91ef9d1dc2b42 +Subproject commit fc31f0776e5af113e6dba164776cf0f560c08eac From 8eeb3524d1f18cf099be2f542730fedcf6677080 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 29 Jul 2026 16:04:50 +0000 Subject: [PATCH 15/19] Update content submodule to latest main --- src/content | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content b/src/content index fc31f0776e..f33d67fdac 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit fc31f0776e5af113e6dba164776cf0f560c08eac +Subproject commit f33d67fdacc743ebce6bdb8e655654269a1c6c76 From 7cbe967b5775df29a071953faddf9cf23c911683 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 29 Jul 2026 17:50:33 +0100 Subject: [PATCH 16/19] =?UTF-8?q?fix(re-consent):=20make=20the=20terms=20p?= =?UTF-8?q?rompt=20escapable,=20per=20our=20own=20ToS=20=C2=A717?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modal shipped as a hard gate: preventClose, hidden close button, and a single Accept CTA. It mounts in the mobile-ui layout, so it covered the whole app, including /withdraw. That conflicts with the terms it enforces. §17.2 gives material changes a 30-day runway and offers the click-through as a way to accept sooner, by choice. We published on 2026-07-15, so the new terms take effect around 2026-08-14; a hard gate enforces them about two weeks early. §17.3 says a user who does not agree must stop using the Services. For a non-custodial wallet that must still leave a path to their own funds. So the prompt now defers instead of blocking: - "Not now" (plus close, backdrop and Escape) dismisses it. The checkbox does not gate the exit. - A dismissal snoozes the prompt for 3 days per user, in localStorage. It never writes a ledger row, because a refusal is not consent. - Accept reports modal_cta_clicked, not modal_dismissed. Both events existed already. We need the accept-vs-refuse ratio to measure the rollout. - Copy no longer says "To keep using Peanut". That claim is no longer true. Accept stays the primary action: purple, shadow, first in the stack. --- .../ReConsentModal/__tests__/index.test.tsx | 86 +++++++++++++++++-- .../Global/ReConsentModal/index.tsx | 68 +++++++++++---- src/components/Global/ReConsentModal/utils.ts | 48 +++++++++++ 3 files changed, 179 insertions(+), 23 deletions(-) create mode 100644 src/components/Global/ReConsentModal/utils.ts diff --git a/src/components/Global/ReConsentModal/__tests__/index.test.tsx b/src/components/Global/ReConsentModal/__tests__/index.test.tsx index bc067ae52e..c8088fa654 100644 --- a/src/components/Global/ReConsentModal/__tests__/index.test.tsx +++ b/src/components/Global/ReConsentModal/__tests__/index.test.tsx @@ -1,12 +1,13 @@ /** @jest-environment jsdom */ /** - * ReConsentModal — the ToS §17 blocking click-through. + * ReConsentModal — the ToS §17 click-through. * - * This modal gates the entire app, so the safety properties matter more than - * the happy path: a failed status check must NEVER lock the app (fail-open), - * a failed accept must keep the retry path alive, and an account switch must - * not leak the previous user's consent state (a regression already caught - * once in review). + * The safety properties matter more than the happy path: a failed status check + * must NEVER block the app (fail-open), a failed accept must keep the retry + * path alive, an account switch must not leak the previous user's consent state + * (a regression already caught once in review), and — because §17.2 gives a + * 30-day runway and §17.3 requires a decliner to still reach their funds — the + * prompt must ALWAYS be escapable and must never ledger a refusal as consent. */ import React from 'react' import { render, screen, fireEvent, act } from '@testing-library/react' @@ -64,7 +65,8 @@ jest.mock('@/components/Global/DocsLink', () => ({ default: ({ children }: { children: React.ReactNode }) => {children}, })) -jest.mock('posthog-js', () => ({ capture: jest.fn() })) +const mockCapture = jest.fn() +jest.mock('posthog-js', () => ({ capture: (...args: unknown[]) => mockCapture(...args) })) const mockCaptureException = jest.fn() jest.mock('@sentry/nextjs', () => ({ @@ -85,9 +87,14 @@ const flush = () => act(async () => {}) beforeEach(() => { jest.clearAllMocks() + // the snooze is persisted per-user in localStorage — a leaked entry would + // silently suppress the prompt in every later test + window.localStorage.clear() mockUser = { user: { userId: 'user-1' } } }) +const capturedEvents = () => mockCapture.mock.calls.map(([event]) => event) + describe('ReConsentModal', () => { it('fails open: a failed status check never shows (or locks) the modal', async () => { mockGetStatus.mockRejectedValue(new Error('api down')) @@ -138,6 +145,71 @@ describe('ReConsentModal', () => { expect.objectContaining({ slug: 'privacy' }), ]) expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + // acceptance must be distinguishable from refusal in analytics — + // the accept-vs-postpone ratio is the rollout's headline metric + expect(capturedEvents()).toEqual(['modal_shown', 'modal_cta_clicked']) + }) + + it('"Not now" is always available and never gated on the checkbox', async () => { + mockGetStatus.mockResolvedValue({ needsReConsent: true, documents: [statusDoc('terms')] }) + render() + await flush() + + expect(screen.getByText('Accept & continue')).toBeDisabled() + // the escape hatch must not require ticking a consent box first + expect(screen.getByText('Not now')).not.toBeDisabled() + }) + + it('"Not now" dismisses without recording any consent (a refusal is not a ledger row)', async () => { + mockGetStatus.mockResolvedValue({ needsReConsent: true, documents: [statusDoc('terms')] }) + render() + await flush() + + await act(async () => { + fireEvent.click(screen.getByText('Not now')) + }) + + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + expect(mockAccept).not.toHaveBeenCalled() + expect(capturedEvents()).toEqual(['modal_shown', 'modal_dismissed']) + }) + + it('a postponed prompt stays away on the next session, then returns once the snooze lapses', async () => { + mockGetStatus.mockResolvedValue({ needsReConsent: true, documents: [statusDoc('terms')] }) + const first = render() + await flush() + await act(async () => { + fireEvent.click(screen.getByText('Not now')) + }) + first.unmount() + + // fresh session (remount → fresh refs), still inside the snooze window + render() + await flush() + expect(screen.queryByTestId('modal')).not.toBeInTheDocument() + // and we don't even spend the request while snoozed + expect(mockGetStatus).toHaveBeenCalledTimes(1) + + // snooze lapses → the prompt comes back + window.localStorage.setItem('peanut.reconsent.snoozedUntil.user-1', String(Date.now() - 1)) + render() + await flush() + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + + it('one user postponing does not suppress the prompt for a different account', async () => { + mockGetStatus.mockResolvedValue({ needsReConsent: true, documents: [statusDoc('terms')] }) + const first = render() + await flush() + await act(async () => { + fireEvent.click(screen.getByText('Not now')) + }) + first.unmount() + + mockUser = { user: { userId: 'user-2' } } + render() + await flush() + expect(screen.getByTestId('modal')).toBeInTheDocument() }) it('a failed accept keeps the modal, shows the error, and leaves retry enabled', async () => { diff --git a/src/components/Global/ReConsentModal/index.tsx b/src/components/Global/ReConsentModal/index.tsx index 974aef1fe3..9691c0a612 100644 --- a/src/components/Global/ReConsentModal/index.tsx +++ b/src/components/Global/ReConsentModal/index.tsx @@ -8,6 +8,7 @@ import { useAuth } from '@/context/authContext' import { acceptedLegalDocument, consentApi, type ConsentStatusDocument } from '@/services/consent' import { LEGAL_DOCUMENT_VERSIONS, type LegalDocumentSlug } from '@/constants/legal-versions.generated' import { ANALYTICS_EVENTS, MODAL_TYPES } from '@/constants/analytics.consts' +import { isReConsentSnoozed, snoozeReConsent } from './utils' const DOC_LABELS: Record = { terms: { name: 'Terms of Service', href: '/terms' }, @@ -19,12 +20,21 @@ const DOC_LABELS: Record = { 'card-prohibited-activities': { name: 'Prohibited Activities Policy', href: '/card-prohibited-activities' }, } +/** Keep "Not now" stacked BELOW the primary CTA at every width — side-by-side + * would read as two equally-weighted choices. */ +const STACKED_CTAS = 'flex-col sm:flex-col' + /** * Re-consent click-through (tos-v1 phase 2, ToS §17): when a legal document's - * published version moves past what the user last provably accepted, this - * blocking modal lists the updated documents and appends fresh consent-ledger - * rows on acceptance. Backed by GET /users/consent/status and - * POST /users/consent/accept — see peanut-api. + * published version moves past what the user last provably accepted, this modal + * lists the updated documents and appends fresh consent-ledger rows on + * acceptance. Backed by GET /users/consent/status and POST /users/consent/accept. + * + * It is a PROMPT, not a gate. §17.2 gives material changes 30 days and offers + * the click-through as a way to accept sooner; §17.3 requires that a user who + * declines can still stop using the Services — which, for a non-custodial + * wallet, means they must be able to reach `/withdraw`. So "Not now" always + * exists, and dismissing never writes a ledger row (declining is not consent). */ const ReConsentModal = () => { const { user } = useAuth() @@ -45,6 +55,8 @@ const ReConsentModal = () => { setOutdatedDocs([]) setChecked(false) setError(null) + // a recent "Not now" defers the prompt — don't even spend the request + if (isReConsentSnoozed(userId)) return consentApi .getStatus() .then((status) => { @@ -62,7 +74,7 @@ const ReConsentModal = () => { }) }) .catch((e) => { - // a failed status check must never lock the app — retry next + // a failed status check must never block the app — retry next // session. Sentry (not console): a systematic failure here means // re-consent silently stops rolling out, and prod must say so. Sentry.captureException(e, { tags: { feature: 're-consent', action: 'status' } }) @@ -75,7 +87,9 @@ const ReConsentModal = () => { setError(null) try { await consentApi.accept(outdatedDocs.map((d) => acceptedLegalDocument(d.slug as LegalDocumentSlug))) - posthog.capture(ANALYTICS_EVENTS.MODAL_DISMISSED, { + // CTA_CLICKED, not DISMISSED — acceptance and refusal must be + // distinguishable, since their ratio is the rollout's headline metric + posthog.capture(ANALYTICS_EVENTS.MODAL_CTA_CLICKED, { modal_type: MODAL_TYPES.RE_CONSENT, documents: outdatedDocs.map((d) => d.slug), }) @@ -84,8 +98,8 @@ const ReConsentModal = () => { // must start with an unticked box setChecked(false) } catch (e) { - // Sentry (not console): if /accept fails systematically, every user - // sits behind this undismissable modal — that must be visible in prod + // Sentry (not console): if /accept fails systematically, nobody can + // record consent at all — that must be visible in prod Sentry.captureException(e, { tags: { feature: 're-consent', action: 'accept' } }) setError('Could not save your acceptance — please try again.') } finally { @@ -93,27 +107,40 @@ const ReConsentModal = () => { } } + /** "Not now", the close button, backdrop and Escape all land here. */ + const handlePostpone = () => { + if (submitting) return + const userId = user?.user.userId + if (userId) snoozeReConsent(userId) + posthog.capture(ANALYTICS_EVENTS.MODAL_DISMISSED, { + modal_type: MODAL_TYPES.RE_CONSENT, + documents: outdatedDocs.map((d) => d.slug), + }) + setOutdatedDocs([]) + setChecked(false) + setError(null) + } + if (!outdatedDocs.length) return null return ( undefined} - preventClose - hideModalCloseButton + onClose={handlePostpone} icon="info" title="We've updated our terms" content={ - <> +

- To keep using Peanut, please review and accept the updated documents: + We've refreshed the documents below. Give them a read and accept when you're ready — you can + keep using Peanut in the meantime.

-
    +
      {outdatedDocs.map((doc) => { const label = DOC_LABELS[doc.slug] ?? { name: doc.slug, href: `/${doc.slug}` } return (
    • - + {label.name}
    • @@ -121,7 +148,7 @@ const ReConsentModal = () => { })}
    {error &&

    {error}

    } - +
} checkbox={{ text: 'I have read and accept the updated documents', @@ -131,10 +158,19 @@ const ReConsentModal = () => { ctas={[ { text: submitting ? 'Saving…' : 'Accept & continue', + variant: 'purple', + shadowSize: '4', disabled: !checked || submitting, onClick: handleAccept, }, + { + text: 'Not now', + variant: 'transparent', + disabled: submitting, + onClick: handlePostpone, + }, ]} + ctaClassName={STACKED_CTAS} /> ) } diff --git a/src/components/Global/ReConsentModal/utils.ts b/src/components/Global/ReConsentModal/utils.ts new file mode 100644 index 0000000000..188a32f5ec --- /dev/null +++ b/src/components/Global/ReConsentModal/utils.ts @@ -0,0 +1,48 @@ +/** + * Local snooze for the re-consent prompt. + * + * ToS §17.2 gives material changes a 30-day runway and frames the in-app + * click-through as a way to accept *sooner* — voluntarily. §17.3 then says a + * user who does not agree must be able to stop using the Services, which for a + * non-custodial wallet has to include reaching their funds. So the prompt is a + * reminder, not a lock: "Not now" defers it instead of gating the app. + * + * Deliberately localStorage and not the consent ledger — declining is not a + * consent event and must never write a row. Losing the snooze (cleared storage, + * another device) just re-shows the prompt, which is the safe direction. + */ + +/** Long enough not to nag a wallet people open daily, short enough to still land. */ +export const RE_CONSENT_SNOOZE_DAYS = 3 + +const snoozeKey = (userId: string) => `peanut.reconsent.snoozedUntil.${userId}` + +/** Storage is unavailable during SSR and throws outright in Safari private mode. */ +function readStorage(key: string): string | null { + try { + return typeof window === 'undefined' ? null : window.localStorage.getItem(key) + } catch { + return null + } +} + +function writeStorage(key: string, value: string): void { + try { + if (typeof window !== 'undefined') window.localStorage.setItem(key, value) + } catch { + // a full or blocked quota just means the prompt returns next session + } +} + +export function isReConsentSnoozed(userId: string): boolean { + const raw = readStorage(snoozeKey(userId)) + if (!raw) return false + const until = Number(raw) + // a corrupt value must not suppress the prompt forever + return Number.isFinite(until) && until > Date.now() +} + +export function snoozeReConsent(userId: string): void { + const until = Date.now() + RE_CONSENT_SNOOZE_DAYS * 24 * 60 * 60 * 1000 + writeStorage(snoozeKey(userId), String(until)) +} From 9c776d9bb7153388b3053aa5ff8cf64276543597 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 29 Jul 2026 18:00:43 +0100 Subject: [PATCH 17/19] fix(re-consent): defer to the document's effective date, not a fixed interval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Not now" now snoozes until the documents actually take effect — the frontmatter version plus the 30 days our own ToS §17.2 promises — instead of a flat 3 days. A document posted today buys the user its full notice period. Past that date §17.3 already makes continued use acceptance, so the prompt only still asks in order to record explicit consent. That earns a gentle cadence, not a prompt on every app open, so the snooze floors at MIN_SNOOZE_DAYS. --- .../ReConsentModal/__tests__/index.test.tsx | 34 +++++++++++++ .../Global/ReConsentModal/index.tsx | 8 ++- src/components/Global/ReConsentModal/utils.ts | 51 ++++++++++++++----- 3 files changed, 79 insertions(+), 14 deletions(-) diff --git a/src/components/Global/ReConsentModal/__tests__/index.test.tsx b/src/components/Global/ReConsentModal/__tests__/index.test.tsx index c8088fa654..b806d315ad 100644 --- a/src/components/Global/ReConsentModal/__tests__/index.test.tsx +++ b/src/components/Global/ReConsentModal/__tests__/index.test.tsx @@ -74,6 +74,40 @@ jest.mock('@sentry/nextjs', () => ({ })) import ReConsentModal from '../index' +import { nextPromptAt, LEGAL_NOTICE_PERIOD_DAYS, MIN_SNOOZE_DAYS } from '../utils' + +const DAY_MS = 24 * 60 * 60 * 1000 + +describe('nextPromptAt', () => { + // 2026-07-15 + 30d = 2026-08-14, the date the current terms take effect + const posted = '2026-07-15' + const dayAfterPosting = Date.parse('2026-07-16') + + it('defers to the effective date — the full §17.2 notice period, not a fixed interval', () => { + expect(nextPromptAt([posted], dayAfterPosting)).toBe(Date.parse('2026-08-14')) + }) + + it('uses the LATEST effective date when several documents are shown at once', () => { + expect(nextPromptAt(['2026-07-01', posted], dayAfterPosting)).toBe(Date.parse('2026-08-14')) + }) + + it('floors to MIN_SNOOZE_DAYS once a document is already past its notice period', () => { + // §17.3 already makes continued use acceptance here, so the prompt only + // still asks to record explicit consent — it must not nag every open + const longAfter = Date.parse('2027-01-01') + expect(nextPromptAt([posted], longAfter)).toBe(longAfter + MIN_SNOOZE_DAYS * DAY_MS) + }) + + it('falls back to the floor rather than throwing on an unparseable version', () => { + const now = Date.parse('2026-07-29') + expect(nextPromptAt(['not-a-date'], now)).toBe(now + MIN_SNOOZE_DAYS * DAY_MS) + expect(nextPromptAt([], now)).toBe(now + MIN_SNOOZE_DAYS * DAY_MS) + }) + + it('pins the notice period to the 30 days our own ToS §17.2 promises', () => { + expect(LEGAL_NOTICE_PERIOD_DAYS).toBe(30) + }) +}) const statusDoc = (slug: string) => ({ slug, diff --git a/src/components/Global/ReConsentModal/index.tsx b/src/components/Global/ReConsentModal/index.tsx index 9691c0a612..6a70dc04cf 100644 --- a/src/components/Global/ReConsentModal/index.tsx +++ b/src/components/Global/ReConsentModal/index.tsx @@ -111,7 +111,13 @@ const ReConsentModal = () => { const handlePostpone = () => { if (submitting) return const userId = user?.user.userId - if (userId) snoozeReConsent(userId) + // defer to the date these documents actually take effect (§17.2), not a + // fixed interval — a doc posted today buys the user its full 30 days + if (userId) + snoozeReConsent( + userId, + outdatedDocs.map((d) => d.currentVersion) + ) posthog.capture(ANALYTICS_EVENTS.MODAL_DISMISSED, { modal_type: MODAL_TYPES.RE_CONSENT, documents: outdatedDocs.map((d) => d.slug), diff --git a/src/components/Global/ReConsentModal/utils.ts b/src/components/Global/ReConsentModal/utils.ts index 188a32f5ec..c07321277d 100644 --- a/src/components/Global/ReConsentModal/utils.ts +++ b/src/components/Global/ReConsentModal/utils.ts @@ -1,23 +1,33 @@ /** * Local snooze for the re-consent prompt. * - * ToS §17.2 gives material changes a 30-day runway and frames the in-app - * click-through as a way to accept *sooner* — voluntarily. §17.3 then says a - * user who does not agree must be able to stop using the Services, which for a - * non-custodial wallet has to include reaching their funds. So the prompt is a - * reminder, not a lock: "Not now" defers it instead of gating the app. + * ToS §17.2 gives material changes a 30-day notice period and offers the in-app + * click-through as a way to accept sooner, by choice. §17.3 then says a user who + * does not agree must be able to stop using the Services — which, for a + * non-custodial wallet, has to include reaching their own funds. So the prompt + * is a reminder, not a lock: "Not now" defers it to the date the documents + * actually take effect. * * Deliberately localStorage and not the consent ledger — declining is not a * consent event and must never write a row. Losing the snooze (cleared storage, - * another device) just re-shows the prompt, which is the safe direction. + * another device) only re-shows the prompt, which is the safe direction. */ -/** Long enough not to nag a wallet people open daily, short enough to still land. */ -export const RE_CONSENT_SNOOZE_DAYS = 3 +/** ToS §17.2 — material changes take effect no earlier than 30 days after posting. */ +export const LEGAL_NOTICE_PERIOD_DAYS = 30 + +/** + * Floor for a document already past its notice period. Past that date §17.3 + * makes continued use acceptance on its own, so the prompt only still asks in + * order to record explicit consent — worth a gentle cadence, not every app open. + */ +export const MIN_SNOOZE_DAYS = 7 + +const DAY_MS = 24 * 60 * 60 * 1000 const snoozeKey = (userId: string) => `peanut.reconsent.snoozedUntil.${userId}` -/** Storage is unavailable during SSR and throws outright in Safari private mode. */ +/** Storage is absent during SSR and throws outright in Safari private mode. */ function readStorage(key: string): string | null { try { return typeof window === 'undefined' ? null : window.localStorage.getItem(key) @@ -30,10 +40,26 @@ function writeStorage(key: string, value: string): void { try { if (typeof window !== 'undefined') window.localStorage.setItem(key, value) } catch { - // a full or blocked quota just means the prompt returns next session + // a full or blocked quota only means the prompt returns next session } } +/** + * When the prompt may reappear after a "Not now": the latest effective date + * across the documents shown, floored to MIN_SNOOZE_DAYS. + * + * `currentVersion` is the document's frontmatter `last_updated` (an ISO date), + * so effective = version + 30 days. An unparseable version contributes nothing + * rather than throwing — the floor still applies. + */ +export function nextPromptAt(versions: string[], now: number = Date.now()): number { + const effectiveDates = versions + .map((version) => Date.parse(version)) + .filter((parsed) => Number.isFinite(parsed)) + .map((parsed) => parsed + LEGAL_NOTICE_PERIOD_DAYS * DAY_MS) + return Math.max(now + MIN_SNOOZE_DAYS * DAY_MS, ...effectiveDates) +} + export function isReConsentSnoozed(userId: string): boolean { const raw = readStorage(snoozeKey(userId)) if (!raw) return false @@ -42,7 +68,6 @@ export function isReConsentSnoozed(userId: string): boolean { return Number.isFinite(until) && until > Date.now() } -export function snoozeReConsent(userId: string): void { - const until = Date.now() + RE_CONSENT_SNOOZE_DAYS * 24 * 60 * 60 * 1000 - writeStorage(snoozeKey(userId), String(until)) +export function snoozeReConsent(userId: string, versions: string[]): void { + writeStorage(snoozeKey(userId), String(nextPromptAt(versions))) } From d7167876f90ec65047944745854760c713b29776 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 29 Jul 2026 18:00:44 +0100 Subject: [PATCH 18/19] chore(legal-versions): regenerate from the pinned content submodule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The committed file said card-terms-international was 2026-06-01. The content submodule this branch pins says 2026-07-14 — the §8.4 Nigerian Users clause. `predev`/`prebuild` regenerate this file, so any dev server or Vercel build already produced the newer value and left the tree dirty. WARNING, cross-repo: peanut-api CURRENT_LEGAL_VERSIONS still says 2026-06-01 for the same document. sanitizeEchoedDocuments treats a client version newer than the server's as an attack and clamps it, dropping the hash. So the ledger would record 2026-06-01 for users who were shown 2026-07-14, and the clause update would never trigger re-consent. The constant needs the same bump in peanut-api-ts#1255. --- src/constants/legal-versions.generated.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/constants/legal-versions.generated.ts b/src/constants/legal-versions.generated.ts index 2042141bf2..c5973fe12c 100644 --- a/src/constants/legal-versions.generated.ts +++ b/src/constants/legal-versions.generated.ts @@ -21,8 +21,8 @@ export const LEGAL_DOCUMENT_VERSIONS = { hash: 'd0ea9e1b239179c9ca4e5c26badb49506d8453399958ed040f68bb6650fd6e6c', }, 'card-terms-international': { - version: '2026-06-01', - hash: 'a6476057cd5bcf553f2c1faffc5261ad3c5e59eecbc8bf068e3b561df22035cf', + version: '2026-07-14', + hash: '9eb731e649de21ef8a28fca821f4123d63be55f0765a0843fe42bac31bbbe0bd', }, 'card-terms-us': { version: '2026-06-01', From 4c7a0fcc14cc8fbfeb727d62ec3e2a4576019fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Wed, 29 Jul 2026 14:51:20 -0300 Subject: [PATCH 19/19] hotfix: redirect bare card legal slugs to their /en pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /card-terms-us etc. 404'd while /en/card-terms-us worked — same redirect the terms/privacy slugs already have. --- redirects.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/redirects.json b/redirects.json index e66e25ab54..a328318fa5 100644 --- a/redirects.json +++ b/redirects.json @@ -64,6 +64,11 @@ "destination": "/en/privacy", "permanent": false }, + { + "source": "/:slug(card-terms-us|card-terms-international|card-esign|card-privacy|card-prohibited-activities)", + "destination": "/en/:slug", + "permanent": false + }, { "source": "/docs", "destination": "/en/help",