Skip to content

chore(mobile-release): sync main + localization/ESLint cleanup for the next build - #2483

Merged
innolope-dev merged 55 commits into
mobile-releasefrom
chore/mobile-release-sync
Jul 23, 2026
Merged

chore(mobile-release): sync main + localization/ESLint cleanup for the next build#2483
innolope-dev merged 55 commits into
mobile-releasefrom
chore/mobile-release-sync

Conversation

@innolope-dev

Copy link
Copy Markdown
Collaborator

Brings mobile-release up to date ahead of the next mobile build. Two merge commits, reviewable separately.

1. mainmobile-release (53 commits)

Push-tap routing, OneSignal failure reporting, biometric app lock, fresh-passkey step-up, report-only CSP, Manteca per-rail wallet hotfix, Rhino route minimums, Sentry noise fixes.

Nine conflicts, every one "main adds behaviour" vs "mobile-release adds i18n". Resolved to keep both in all cases:

file resolution
ClientProviders main's AppLockGate now wraps children inside the AppIntlProvider > ContextProvider nesting
authContext took clearStepUpToken(); dropped main's duplicate query-clear
PhysicalCardScreen main's null-position fix expressed through the translation layer
withdraw main's USD-pinned validation + mobile-release's translated errors
ApplicationStatusScreen main's proof-of-address props + COPY_KEYS lookup
notifications kept the localized date-group header
src/content main's production-published pointer (ca7f9ca)

Two worth a reviewer's eye:

  • ClientProvidersAppIntlProvider must stay above ContextProvider. Getting this backwards 500s the whole app and unit tests do not catch it; only a full-app render does.
  • authContext — main also cancelled/cleared queries at its insertion point. That already happens earlier here, deliberately before the token wipe: otherwise an in-flight /users/me re-persists a sliding-refresh token and logout never sticks on Android. Kept the existing order.

src/content: the two pointers had diverged. Took main's production publish; the only legal-copy delta is a support@ contact address, and the Rain-superset prohibited-activities expansion is in both.

2. Localization + ESLint cleanup (#2447)

ESLint errors: 450 → 0 (CI's eslint .). 62 warnings remain, all pre-existing exhaustive-deps.

3. Untranslated strings this surfaced

The jsx-no-literals guard from #2447 fired the moment the two lines met — main shipped new UI in English only:

  • AppLock (the native lock screen): title, prompt, failed prompt, Unlock, Log out. Two of those sit inside expression containers where the rule cannot see them; translated anyway, or the lock screen ships half-English.
  • ApplicationStatusScreen: "Upload proof of address".

Added an appLock namespace + card.uploadProofOfAddress across en / es-419 / pt-BR.

Verification

  • Full suite 164 suites, 2148 passed, 3 skipped
  • Typecheck clean; eslint . 0 errors; prettier clean on every tracked file
  • Two suites from main rendered components that now call useTranslations — wrapped in NextIntlClientProvider, same pattern as the existing qr-pay tests

Not included

fix/spendable-balance-last-known-main (#2482, the $0-balance fix) targets main and is not merged yet. It'll need to come across before the build, or be cherry-picked.

⚠️ Needs a real device smoke test before building — the conflicts touched auth/logout, the lock screen, notifications and provider nesting. Cold start, lock/unlock, logout, push tap, and a card screen at minimum.

innolope-dev and others added 30 commits July 16, 2026 20:16
Array.prototype.sort is in-place, so the sortedBadges useMemo mutated the
caller-owned badges array during render. Sort a copy so the memo callback is
pure and safe under concurrent/StrictMode double-invocation.

No user-visible change today: the only caller passes locally-owned state that
is repopulated per mount, and the resulting order is the desired one anyway.
The anti-rot scan only consulted ITEMS_TO_DISABLE, so a page whose
server-only exports are already stripped by a P0_TRANSFORMS stub still
failed the native build. This is live on main: (mobile-ui)/claim/page.tsx
has force-dynamic and a matching stub, and the scan rejects it.

Skip files handled by a transform, and cover the scan with a unit test.
getPhysicalWaitlist types position as nullable and the joined branch is
gated on joinedAt, so a user on the list with no queue position was told
'You are #null on the list.' Drop the number for that case and format
real positions for readability.
The hardcoded "1,234" assumed an en-US default locale, so the test failed
under any locale with a different thousands separator (de_DE renders 1.234).
Assert against toLocaleString output instead — that is the actual contract.
Sync the OpenAPI snapshot + regenerated TS types with the new admin
diagnostics endpoint added in peanut-api-ts (surface failed notification
sends). Additive only — no existing paths change.
Hotfix port to main of peanut-ui#2438 (branch was dev-based; every
touched file is byte-identical between dev and main, so this is the
same reviewed content squashed onto main). Pairs with peanut-api-ts
hotfix/rain-poa-selfheal — deploy the API first.

When the rain rail carries the sumsub:proof_of_address next-action, the
card status screens (requires-info / rejected) render a primary
"Upload proof of address" CTA driving a dedicated lightweight flow:
resubmit action -> second SumsubKycWrapper -> inline initiation errors
-> optimistic "we received your document" banner (auto-reset when the
backend state takes over). Double-click guard; analytics tagged
source: poa-self-heal.

Full review trail on #2438.
feat(card): proof-of-address upload on stuck Rain applications
…itle

captureConsoleIntegration promotes every console.error/warn into a Sentry
event, and fetchWithSentry both captures the underlying failure AND
re-throws a ServiceUnavailableError wrapper that gets captured again by
global handlers. One API timeout could produce three Sentry events.

- sentry.utils: ignore ServiceUnavailableError (always a wrapper whose
  cause was already captured at the fetch site) — PEANUT-UI-QDJ
- fetchWithSentry: console.info instead of console.error for the raw
  fetch rejection; the explicit captures below carry the full context
- useTokenPrice: fallback log at console.info — the fallback is graceful
  and the failure is already reported — PEANUT-UI-MH5
- BadgeStatusDrawer: badge name becomes the DrawerTitle, fixing the Radix
  'DialogContent requires a DialogTitle' a11y warning — PEANUT-UI-MJS
The SKIP_REPORTING entry added in f3d8090 stopped expected 'Invalid
Invite' 400s from the setup-flow invite input flooding Sentry
(PEANUT-UI-QDR, ~2,850 events / 701 users in 14 days). Nothing pinned
that behavior: no test exercised the suppression path in fetchWithSentry
at all, so a refactor of the skip rules could silently bring the noise
back.

Cover the three contract points: /invites/validate 400 emits neither a
Sentry event nor a console.warn, a 500 on the same endpoint still
reports as an error, and unlisted endpoints still report their 400s.
Capacitor plugin calls executing inside the /crisp-proxy iframe on native
throw '"<Plugin>" plugin is not implemented on ios' because the bridge is
not injected into iframes. Pure noise, not a broken binary (PEANUT-UI-QV3:
2,211 events in 14 days from 6 users). Filter the whole message family
(StatusBar, Keyboard, App, ...) in shouldIgnoreError via a regex pattern,
and add unit coverage for the ignore rules.
Radix Dialog (via vaul) logs 'DialogContent requires a DialogTitle' for
screen-reader accessibility when a drawer opens without a title. The badge
unlock drawer on /home rendered no DrawerTitle, and captureConsole forwarded
the warning to Sentry (PEANUT-UI-MJS, ~1.7k events / 854 users in 14 days).

Add an accessibleTitle prop to DrawerContent that renders a visually hidden
DrawerTitle, use it in BadgeStatusDrawer, and cover the labelling contract
with tests.
… fields

Matching against one concatenated blob of message + exception value/type +
culprit let a pattern match across field boundaries and suppress an
unrelated event. Test each field independently; regexes now also run
against the original (non-lowercased) text with their own flags.
The /crisp-proxy iframe source of the plugin-not-implemented events was
already fixed (972f354); residual events come from stale binaries, and
a message filter would permanently mask a top-frame plugin failure on a
fresh binary — a genuinely broken build we want reported. The Sentry
issue is resolved in the dashboard instead (reopens as regressed if it
recurs).

Keep the per-field matching in shouldIgnoreError: the old concatenated
blob let a pattern match across unrelated fields and suppress legitimate
events. Tests now cover per-field matching, the cross-boundary case, and
the previously untested ignore list.
…a7f9ca)

Bumps src/content on production (main) from e3a05f7 → ca7f9ca
(peanut-content latest = mono@174e21d). Single-file submodule
pointer change. Publishes content already merged + mirrored from mono that the
dev-targeted auto-PRs never promote to main.
…202607201712

content: publish latest to production (src/content → peanut-content@ca7f9ca)
…ubstring safety

Adds root-level sentry.utils.test.ts for the SDK-config filter's alreadyReported
entry (distinct from src/utils/sentry.utils.ts, the fetchWithSentry wrapper), and
documents that substring-matching ServiceUnavailableError is safe only because it
is our internal wrapper name.
Rhino rejects bridge deposits under the route minimum (UNDER_MIN) and
parks the funds at the SDA with no auto-refund — a $2.50 withdrawal to
Ethereum (min $5) sat stranded since 07-15 with the intent COMPLETED.
Floor every crypto withdrawal at $0.50 on the amount step (red, blocking),
warn $0.50–$5 amounts that Ethereum needs $5, and hard-block chain-aware
minimums (ETH $5 / Tron $10) at review time before any charge exists.
The withdraw chain picker's NON_EVM_WITHDRAW_CHAINS entry carries
chainId 'tron', so the '728126428' map key was unreachable and Tron
silently degraded to the $0.50 default. Caught by adversarial replay
review before push.
…t tests

Adversarial review findings: (1) the review-time minimum error said 'pick
a different network' but nothing cleared it on chain change, so Review
stayed dead after following the instruction; (2) the Tron test asserted a
hand-typed id no picker record carries — now asserts the REAL registry
records; (3) the Ethereum notice read the app-wide token price on a
USD-pinned input; (4) stale 'no minimum' comment contradicted the new
floor; (5) notice copy works under the Send header too.
CodeRabbit major: the amount page's AmountInput is hard-pinned to USD,
but the min/balance gates multiplied by the app-wide selectedTokenData
price — a stale non-USD price could let $0.01 past the $0.50 floor (or
false-block valid amounts). The typed value IS the USD value; compare it
directly. Also: the Ethereum notice no longer overclaims that every
other network accepts the amount (Tron is $10).
Product call (Aleks): same-chain Arbitrum USDC withdrawals are direct
transfers — no Rhino, no minimum — and must keep parity with
send-via-link and P2P sends. Drop the blanket $0.50 amount-step floor;
warn there instead (destination unknown), and enforce the per-network
Rhino minimums at review time only for bridged/swapped routes.
Manteca asked (Facundo, 2026-07-21) that QR-payment funding stop going to the
shared deposit wallet: Argentine QRs (MercadoPago/QR3) now fund one wallet,
Pix — and eventually any non-AR QR — another. Withdrawals and claims keep
funding the legacy shared address. Backend counterpart validates per-rail and
tolerates the legacy address for stale clients — deploy the API change first.
…lection

Product call (Aleks): no yellow warning on the amount page; the
per-network Rhino minimum blocks at review, where the destination is
actually chosen.
Sessions persist for weeks, so an unlocked phone was enough to reach a
funded account. Gate the native app behind a local WebAuthn assertion on
cold start and after LOCK_AFTER_BACKGROUND_MS in the background.

The assertion is checked locally, not proven to the API: the threat is
physical device access, and the OS will not produce one without the
user's biometric or passcode. Server-side proof of freshness belongs to
step-up auth on sensitive endpoints, which is a separate change.

Fails open when there is no credential to prompt against or the platform
has no WebAuthn — a lock that cannot be lifted would strand the user.
The app ships no script-src today, so an XSS anywhere runs unconstrained
and can exfiltrate to any origin. Enforcing a guessed allow-list would
blank the app, so this ships REPORT-ONLY: it collects violations from
real traffic until the list is known complete, then gets promoted to the
enforcing header.

Reports go to Sentry via a report-uri derived from the browser DSN; the
policy still ships (without reporting) when the DSN is absent.

object-src 'none' and base-uri 'self' are added to the ENFORCING policy
now — the app embeds no plugins and sets no <base>, so neither can break
a working page.

Known-loose and to be tightened before promotion: 'unsafe-inline' and
'unsafe-eval' in script-src (Next's bootstrap and the wallet SDKs), and
a connect-src that cannot enumerate every env-driven chain RPC.
Card PAN/CVV, PIN, withdrawals and bank-account add were authorized by
the session cookie alone. They now carry an x-step-up-token proving a
WebAuthn assertion from the last five minutes.

The proof is cached for its lifetime, so a multi-step flow (approve then
prepare) costs one Face ID prompt rather than one per request, and is
dropped on logout so it can't outlive the session it belongs to.

Rain calls opt in with stepUp: true on the single rainRequest choke point
instead of threading a header through every call site.

Pairs with peanut-api-ts, where enforcement stays behind STEP_UP_ENFORCED
until a build carrying this ships.
The report URI hardcoded https and dropped everything but the last path
segment, so a path-prefixed DSN (self-hosted Sentry under a sub-path)
posted reports to an endpoint that doesn't exist, and an http DSN was
silently upgraded.

Verified against a standard ingest DSN, a path-prefixed one, and a plain
http localhost DSN.
innolope-dev and others added 23 commits July 22, 2026 10:42
Reading the jwt cookie directly wrongly threw 'Authentication required'
on native, where JS never holds the token and the native cookie jar
authenticates requests. apiFetch is the path every other service uses:
Authorization header on web, cookie jar on Capacitor, demo-mode routing
included.
report-uri alone is deprecated and Chromium is where most violations
will come from once report-to is the only mechanism; shipping both (plus
the Reporting-Endpoints header the report-to group resolves against)
keeps the violation stream complete across engines, so the eventual
promotion to enforcing isn't decided on partial data.
The catch block's NotSupportedError path also resolves to unsupported
(which the gate treats as unlock), so 'only the pre-flight checks fail
open' undersold it. Also spell out in the module header that this is a
privacy screen, not account protection — the session token is untouched
by this PR and the gate disappears if the stored credential id is
stripped.
fix(badges): sort a copy in BadgesRow instead of mutating the prop
…ll-position

fix(card): don't render "#null" for a waitlist entry without a position
…0-sentry-suppression

test(sentry): pin /invites/validate 400 suppression in fetchWithSentry
fix(a11y): always give DrawerContent an accessible DialogTitle
…ions-admin-recent

chore(types): add FE types for GET /notifications/admin/recent
…rm-scan

fix(native): don't flag P0_TRANSFORMS pages as uncovered server routes
…plugin-noise

fix(sentry): match ignore patterns per-field in shouldIgnoreError
fix(sentry): stop double-counting fetch failures, add missing DrawerTitle
…-guard

fix: block crypto withdrawals below Rhino route minimums
Tapping a push left the app: OneSignal fires its own ACTION_VIEW/openURL for the
notification's launch URL, so Android only re-entered the app for paths in the
App Links filter, and iOS — which won't re-enter an app for its own universal
link — always bounced to Safari.

Suppress launch URLs on both platforms and route the tap from the click listener
in useNativePlugins, reusing the App Links deep-link path. That covers
destinations outside the intent filter and doesn't depend on link verification.

deepLinkToNativePath now accepts a bare path (push payloads carry one), rejects
off-domain links instead of rewriting them, and maps two destinations that had
no native route: /receipt/<id> and /<username>?chargeId=.

/receipt/[entryId] is a server component stripped from the static export, so add
a client twin at (mobile-ui)/receipt reading ?id=&kind= — receipts are the most
common push destination and previously 404'd in-app.

Also widen App Links to /history, /rewards, /badges and /profile (email CTA
targets that still open the browser today), and map absolute peanut.me hrefs in
the in-app inbox back to in-app paths.
…paths

Three fixes from review:

deepLinkToNativePath only guarded the URL parse, but decodeURIComponent throws
URIError on a stray '%'. This PR made the function reachable from a render path
(the notifications list calls it inside .map), so one malformed ctaDeeplink would
have thrown during render and blanked the page for everyone. Guard the whole
parse-and-map body so bad input degrades to null.

The single-segment chargeId branch treated any one-segment path as a username, so
/rewards?chargeId=x would have been rewritten to /pay-request — and this PR is
what added /rewards, /history, /badges and /profile to App Links. Gate it on the
same isReservedRoute + couldBeRecipient rules the web catch-all already uses.

On the receipt screen, isError also trips on a failed background poll, so a flaky
refetch bounced the user to /home mid-settlement. Bail out only when there's no
data at all.
…owser

Two gaps in the push tap routing:

- Capacitor retains a cold-start click only until the first JS listener
  attaches, which adapter.init() (useNotifications) does on its own async
  path. If it wins the race against useNativePlugins registering the
  routing callback, the retained tap was consumed by an empty listener
  set. The adapter now holds the last unconsumed click and replays it to
  the next listener.

- With launch URLs suppressed, an off-domain https deep link (operator
  sends) opened the app and went nowhere. Hand those to the system
  browser instead.
Native OneSignal failure paths only console.warn'd, so zero events tagged
environment:native ever reached Sentry while the same failures on web are
visible via captureConsole. Capture the swallowed classes, each once per
session at warning level, tagged feature:onesignal:

- external_id login permanently disabled after an identity verification
  error (silently unlinks the device, pushes target 0 recipients)
- native app listener init failure (push-tap deep links never route)
- missing NEXT_PUBLIC_ONESIGNAL_APP_ID at adapter init
- tag the existing onesignal_init capture with feature:onesignal
feat(security): biometric app lock for the native app
feat(security): report-only CSP with a script-src allow-list
feat(security): prove a fresh passkey assertion on sensitive actions
…try-visibility

fix(notifications): surface native OneSignal failures in Sentry
…-routing

fix(native): route push notification taps inside the app
Brings 53 commits from main into the release branch ahead of the next
mobile build: push-tap routing, OneSignal failure reporting, biometric
app lock, fresh-passkey step-up, report-only CSP, Manteca per-rail
wallet hotfix, Rhino route minimums and the Sentry noise fixes.

Nine conflicts, all main-adds-behaviour vs mobile-release-adds-i18n.
Resolved to keep BOTH in every case:

- ClientProviders: main's AppLockGate now wraps children INSIDE
  mobile-release's AppIntlProvider > ContextProvider nesting. Getting
  this backwards 500s the whole app and unit tests don't catch it.
- authContext: took main's clearStepUpToken(); dropped its duplicate
  query cancel/clear, which already runs earlier here — deliberately
  BEFORE the token wipe, or an in-flight /users/me re-persists a
  sliding-refresh token and logout never sticks on Android.
- PhysicalCardScreen: main's null-position fix expressed through the
  translation layer. Needed a new onListBodyNoPosition key in all three
  locales rather than interpolating '#null'.
- withdraw: kept main's USD-pinned validation (no token-price scaling)
  with mobile-release's translated error copy; dropped the now-dead
  tokenSelectorContext and INSUFFICIENT_BALANCE_MESSAGE imports.
- ApplicationStatusScreen: main's proof-of-address props with
  mobile-release's COPY_KEYS lookup.
- notifications page: kept the localized date-group header, dropped
  main's hardcoded-English formatGroupHeaderDate.
- src/content: took main's production-published pointer (ca7f9ca). The
  only legal-copy delta is a support@ contact address; the Rain-superset
  prohibited-activities expansion is present in both.

Two suites from main rendered components that now call useTranslations;
wrapped both in NextIntlClientProvider, same pattern as the existing
qr-pay and SumsubKycWrapper tests.

Full suite green: 162 suites, 2133 passed.
Brings the en / es-419 / pt-BR localization and the ESLint cleanup into
the release branch for the next mobile build.

ESLint errors on this branch: 450 -> 0 (CI's `eslint .`; 62 warnings
remain, all pre-existing exhaustive-deps).

Nine conflicts:

- cancel-deposit copy: kept mobile-release's flat keys over #2447's ICU
  `select` restructure. Its component side reads `pendingCancel.noun`,
  but the field here is `kind` — taking it would have silently shown
  deposit copy for requests. Pure refactor of the same five strings, so
  nothing is lost. `cancelConfirm.*` and the unused `kyc.wrapper.*` keys
  are dropped; verified the latter are unreferenced on #2447 too.
- AddMoneyBankDetails: took #2447's sender/recipient name-match checklist
  (real functionality mobile-release lacked).
- SumsubKycWrapper test: took #2447's imports — its body needs `act` and
  the options passthrough.
- three other test files: kept mobile-release's imports; their merged
  bodies already declare an IntlWrapper, so #2447's side would have
  duplicated the declaration.

Localized the strings main shipped untranslated, which the jsx-no-literals
guard from #2447 caught the moment the two lines met:

- AppLock (native lock screen): title, prompt, failed prompt, unlock and
  log-out. The two prompts sit inside expression containers so the rule
  could not see them — translated anyway, or the screen ships half English.
- ApplicationStatusScreen: 'Upload proof of address'.

New appLock namespace + card.uploadProofOfAddress across all three locales.

Full suite green: 164 suites, 2148 passed. Typecheck clean. Prettier
clean on every tracked file.
@innolope-dev innolope-dev self-assigned this Jul 22, 2026
@vercel

vercel Bot commented Jul 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
peanut-wallet Ready Ready Preview, Comment Jul 22, 2026 9:49pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b128180d-71ac-44f7-856a-3d5beac5a6a6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/mobile-release-sync

Comment @coderabbitai help to get the list of available commands.

@innolope-dev
innolope-dev merged commit 2e241a3 into mobile-release Jul 23, 2026
12 checks passed
innolope-dev added a commit that referenced this pull request Jul 23, 2026
… fix

Rolls up for the next TestFlight + Play internal build:
  - main → mobile-release sync (push routing, app lock, step-up, CSP,
    Manteca per-rail hotfix, Rhino minimums, Sentry noise) — #2483
  - en / es-419 / pt-BR localization + 0 ESLint errors — #2447
  - $0-balance fix: last-known spendable, balanceUnavailable, no
    non-card /rain/cards poll — cherry-picked from #2482
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants