chore: back-merge main → dev (pre-release 2026-08-05) - #2611
Conversation
'Peanut is locked / Could not confirm it is you' read as scary and technical. Reframe as a plain log-in ask: 'Welcome back!' with 'Please log in (again) to access the app.' and a 'Log in' button.
…ssification, full auth clear Ports the applicable subset of mobile-release 9cdb8ae: - demo: add /rain/cards fixture so overview consumers never see the {} fallback (crashes deref of .status/.cards — PEANUT-UI-RM6 on native) - passkey: classify WebKit 'Load failed' fetch errors as NETWORK instead of the LOGIN_ERROR modal (PEANUT-UI-QV6) - auth: clearAuthState delegates to clearAuthToken so native token stores are cleared too, not just the jwt cookie
Review follow-up: clearAuthToken's Preferences/cookie-jar clears are async; make clearAuthState await them so callers can't proceed on a half-cleared session (in-memory clear was already synchronous).
…new deposit
The BRL/PIX success screen wired both its exits — the Done CTA and the
NavHeader back arrow — to onBack, which the parent defines as
setUrlState({ step: 'inputAmount' }). Tapping Done on a completed deposit
therefore dropped the user back on the amount input, i.e. straight into a
new deposit: the deposit read as unfinished and invited an accidental
repeat payment.
Give the screen a distinct onDone exit (router.replace('/home')). replace,
not push, so device/browser back can't pop into the finished flow — whose
step=showQR would redirect to inputAmount and reproduce the same bug.
The expired-QR "Go back" keeps onBack; that one is a real go-back.
Users whose Bridge customer owes 'additional verification' tasks (the hosted Persona re-verification and/or a ToS (re-)acceptance) could only see them in Bridge's dashboard — in-app there was nothing actionable: the activation card deliberately stands down for anyone who can already transact, and advisory (future-dated) tasks arrive as orphan nextActions no rail references. New self-hiding PendingVerificationTasks card on /home, sibling of ActivationCTAs (outside its stand-down on purpose). It reads top-level capability nextActions — the only surface that catches both blocking tasks and advisory orphans — and renders one row per task: - accept-tos → mounts the existing BridgeTosStep wholesale (link fetch, iframe, signedAgreementId confirm, rail await) - bridge-hosted (new kind, BE api#TBD) → exchanges the key for Bridge's hosted verification URL via start-action and opens it in the existing IframeWrapper (its Persona 'complete' postMessage handler already covers completion; refetch user on close) Old-BE tolerance: an older backend never emits bridge-hosted and only emits accept-tos for blocking users — the card simply renders less. No NEXT_PUBLIC_API_VERSION bump (additive contract).
Adversarial review found the reported defect surviving one state earlier.
'processing' means the poller saw stage >= 2 — the fiat has already left
the user's bank — and the spinner's only affordance was a back arrow wired
to onBack, i.e. the amount input, still pre-filled from the URL with the
amount just paid. One tap from paying twice, while the first deposit is
mid-settlement. Route it to onDone like the settled state.
Leaving is safe: the credit is posted server-side by the webhook/poller,
not by this screen's polling.
Also cover the line that actually fixes the user-visible bug — the parent's
router.replace('/home'). Without it a regression that rewires onDone back
to setUrlState({ step: 'inputAmount' }) restores the bug with a green suite.
- Open flows are snapshotted at tap time and mounted ABOVE the self-hiding early return: the task list re-derives from the ~4s user auto-refresh, and the previous shape force-unmounted an OPEN ToS modal / hosted iframe mid-verification when the task flapped away. The card hides; the flow finishes. - Each ToS row opens the modal for ITS variant (a user can owe base AND SEPA v2 simultaneously — the single find() gave the second row the first row's copy). - IframeWrapper ignores window messages while hidden: surfaces keep a wrapper mounted after manual close (multi-phase KYC ToS), and a sibling iframe's signedAgreementId event fired BOTH handlers — double ToS confirms + phantom flow transitions. This PR adds the second ToS surface on /home that made the latent hazard reachable, so it carries the guard. - Copy is population-aware (advisory 'keep … available' vs blocking 'enable'), start-action failures show friendly copy + resync the user (a 403 means the action aged out), malformed effectiveDate renders no deadline instead of 'Invalid Date', errors clear when the task set changes, and all task buttons disable while the hosted URL fetch is in flight (no stacked modals).
Aleks: the /home card should be closable like the carousel CTAs, without losing the tasks. The X persists the dismissal per task-key set (a DIFFERENT set of pending tasks re-shows the card); the same component mounts non-dismissibly under Profile → Unlocked regions, which is where dismissed users find their tasks again.
…0772) Carry locale + invite code + campaign tag + destination path from mobile web through the app-store hop. Android rides the Play Install Referrer (app-local InstallReferrerPlugin, resolve-once + 5s timeout); iOS rides a clipboard hand-off double-gated by prompt-free UIPasteboard checks (hasStrings + detectPatterns probable-web-url) so unrelated clipboard content never triggers the paste alert. One-shot restore on first launch: 30-day normalized inviteCode/campaignTag cookies (setup flow already reads them), locale persisted under the app-locale key the upcoming in-app i18n (on dev) reads — @capacitor/preferences added so the preference lands in native storage and the plugin ships in this binary — and navigation to the destination only when no real deep link landed. Restore is not awaited in init so a pending paste prompt or slow referrer service can never block the push listener or splash hide. Base is main (not dev) so the native release pipeline can cut a testable binary; back-merge to dev will need a small locale-handling reconcile with dev's src/i18n/app (marked in deferred-link.ts).
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)
With ToS + hosted verification pending together the stacked card grew tall; reuse the HomeCarouselCTA embla setup so each task is its own full-width slide (identical footprint to the single-task card, dots + swipe only when there's more than one). jest.setup gains matchMedia and IntersectionObserver stubs — embla needs both at init and jsdom has neither, same gap the existing ResizeObserver stub covers.
Lock/cancel sign a withdrawal whose purpose is draining Rain collateral back to the wallet, but since cb302d3 removed the smartBalance:0n input, routing fell through to live-balance strategy selection: any user whose wallet USDC covered their spending power routed smart-only, tripped the modals' own strategy check, and could neither lock nor cancel ('Unexpected withdrawal strategy', prod, 3 users affected). Also fail closed when the card overview hasn't loaded: undefined read as zero spending power, silently skipping the withdrawal and getting the action rejected server-side ('Withdrawal signature required'). Port of the dev-based #2570 (closed in favor of this main-based hotfix; that branch holds the i18n-ified variant for the dev back-merge).
external-wallet withdrawals have no recipient username or parsed identifier, so the drawer's userName was undefined and the header rendered the literal string. fall back to the recipient address (shortened by printableUserHandle) like recipientName already does.
Now that tasks are separate carousel slides, one X hiding ALL of them reads as a bug — each slide gets its own X and the preference stores dismissed task keys instead of the joined set. A dismissed key stays hidden on /home until the task resolves (keys are a tiny stable vocabulary, so no pruning); the Profile mount still shows everything.
…lash a cold mount (mobile reopening the pwa reloads the page) showed the raw address for a beat while the lookup ran — pay the 404, engage the client fallback, resolve, flip. persist resolved names for 24h and paint them immediately on mount while the lookup revalidates. an authoritative server "no name" evicts the entry so stale names can't mask reality.
…hape CodeRabbit: the JSDoc still described the pre-carousel comma-joined set string.
coderabbit: JSON.parse('null') passes the try/catch but null[address]
throws during render. treat any non-object root as an empty cache.
…k href in sync code-review findings: (1) with the server route erroring (today's prod state) the only eviction path was server null, which never fires — a client lookup settling '' (justaname's not-found) now also masks and evicts, so a released/transferred ens name can't keep painting from cache. (2) AddressLink's effect never reset urlAddress on the name→address downgrade, leaving the href pointing at a name the address may no longer own — reachable now that cached names can be evicted mid-mount. (3) server '' responses treated same as null.
…0729-155858 content: publish latest to production (src/content → peanut-content@fc31f07)
…0729-160449 content: publish latest to production (src/content → peanut-content@f33d67f)
…tor branch The /api/exchange-rate route and the Capacitor branch of useExchangeRate were hand-mirrored copies that had already diverged twice: the route's cross-pair branch in fetchFromCurrencyPrice was unreachable dead code (every caller passes USD on one side), and the Frankfurter fallback applied the 0.995 spread twice on the route's non-provider cross path vs once in Capacitor. Hoisting the Capacitor implementation into utils/currency.ts (displayRateFromPrices + fetchDisplayRate) makes future drift impossible and deletes ~115 lines of route helpers, including the stale claim that callers stack a Peanut fee on the fallback spread (the developer fee is 0). Rate math is now pinned once in utils/__tests__/currency.test.ts; the route test shrinks to wiring (delegation, 400 on bad params, 500 on total failure).
…l displayed The commit test only asserted createOnramp was called; now it pins the submitted amount to the same string the modal rendered, so a regression that converts or mangles the amount between display and submission fails the suite.
…2026-05-08 Comments across the onramp-quote surface asserted Peanut charges a 50bps developer fee. BRIDGE_DEVELOPER_FEE_RATE was zeroed on 2026-05-08 (the fee was charged without in-app disclosure), so netRate === grossRate and applyBridgeCrossCurrencyFee is an identity function today. Rewritten to state the fee is currently 0 and that the net/gross split and fee helpers are kept deliberately for the planned FX-margin re-enable. No behavior change.
…de of the pin Adversarial review caught that the commit-path test asserted only the submitted argument; the displayed-equals-submitted claim needs both sides.
…lls useCurrency into the server route Vercel's webpack build rejected the API route importing utils/currency: it re-exports through hooks/useCurrency (useState/useEffect), which is client-only, and an RSC module may not depend on it. The local build had passed on a stale .next cache. fx.utils.ts imports only actions/currency, so both the server route and the Capacitor client branch can share it.
…ck pairs were breaking the reciprocal contract CodeRabbit caught that the Frankfurter fallback multiplied each requested orientation by 0.995, so A→B and B→A composed to 0.995² instead of 1 — the exact asymmetry this PR exists to remove, resurfacing on every fallback-served pair. The fallback now synthesizes a sell-side USD-leg price per currency (mid × 0.995) and converts through displayRateFromPrices, the same policy as the provider path, so one pair implies one price everywhere.
…ting fix(add-money): PIX deposit Done exits to home instead of starting a new deposit
…sell-both-directions fix: quote both exchange-rate display orientations off the sell rate + DRY the display-rate path
The proxy 307'd /setup → /home whenever a jwt-token cookie merely existed, while (mobile-ui)/layout.tsx redirects /home → /setup whenever /users/me says logged-out. Any 'cookie present but session dead' state (expired JWT, half-completed signup, API 5xx/offline) ping-ponged forever — and the installed PWA cold-starts on /home (manifest start_url) with no URL bar to escape, so logged-out PWA users were fully locked out (TASK-21050). Cookie presence can't prove a session. The authenticated-at-/setup case is already handled client-side by the existing-session prompt in (setup)/setup/page.tsx (trusts /users/me), which the middleware bounce was shadowing on web. Delete the bounce; add an e2e regression test pinning 'stale cookie on /setup must stay on /setup'.
… /setup Adversarial review finding: with the middleware bounce gone, an authenticated iOS-Safari (non-PWA) user who opens /setup mounts the (setup) layout, which arms showIosPwaInstallScreen. 'Continue as X' soft-navs to /home with the singleton store alive, and (mobile-ui) renders the escape-proof ForceIOSPWAInstall wall. Clear the flag in the continue handler — this visit was not a setup session. (Clearing on layout unmount instead would break the intended post-signup wall.)
Adversarial review finding: URL-based assertions are vacuous on the old code — a 401 from /users/me clears the stale cookie client-side and the loop's own /home → /setup leg can restore the URL before it's read. Assert the raw document status via context.request (maxRedirects: 0): old middleware 307s, fixed code 200s — deterministic, timing-free, API-free. Stub /users/me to 401 for the browser-side smoke so the forged token never leaves for a real API.
fix: logged-out PWA /setup redirect loop (TASK-21050)
…ble; source-matched iframe messages
/code-review (high) 2026-08-04:
Finding 1 (CONFIRMED): handleHostedClose('tos_accepted') only refetched the
user — the resolver is pure, so the embedded acceptance was never recorded
until a Bridge webhook landed, and tapping the still-visible ToS card 409'd
into 'Could not load terms'. The hosted path now runs
confirmBridgeTosAndAwaitRails (the same canonical confirm every other ToS
surface uses) with the iframe staying open; on failure it still resyncs.
Finding 2 (CONFIRMED): a blocking task's dismissal fingerprint is constant
over time (accept-tos||due-now), so an old dismissal would hide a NEW
same-variant requirement while the user's rails are gated — and the orphan
bridge-hosted task has no other surface outside Profile. There is nothing
unique to fingerprint a new due-now round WITH, so the fix is one level up:
blocking (due-now) tasks no longer render an X and ignore stored
fingerprints (pre-fix localStorage entries included); advisory dismissal
behavior is unchanged.
Finding 3 (PLAUSIBLE): IframeWrapper now reacts only to messages whose
source is its OWN iframe instead of gating on visible — the sibling
double-confirm protection survives (now even against two concurrently
VISIBLE wrappers, which the visible-guard let through and finding 1's new
confirm call would have turned into a double confirm), while a completion
landing as the modal hides is no longer dropped. New IframeWrapper spec pins
own-handled / sibling-ignored / sourceless-ignored.
Finding 4: formatDeadline duplicated AdvisoryPreemptModal's formatter with a
different month style. One shared formatEffectiveDate in format.utils serves
both surfaces (long month everywhere); the add-money suite's format.utils
mock now spreads requireActual so partial mocks don't break on new exports.
…he PWA The button synthesized an <a target=_blank> and called .click() — an untrusted event. Chrome grants the popup (transient activation) but excludes untrusted link activations from WebAPK link capturing, so every tap opened this same screen in a plain browser tab instead of the installed app: the logged-out 'Open Peanut app' boomerang that TASK-21050's middleware fix unmasked (session evidence: 4 identical /setup loads at ~1.4s cadence per tap). A trusted tap on a real in-scope target=_blank anchor is what link capturing honors. Styled with the same btn classes the Button would compose.
…rable Adversarial review: the anchor fix bets on Chrome's link-capturing behavior that can't be verified headless, and a failed handoff (plain new tab of the same screen) was indistinguishable from success in telemetry. Repeat pwa_open_app_clicked fires within one session now flag a device where capture is not engaging. Capture runs without preventDefault, so the trusted navigation is untouched.
…chor fix: real anchor for 'Open Peanut app' — untrusted synthetic clicks skip WebAPK handoff
…regressions fix(demo+passkey): /rain/cards demo fixture, WebKit 'Load failed' classification, full auth clear
Adds the 9JA artwork plus the BADGES entry and the ?campaign=naija mapping. No INVITE_CODE_TO_CAMPAIGN_MAP entry: no invite code 'naija' exists, so that route would dead-end on 'Invalid Invite Code'. Merge the peanut-api-ts side first — this renders the badge but cannot award it.
Adds the tereré jarra artwork plus the BADGES entry and the ?campaign=terere mapping. Same shape as NAIJA: no INVITE_CODE_TO_CAMPAIGN_MAP entry, because no invite code 'terere' exists. Merge the peanut-api-ts side first — this renders the badge but cannot award it.
feat(badges): NAIJA and TERERE badge artwork and rendering
…n-tasks feat(home): pending Bridge verification tasks card
…into-dev-20260805 # Conflicts: # src/app/(mobile-ui)/card/page.tsx # src/app/(mobile-ui)/withdraw/crypto/page.tsx # src/app/api/exchange-rate/route.ts # src/components/AddMoney/components/MantecaAddMoney.tsx # src/components/AddMoney/components/MantecaPixQrDeposit.tsx # src/components/AddMoney/components/__tests__/MantecaAddMoney.test.tsx # src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx # src/components/Claim/Link/views/MantecaReviewStep.tsx # src/components/Global/AppLock/index.tsx # src/components/Global/GeneralRecipientInput/index.tsx # src/components/Global/IframeWrapper/index.tsx # src/components/Setup/Views/InstallPWA.tsx # src/components/Withdraw/views/Initial.withdraw.view.tsx # src/constants/analytics.consts.ts # src/services/rain.ts # src/utils/general.utils.ts
- port the NITA creator-link test to dev's plural resolveCampaigns API - wrap main's new suites (IframeWrapper, LockCardModal, PIX exits) in IntlWrapper - mock localeApplied() so splash-hide asserts don't race dev's 2s locale hold - add useRouter to the MantecaAddMoney nav mock (needed by main's onDone routing) - split the add/add MantecaAddMoney test conflict into two files: denomination suite (dev) + exit-contract suite (main)
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
Code-analysis diffPainscore total: 7008.58 → 7057.75 (+49.17) 🆕 New findings (194)
…and 174 more. ✅ Resolved (178)
…and 158 more. 📈 Painscore deltas (top movers)
|
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
Back-merge of prod hotfixes from main into dev before the next release, so dev is a clean superset of main. dev's localization layer is preserved throughout — every conflicted screen keeps its t() strings while adopting main's logic fixes.
What main had that dev did not (highlights)
Conflict resolution policy
Prod hotfix logic kept in every case; dev's i18n (t() strings, IntlWrapper) preserved on top. Notables:
MantecaPixQrDeposit— main's onDone exit contract + dev's localized stringsMantecaReviewStep— main's sell-rate display fix + dev's localized labelAppLock— kept dev's copy: its localization (Jul 24) postdates and supersedes main's friendlier-copy hotfix (Jul 23)exchange-rate/route.ts— main's fetchDisplayRate; dev's dead Manteca-set import droppedMantecaAddMoney.test.tsx— split into two files: dev's denomination suite + main's exit-contract regression suite (MantecaAddMoney.exits.test.tsx)Verification
npm run typecheckcleannpm test: 201/201 suites, 2612 passed, 0 failedpnpm prettier --check .cleanSupersedes #2586 (stale, 53 commits behind main).