[TASK-20903] Prod release SP-153 (cherry-picks) — ENS + consent client → main - #2569
Conversation
ENS names can hold a different address per chain (ENSIP-11). The withdraw flow resolved every name to its mainnet record and sent on the selected chain — funds loss when the name points elsewhere on that chain (reported by an external ENS-savvy tester with test.ses.eth: mainnet and Arbitrum records differ). - resolveEns/validateAndResolveRecipient accept the destination chainId and forward it to /ens/:name?chainId= (api-ts #1236). - Switching chains after typing a name re-resolves it for the new chain instead of silently keeping the old address. - The resolved address is now shown under the input as soon as the name validates, and again in the compatibility warning modal — the user sees where funds go BEFORE any warning/confirm step. (cherry picked from commit f2c0aac)
…flight Between switching chains and the new resolution landing, recipient.address still holds the previous chain's address and Review stayed clickable — the exact wrong-chain send this PR fixes, in a narrow race window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 679c359)
crypto-withdraw-confirm.test.tsx (new on dev) mocks general.utils with only the functions the page used at the time; the compatibility-modal change now also renders printableAddress from that module. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 4e56b63)
The backend's new consent_records ledger stores what the user was actually shown, so every acceptance surface now echoes the displayed document versions instead of letting the server assume: signup sends terms+privacy via the x-accepted-legal header (the ZeroDev SDK owns the register/verify body), card apply sends the exact regional doc set from CardTermsScreen, and versions+hashes are generated from the src/content legal frontmatter at build time — no hardcoded versions anywhere. Adds the ToS §17 re-consent flow: a blocking click-through modal when a published document version moves past the user's last provably-accepted one. Fails open — a dead consent endpoint never locks the app. (cherry picked from commit e1bdcf9)
The single-line entries exceeded printWidth 120, so every predev run left a prettier-dirty tree; one field per line keeps the generated file byte-stable under formatting. (cherry picked from commit c0a3448)
A logout followed by login as a different account in the same SPA session skipped the second user's consent-status check. (cherry picked from commit a0bccaf)
Every dev/build entrypoint now regenerates the legal-version constants (dev:clean, dev:fallback, analyze bypassed the predev hook and could ship stale version/hash echoes). ReConsentModal state is fully isolated per account: switching users clears the doc list, checkbox, and error, a slow status response for the previous account is discarded, and the checkbox resets after a successful acceptance. (cherry picked from commit be5545b)
…ent failures to Sentry Review follow-ups (Hugo on #2456): - ReConsentModal tests: fail-open on status failure (must never lock the app), accept failure keeps the retry path, stale-response discard on account switch (regression already caught once in review), undisplayable slug filtering, once-per-user-per-session check. - consent service tests: pin the exact signup/card doc sets per region and that every echoed entry carries its own generated version+hash — silent drift here mis-ledgers legal consent. - Both catch blocks now Sentry.captureException instead of console.error: a systematic /accept failure traps every user behind an undismissable modal, and prod must be able to see that. (cherry picked from commit f06fd71)
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughAdds legal-document version generation and consent recording, introduces a re-consent modal, propagates chain IDs for ENS resolution during withdrawals, and expands crypto withdrawal compatibility messaging with formatted destination addresses. ChangesConsent ledger and re-consent
Chain-aware ENS resolution
Crypto withdrawal compatibility messaging
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant ReConsentModal
participant consentApi
participant ConsentAPI
ReConsentModal->>consentApi: getStatus()
consentApi->>ConsentAPI: GET /users/consent/status
ConsentAPI-->>consentApi: consent status
consentApi-->>ReConsentModal: outdated documents
ReConsentModal->>consentApi: accept(documents)
consentApi->>ConsentAPI: POST /users/consent/accept
ConsentAPI-->>consentApi: recorded count
consentApi-->>ReConsentModal: acceptance result
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint install failed: dependency version conflict. Check your lock file or package.json. Comment |
Code-analysis diffPainscore total: 6308.27 → 6334.45 (+26.18) 🆕 New findings (67)
…and 47 more. ✅ Resolved (60)
…and 40 more. 📈 Painscore deltas (top movers)
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with 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.
Inline comments:
In `@src/components/Withdraw/views/Initial.withdraw.view.tsx`:
- Around line 80-108: Update the selected-chain resolution effect around
validateAndResolveRecipient so in-flight validation from a previous chain is
cancelled or ignored when the validator or chain changes, preventing stale
results from updating recipient or re-enabling Review. Ensure
ValidatedInput/checkAddress participates in this invalidation, and add a
regression test covering a chain switch while validation is pending.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 4898fe75-3425-4232-9dbc-0fd23a49edcb
⛔ Files ignored due to path filters (1)
src/constants/legal-versions.generated.tsis excluded by!**/*.generated.*
📒 Files selected for processing (18)
package.jsonscripts/generate-legal-versions.mjssrc/app/(mobile-ui)/card/page.tsxsrc/app/(mobile-ui)/layout.tsxsrc/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsxsrc/app/(mobile-ui)/withdraw/crypto/page.tsxsrc/app/actions/ens.tssrc/components/Global/GeneralRecipientInput/index.tsxsrc/components/Global/ReConsentModal/__tests__/index.test.tsxsrc/components/Global/ReConsentModal/index.tsxsrc/components/Withdraw/views/Initial.withdraw.view.tsxsrc/constants/analytics.consts.tssrc/hooks/useZeroDev.tssrc/lib/validation/recipient.test.tssrc/lib/validation/recipient.tssrc/services/__tests__/consent.test.tssrc/services/consent.tssrc/services/rain.ts
| 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]) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -n -C4 'function ValidatedInput|const ValidatedInput|export.*ValidatedInput' src
rg -n -C6 'validate={checkAddress}|onUpdate={onInputUpdate}' src/components
rg -n -C5 'InitialWithdrawView|GeneralRecipientInput' --glob '*test*' srcRepository: peanutprotocol/peanut-ui
Length of output: 4851
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- ValidatedInput outline ---'
ast-grep outline src/components/Global/ValidatedInput/index.tsx --view expanded || true
echo '--- GeneralRecipientInput outline ---'
ast-grep outline src/components/Global/GeneralRecipientInput/index.tsx --view expanded || true
echo '--- Withdraw view outline ---'
ast-grep outline src/components/Withdraw/views/Initial.withdraw.view.tsx --view expanded || trueRepository: peanutprotocol/peanut-ui
Length of output: 958
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '1,260p' src/components/Global/ValidatedInput/index.tsx
printf '\n---\n'
sed -n '1,260p' src/components/Global/GeneralRecipientInput/index.tsx
printf '\n---\n'
sed -n '1,220p' src/components/Withdraw/views/Initial.withdraw.view.tsx
printf '\n---\n'
sed -n '1,260p' src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsxRepository: peanutprotocol/peanut-ui
Length of output: 36925
🏁 Script executed:
#!/bin/bash
set -euo pipefail
for f in \
src/components/Global/ValidatedInput/index.tsx \
src/components/Global/GeneralRecipientInput/index.tsx \
src/components/Withdraw/views/Initial.withdraw.view.tsx \
src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx
do
echo "===== $f ====="
nl -ba "$f" | sed -n '1,260p'
echo
doneRepository: peanutprotocol/peanut-ui
Length of output: 262
Prevent stale chain validations from overwriting the recipient ValidatedInput keeps an in-flight checkAddress promise alive across chainId changes, so a previous chain’s ENS result can still write the old resolved address back into recipient and re-enable Review. Cancel/ignore pending validations when the validator changes, and add a chain-switch race regression test.
🤖 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/Withdraw/views/Initial.withdraw.view.tsx` around lines 80 -
108, Update the selected-chain resolution effect around
validateAndResolveRecipient so in-flight validation from a previous chain is
cancelled or ignored when the validator or chain changes, preventing stale
results from updating recipient or re-enabling Review. Ensure
ValidatedInput/checkAddress participates in this invalidation, and add a
regression test covering a chain switch while validation is pending.
The modal shipped as a hard gate: preventClose, hidden close button, and a single Accept CTA. It mounts in the mobile-ui layout, so it covered the whole app, including /withdraw. That conflicts with the terms it enforces. §17.2 gives material changes a 30-day runway and offers the click-through as a way to accept sooner, by choice. We published on 2026-07-15, so the new terms take effect around 2026-08-14; a hard gate enforces them about two weeks early. §17.3 says a user who does not agree must stop using the Services. For a non-custodial wallet that must still leave a path to their own funds. So the prompt now defers instead of blocking: - "Not now" (plus close, backdrop and Escape) dismisses it. The checkbox does not gate the exit. - A dismissal snoozes the prompt for 3 days per user, in localStorage. It never writes a ledger row, because a refusal is not consent. - Accept reports modal_cta_clicked, not modal_dismissed. Both events existed already. We need the accept-vs-refuse ratio to measure the rollout. - Copy no longer says "To keep using Peanut". That claim is no longer true. Accept stays the primary action: purple, shadow, first in the stack.
…interval "Not now" now snoozes until the documents actually take effect — the frontmatter version plus the 30 days our own ToS §17.2 promises — instead of a flat 3 days. A document posted today buys the user its full notice period. Past that date §17.3 already makes continued use acceptance, so the prompt only still asks in order to record explicit consent. That earns a gentle cadence, not a prompt on every app open, so the snooze floors at MIN_SNOOZE_DAYS.
The committed file said card-terms-international was 2026-06-01. The content submodule this branch pins says 2026-07-14 — the §8.4 Nigerian Users clause. `predev`/`prebuild` regenerate this file, so any dev server or Vercel build already produced the newer value and left the tree dirty. WARNING, cross-repo: peanut-api CURRENT_LEGAL_VERSIONS still says 2026-06-01 for the same document. sanitizeEchoedDocuments treats a client version newer than the server's as an attack and clamps it, dropping the hash. So the ledger would record 2026-06-01 for users who were shown 2026-07-14, and the clause update would never trigger re-consent. The constant needs the same bump in peanut-api-ts#1255.
fix(re-consent): make the terms prompt escapable, per our own ToS §17
Hugo0
left a comment
There was a problem hiding this comment.
Approved.
Verified on the branch tip:
- Consent ledger echo, ENS chain-aware resolution, and the withdraw resolved-address surface all read clean.
- #2579 is merged in, so the re-consent modal ships escapable — "Not now" + close/backdrop/Escape, deferring to the document's effective date, and a dismissal writes no ledger row. That was the blocker; it's resolved.
legal-versions.generated.tsnow matches the pinned content submodule (card-terms-international= 2026-07-14).
One cross-repo note carried over from the paired PR, not blocking this one: peanut-api CURRENT_LEGAL_VERSIONS['card-terms-international'] is still 2026-06-01, so this FE will echo 2026-07-14 and the backend will clamp it. Details on peanut-api-ts#1255 — worth landing that one-liner before this reaches users.
Deploy order unchanged: BE + migration first, then this.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with 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.
Inline comments:
In `@src/components/Global/ReConsentModal/index.tsx`:
- Around line 58-59: In src/components/Global/ReConsentModal/index.tsx lines
58-59, handle logout by clearing modal state, resetting lastCheckedUserId, and
invalidating any pending status request. In lines 90-107, capture the initiating
user/session token and guard success, failure, and finally updates so stale
async completions are ignored after authentication changes; add a regression
test covering an account switch during acceptance.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 07e9e0c3-8333-4420-a831-1124a49a24f9
⛔ Files ignored due to path filters (1)
src/constants/legal-versions.generated.tsis excluded by!**/*.generated.*
📒 Files selected for processing (3)
src/components/Global/ReConsentModal/__tests__/index.test.tsxsrc/components/Global/ReConsentModal/index.tsxsrc/components/Global/ReConsentModal/utils.ts
| // a recent "Not now" defers the prompt — don't even spend the request | ||
| if (isReConsentSnoozed(userId)) return |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Invalidate re-consent work across auth-session changes. The modal tracks a user ID for status requests but does not invalidate state or async completions when authentication changes.
src/components/Global/ReConsentModal/index.tsx#L58-L59: on logout, clear modal state, resetlastCheckedUserId, and invalidate the pending status request.src/components/Global/ReConsentModal/index.tsx#L90-L107: capture the initiating user/session token and ignore stale success, failure, and finally updates; add an account-switch-during-accept regression test.
📍 Affects 1 file
src/components/Global/ReConsentModal/index.tsx#L58-L59(this comment)src/components/Global/ReConsentModal/index.tsx#L90-L107
🤖 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/Global/ReConsentModal/index.tsx` around lines 58 - 59, In
src/components/Global/ReConsentModal/index.tsx lines 58-59, handle logout by
clearing modal state, resetting lastCheckedUserId, and invalidating any pending
status request. In lines 90-107, capture the initiating user/session token and
guard success, failure, and finally updates so stale async completions are
ignored after authentication changes; add a regression test covering an account
switch during acceptance.
Release page: prod release sp 153
dev is not releaseable right now, so this branch cherry-picks onto
mainonly what ships now (supersedes #2567 as the FE release vehicle). Pairs with backend peanut-api-ts #1255.Initial.withdraw.view.tsx: kept main's chain-change error-clearing effect AND the ENS re-resolution effect as separate effects — the final file state is byte-identical to dev's resolved version.Notes
tsc --noEmitclean (only the usual missing-asset-declaration noise from a scriptless install; CI covers the real build).🤖 Generated with Claude Code
Summary by CodeRabbit