Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
95b07e0
feat: chain-aware ENS resolution + visible resolved address in withdraw
0xkkonrad Jul 24, 2026
c88ce7f
fix: invalidate recipient while chain-switch ENS re-resolution is in …
jjramirezn Jul 24, 2026
f92d52d
test: add printableAddress to the general.utils mock
jjramirezn Jul 24, 2026
7de6e7b
feat: consent-ledger echo + re-consent click-through (tos-v1 phase 2)
jjramirezn Jul 20, 2026
a172da1
fix: emit prettier-stable output from the legal-versions generator
jjramirezn Jul 20, 2026
849ee41
fix: key the re-consent check to the user, not the session
jjramirezn Jul 20, 2026
6133d6f
fix: apply CodeRabbit findings on the consent client
jjramirezn Jul 20, 2026
b6e9845
test(re-consent): pin modal safety properties + doc sets; report cons…
jjramirezn Jul 24, 2026
a3c1497
fix(card): force collateral-only routing on lock/cancel
abalinda Jul 29, 2026
27e8eae
fix(withdraw): stop success receipt showing 'Sent to undefined'
kushagrasarathe Jul 29, 2026
fdbde98
fix(ens): warm-cache resolved names in localstorage to stop address f…
kushagrasarathe Jul 29, 2026
3645d59
fix(ens): guard warm-cache root shape against malformed localstorage
kushagrasarathe Jul 29, 2026
aa5da01
fix(ens): evict warm cache on client-settled no-name; keep AddressLin…
kushagrasarathe Jul 29, 2026
b10ece9
Update content submodule to latest main
Hugo0 Jul 29, 2026
28a5cc5
Merge pull request #2575 from peanutprotocol/auto/update-content-2026…
Hugo0 Jul 29, 2026
8eeb352
Update content submodule to latest main
Hugo0 Jul 29, 2026
e00563e
Merge pull request #2576 from peanutprotocol/auto/update-content-2026…
Hugo0 Jul 29, 2026
b151e80
Merge pull request #2571 from peanutprotocol/hotfix/card-lock-force-c…
jjramirezn Jul 29, 2026
ff5bb72
Merge pull request #2572 from peanutprotocol/hotfix/withdraw-receipt-…
jjramirezn Jul 29, 2026
ede6c7a
Merge pull request #2573 from peanutprotocol/fix/ens-name-warm-cache
jjramirezn Jul 29, 2026
7cbe967
fix(re-consent): make the terms prompt escapable, per our own ToS §17
Hugo0 Jul 29, 2026
9c776d9
fix(re-consent): defer to the document's effective date, not a fixed …
Hugo0 Jul 29, 2026
d716787
chore(legal-versions): regenerate from the pinned content submodule
Hugo0 Jul 29, 2026
46f321a
Merge pull request #2579 from peanutprotocol/fix/re-consent-soft-gate
jjramirezn Jul 29, 2026
e8eca13
Merge pull request #2569 from peanutprotocol/release/sp-153-cherry-picks
jjramirezn Jul 29, 2026
4c7a0fc
hotfix: redirect bare card legal slugs to their /en pages
jjramirezn Jul 29, 2026
e914cb6
Merge pull request #2581 from peanutprotocol/hotfix/card-legal-redirects
Hugo0 Jul 29, 2026
54fcd01
Merge branch 'main' into dev — back-merge SP-153 + hotfixes
Hugo0 Jul 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down
5 changes: 5 additions & 0 deletions redirects.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
65 changes: 65 additions & 0 deletions scripts/generate-legal-versions.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Generates src/constants/legal-versions.generated.ts from the legal content
// submodule (src/content/content/legal/<slug>/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`)
10 changes: 8 additions & 2 deletions src/app/(mobile-ui)/card/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand All @@ -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 () => {
Expand Down
3 changes: 3 additions & 0 deletions src/app/(mobile-ui)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -235,6 +236,8 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
{/* Modal */}
<GuestLoginModal />

<ReConsentModal />

<SupportDrawer />

<QRScannerOverlay />
Expand Down
3 changes: 3 additions & 0 deletions src/app/(mobile-ui)/withdraw/crypto/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,9 @@ export default function WithdrawCryptoPage() {
description={
<div className="space-y-2">
<p>{t('compatibilityModal.description')}</p>
{/* 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 && (
<p>
{t('compatibilityModal.sendingTo')}{' '}
Expand Down
7 changes: 7 additions & 0 deletions src/components/Card/CancelCardModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ const CancelCardModal: FC<Props> = ({ 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.
Expand All @@ -75,6 +81,7 @@ const CancelCardModal: FC<Props> = ({ 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'))
Expand Down
16 changes: 12 additions & 4 deletions src/components/Card/LockCardModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ const LockCardModal: FC<Props> = ({ 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')
}
Comment on lines +69 to +74

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Localize the loading failure message.

Both modals expose a hard-coded English error through setError, bypassing the existing card translations.

  • src/components/Card/LockCardModal.tsx#L69-L74: replace the literal with a t('errors.…') key.
  • src/components/Card/CancelCardModal.tsx#L63-L68: use the same translated key.

Based on supplied library context, card errors are localized through t(...).

📍 Affects 2 files
  • src/components/Card/LockCardModal.tsx#L69-L74 (this comment)
  • src/components/Card/CancelCardModal.tsx#L63-L68
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/Card/LockCardModal.tsx` around lines 69 - 74, The loading
failure messages in LockCardModal and CancelCardModal are hard-coded English and
must use the existing card translations. In both files, update the
overview-loading error paths to throw the same appropriate t('errors.…')
translation key, reusing each modal’s existing t function and preserving the
current fail-closed behavior.

// 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
Expand All @@ -76,15 +82,17 @@ const LockCardModal: FC<Props> = ({ 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'))
Expand Down
165 changes: 165 additions & 0 deletions src/components/Card/__tests__/LockCardModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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 ? <div>{children}</div> : null,
}))
jest.mock('@/components/Card/SlideToAction', () => ({
__esModule: true,
default: ({ label, onComplete, disabled }: { label: string; onComplete: () => void; disabled?: boolean }) => (
<button onClick={onComplete} disabled={disabled}>
{label}
</button>
),
}))

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 }) => (
<NextIntlClientProvider locale="en" messages={en} timeZone="UTC">
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
{children}
</QueryClientProvider>
</NextIntlClientProvider>
)

const setup = (overview?: { balance: { spendingPower: number } }) => {
mockOverview.mockReturnValue({ overview })
mockUseWallet.mockReturnValue({ address: WALLET })
mockUseSignSpendBundle.mockReturnValue({ signSpend: mockSignSpend })
}

const renderLock = () =>
render(<LockCardModal cardId="card-1" mode="lock" isOpen onClose={jest.fn()} />, { wrapper: Wrapper })
const renderCancel = () => render(<CancelCardModal cardId="card-1" isOpen onClose={jest.fn()} />, { 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 })
})
})
3 changes: 3 additions & 0 deletions src/components/Global/AddressLink/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand Down
Loading
Loading