release: dev → main (Red ATM kill · avalanche cleanup · deposit rails · pix/p2p fixes)#2245
Conversation
Bumps src/content f204f50..5cbbe4f (peanut-content main). Headline change:
scrub the Argentina cardless cash-withdrawal ("Red ATM") marketing across
all 5 locales — it was promoted as a live feature but is being pulled. The
RedATM withdrawal flow in the app is untouched; this is marketing copy only.
Competitor/pain-point ATM copy is intentionally preserved (foreign-ATM fee
comparisons, Western Union, Revolut/BLIK).
Content-only: zero code paths touched. Supersedes the handrolled #2226 and
the stale auto/update-content PRs, which were based off main and showed
phantom code diffs against dev.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rogress bar)
Two cosmetic-but-confusing display bugs in the bill-split / request-pot UI,
both reported by Konrad:
1. Slider "snap to 50% → 49.98%": the slider snaps the thumb to a clean
percentage on drag, but AmountInput re-derives the thumb position from a
cent-floored amount ($33.37 pot → 50% = $16.685 → floored $16.68 → 49.98%),
and the Slider's sync effect then clobbered the snapped value. The amount is
correct (you can't pay half a cent); only the label drifted. The sync effect
now keeps the thumb on a snap point through sub-cent drift.
2. Request-pot progress bar blank for pots that collected >= $1,000: the receipt
passed the comma-grouped display string to Number() (Number("1,234.56") ===
NaN), blanking the bar. Use the raw numeric totalAmountCollected, matching
how `goal` already uses transaction.amount.
Display-only; no amounts or money paths change. Complements api-ts #1027 (the
send-link $0 phantom) as part of the same P2P display-correctness sweep.
Card payments couldn't be split. The "Split this bill" CTA + /request prefill +
auto-create already ship for QR pays (gated on isQRPayment); this extends the
gate to card spends so a Peanut-card purchase can seed a bill-split request
(amount = the spend's USD value, comment = "Bill split for {merchant}").
- isCardSpend predicate: CARD_SPEND_AUTH/CLEAR only — refunds + auth reversals
excluded (you didn't pay those).
- Threaded through useReceiptViewModel; CTA gate is now (isQRPayment || isCardSpend)
and also excludes failed/declined spends.
- Omit the merchant param when userName is the "Card payment" fallback so the
comment isn't "Bill split for Card payment".
FE-only — reuses the existing /request creation + POST /requests.
…eRabbit) A merchant name with reserved characters (e.g. "Tigers & Lions") would break the `/request?amount=…&merchant=…` query string and corrupt the request prefill. encodeURIComponent the merchant value. Addresses CodeRabbit's inline finding on #2235.
fix(p2p): request-pot display bugs — slider 49.98% snap + $1k+ progress bar NaN
normalizePixPhoneNumber returned the raw input untouched when the cleaned value already started with '+', so '+55-11-99999-9999' kept its separators and stayed non-canonical — conflicting with the helper's contract and risking PIX-key mismatches. Always return the separator-free +55 form. From release-PR #2236 CodeRabbit triage; adds the separator-with-+ regression cases the existing +-prefix tests never exercised.
…zation fix(pix): canonicalize +55 phone keys that already carry a + prefix
…uilder The CTA's onClick inlined the /request URL build, including the two bug-prone bits — the "Card payment" merchant fallback and the URL-encode CodeRabbit caught on #2235. Extract to buildSplitBillRequestUrl and lock it with unit tests (encode reserved chars, omit fallback, omit empty merchant). Pure refactor + tests; no behavior change.
Fast-forward the Red ATM scrub bump from the intermediate 5cbbe4f to the current peanut-content main tip 358b6a6, so dev matches mono-main content (and converges with the main-targeted prod bump #2242 — keeps the next dev->main release from re-reverting the Red ATM removal). Content-only, no code paths touched.
Symmetric with the merchant encode — the param type allows string, so encode
amount defensively. No behavior change for the numeric amounts callers pass
(encodeURIComponent("12.5") === "12.5"); existing assertions unchanged.
test(split-bill): lock the Split-this-bill CTA URL builder
Mirrors the main-targeted companions (#2242). The 358b6a6 content bump removes the avalanche deposit + withdraw marketing pages (avalanche is advertised in SEO + DEPOSIT_RAILS but never wired into rhino.consts — a false claim). Sync the code so the content gate passes: - DEPOSIT_RAILS / verify-content RAIL_SLUGS: drop avalanche so the SEO route + sitemap stop emitting /deposit/via-avalanche - redirects.json: 301 old via-avalanche + withdraw/avalanche -> /supported-networks - .verify-content-baseline: 745 -> 740 ETH untouched (we support it). Deeper deposit-flow avalanche/scroll cleanup stays with #2216 (overlaps these files -> trivial rebase).
The rail list lived in two places — DEPOSIT_RAILS (exchanges.ts) and a hand-copied RAIL_SLUGS Set in scripts/verify-content.ts, guarded only by a 'keep in sync' comment. Removing avalanche meant editing both; the lists had already drifted before. Extract to src/data/seo/deposit-rails.ts — an import-free module both the app (routes + sitemap via exchanges.ts) and the standalone content gate (verify-content.ts) import. Add/remove a rail in one place now. No behaviour change: same 9 rails, same classification. (Registering faster-payments + spei as rails is a follow-up — it's a content-input regeneration job, not a code add: the from-→via- flip breaks generated RelatedLinks that must be fixed at the mono input layer and re-mirrored.)
Missing `name` falls back to title-casing; missing `recommended_network` falls back to 'arbitrum' (pickRecommendedNetwork), not title-casing. Per CodeRabbit review.
…-atm chore(content): bump src/content — remove Red ATM from marketing
Faster Payments (UK/GBP) and SPEI (MX/MXN) are live deposit rails but were
served as /deposit/from-{slug} (exchange framing) — inconsistent with
SEPA/ACH/wire. Register both in DEPOSIT_RAILS so the route + sitemap emit
/deposit/via-{slug}, bump src/content to the matching content fix (the 14
RelatedLinks now point to via-), and 301 the old from- URLs to via- for any
indexed/shared links.
Content side: peanut-content 358b6a6 -> 5eb8453 (mono@8a061d5) — flips the
links + adds FP/SPEI to the fiat-rail list in intent-taxonomy.md so
regeneration keeps them as rails. Verified: content gate green, typecheck +
src/data tests pass.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThis PR bundles six independent functional changes and a product documentation update: extends "Split this bill" to card spend transactions via a new ChangesSplit Bill for Card Spend
Slider Snap-Stick Fix
SEO Deposit Rails Refactor and Avalanche Redirects
Network Support Updates
PIX Phone Normalization Fix
Product Documentation Update for LLM Training
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~28 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
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/Slider/__tests__/index.test.tsx`:
- Around line 9-15: The ResizeObserverStub is being assigned to
globalThis.ResizeObserver at the module level without any cleanup, causing the
mock to persist across all tests and potentially affecting other test files.
Move the ResizeObserverStub class definition outside of any setup/teardown, but
wrap the assignment of ResizeObserverStub to globalThis.ResizeObserver in a
beforeEach hook that sets it up, and add an afterEach hook that restores the
original ResizeObserver value (save it before the beforeEach modifies it). This
ensures the stub is only active during each test execution and the original
value is restored afterward, preventing test pollution.
🪄 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: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 385e623e-3e7f-481a-8267-ea46b46b22ba
📒 Files selected for processing (17)
.verify-content-baselineredirects.jsonscripts/verify-content.tssrc/components/Global/Slider/__tests__/index.test.tsxsrc/components/Global/Slider/index.tsxsrc/components/TransactionDetails/TransactionDetailsReceipt.tsxsrc/components/TransactionDetails/__tests__/splitBill.utils.test.tssrc/components/TransactionDetails/__tests__/transaction-predicates.test.tssrc/components/TransactionDetails/splitBill.utils.tssrc/components/TransactionDetails/transaction-predicates.tssrc/components/TransactionDetails/useReceiptViewModel.tssrc/contentsrc/data/seo/deposit-rails.tssrc/data/seo/exchanges.tssrc/data/seo/index.tssrc/utils/__tests__/withdraw.utils.test.tssrc/utils/withdraw.utils.ts
# Conflicts: # src/content
llms.txt was too short and undersold the product. Rewrite (GA-informed: Argentina + Brazil dominate; mercadopago + pix are the top public pages) to lead with the differentiators: pay PIX in Brazil with no CPF, pay MercadoPago in Argentina with no DNI, the Peanut Card, then send/receive/deposit/withdraw, how-it-works, and where-it-works (live vs roadmap). Also fixes stale facts in llms-full.txt: smart accounts are on Arbitrum (not 'Base/Ethereum L2'); the card is virtual Visa only (not physical). No backend provider names; 'ATM cash withdrawal' omitted (Red ATM was just killed from marketing).
Code-analysis diffPainscore total: 5761.09 → 5745.45 (-15.64) 🆕 New findings (249)
…and 229 more. ✅ Resolved (230)
…and 210 more. 📈 Painscore deltas (top movers)
|
…allowlist The card moved from a CARD_PIONEER allowlist (US+18 LATAM+10 African) to a denylist: eligible everywhere except the issuer's ~17 prohibited-issuance countries (China, India, Russia, Turkey, Vietnam, Iran, Israel, ...). Source of truth: peanut-api-ts/src/card/geo-eligibility.ts. The earlier llms copy came from product/card.md which is stale post-rehaul (flagged for update).
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
feat(seo): register Faster Payments + SPEI as deposit rails (via-, not from-)
…ale Avalanche FAQ Carries forward the still-needed deposit-flow half of #2216 (the avalanche *marketing* half already shipped via #2232): - rhino.consts: remove SCROLL from SUPPORTED_EVM_CHAINS — Rhino's live config (getSupportedConfigs) no longer returns an SDA for it (audit 2026-06-11), and a deposit on an unsupported chain is silently lost. The chainId map keeps 534352->SCROLL so any stray deposit stays identifiable for recovery. - TokenAndNetworkConfirmationModal: compute the '+N EVM' overflow from the list length (was hardcoded '+4') so it stays correct, and drop the now-unused RHINO_SUPPORTED_CHAINS import. - merchants.ts: drop Avalanche from the Argentina funding FAQ (false claim). Supersedes #2216.
…removal fix(deposit): stop offering Scroll deposits (silently lost) + drop stale Avalanche FAQ
…display formatting
Two DRY follow-ups to the gate fix, both single-sourced in the wallet hook:
1. Messaging — useWallet.spendBlockReason(amount) classifies a blocked spend as
'settling' (covered by the displayed balance but part is still mid-rebalance:
<= display, > available-now) vs 'insufficient' (exceeds the displayed total).
The five legacy gates (send-link, qr-pay, withdraw page/bank/manteca) map that
to one shared SPEND_BLOCK_MESSAGE instead of four bespoke strings. The
'settling' copy is deliberately generic ("Your balance is updating...") so it
never exposes the card-collateral mechanic, and it only shows in the rare
~10-45s in-transit window: in the 99% case display == available so only the
normal "insufficient" path is reachable.
2. Display formatting — send-link, withdraw page/manteca and both request views
render the hook's formattedSpendableBalance instead of re-deriving locally
(printableUsdc vs formatAmount diverged: commas vs none). One formatter,
consistent across screens; orphaned imports removed.
Displayed balance is unchanged: always the full spendable total (smart +
collateral, incl. in-transit). Only the spend gate is strict, and only briefly.
hasSufficientSpendableBalance stays for the features/payments flows; both share a
parseUsdToBaseUnits helper. Tests revived/extended; full unit suite green.
CodeRabbit review on the messaging/format commit: - Send-link: the Retry button isn't disabled on a balance error (unlike the other flows, which disable their submit), so handleOnNext could reach createLink with a blocked amount and fail at execution. Re-check spendBlockReason at the top of handleOnNext so the block holds at submit too. - withdraw/manteca: use an explicit `balance !== undefined` guard for the displayed balance so a real $0 balance shows "$0.00" instead of being hidden, matching the other consolidated flows (the old truthy guard dropped 0n).
2026-06-02 21:24 UTC, PEANUT-API-5P/5M/5N. A user with a UK GBP bank
account tried to offramp USDC → EUR via SEPA. Bridge rejected with
"country is not supported for SEPA" — SEPA only credits EUR-denominated
Eurozone accounts, not GBP/UK.
Root cause was in BankFlowManager.handleCreateOfframpAndClaim:
const destination = getOfframpCurrencyConfig(
account.country ?? selectedCountry!.id
)
When `account.country` is missing, the picker falls back to
`selectedCountry`. If `selectedCountry` doesn't match the saved-account's
actual type (e.g. user picks a random country, or country resolution is
buggy), the helper's "everything unknown → EUR/SEPA" default fires —
even for a clearly GBP/UK account.
The account's own `type` already carries the right answer for every
Bridge destination we support (us/gb/clabe/iban). Adding a tiny helper
`getOfframpConfigFromAccount` that derives currency+rail directly from
account.type closes this class of bug.
- `gb` → gbp/faster_payments
- `us` → usd/ach
- `clabe` → mxn/spei
- `iban` → eur/sepa
- `manteca` → throws (must use the Manteca offramp path, not Bridge)
- type missing → falls back to country-based picking (prior behavior)
The helper also accepts BE Prisma-shape suffixes (`BANK_IBAN`,
`BANK_ACCOUNT_GB`, etc.) so it doesn't matter which side of the API the
account row came from.
7 new unit tests in bridge.utils.test.ts cover:
- The GB regression (was EUR/SEPA, now GBP/faster_payments)
- All 4 supported account types map to the right rail
- BE Prisma-suffix shapes (BANK_IBAN, BANK_ACCOUNT_GB)
- Manteca throws to surface a wrong-path call early
- Missing-type fallback to country picking still works
All 71 bridge.utils tests passing.
Companion PR: peanut-api-ts #964 — surfaces Bridge's actual error
message in Sentry + stops Discord-paging on user-input 4xx errors.
Even after this FE fix, other user-input mistakes (e.g. a Bridge
customer status change mid-flow) shouldn't page on-call.
The bank-details form rejected any IBAN whose country didn't equal the
country selected on the previous screen ("IBAN does not match the selected
country"). SEPA routes by IBAN, so the dropdown is cosmetic for a EUR payout —
this gate blocked two legit cases: a German IBAN with Spain selected, and a UK
user withdrawing EUR to a GB IBAN (Ibrahima / bobbyfresco).
Gate on actual support instead: validateBankAccount() hits the BE
allowedCountries check (SEPA/US/CA), so structurally-valid-but-unsupported
IBANs are still rejected — just with an honest "not supported" message rather
than a false country-mismatch. Drops the now-orphaned getCountryFromIban import.
…ropdown Follow-on to dropping the IBAN-country gate: the add-bank-account payload set countryCode/countryName/address.country from the country picked on the previous screen. With the gate gone, a German IBAN entered under "Spain" would reach Bridge with countryCode=ESP and 400. SEPA routes by IBAN, so derive all three country fields from the IBAN itself for IBAN accounts — keeping the payload internally consistent exactly as the old equality gate guaranteed, just sourced from the IBAN. Unblocks the GB-IBAN-EUR case (GB IBAN -> GBR) too. Adds unit coverage for getCountryCodeForWithdraw (GB->GBR/DE->DEU/ES->ESP/US->USA, idempotent on ISO-3) and getCountryFromIban, the derivation the fix relies on.
…IBAN source CodeRabbit (2 major): - BankFlowManager: the bankDetails objects (new-account, saved-account, guest) dropped `type`, so getOfframpConfigFromAccount() fell back to country routing — defeating the derive-from-type fix. Carry `type` through all three (guest path derives it from the response shape: iban/clabe/sort_code→gb/account→us). - DynamicBankAccountForm: derive the IBAN country from a single normalized source (cleanedAccountNumber, falling back to the `iban` form value) so countryCode is never derived off an empty string → no empty countryCode to Bridge.
T1.4: destinationDetails derived the currency/rail from a country switch whose default returned an empty rail, so a UK account typed anything but GB (pre-BANK_GB mistype, or a Prisma-shaped 'BANK_GB') dead-ended on 'External account ID is missing.'. Derive from the account via getOfframpConfigFromAccount (GB->GBP), consistent with the claim flow. T1.2: harden the freshly-added-account fallback — if the add-bank-account response lacks bridgeAccountId, surface a retryable error instead of navigating to a confirm screen that dead-ends on 'Bank account is missing.'.
…op settling-at-input) Gating money-flows at input on an "available-now" subset was wrong: the FE balance is only ~30s-polled while the spend routing reads the chain live at submit (#2234), so an input-time available-now gate blocks spends that would actually succeed — and the ~15s flow naturally lets the ~10-45s collateral rebalance settle. So gate on the DISPLAYED balance (block only a true shortfall) and let in-transit spends fail late on live execution. - useWallet: hasSufficientSpendableBalance now gates on the displayed spendableBalance. Deletes spendBlockReason / availableSpendableBalance / SPEND_BLOCK_MESSAGE and the whole "settling at input" apparatus (−55 net). - Refetch on failure (TanStack): on InsufficientSpendableError the routing already re-read live smart balance, so the two bundle hooks (useSpendBundle / useSignSpendBundle) invalidate [RAIN_CARD_OVERVIEW_QUERY_KEY] before throwing — the displayed balance + a retry de-stale immediately instead of waiting out the 30s poll. - Informative failure copy: the post-gate in-transit failure now says "Your balance isn't fully available yet — try again in a few seconds" (one shared BALANCE_SETTLING_MESSAGE) across send (ErrorHandler), qr-pay, manteca, useSendMoney — instead of a misleading "add funds". - Keeps the display-field fix + the formattedSpendableBalance formatting consolidation. Net -39 lines.
… docs, test) Verified findings from the high-effort review pass: - Send handleOnNext: gate only once balance has loaded (`balance !== undefined`), else a tap before the query resolves false-rejected with "not enough balance" (hasSufficientSpendableBalance returns false on undefined). - useSendMoney.onError: invalidate ['balance', address] after the optimistic rollback — the rollback was discarding the fresh live balance useSpendBundle fetched mid-flight, leaving the smart portion stale until the next 30s poll. - contribute-pot RequestPotActionList: gate the loading-flash on isFetchingSpendableBalance (smart + Rain overview), not isFetchingBalance (smart only) — the latter flashed a false "insufficient" for split-funds users while the overview loaded. - Extracted the gate to a pure, exported `isDisplayBalanceSufficient` and unit- tested the gate-on-display contract (CONTRIBUTING: hooks that gate need a test). - Fixed stale JSDoc that still claimed the gate runs on available-now (formattedSpendableBalance note, computeAvailableSpendable/DisplaySpendable), and scoped the "shared copy" comment to send/pay/withdraw. Not changed (by design / accepted): gate widened to fail-late for the features/payments flows (intended); manteca float-rounding at the boundary fails-safe; the cosmetic toast+inline double-surface on a sendMoney settling failure (follow-up).
…unts BigInt(Math.floor(Infinity * 1e6)) throws a RangeError; a pasted/oversized amount (parseFloat -> Infinity) must fail the gate, not crash the render. isNaN didn't catch Infinity; use Number.isFinite. + tests.
fix(withdraw): UK GBP + GB-IBAN-EUR + intra-SEPA bank withdrawals (PR B — frontend)
…nd, precision)
Max-effort worst-case review found two real bugs I'd introduced plus robustness gaps:
- [money] Orphan charges: the features/payments flows (direct-send, contribute-pot,
semantic-request) createCharge BEFORE sendMoney, so widening their gate to the
displayed total let an in-transit amount pass, create a backend charge, then fail
late — an unpaid charge per retry. Restore the meaningful split:
hasSufficientSpendableBalance now gates on AVAILABLE-NOW (those charge-first flows),
while the no-pre-charge flows (send-link, qr-pay, withdraw) gate on the DISPLAYED
total via the renamed pure helper isAmountWithinBalance(amount, balance).
- [stuck] qr-pay Pay + manteca Withdraw buttons dead-ended after a settling failure:
the settling message made isBlockingError / disabled true with no way to clear, while
the copy says "try again". Exempt BALANCE_SETTLING_MESSAGE so the retry button stays live.
- [precision/crash] Gate now parses the amount with parseUnits (exactly what the spend
uses) instead of float Math.floor(amount*1e6): kills the boundary divergence AND fails
closed (returns false, never a BigInt(Infinity) RangeError) on adversarial input.
- [load] withdraw page no longer false-blocks ("insufficient") during the balance-load
window (maxDecimalAmount=0); guarded on a loaded balance, re-validates when it lands.
- [robustness] ErrorHandler matches the typed error name, not just the message string.
- [cleanup] removed the orphaned PEANUT_WALLET_TOKEN_DECIMALS import in bank/page.
Tests: pure isAmountWithinBalance (incl. Infinity/overflow), useSendMoney onError refetch.
Full unit suite green.
…ring load CodeRabbit on the adversarial-fix commit: - Send-link (Major): the balance effect cleared errorState on every sufficient- balance pass, wiping a submit-time failure message (e.g. the settling copy) the moment loading returned to idle. Now it only clears OUR balance-gate error (INSUFFICIENT_BALANCE_MESSAGE), never a handleOnNext failure message. - withdraw page (Minor): isContinueDisabled used maxDecimalAmount (=0 while the balance loads), disabling Continue during the load window. Guard it on a loaded balance, matching the validateAmount fix.
…lance fix(request): show spendable balance incl. card collateral
feat(bridge): pass claimer details for travel rule in guest claims
Add-money made the user pick the method twice: 'Bank Transfer' at the entry (AddMoneyMethodSelection), then again on /add-money/[country] (which renders AddWithdrawCountriesList, the per-country method picker). The withdraw flow already avoids this via withdrawBankUrl (country click → bank step directly); add-money had no equivalent and used addMoneyCountryUrl → method picker. Add addMoneyBankUrl (mirrors withdrawBankUrl) and use it in handleCountryClick (only renders in the bank branch, so method is already 'bank'). Country click now goes straight to the bank step on web and native. Tests for both modes.
Update the assertion that codified the old double-select behavior (/add-money/[country]) to the new direct-to-bank navigation.
…k-selection fix(add-money): skip the redundant second bank/method selection
The amount field stopped auto-focusing when entering some flows (reported on add money): it used React's `autoFocus` prop, which only fires at the exact moment the input mounts and silently no-ops when the input mounts after a client-side navigation / step transition. Replace it with an explicit inputRef.focus() in a useEffect gated on the same desktop-only condition (shouldAutoFocus = deviceType === WEB), so focus lands regardless of mount timing. Mobile is unchanged (autofocus stays off by design). Shared component — restores focus across send / withdraw / qr-pay / add-money without altering the mobile path.
fix(amount-input): reliably autofocus the amount field on desktop
Production release — promotes
dev→main(→ peanut.me).Included (merged to dev)
activationCelebratedAtmilestone (fixes the re-firing modal for long-time users).+55phone keys already carrying a+.#2253 reads
activation_celebrated_atfrom the API. This FE release MUST ship after peanut-api-ts #1035 is deployed to prod ANDprisma migrate deployhas run there. If the FE ships first:GET /users/me500s and the modal re-shows to already-unlocked users.Action items / deploy checklist
dev(the old "hold for feat(seo): register Faster Payments + SPEI as deposit rails (via-, not from-) #2244" is cleared — feat(seo): register Faster Payments + SPEI as deposit rails (via-, not from-) #2244 is in)prisma migrate deployrun (column exists + ~2,707 users stamped) — confirm before mergingmain(Vercel builds peanut.me)get-userreturnsactivationCelebratedAt; avalanche deposit pages 301 → /supported-networks; FP/SPEI via- URLs 200Risk
Content/SEO (Red ATM, avalanche, FP/SPEI taxonomy) + small FE fixes (pix phone key, p2p display) + the unlock-modal milestone (#2253). No FE schema change, but #2253 has the hard API-migration ordering above.
Deploy
Merge → Vercel builds prod. Gate on #1035's prod migration first. Monitor prod deploy + smoke after merge.