diff --git a/package.json b/package.json index ffa44acdb9..cf156308b6 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,12 @@ "dev:fallback": "next dev --webpack", "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/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", 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 16c846fdbe..ed33e14215 100644 --- a/src/app/(mobile-ui)/card/page.tsx +++ b/src/app/(mobile-ui)/card/page.tsx @@ -24,6 +24,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' @@ -349,7 +350,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) @@ -364,7 +370,7 @@ const CardPage: FC = () => { posthog.capture(ANALYTICS_EVENTS.CARD_APPLY_FAILED, { error_message: message }) } }, - [advanceFromApplyResponse, t] + [advanceFromApplyResponse, pendingTerms, t] ) 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/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index 31d279ec9b..8ed7e1be1c 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -623,6 +623,9 @@ export default function WithdrawCryptoPage() { description={

{t('compatibilityModal.description')}

+ {/* 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 && (

{t('compatibilityModal.sendingTo')}{' '} diff --git a/src/components/Card/CancelCardModal.tsx b/src/components/Card/CancelCardModal.tsx index 0e40d2d94f..4529021b07 100644 --- a/src/components/Card/CancelCardModal.tsx +++ b/src/components/Card/CancelCardModal.tsx @@ -60,6 +60,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. @@ -75,6 +81,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(t('errors.unexpectedStrategy')) diff --git a/src/components/Card/LockCardModal.tsx b/src/components/Card/LockCardModal.tsx index 0ba5dea67a..01b22e7856 100644 --- a/src/components/Card/LockCardModal.tsx +++ b/src/components/Card/LockCardModal.tsx @@ -66,6 +66,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 @@ -76,15 +82,17 @@ const LockCardModal: FC = ({ cardId, mode, isOpen, onClose }) => { if (!smartWalletAddress) { throw new Error(t('errors.walletNotReady')) } - // 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(t('errors.unexpectedStrategy')) diff --git a/src/components/Card/__tests__/LockCardModal.test.tsx b/src/components/Card/__tests__/LockCardModal.test.tsx new file mode 100644 index 0000000000..c5300b4570 --- /dev/null +++ b/src/components/Card/__tests__/LockCardModal.test.tsx @@ -0,0 +1,165 @@ +/** + * 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 { NextIntlClientProvider } from 'next-intl' +import en from '@/i18n/app/messages/en.json' +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', +} + +// Real `en.json`, not a key-echoing stub: the assertions below match on the +// user-visible strings ("Slide to Lock", "Card locked"), so the messages have to +// be the published ones. Both modals call useTranslations('card'). +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 }) + }) +}) 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/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/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index aa8964b4aa..0c4b927458 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -283,6 +283,7 @@ export const MODAL_TYPES = { CARD_PIONEER: 'card_pioneer', KYC_COMPLETED: 'kyc_completed', INVITE: 'invite', + RE_CONSENT: 're_consent', } as const /** diff --git a/src/constants/legal-versions.generated.ts b/src/constants/legal-versions.generated.ts new file mode 100644 index 0000000000..c5973fe12c --- /dev/null +++ b/src/constants/legal-versions.generated.ts @@ -0,0 +1,41 @@ +// 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 = { + 'card-esign': { + version: '2026-06-09', + hash: '5ed12f98481fb249909563882564f4157b916059b42ee814d9ce57e8026777c8', + }, + 'card-privacy': { + version: '2026-06-01', + hash: 'eaee605fc002d723c148643048001ddf8b7886511d1d8bef225ef97268e5efb1', + }, + 'card-prohibited-activities': { + version: '2026-06-17', + hash: 'd0ea9e1b239179c9ca4e5c26badb49506d8453399958ed040f68bb6650fd6e6c', + }, + 'card-terms-international': { + version: '2026-07-14', + hash: '9eb731e649de21ef8a28fca821f4123d63be55f0765a0843fe42bac31bbbe0bd', + }, + 'card-terms-us': { + version: '2026-06-01', + hash: '431def76a1838075b8110dff955da06a3d561d61229117c14127deef9f09e1ca', + }, + privacy: { + version: '2026-07-15', + hash: '921c1da00646a4ab8f6c9b663d9ba130acbc294f1645f3e3d05ad264744b66c8', + }, + terms: { + version: '2026-07-15', + hash: '6ed61c54c9b6c1c40ab053c597c83288b36bf07f0470627b67634db0e41d7398', + }, +} as const + +export type LegalDocumentSlug = keyof typeof LEGAL_DOCUMENT_VERSIONS diff --git a/src/features/payments/shared/components/PaymentSuccessView.tsx b/src/features/payments/shared/components/PaymentSuccessView.tsx index c254295d6a..c8ed9fc137 100644 --- a/src/features/payments/shared/components/PaymentSuccessView.tsx +++ b/src/features/payments/shared/components/PaymentSuccessView.tsx @@ -172,7 +172,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, diff --git a/src/hooks/__tests__/usePrimaryNameServer.test.tsx b/src/hooks/__tests__/usePrimaryNameServer.test.tsx index ce148a5f5d..3bb6da99a5 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,44 @@ 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('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 ec93fec9b6..ff07a79fd1 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,46 @@ 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 { + // 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 {} + } +} + +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 +93,21 @@ export function usePrimaryNameServer(address?: string): { primaryName: string | priority: 'onChain', }) - return { primaryName: data ?? (isError ? 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 + 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) } } diff --git a/src/hooks/useZeroDev.ts b/src/hooks/useZeroDev.ts index 57afa54fc9..8be51de55d 100644 --- a/src/hooks/useZeroDev.ts +++ b/src/hooks/useZeroDev.ts @@ -15,6 +15,7 @@ import { useCallback, useContext } from 'react' import type { TransactionReceipt, Hex, Hash } from 'viem' import { captureException } from '@sentry/nextjs' import { invitesApi } from '@/services/invites' +import { signupConsentDocuments } from '@/services/consent' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { isCapacitor, getNativeRpId } from '@/utils/capacitor' @@ -73,7 +74,10 @@ export const useZeroDev = () => { passkeyName: _getPasskeyName(username), passkeyServerUrl: PASSKEY_SERVER_URL as string, mode: WebAuthnMode.Register, - passkeyServerHeaders: {}, + // Consent-ledger echo (tos-v1 phase 2): the ZeroDev SDK owns the + // register/verify request body, so the terms+privacy versions the + // signup screen displayed ride in a header the backend ledgers. + passkeyServerHeaders: { 'x-accepted-legal': JSON.stringify(signupConsentDocuments()) }, rpID: rpId, }) 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) + }) +}) diff --git a/src/services/consent.ts b/src/services/consent.ts new file mode 100644 index 0000000000..d20e0d8834 --- /dev/null +++ b/src/services/consent.ts @@ -0,0 +1,68 @@ +import { apiFetch } from '@/utils/api-fetch' +import { LEGAL_DOCUMENT_VERSIONS, type LegalDocumentSlug } from '@/constants/legal-versions.generated' + +/** + * Consent ledger (tos-v1 phase 2). Every acceptance surface echoes the + * documents it actually displayed — slug + version (frontmatter last_updated) + * + sha256 of the en.md — so peanut-api's append-only `consent_records` table + * stores what the user was shown, not what either side assumes. + */ + +export interface AcceptedLegalDocument { + slug: string + version: string + hash: string +} + +export interface ConsentStatusDocument { + slug: string + currentVersion: string + acceptedVersion: string | null + acceptedAt: string | null + needsAcceptance: boolean +} + +export interface ConsentStatusResponse { + documents: ConsentStatusDocument[] + needsReConsent: boolean +} + +export const acceptedLegalDocument = (slug: LegalDocumentSlug): AcceptedLegalDocument => ({ + slug, + version: LEGAL_DOCUMENT_VERSIONS[slug].version, + hash: LEGAL_DOCUMENT_VERSIONS[slug].hash, +}) + +/** Documents the signup screen presents ("By creating account you agree with…"). */ +export const signupConsentDocuments = (): AcceptedLegalDocument[] => [ + acceptedLegalDocument('terms'), + acceptedLegalDocument('privacy'), +] + +/** Peanut-owned documents the card agreement screen presents (CardTermsScreen); + * the third-party Issuer Privacy Policy has no slug of ours and is not ledgered. */ +export const cardConsentDocuments = (isUsResident: boolean): AcceptedLegalDocument[] => + isUsResident + ? [ + acceptedLegalDocument('card-terms-us'), + acceptedLegalDocument('card-esign'), + acceptedLegalDocument('card-privacy'), + ] + : [acceptedLegalDocument('card-terms-international'), acceptedLegalDocument('card-esign')] + +export const consentApi = { + getStatus: async (): Promise => { + const response = await apiFetch('/users/consent/status', { method: 'GET' }) + if (!response.ok) throw new Error(`Failed to load consent status (${response.status})`) + return await response.json() + }, + + accept: async (documents: AcceptedLegalDocument[]): Promise<{ recorded: number }> => { + const response = await apiFetch('/users/consent/accept', { + method: 'POST', + body: JSON.stringify({ documents }), + }) + if (!response.ok) throw new Error(`Failed to record acceptance (${response.status})`) + return await response.json() + }, +} diff --git a/src/services/rain.ts b/src/services/rain.ts index 89d1e83c26..82e6661cef 100644 --- a/src/services/rain.ts +++ b/src/services/rain.ts @@ -15,6 +15,7 @@ import { apiFetch } from '@/utils/api-fetch' import { getAuthToken } from '@/utils/auth-token' import { isCapacitor } from '@/utils/capacitor' import type { SignedRainWithdrawal } from '@/hooks/wallet/useSignSpendBundle' +import type { AcceptedLegalDocument } from '@/services/consent' // ─── Types ────────────────────────────────────────────────────────────────── @@ -548,7 +549,15 @@ export const rainApi = { * machine route. */ applyForCard: async ( - opts: { termsAccepted?: boolean; serializedApproval?: string; confirmedResidenceCountry?: string } = {} + opts: { + termsAccepted?: boolean + serializedApproval?: string + confirmedResidenceCountry?: string + /** Consent-ledger echo: the legal documents the agreement screen + * actually displayed (slug + version + hash), so the backend + * records what the user was shown. Only sent with acceptance. */ + acceptedDocuments?: AcceptedLegalDocument[] + } = {} ): Promise => { // `serializedApproval` is consumed only by the re-issue branch on the // backend (where a RainCard row is created synchronously). First-time @@ -557,6 +566,9 @@ export const rainApi = { const body: Record = { termsAccepted: opts.termsAccepted === true } if (opts.serializedApproval) body.serializedApproval = opts.serializedApproval if (opts.confirmedResidenceCountry) body.confirmedResidenceCountry = opts.confirmedResidenceCountry + if (opts.termsAccepted === true && opts.acceptedDocuments?.length) { + body.acceptedDocuments = opts.acceptedDocuments + } return rainRequest({ method: 'POST', path: '/rain/cards',