Conversation
chore: remove residual Squid naming + dead types after Rhino SDA cutover
The Mobula calls in src/app/actions/tokens.ts ran in the browser bundle
(no 'use server', client-side `unstable_cache` shim, imported by client
hooks). `process.env.MOBULA_API_*` is undefined client-side, so the URL
collapsed to "undefined/api/1/market/data?…" — relative URL caught by
the Next.js dev server (200 from a default page handler — Mobula was
never reached). Native (Capacitor) builds also can't use server actions,
so server-side env reads are not an option.
- New `src/services/tokens-price.ts` calls peanut-api-ts via apiFetch:
GET /tokens/price (replaces fetchTokenPrice)
GET /tokens/wallet-portfolio (replaces fetchWalletBalances)
- `useTokenPrice`, `Claim.tsx`, and `recover-funds/page.tsx` swapped
onto the new service.
- `estimateTransactionCostUsd` (still in app/actions/tokens.ts) now
imports fetchTokenPrice from the service for its native-token-price
lookup.
- Deletes the broken Mobula bodies + IMobula* types + MOBULA_API_*
consts from app/actions/tokens.ts. fetchTokenDetails (on-chain RPC,
no Mobula) stays.
`src/app/api/health/mobula/route.ts` is a Next.js Route Handler running
server-side, so it can keep direct Mobula access. Untouched.
Pairs with peanut-api-ts #689.
apiFetch with /api/peanut${path} only works for paths that have an
explicit route handler at src/app/api/peanut/<path>/route.ts. There's
no catch-all there, so /api/peanut/tokens/price was silently served
the layout HTML (200 with <!doctype html>); response.json() then threw,
queryFn caught and returned null, and selectedTokenData stayed undefined
— Review button disabled on ETH selection.
serverFetch auto-routes by method through the existing catch-alls under
/api/proxy/get/[...slug] (GET) and /api/proxy/[...slug] (POST), which
do forward to PEANUT_API_URL with the api-key header.
Also drops the temporary debug overlay below the Review button.
Capacitor builds have no Next.js server, so any /api/proxy/* or /api/peanut/* path is dead. Calling PEANUT_API_URL directly via fetchWithSentry works identically on web (CORS-allowed origin) and native (direct HTTPS). The endpoints are public on the backend with per-IP rate limit + CORS — no api-key needed.
Pairs with peanut-api-ts: backend now accepts any token Rhino supports. inferTokenSymbol returns whatever the FE's curated peanutTokenDetails lists (ETH, WETH, …) instead of nulling out non-stablecoins. The token selector already shows the curated list — this just lets the calculate step pass through without throwing 'Cannot infer Rhino token symbol'.
/charges goes through /api/proxy/withFormData (multipart) which was silently dropping the JWT. Backends that gate on auth (or soft-auth for owner resolution) saw no user — withdraw to external addresses FK-violated on transaction_intents_user_id_fkey because the authenticated creator was unreachable. Parity with the JSON proxy at /api/proxy/[...slug].
Same fix as services/tokens-price.ts. /api/peanut/<path> has no route handler — Next.js silently returns the layout HTML so response.json() threw 'Unexpected token <'. Direct call to PEANUT_API_URL with the JWT manually forwarded for the backend's verifyAuth preHandler. Works on web (CORS) and native (no Next.js server).
useCrossChainTransfer now branches by destination token:
- USDC/USDT → SDA (existing 'transfer-and-forget' UX, webhook settles)
- everything else → bridge quote/commit (user signs calldata to Rhino's
bridge contract directly)
Bridge path produces the same {transactions, receiveAmount, feeUsd}
shape as SDA so the Confirm view doesn't need a code change. Adds:
- quoteExpiresAt / isQuoteExpired — Rhino quotes live ~60s; consumers
can re-calculate before commit if the user lingered
- commitmentId — needed to poll bridge status after the user signs
- pollBridgeStatus(id) — 3s-interval poll until COMPLETED/FAILED/EXPIRED
Pairs with peanut-api-ts /rhino/bridge/* routes (PR #689).
Pairs with peanut-api-ts: backend branches getUserQuote vs getSwapUserQuote on tokenIn/tokenOut equality. FE always passes USDC (Peanut wallet token) as tokenIn; tokenOut is whatever the user selected. isSwap echoes back from quote into commit so the backend picks the right finalisation path.
isCrossChainWithdrawal only compared chain IDs, so USDC → ETH on Arbitrum (same chain, different token) silently took the same-chain collateral-only path: sendMoney does USDC.transfer(recipient, amount), recipient gets USDC, the user-selected ETH never happens. Use the (isXChain || isDiffToken) booleans from useCrossChainTransfer — the hook already computes them and routes calculate() to the bridge swap path for non-stablecoin destinations. Now the confirm-side gate matches: cross-chain OR cross-token both go through sendTransactions, which runs the calldata the hook prepared.
…erOp Rhino's bridge contract pulls USDC from the kernel SA via transferFrom, which requires an allowance. Without an approve in the same batch, the bridge call reverts during simulation with 'ERC20: transfer amount exceeds allowance' (paymaster rejects accordingly). Same pattern Squid used in the legacy flow. Approve + bridge sent as a 2-call batch in one userOp so they're atomic; the kernel encodes them through executeBatch.
Rhino rejects mode='receive' on cross-chain swap routes with 'InvalidRequest: Receive mode is not supported for the selected tokens'. Same-chain swap accepts 'receive', so keep that for the UX 'merchant gets X' framing; cross-chain falls back to 'pay' where the input amount is the source USDC the user spends and Rhino computes the destination output.
Rhino has two distinct deposit primitives:
- same-chain atomic swap → getSwapCalldata returns Rhino's swap-contract
call data; user signs it directly
- cross-chain (bridge or swap) → user calls
bridgeContract.depositWithId(tokenAddress, amount, commitmentId)
(the SDK's DVFDepositContract.handleTokenDeposit path)
Backend's commitBridgeQuote now branches on isSameChainSwap and returns
either {kind: 'swap-calldata', calldata, contractAddress} or
{kind: 'deposit-with-id', contractAddress}. FE constructs the
depositWithId call itself for cross-chain — no Rhino API for it.
Cross-chain commitmentId is the hex-encoded BigInt of the quoteId
(SDK convention).
Pairs with peanut-api-ts /rhino/bridge/commit changes.
fix(tokens): fetch token price + wallet balances via api-ts backend
…apply The immediate post-Sumsub re-apply raced against Sumsub's auto-review: backend would still see `incomplete`, the WebSDK would re-mount against an already-approved applicant, and the user would land back on the "Start Secure Verification" interstitial. Tapping the close button after re-entry also showed a misleading "Stop verification?" warning because the 3s early-event guard suppressed the GREEN signal that would otherwise flip the submitted flag. - Poll applyForCard 1s/15s while showing the pending screen, then advance on the first non-incomplete response (timeout surfaces a retry message). - Treat early `completed + GREEN` events as evidence the user already submitted (suppresses the close-warning) without auto-closing — the guard's original purpose (not closing on stale RED+RETRY) stays intact. Logic extracted to two pure helpers with unit coverage.
`handleApply` and `handleSumsubComplete` had near-identical
main-kyc-required / terms-required / default branching. Extract to
`advanceFromApplyResponse` so the two paths can't drift, and shrink
`card/page.tsx` complexity in the bargain. The `incomplete` branch is
caller-specific (open Sumsub vs keep polling) and stays inline.
Also switch `pollUntilApplyAdvances` to return `null` on timeout instead
of a synthetic `{ status: 'timeout' }` sentinel — the sentinel overlapped
the `ApplyForCardResponse` fallback branch and blocked TS narrowing.
Without an abort signal, an impatient user navigating away from the pending screen during a slow Sumsub auto-review would burn up to 15 sequential apply requests over 15s — and trigger setState on an unmounted component on the way out. The slow-Sumsub path is exactly when users get twitchy, so this is the case the abort guard matters. - pollUntilApplyAdvances accepts an optional AbortSignal and exits the loop on abort (no further fetches, no sleep tail, returns null). - card/page.tsx creates an AbortController per poll, stores it in a ref, and aborts on unmount via useEffect cleanup. Post-await guards skip the setState calls if aborted. Note: this aborts the loop, not the in-flight fetch (rainApi doesn't take signals today). Worst-case wasted fetches drop from 15 to 1.
fix(card): unstick users post-Sumsub by polling instead of single re-apply
- ActivationCTAs: keep dev's iconBg + dismissable fields, keep main's useProviderRejectionStatus - underMaintenance: keep dev's disableXchain naming + values, keep main's CROSS_CHAIN_DISABLED_MESSAGE - content submodule: take main's updated submodule (delete-account help article)
- sumsub.ts: rewrite initiateSelfHealResubmission to use serverFetch - Initial.view/Confirm.view: rename disableSquidSend → disableXchainSend - ActivationCTAs: add iconBg to provider rejection overrides - format: apply pnpm format
- blocked state: route to crisp support chat instead of opening KYC modal in manteca withdraw, add-money, and claim flows - stale needsBridgeEnrollment: refetch user after KYC success in add-money/bank and withdraw/bank pages - FINAL vs PROVIDER_FINAL: treat both as terminal blocked state in useProviderRejectionStatus and useQrKycGate - useQrKycGate: add user?.rails to useCallback deps to fix stale closure
- ActivationCTAs: exclude card step from provider rejection override - sumsub.ts: validate self-heal response fields before returning
chore: sync main into dev (conflicts resolved)
internal dev tool for visualizing the complete KYC flow — entry points, sumsub verification, provider submission, self-heal resubmit pipeline, cross-region flows, and post-approval UX. loads mermaid from CDN, no npm dependency needed.
page.tsx is now a server component that reads flow-diagram.md from mono at runtime. MermaidRenderer.tsx is the client component that loads mermaid from CDN and renders the parsed blocks. single source of truth — edit the markdown in mono, refresh to see changes.
Peanut no longer accepts SimpleFi as a QR payment processor. This strips ~900 lines of dead branching from the QR-pay page and the surrounding plumbing (service, scanner, KYC gate, type narrowings). What's removed: - `services/simplefi.ts` (entire file — no callers left) - The SIMPLEFI processor branch in `qr-pay/page.tsx`: state, handlers, WebSocket polling, success UI, retry path. Page drops from 1744 → ~840 lines. - `EQrType.SIMPLEFI_*` enum members + parser (`parseSimpleFiQr`, regex variants) in `DirectSendQR/utils.ts` + corresponding scanner cases and recognizer tests. - `'MANTECA' | 'SIMPLEFI'` types narrowed to `'MANTECA'` in `useQrKycGate.ts`, `qr-payment.utils.ts`, `underMaintenance.config.ts`. The "skip KYC for SIMPLEFI" branch becomes unreachable. What's kept (historical-data display): - `EHistoryEntryType.SIMPLEFI_QR_PAYMENT` and the `simplefiQrPayment` transaction-display strategy + registry — old SimpleFi payments still show up correctly in users' transaction history. - `assets/payment-apps` `SIMPLEFI` logo export — referenced by the history strategy. - `utils/history.utils.ts` SIMPLEFI labelling — same reason. Companion peanut-api-ts PR drops the matching backend dead-code stubs.
sync: main → dev — SUPPORT_SURVIVOR badge (2026-05-14)
…E routes
Drift had accumulated since the last `pnpm gen:api` — backend had added 7
real routes that FE was calling via raw `serverFetch` strings (no type
safety). All 7 are now typed in `paths`:
- /rhino/bridge/{chains,commit,quote,status/{bridgeId}}
- /tokens/{price,wallet-portfolio}
- /users/identity/resubmit
Method: fetched current `/openapi.json` from staging, then merged in the
34 `/dev/*` paths from the previous FE snapshot (test-mode-only routes
the harness uses; staging strips them at deploy via `__INCLUDE_DEV_ROUTES__`).
No schema loss — both sides use inline TypeBox schemas, none in
`components.schemas`.
The big diff is reformatting churn from openapi-typescript regenerating
the whole file; the actual route additions are small.
Out of scope for this PR but worth a follow-up: automate this so it doesn't
drift again. Either a CI job that pulls the live spec and opens a PR, or a
peanut-api-ts deploy step that pushes the spec to peanut-ui.
…2024) * feat(bug-bounty): home-carousel CTA + finish SUPPORT_SURVIVOR badge utils Frontend half of the bug-bounty program (TASK-19358). Surfaces a CTA on the home carousel that opens Crisp with a prefilled "I found a bug:" message. Crispy (the support agent) classifies the report and calls the new internal grant endpoint (separate peanut-api-ts PR) to send the user $5 USDC and the SUPPORT_SURVIVOR badge. ## Changes - **public/badges/support_survivor.svg** — vectorized badge asset from the approved v2 design. Pulled in from PR #1962 (closing that PR in favor of this one to ship the badge mapping + description + CTA as one cohesive frontend unit). - **src/components/Badges/badge.utils.ts** - `CODE_TO_PATH.SUPPORT_SURVIVOR → /badges/support_survivor.svg` - `PUBLIC_DESCRIPTIONS.SUPPORT_SURVIVOR` — the third-person blurb rendered on /badges. (#1962 missed this; rolled in here.) - **src/components/Global/Icons/Icon.tsx** — added Lucide `Bug` icon + `'bug'` name to the IconName union. Used by the new CTA. - **src/hooks/useHomeCarouselCTAs.tsx** — bug-bounty CTA gated on `isActivated`. Click prefills Crisp with "I found a bug: " and opens the chat. Server enforces real eligibility (email verified, ≥1 successful payment OR KYC approved, lifetime caps, daily budget) — the activation gate just hides it from cold accounts where the reward would be denied. ## Server side (separate PR) The backend endpoint `POST /internal/support/grant` lives in peanut-api-ts (TODO: open that PR next). The Crispy skill that calls it lives in crispy (TODO: third PR). This PR is the user-visible half — safe to ship standalone since the CTA only opens Crisp; no backend dependency. * feat(bug-bounty): rewire CTA to SupportDrawer + new copy + pink default + kyc icon Tested locally via the mono harness (./scripts/dev on :3050) and three things were wrong with the initial cut. Fixes from this round: ## 1. Click did nothing (root cause) The CTA called `window.$crisp.push(...)`, the Crisp loader pattern. That only works on `/[locale]/(marketing)/*` routes where the `client.crisp.chat/l.js` script is injected by the marketing layout. On `(mobile-ui)/home` Crisp lives inside `SupportDrawer`'s iframe and the parent window has no `$crisp` global — pushes went to a queue that never resolves. Now uses the existing `openSupportWithMessage` helper from `ModalsContext`, which sets `supportPrefilledMessage` + opens `SupportDrawer`. Same path the other mobile-ui flows use to open support (Manteca add-money, claim flow, withdraw, etc). ## 2. Copy Title: `Help us improve and get $5!` Description: `Report a bug. Get rewarded! No questions asked.` Original from Hugo's first ask: "found a bug, message us in support. You'll get 5 bucks, 5 euros or 5 dollars, no questions asked." Refined after a live test pass — drops the "earn $5 + a badge" framing in favour of the simpler one-line bounty hook. ## 3. Background colour + icon - Background: `bg-primary-1` (pink) instead of `bg-secondary-1` (yellow). Yellow was visually identical to qr-payment, ios-pwa- install, latam-cashback-invite, and kyc-prompt. Pink stands out and matches the "reward" framing already used by perks/invite. - Bug icon bumped 16 → 20 to read at a glance. ## 4. KYC prompt icon Swapped `shield` → `qr-code` on the "Unlock QR code payments" CTA. The shield read as a security/verification cue rather than a payments cue. ## Dev cheat `?cheat=ctas` on `/home` bypasses every gate so we can compare all carousel CTAs side by side. Pure local-iteration tool; will be dropped before merge once we're done picking the colour. ## Routes/files - src/hooks/useHomeCarouselCTAs.tsx — gate flips, copy, colour, icon, click handler rewire, cheat block * refactor(carousel): simplify pass — useSearchParams + trim stale comment Two reviewer findings actioned: 1. **Switch raw URLSearchParams → `useSearchParams` from `next/navigation`.** CLAUDE.md bans manual `URLSearchParams` parsing in favour of nuqs / the Next.js hook. For a one-off dev cheat read, useSearchParams is the lighter idiomatic fix (already importing useRouter from the same module). Same value, slightly less noisy + reacts to nav. 2. **Trim stale "three color variants" comment.** The previous round had three bug-bounty colour variants in cheat mode for side-by- side picking; I dropped two of them but left the comment that referenced them. Shortened to one line. Skipped per reviewer guidance: - NODE_ENV-gating the cheat — tree-shaking footgun in preview builds. - `pushIf(gate, cta)` helper to dedupe the `cheatAllCTAs || (...)` prefix — would hide the gate next to the payload, break symmetry with the other 6 CTAs. - Hoisting cheat eval out of useCallback — micro-allocation, below noise. * chore(carousel): remove ?cheat=ctas dev helper before merge CodeRabbit flagged the cheat as a prod surface — any user could append ?cheat=ctas to /home and see every CTA regardless of eligibility. The CTAs themselves are non-destructive (just modal opens / navigation), but it's noise + a small attack surface we don't need. The cheat served its purpose: Hugo eyeballed yellow/pink/green/bare side-by-side, picked pink, copy iterated, click handler rewired to SupportDrawer. With the design locked, the cheat block + all the `cheatAllCTAs || (...)` gate flips can go. Reverted: imports, the const, all 7 gate patches, the deps array entry. Functional change is zero — the bug-bounty CTA, the kyc- prompt icon swap, and the openSupportWithMessage rewire all stay. * fix(carousel): persist notification-prompt dismiss for 7 days like every other CTA Original intent of TRANSIENT_CTA_IDS: > CTAs gated by external state that can flip back (e.g. notification > permission) must not be persisted — they should re-evaluate every > session. Sound on paper, wrong in practice. The 95% case is "user doesn't want notifications right now" — under the old behaviour they got pinged again every page reload until they finally gave up and granted, which is annoying. The 5% case it was guarding against (user enables permission, then revokes within 7 days, and wants the in-app prompt to re-appear during that window) is rare enough that the cost-to-benefit doesn't justify pestering everyone else every session. With the cooldown in place: - Dismiss → quiet for 7 days, same as every other CTA. - Permission gates still fire — if `isPermissionGranted` flips true, the CTA hides regardless of dismiss record. - If permission flips back from granted to denied within the 7-day window, the user just waits out the remaining days. Not catastrophic. `TRANSIENT_CTA_IDS` had a single member ('notification-prompt'); with it gone the set is empty so the whole mechanism dies. `dismissCTA` is now a single straight path — always record, always persist, always remove from current carousel.
These files are regenerated by `pnpm gen:api` (snapshot pulled from BE staging /openapi.json). Letting prettier touch them creates pointless diff churn on every regen — same reason the snapshot-bake fixtures above are ignored. Drops the format-check failure that just hit PR #2030.
… changed
This is the systemic fix for the drift the rest of this PR is patching by
hand. `pnpm gen:api` reads from a committed snapshot; nothing kept that
snapshot in sync with the live BE, so it drifted (we were 7 routes behind
when this PR was opened — type-safety on /rhino/bridge/*, /tokens/*, and
/users/identity/resubmit was silently absent).
## What
- `scripts/sync-openapi.mjs` — fetches a live OpenAPI spec, merges in the
/dev/* paths from the existing FE snapshot (test-mode-only routes the
Nutcracker harness uses; staging strips them at deploy), writes back to
src/types/api.openapi.json. Designed to be idempotent: re-running with
no BE change produces zero diff.
- `.github/workflows/sync-openapi.yml` — runs daily at 08:00 UTC weekdays
(and on workflow_dispatch). Calls the script, runs `pnpm gen:api`, and
if anything changed, opens an auto-PR against dev. Uses the stock
GITHUB_TOKEN — no cross-repo auth, no PAT.
- Regenerated src/types/api.openapi.json + api.generated.ts via the new
script so the committed snapshot matches what the workflow would
produce. Without this, every workflow run would open a spurious "no-op"
PR for the formatting-difference between the script and prettier.
## Why no peanut-api-ts change
OpenAPI is one-way: BE auto-generates /openapi.json from its Fastify route
schemas via `app.swagger()`. FE consumes it. The contract lives entirely
in the BE; FE just needs a fresh snapshot. Pulling beats pushing — no
secret coordination, no BE deploy modification, idempotent.
## Test plan
- [x] Script runs locally, output is idempotent against itself
- [x] `pnpm test` 52/52 green (1069 tests)
- [ ] After merge: workflow_dispatch the new workflow once to confirm it
produces zero diff (idempotency check). Then leave it on the cron.
…apshot chore(types): refresh openapi snapshot — type-safety for 7 new BE routes
…Hog cross-link Adds bi-directional linking between FE Sentry errors and PostHog user profiles: - Each Sentry exception → `$exception` event in PostHog with a Sentry deeplink - Each Sentry event → PostHog tag pointing back at the user + session replay Pre-req: NEXT_PUBLIC_POSTHOG_KEY and NEXT_PUBLIC_SENTRY_DSN both set per env. posthog.init() runs in instrumentation-client.ts; the integration captures the singleton lazily, so load order doesn't matter.
…gration feat(observability): wire posthog.sentryIntegration for Sentry ↔ PostHog cross-link
When the BE history fetcher can't attach a Peanut username to a counterparty, the row currently renders as a truncated 0x address. This adds a lazy JustaName `usePrimaryName` lookup at the row level so addresses upgrade to their ENS primary name (e.g. peanuthelp.peanut.me, vitalik.eth) once the async resolves. Performance: the hook short-circuits when the field is already a username (`isAddress(name)` guard), JustaName SWR-caches by address across rows, and the synchronous render path is unchanged — addresses paint immediately and upgrade in place when the lookup resolves.
…y-rows feat(activity): lazy ENS reverse-lookup for unknown counterparties
Adds 'survivor' to INVITE_CODE_TO_CAMPAIGN_MAP so support agents can share an invite link (e.g. /invite?code=survivor) that auto-awards the SUPPORT_SURVIVOR badge on signup. Pairs with peanut-api-ts whitelisting SUPPORT_SURVIVOR on /badge/award. Badge-only — the $5 USDC bundle still requires the admin grant endpoint.
…vite-link feat(invites): map 'survivor' invite code → SUPPORT_SURVIVOR badge
Triggers a fresh Vercel build for the dev branch so the updated
NEXT_PUBLIC_SENTRY_DSN scope ("All Environments") gets inlined into the
staging.peanut.me bundle. Vercel doesn't auto-rebuild on env-var scope
changes — the cached bundle from the previous build had dsn=undefined.
Inline comment captures the trap so the next person who edits Sentry
env vars knows to push a commit / clear build cache.
…uild chore(sentry): force staging rebuild + document Vercel env-scope footgun
jjramirezn
approved these changes
May 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Production Release — FE
Companion BE PR: peanutprotocol/peanut-api-ts#747
Release tracker: https://www.notion.so/peanutprotocol/Release-Process-21e83811757980b79393db870361fc5d
QA tracker: https://www.notion.so/peanutprotocol/QA-For-Prod-Release-card-decomplexify-35983811757980afa1b8ffccb4759dc0
Stats
main)Major themes
1. Peanut Card UI — new product, whitelist-gated
UI is hidden unless
users.card_access_granted_atis non-null. Card screens, lifecycle, history, virtual art.Key PRs: card-ui (#1904 merge) · #1909 activity history · #1918 KYC required · #1919/#1920 denied screen + followup · #1926 details TC checkbox · #1931 card improvements · #1944/#1945 pay icon + titlecase · #1946 activity card USD guard · #1960 feat/fix-card · #1958 access bypass maintenance · #1961 lock/cancel/collateral return · #1967 push notifications · #1979 decline rebalance polish · #1981 auth lifecycle
2. Decomplexify follow-ups
UI side of the ledger cutover. New entry kinds, type gates, conditional history hooks.
3. Native app (Android)
feat/native-appbaseline4. Manteca / spend bundle
5. KYC + Sumsub
6. Cross-chain & withdraw fixes
7. Passkey / auth fixes
8. DevX / cleanup
ENV vars (set on Vercel before deploy)
To be fixed on
devbefore merge (Hugo, in a branched session):main— touchespublic/badges/founder_house.png,src/components/Badges/badge.utils.ts,src/components/Invites/InvitesPage.tsx. Needs sync-merge into dev before this PR is mergeable.Test plan
npm test,npm run buildRelease execution