diff --git a/package.json b/package.json index 639d2cf6e8..bc338b820d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,12 @@ "dev:fallback": "next dev", "postinstall": "node scripts/copy-flags.mjs", "copy-flags": "node scripts/copy-flags.mjs", - "prebuild": "node scripts/copy-flags.mjs", + "generate:legal-versions": "node scripts/generate-legal-versions.mjs", + "predev": "pnpm generate:legal-versions", + "predev:clean": "pnpm generate:legal-versions", + "predev:fallback": "pnpm generate:legal-versions", + "preanalyze": "pnpm generate:legal-versions", + "prebuild": "node scripts/copy-flags.mjs && pnpm generate:legal-versions", "build": "next build --webpack", "start": "next start", "lint": "eslint .", diff --git a/scripts/generate-legal-versions.mjs b/scripts/generate-legal-versions.mjs new file mode 100644 index 0000000000..e209d2b34f --- /dev/null +++ b/scripts/generate-legal-versions.mjs @@ -0,0 +1,65 @@ +// Generates src/constants/legal-versions.generated.ts from the legal content +// submodule (src/content/content/legal//en.md): version = frontmatter +// `last_updated`, hash = sha256 of the raw en.md bytes. The consent ledger +// (peanut-api `consent_records`) stores what the user was actually shown, so +// these values must come from the same files the legal pages render — never +// hand-edit the generated file. Runs on predev/prebuild; commit the output so +// CI and static analysis see it without running the generator. +import { createHash } from 'node:crypto' +import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const legalDir = join(root, 'src/content/content/legal') +const outFile = join(root, 'src/constants/legal-versions.generated.ts') + +if (!existsSync(legalDir)) { + // Submodule not checked out (e.g. a bare CI job) — keep the committed file. + console.warn('[legal-versions] src/content submodule missing, keeping committed constants') + process.exit(0) +} + +const entries = [] +for (const slug of readdirSync(legalDir).sort()) { + const file = join(legalDir, slug, 'en.md') + if (!existsSync(file)) continue + const raw = readFileSync(file) + const match = /^last_updated:\s*["']?(\d{4}-\d{2}-\d{2})["']?\s*$/m.exec(raw.toString('utf8')) + if (!match) { + console.error(`[legal-versions] ${slug}/en.md has no last_updated frontmatter — refusing to generate`) + process.exit(1) + } + const hash = createHash('sha256').update(raw).digest('hex') + entries.push({ slug, version: match[1], hash }) +} + +// output must be byte-stable under prettier so predev never dirties the tree: +// one field per line (printWidth 120), keys quoted only when not identifiers +const key = (slug) => (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(slug) ? slug : `'${slug}'`) +const body = entries + .map( + ({ slug, version, hash }) => + ` ${key(slug)}: {\n version: '${version}',\n hash: '${hash}',\n },` + ) + .join('\n') + +writeFileSync( + outFile, + `// AUTO-GENERATED by scripts/generate-legal-versions.mjs — do not edit. +// version = frontmatter last_updated; hash = sha256 of the raw en.md in the +// src/content submodule. Echoed to the consent ledger so the backend records +// exactly which document revision the user was shown. +export interface LegalDocumentVersion { + version: string + hash: string +} + +export const LEGAL_DOCUMENT_VERSIONS = { +${body} +} as const + +export type LegalDocumentSlug = keyof typeof LEGAL_DOCUMENT_VERSIONS +` +) +console.log(`[legal-versions] wrote ${entries.length} documents to src/constants/legal-versions.generated.ts`) diff --git a/src/app/(mobile-ui)/card/page.tsx b/src/app/(mobile-ui)/card/page.tsx index 1e39da95af..09e67ec47e 100644 --- a/src/app/(mobile-ui)/card/page.tsx +++ b/src/app/(mobile-ui)/card/page.tsx @@ -23,6 +23,7 @@ import PageContainer from '@/components/0_Bruddle/PageContainer' import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' import { initiateSelfHealResubmission } from '@/app/actions/sumsub' import { rainApi, type ApplyForCardResponse } from '@/services/rain' +import { cardConsentDocuments } from '@/services/consent' import { useGrantSessionKey } from '@/hooks/wallet/useGrantSessionKey' import { useCapabilities } from '@/hooks/useCapabilities' import { useModalsContext } from '@/context/ModalsContext' @@ -346,7 +347,12 @@ const CardPage: FC = () => { with_session_key: !!serializedApproval, }) try { - const res = await rainApi.applyForCard({ termsAccepted, serializedApproval }) + // Consent-ledger echo: on acceptance, send the exact documents + // CardTermsScreen displayed for this region (version + hash). + const acceptedDocuments = termsAccepted + ? cardConsentDocuments(pendingTerms?.isUsResident ?? false) + : undefined + const res = await rainApi.applyForCard({ termsAccepted, serializedApproval, acceptedDocuments }) posthog.capture(ANALYTICS_EVENTS.CARD_APPLY_SUCCEEDED, { outcome: res.status }) if (res.status === 'incomplete' && 'sumsubAccessToken' in res) { setSumsubToken(res.sumsubAccessToken) @@ -361,7 +367,7 @@ const CardPage: FC = () => { posthog.capture(ANALYTICS_EVENTS.CARD_APPLY_FAILED, { error_message: message }) } }, - [advanceFromApplyResponse] + [advanceFromApplyResponse, pendingTerms] ) const handleAcceptTerms = useCallback(async () => { diff --git a/src/app/(mobile-ui)/layout.tsx b/src/app/(mobile-ui)/layout.tsx index 1ddebfa490..f68181f793 100644 --- a/src/app/(mobile-ui)/layout.tsx +++ b/src/app/(mobile-ui)/layout.tsx @@ -1,6 +1,7 @@ 'use client' import GuestLoginModal from '@/components/Global/GuestLoginModal' +import ReConsentModal from '@/components/Global/ReConsentModal' import PeanutLoading from '@/components/Global/PeanutLoading' import TopNavbar from '@/components/Global/TopNavbar' import WalletNavigation from '@/components/Global/WalletNavigation' @@ -235,6 +236,8 @@ const Layout = ({ children }: { children: React.ReactNode }) => { {/* Modal */} + + diff --git a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx index f44f8cbb99..b142019550 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx @@ -85,6 +85,7 @@ jest.mock('@/utils/withdraw.utils', () => ({ jest.mock('@/utils/general.utils', () => ({ isTxReverted: (receipt: { status?: string } | null) => receipt?.status === 'reverted', + printableAddress: (address: string) => `${address.slice(0, 6)}...${address.slice(-4)}`, })) jest.mock('@/utils/url.utils', () => ({ 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/Global/ReConsentModal/__tests__/index.test.tsx b/src/components/Global/ReConsentModal/__tests__/index.test.tsx new file mode 100644 index 0000000000..b806d315ad --- /dev/null +++ b/src/components/Global/ReConsentModal/__tests__/index.test.tsx @@ -0,0 +1,297 @@ +/** @jest-environment jsdom */ +/** + * ReConsentModal — the ToS §17 click-through. + * + * 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' + +const mockGetStatus = jest.fn, []>() +const mockAccept = jest.fn, [unknown]>() +jest.mock('@/services/consent', () => ({ + consentApi: { + getStatus: () => mockGetStatus(), + accept: (docs: unknown) => mockAccept(docs), + }, + acceptedLegalDocument: (slug: string) => ({ slug, version: '2026-07-15', hash: 'a'.repeat(64) }), +})) + +let mockUser: { user: { userId: string } } | null = null +jest.mock('@/context/authContext', () => ({ + useAuth: () => ({ user: mockUser }), +})) + +jest.mock('@/components/Global/ActionModal', () => ({ + __esModule: true, + default: (props: { + visible: boolean + title?: string + content?: React.ReactNode + checkbox?: { text: string; checked: boolean; onChange: (checked: boolean) => void } + ctas?: { text: string; disabled?: boolean; onClick: () => void }[] + }) => { + const checkbox = props.checkbox + if (!props.visible) return null + return ( +
+

{props.title}

+
{props.content}
+ {checkbox && ( + checkbox.onChange(e.target.checked)} + /> + )} + {props.ctas?.map((c) => ( + + ))} +
+ ) + }, +})) + +jest.mock('@/components/Global/DocsLink', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => {children}, +})) + +const mockCapture = jest.fn() +jest.mock('posthog-js', () => ({ capture: (...args: unknown[]) => mockCapture(...args) })) + +const mockCaptureException = jest.fn() +jest.mock('@sentry/nextjs', () => ({ + captureException: (...args: unknown[]) => mockCaptureException(...args), +})) + +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, + currentVersion: '2026-07-15', + acceptedVersion: null, + acceptedAt: null, + needsAcceptance: true, +}) + +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')) + 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() + // 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 () => { + 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 new file mode 100644 index 0000000000..6a70dc04cf --- /dev/null +++ b/src/components/Global/ReConsentModal/index.tsx @@ -0,0 +1,184 @@ +'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' +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' }, + privacy: { name: 'Privacy Policy', href: '/privacy' }, + 'card-terms-us': { name: 'Card Terms (U.S.)', href: '/card-terms-us' }, + 'card-terms-international': { name: 'Card Terms (International)', href: '/card-terms-international' }, + 'card-esign': { name: 'E-Sign Consent', href: '/card-esign' }, + 'card-privacy': { name: 'Account Opening Privacy Notice', href: '/card-privacy' }, + '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 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() + const [outdatedDocs, setOutdatedDocs] = useState([]) + const [checked, setChecked] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const lastCheckedUserId = useRef(null) + + useEffect(() => { + // once per user per session — keyed by userId so a logout → login as a + // different account still gets its own check + const userId = user?.user.userId + if (!userId || lastCheckedUserId.current === userId) return + lastCheckedUserId.current = userId + // account switched: none of the previous user's consent state may leak + // into this session (an already-populated modal or a pre-ticked box) + 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) => { + // a slow response for the previous account must not populate + // the modal for whoever is logged in now + if (lastCheckedUserId.current !== userId) return + if (!status.needsReConsent) return + // only prompt for documents this client can actually display + const docs = status.documents.filter((d) => d.needsAcceptance && d.slug in LEGAL_DOCUMENT_VERSIONS) + if (!docs.length) return + setOutdatedDocs(docs) + posthog.capture(ANALYTICS_EVENTS.MODAL_SHOWN, { + modal_type: MODAL_TYPES.RE_CONSENT, + documents: docs.map((d) => d.slug), + }) + }) + .catch((e) => { + // 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' } }) + }) + }, [user]) + + const handleAccept = async () => { + if (!checked || submitting) return + setSubmitting(true) + setError(null) + try { + await consentApi.accept(outdatedDocs.map((d) => acceptedLegalDocument(d.slug as LegalDocumentSlug))) + // 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), + }) + setOutdatedDocs([]) + // a future appearance of this modal (version bump, account switch) + // must start with an unticked box + setChecked(false) + } catch (e) { + // 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 { + setSubmitting(false) + } + } + + /** "Not now", the close button, backdrop and Escape all land here. */ + const handlePostpone = () => { + if (submitting) return + const userId = user?.user.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), + }) + setOutdatedDocs([]) + setChecked(false) + setError(null) + } + + if (!outdatedDocs.length) return null + + return ( + +

+ 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} + +
  • + ) + })} +
+ {error &&

{error}

} +
+ } + checkbox={{ + text: 'I have read and accept the updated documents', + checked, + onChange: setChecked, + }} + 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} + /> + ) +} + +export default ReConsentModal diff --git a/src/components/Global/ReConsentModal/utils.ts b/src/components/Global/ReConsentModal/utils.ts new file mode 100644 index 0000000000..c07321277d --- /dev/null +++ b/src/components/Global/ReConsentModal/utils.ts @@ -0,0 +1,73 @@ +/** + * Local snooze for the re-consent prompt. + * + * 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) only re-shows the prompt, which is the safe direction. + */ + +/** 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 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) + } 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 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 + 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, versions: string[]): void { + writeStorage(snoozeKey(userId), String(nextPromptAt(versions))) +} diff --git a/src/components/Withdraw/views/Initial.withdraw.view.tsx b/src/components/Withdraw/views/Initial.withdraw.view.tsx index fc4ff47e82..852af272a6 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,42 @@ 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 + // Gate Review while the previous chain's address is still in state — + // clicking it mid-resolution would send to that address. + setIsValidRecipient(false) + let stale = false + validateAndResolveRecipient(name, true, 'evm', selectedChainID) + .then((validation) => { + if (stale) return + setRecipient({ name, address: validation.resolvedAddress }) + setIsValidRecipient(true) + }) + .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 +183,7 @@ export default function InitialWithdrawView({ amount, onReview, onBack, isProces { setRecipient(update.recipient) @@ -160,6 +198,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)} +

+ )} +