From 95b07e046ad76414236ef3e981049b4ff86ce0ba Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Fri, 24 Jul 2026 10:26:11 +0000 Subject: [PATCH 01/11] 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 7cbe967b5775df29a071953faddf9cf23c911683 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 29 Jul 2026 17:50:33 +0100 Subject: [PATCH 09/11] =?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 10/11] 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 11/11] 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',