From 89a23c60052da7b5869ed581f4695ef3c4dfc590 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Thu, 16 Jul 2026 20:16:32 +0100 Subject: [PATCH 01/45] fix(badges): sort a copy in BadgesRow instead of mutating the prop 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. --- src/components/Badges/BadgesRow.tsx | 2 +- .../Badges/__tests__/BadgesRow.test.tsx | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 src/components/Badges/__tests__/BadgesRow.test.tsx diff --git a/src/components/Badges/BadgesRow.tsx b/src/components/Badges/BadgesRow.tsx index 696550bda0..f13940f564 100644 --- a/src/components/Badges/BadgesRow.tsx +++ b/src/components/Badges/BadgesRow.tsx @@ -38,7 +38,7 @@ const BadgesRow = ({ badges, className, isSelfProfile = true }: BadgesRowProps) // sort by earnedAt, newest first const sortedBadges = useMemo(() => { - return badges.sort((a, b) => { + return [...badges].sort((a, b) => { const at = a.earnedAt ? new Date(a.earnedAt).getTime() : 0 const bt = b.earnedAt ? new Date(b.earnedAt).getTime() : 0 return bt - at diff --git a/src/components/Badges/__tests__/BadgesRow.test.tsx b/src/components/Badges/__tests__/BadgesRow.test.tsx new file mode 100644 index 0000000000..b5bd3cf17d --- /dev/null +++ b/src/components/Badges/__tests__/BadgesRow.test.tsx @@ -0,0 +1,37 @@ +import { render } from '@testing-library/react' +import type { ComponentProps } from 'react' +import BadgesRow from '@/components/Badges/BadgesRow' + +jest.mock('next/image', () => ({ + __esModule: true, + default: ({ unoptimized, fill, ...rest }: ComponentProps<'img'> & { unoptimized?: boolean; fill?: boolean }) => ( + + ), +})) + +jest.mock('@/components/Tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +const badge = (code: string, earnedAt: string) => ({ + code, + name: code, + description: null, + iconUrl: null, + earnedAt, +}) + +describe('BadgesRow', () => { + it('does not mutate the badges array it is given', () => { + // Oldest first, so a newest-first sort has to reorder them. + const badges = [ + badge('OLDEST', '2024-01-01T00:00:00.000Z'), + badge('MIDDLE', '2024-06-01T00:00:00.000Z'), + badge('NEWEST', '2024-12-01T00:00:00.000Z'), + ] + + render() + + expect(badges.map((b) => b.code)).toEqual(['OLDEST', 'MIDDLE', 'NEWEST']) + }) +}) From fa8937eb7eb535d77b34bc033275f423c635e78f Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Thu, 16 Jul 2026 21:02:33 +0100 Subject: [PATCH 02/45] fix(native): don't flag P0_TRANSFORMS pages as uncovered server routes 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. --- scripts/__tests__/native-build-scan.test.js | 57 +++++++++++++++++++++ scripts/native-build.js | 10 +++- 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 scripts/__tests__/native-build-scan.test.js diff --git a/scripts/__tests__/native-build-scan.test.js b/scripts/__tests__/native-build-scan.test.js new file mode 100644 index 0000000000..cff8347b5d --- /dev/null +++ b/scripts/__tests__/native-build-scan.test.js @@ -0,0 +1,57 @@ +const fs = require('fs') +const path = require('path') +const Module = require('module') + +const SCRIPT_PATH = path.join(__dirname, '..', 'native-build.js') + +// native-build.js is a script, not a module: it calls main() at import time and +// exports nothing. Load the real source with that call stripped so the scan +// helpers can be asserted against the actual app tree. +function loadScriptInternals() { + const source = fs.readFileSync(SCRIPT_PATH, 'utf-8') + const withoutEntrypoint = source.replace(/\nmain\(\)\s*$/, '\n') + expect(withoutEntrypoint).not.toBe(source) + + const exposed = + withoutEntrypoint + + '\nmodule.exports = { isHandledByTransform, detectUncoveredServerRoutes, P0_TRANSFORMS, APP_DIR }\n' + + const mod = new Module(SCRIPT_PATH, null) + mod.filename = SCRIPT_PATH + mod.paths = Module._nodeModulePaths(path.dirname(SCRIPT_PATH)) + mod._compile(exposed, SCRIPT_PATH) + return mod.exports +} + +const { isHandledByTransform, detectUncoveredServerRoutes, P0_TRANSFORMS, APP_DIR } = loadScriptInternals() + +const toPosix = (p) => p.split(path.sep).join('/') + +describe('native build server-route scan', () => { + it('treats every P0_TRANSFORMS entry as handled', () => { + for (const transform of P0_TRANSFORMS) { + expect(isHandledByTransform(transform.path)).toBe(true) + } + }) + + // The scan passes `path.relative(APP_DIR, full)` into the predicate. If the + // predicate compared a different path shape it would silently never match. + it('matches the relative path shape the scan actually computes', () => { + for (const transform of P0_TRANSFORMS) { + const absolute = path.join(APP_DIR, transform.path) + expect(isHandledByTransform(path.relative(APP_DIR, absolute))).toBe(true) + } + }) + + it('does not suppress routes that are not transformed', () => { + expect(isHandledByTransform('some/other/page.tsx')).toBe(false) + expect(isHandledByTransform('api/foo/route.ts')).toBe(false) + expect(isHandledByTransform('(mobile-ui)/claim/layout.tsx')).toBe(false) + }) + + it('does not flag transformed pages as uncovered server routes', () => { + const transformed = new Set(P0_TRANSFORMS.map((t) => t.path)) + const offenders = detectUncoveredServerRoutes().filter((o) => transformed.has(toPosix(o.rel))) + expect(offenders).toEqual([]) + }) +}) diff --git a/scripts/native-build.js b/scripts/native-build.js index 09934ec733..31946721dd 100644 --- a/scripts/native-build.js +++ b/scripts/native-build.js @@ -248,6 +248,14 @@ function isCoveredByDisableList(relPath) { }) } +// P0_TRANSFORMS files are replaced with static-export-safe stubs before `next +// build`, so their server-only exports (generateMetadata, force-dynamic) never +// reach the export — the scan must not flag them. +function isHandledByTransform(relPath) { + const normalized = relPath.split(path.sep).join('/') + return P0_TRANSFORMS.some((t) => t.path === normalized) +} + function detectUncoveredServerRoutes(dir = APP_DIR, found = []) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.name.includes('.disabled') || entry.name.startsWith('_')) continue @@ -258,7 +266,7 @@ function detectUncoveredServerRoutes(dir = APP_DIR, found = []) { detectUncoveredServerRoutes(full, found) continue } - if (isCoveredByDisableList(rel)) continue + if (isCoveredByDisableList(rel) || isHandledByTransform(rel)) continue if (entry.name === 'route.ts' || entry.name === 'route.js') { found.push({ rel, reason: 'route handler (cannot be statically exported)' }) continue From 9aad003f3640fe19780a3ddafa96bb23cc41c45a Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Thu, 16 Jul 2026 17:18:46 +0100 Subject: [PATCH 03/45] fix(card): don't render '#null' for a waitlist entry without a position 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. --- src/components/Card/PhysicalCardScreen.tsx | 4 +- .../__tests__/PhysicalCardScreen.test.tsx | 60 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 src/components/Card/__tests__/PhysicalCardScreen.test.tsx diff --git a/src/components/Card/PhysicalCardScreen.tsx b/src/components/Card/PhysicalCardScreen.tsx index 8154976a6b..72a033da0c 100644 --- a/src/components/Card/PhysicalCardScreen.tsx +++ b/src/components/Card/PhysicalCardScreen.tsx @@ -76,7 +76,9 @@ const PhysicalCardScreen: FC = ({ cardId, last4, onPrev }) => {

You are on the list!

- {`You are #${data.position} on the list. We'll let you know when cards are ready to be shipped.`} + {data.position === null + ? `You are on the list. We'll let you know when cards are ready to be shipped.` + : `You are #${data.position.toLocaleString()} on the list. We'll let you know when cards are ready to be shipped.`}

) : ( diff --git a/src/components/Card/__tests__/PhysicalCardScreen.test.tsx b/src/components/Card/__tests__/PhysicalCardScreen.test.tsx new file mode 100644 index 0000000000..1d29b79772 --- /dev/null +++ b/src/components/Card/__tests__/PhysicalCardScreen.test.tsx @@ -0,0 +1,60 @@ +/** + * PhysicalCardScreen — waitlist "on the list" copy contract. + * + * The joined branch is gated on joinedAt, but the API types position as + * nullable, so a joined user can have no queue position. Interpolating it + * blindly renders "You are #null on the list." + */ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import PhysicalCardScreen from '@/components/Card/PhysicalCardScreen' +import { rainApi } from '@/services/rain' + +jest.mock('@/context/authContext', () => ({ + useAuth: () => ({ user: { accounts: [] }, fetchUser: jest.fn() }), +})) +jest.mock('next/image', () => ({ + __esModule: true, + // eslint-disable-next-line @next/next/no-img-element -- test stub, not real markup + default: (props: Record) => {String(props.alt, +})) +jest.mock('posthog-js', () => ({ __esModule: true, default: { capture: jest.fn() } })) +jest.mock('@/services/rain', () => ({ + rainApi: { getPhysicalWaitlist: jest.fn(), joinPhysicalWaitlist: jest.fn() }, +})) + +const mockGet = rainApi.getPhysicalWaitlist as jest.Mock + +const renderScreen = () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + ) +} + +describe('PhysicalCardScreen — joined waitlist copy', () => { + beforeEach(() => jest.clearAllMocks()) + + it('shows the queue position when the API returns one', async () => { + mockGet.mockResolvedValue({ joinedAt: '2026-01-01T00:00:00Z', position: 42 }) + renderScreen() + expect(await screen.findByText(/You are #42 on the list\./)).toBeInTheDocument() + }) + + it('omits the position rather than rendering "#null" when there is none', async () => { + mockGet.mockResolvedValue({ joinedAt: '2026-01-01T00:00:00Z', position: null }) + renderScreen() + const body = await screen.findByText(/You are on the list\./) + expect(body).toBeInTheDocument() + expect(body.textContent).not.toMatch(/#/) + }) + + it('formats large positions with thousands separators', async () => { + mockGet.mockResolvedValue({ joinedAt: '2026-01-01T00:00:00Z', position: 1234 }) + renderScreen() + expect(await screen.findByText(/You are #1,234 on the list\./)).toBeInTheDocument() + }) +}) From 3b8466e3e9d7104f0aac1fba2527e1dc29b9a119 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 17 Jul 2026 13:54:29 +0100 Subject: [PATCH 04/45] test(card): derive the expected waitlist position from the active locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/Card/__tests__/PhysicalCardScreen.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/Card/__tests__/PhysicalCardScreen.test.tsx b/src/components/Card/__tests__/PhysicalCardScreen.test.tsx index 1d29b79772..c787501ce0 100644 --- a/src/components/Card/__tests__/PhysicalCardScreen.test.tsx +++ b/src/components/Card/__tests__/PhysicalCardScreen.test.tsx @@ -52,9 +52,12 @@ describe('PhysicalCardScreen — joined waitlist copy', () => { expect(body.textContent).not.toMatch(/#/) }) + // Derived rather than hardcoded: the separator is locale-dependent, and the + // contract is that the copy delegates to toLocaleString, not that it says "1,234". it('formats large positions with thousands separators', async () => { mockGet.mockResolvedValue({ joinedAt: '2026-01-01T00:00:00Z', position: 1234 }) renderScreen() - expect(await screen.findByText(/You are #1,234 on the list\./)).toBeInTheDocument() + const body = await screen.findByText(/on the list\./) + expect(body.textContent).toContain(`You are #${(1234).toLocaleString()} on the list.`) }) }) From bf3516e33ac8467c6ac1c2bc0ab1f1419d96e84d Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Fri, 17 Jul 2026 17:39:18 +0100 Subject: [PATCH 05/45] chore(types): add FE types for GET /notifications/admin/recent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/types/api.generated.ts | 75 +++++++++++++++++ src/types/api.openapi.json | 165 +++++++++++++++++++++++++++++++++++++ 2 files changed, 240 insertions(+) diff --git a/src/types/api.generated.ts b/src/types/api.generated.ts index 318eb0f362..f7ad1ff259 100644 --- a/src/types/api.generated.ts +++ b/src/types/api.generated.ts @@ -4,6 +4,81 @@ */ export interface paths { + "/notifications/admin/recent": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Recent notification rows for a user (all channels + statuses) */ + get: { + parameters: { + query: { + userId: string; + limit: number; + }; + header: { + "x-admin-token": string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: { + id: string; + eventType: string; + channel: string; + status: "PENDING" | "SENT" | "FAILED" | "SKIPPED"; + skipReason: string | null; + providerId: string | null; + error: unknown; + createdAt: string; + sentAt: string | null; + }[]; + }; + }; + }; + /** @description Default Response */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Default Response */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/": { parameters: { query?: never; diff --git a/src/types/api.openapi.json b/src/types/api.openapi.json index fd63e10430..9bcce42741 100644 --- a/src/types/api.openapi.json +++ b/src/types/api.openapi.json @@ -8,6 +8,171 @@ "schemas": {} }, "paths": { + "/notifications/admin/recent": { + "get": { + "summary": "Recent notification rows for a user (all channels + statuses)", + "tags": ["admin"], + "parameters": [ + { + "schema": { + "minLength": 1, + "type": "string" + }, + "in": "query", + "name": "userId", + "required": true + }, + { + "schema": { + "minimum": 1, + "maximum": 100, + "default": 20, + "type": "integer" + }, + "in": "query", + "name": "limit", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "header", + "name": "x-admin-token", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "eventType": { + "type": "string" + }, + "channel": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "type": "string", + "enum": ["PENDING"] + }, + { + "type": "string", + "enum": ["SENT"] + }, + { + "type": "string", + "enum": ["FAILED"] + }, + { + "type": "string", + "enum": ["SKIPPED"] + } + ] + }, + "skipReason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "error": {}, + "createdAt": { + "type": "string" + }, + "sentAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "eventType", + "channel", + "status", + "skipReason", + "providerId", + "error", + "createdAt", + "sentAt" + ] + } + } + }, + "required": ["items"] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + }, + "401": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + } + } + } + }, "/": { "get": { "responses": { From c8ee8c7f956b81b010b558b21c26516da7a42f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= <70615692+jjramirezn@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:24:04 -0300 Subject: [PATCH 06/45] feat(card): proof-of-address upload on stuck Rain applications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/(mobile-ui)/card/page.tsx | 94 ++++++++++++++++++- src/app/actions/sumsub.ts | 4 +- .../Card/ApplicationStatusScreen.tsx | 25 ++++- .../ApplicationStatusScreen.test.tsx | 53 +++++++++++ 4 files changed, 168 insertions(+), 8 deletions(-) diff --git a/src/app/(mobile-ui)/card/page.tsx b/src/app/(mobile-ui)/card/page.tsx index 17e3062aa2..1e39da95af 100644 --- a/src/app/(mobile-ui)/card/page.tsx +++ b/src/app/(mobile-ui)/card/page.tsx @@ -21,6 +21,7 @@ import Loading from '@/components/Global/Loading' import { Button } from '@/components/0_Bruddle/Button' import PageContainer from '@/components/0_Bruddle/PageContainer' import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' +import { initiateSelfHealResubmission } from '@/app/actions/sumsub' import { rainApi, type ApplyForCardResponse } from '@/services/rain' import { useGrantSessionKey } from '@/hooks/wallet/useGrantSessionKey' import { useCapabilities } from '@/hooks/useCapabilities' @@ -69,7 +70,7 @@ const CardPage: FC = () => { const { overview, isLoading: overviewLoading, error: overviewError } = useRainCardOverview() const { serializeGrant } = useGrantSessionKey() - const { railsForProvider, isLoading: capabilitiesLoading } = useCapabilities() + const { railsForProvider, nextActionsForRail, isLoading: capabilitiesLoading } = useCapabilities() const { setIsSupportModalOpen } = useModalsContext() const onBack = useSafeBack('/home') @@ -212,6 +213,62 @@ const CardPage: FC = () => { void queryClient.invalidateQueries({ queryKey: [RAIN_CARD_OVERVIEW_QUERY_KEY] }) }, [queryClient]) + // Proof-of-address self-heal — a dedicated, deliberately tiny flow. The + // multi-phase KYC machinery (useMultiPhaseKycFlow) is bank-onboarding + // shaped: its post-approval phase machine polls a mutating endpoint, can + // fan out to Bridge ToS modals, and completes on rail semantics that never + // match the Rain PoA lifecycle. Here we only need: mint an action token → + // open the SDK → on submit, thank the user and refetch. Separate surface + // from the card-application SumsubKycWrapper below — that one is driven by + // applyForCard tokens with its own refresh/poll semantics. + const [poaToken, setPoaToken] = useState(null) + const [poaError, setPoaError] = useState(null) + // Optimistic "we got your document" until the backend webhook flips the + // rail reason to its own review-wait state (may lag the SDK by seconds). + const [poaSubmitted, setPoaSubmitted] = useState(false) + + // The rain rail's self-serve proof-of-address action, when the backend + // classified the application as PoA-fixable (kind 'sumsub' + levelKey + // 'proof_of_address' — emitted for WRONG_ADDRESS-class denials). Absent → + // the status screens keep their contact-support-only shape. + const cardRail = railsForProvider('rain')[0] + const poaAction = cardRail + ? nextActionsForRail(cardRail.id).find( + (action) => action.kind === 'sumsub' && action.levelKey === 'proof_of_address' + ) + : undefined + // Double-click guard: two concurrent initiations race the backend's + // create-action idempotency into minting two Sumsub actions (the id + // collision path deliberately mints a suffixed fresh action). + const poaStartingRef = useRef(false) + const startPoaUpload = useCallback(async () => { + if (poaStartingRef.current) return + poaStartingRef.current = true + posthog.capture(ANALYTICS_EVENTS.CARD_SUMSUB_OPENED, { source: 'poa-self-heal' }) + setPoaError(null) + try { + const response = await initiateSelfHealResubmission('RAIN') + if (response.error || !response.data?.token) { + // Surfaced inline on the status screen — a silent primary CTA on + // a stuck-application screen is worse than no CTA. + setPoaError(response.error ?? 'Could not start the upload. Please try again.') + return + } + setPoaToken(response.data.token) + } finally { + poaStartingRef.current = false + } + }, []) + const onUploadProofOfAddress = poaAction && !poaSubmitted ? () => void startPoaUpload() : undefined + + // Once the backend's own state takes over (the rail's sumsub action gives + // way to the review-wait state, an approval, or a different ask), drop the + // optimistic banner so a later re-offered upload isn't suppressed until + // remount. + useEffect(() => { + if (poaSubmitted && !poaAction) setPoaSubmitted(false) + }, [poaSubmitted, poaAction]) + // Routes a non-incomplete apply response to the right next screen. Shared // by the user-initiated apply path and the post-Sumsub poll, since both // need the same main-kyc-required / terms-required / default fan-out. @@ -599,12 +656,16 @@ const CardPage: FC = () => { ) } - const cardRailReason = railsForProvider('rain')[0]?.reason?.userMessage + const cardRailReason = poaSubmitted + ? 'We received your proof of address — it’s being reviewed.' + : railsForProvider('rain')[0]?.reason?.userMessage return ( setIsSupportModalOpen(true)} + onUploadProofOfAddress={onUploadProofOfAddress} + uploadError={poaError ?? undefined} onPrev={onBack} /> ) @@ -633,14 +694,18 @@ const CardPage: FC = () => { // (meaningless without its reason), the rejected screen is useful // on its own (reassurance + support CTA), so show it now and let // the reason fill in once capabilities resolve. - const cardRailReason = capabilitiesLoading - ? undefined - : railsForProvider('rain')[0]?.reason?.userMessage + const cardRailReason = poaSubmitted + ? 'We received your proof of address — it’s being reviewed.' + : capabilitiesLoading + ? undefined + : railsForProvider('rain')[0]?.reason?.userMessage return ( setIsSupportModalOpen(true)} + onUploadProofOfAddress={onUploadProofOfAddress} + uploadError={poaError ?? undefined} onPrev={onBack} /> ) @@ -657,6 +722,25 @@ const CardPage: FC = () => { return ( {renderState()} + setPoaToken(null)} + onComplete={() => { + // Document submitted to Sumsub. Review + the backend webhook + // stamp happen async — flip the optimistic banner and refetch + // so the screen picks up the backend's wait state when ready. + setPoaToken(null) + setPoaSubmitted(true) + invalidateOverview() + void fetchUser() + }} + onRefreshToken={async () => { + const response = await initiateSelfHealResubmission('RAIN') + if (!response.data?.token) throw new Error(response.error ?? 'Failed to refresh token') + return response.data.token + }} + /> void + /** When the rail carries a self-serve proof-of-address action, this opens + * the Sumsub upload flow — rendered as the primary CTA so users fix it + * themselves instead of messaging support. */ + onUploadProofOfAddress?: () => void + /** Inline failure from starting the upload (a silent primary CTA on a + * stuck-application screen reads as broken). */ + uploadError?: string onPrev?: () => void } @@ -46,7 +54,14 @@ const COPY: Record = { /** Variants where support is the only path forward — these render the CTA. */ const SUPPORT_VARIANTS: ReadonlySet = new Set(['requires-info', 'requires-support', 'rejected']) -const ApplicationStatusScreen: FC = ({ variant, reasonMessage, onContactSupport, onPrev }) => { +const ApplicationStatusScreen: FC = ({ + variant, + reasonMessage, + onContactSupport, + onUploadProofOfAddress, + uploadError, + onPrev, +}) => { const copy = COPY[variant] return (
@@ -69,6 +84,14 @@ const ApplicationStatusScreen: FC = ({ variant, reasonMessage, onContactS {reasonMessage &&

{reasonMessage}

}

{copy.body}

+ {SUPPORT_VARIANTS.has(variant) && onUploadProofOfAddress && ( +
+ + {uploadError &&

{uploadError}

} +
+ )} {SUPPORT_VARIANTS.has(variant) && onContactSupport && ( + + + + ) +} diff --git a/src/utils/__tests__/app-lock.test.ts b/src/utils/__tests__/app-lock.test.ts new file mode 100644 index 0000000000..28c863dc37 --- /dev/null +++ b/src/utils/__tests__/app-lock.test.ts @@ -0,0 +1,65 @@ +/** + * The lock's safety property is asymmetric: failing to lock is a security + * weakness, but failing to UNLOCK strands the user in their own app with no + * way out. These tests pin which failure modes fall which way. + */ +import { webcrypto } from 'node:crypto' + +import { requestLocalUserPresence } from '../app-lock' + +if (!globalThis.crypto?.getRandomValues) { + Object.defineProperty(globalThis, 'crypto', { value: webcrypto, configurable: true }) +} + +const CREDENTIAL_ID = 'YWJjZGVmZ2g' + +function withCredentialsGet(impl: () => Promise) { + Object.defineProperty(globalThis, 'PublicKeyCredential', { value: function () {}, configurable: true }) + Object.defineProperty(globalThis.navigator, 'credentials', { + value: { get: impl }, + configurable: true, + }) +} + +describe('requestLocalUserPresence', () => { + it('reports unsupported when there is no stored credential to prompt against', async () => { + withCredentialsGet(async () => ({}) as Credential) + await expect(requestLocalUserPresence(undefined)).resolves.toBe('unsupported') + }) + + it('reports unsupported when the platform has no WebAuthn', async () => { + Object.defineProperty(globalThis, 'PublicKeyCredential', { value: undefined, configurable: true }) + await expect(requestLocalUserPresence(CREDENTIAL_ID)).resolves.toBe('unsupported') + }) + + it('unlocks when the authenticator returns an assertion', async () => { + withCredentialsGet(async () => ({}) as Credential) + await expect(requestLocalUserPresence(CREDENTIAL_ID)).resolves.toBe('unlocked') + }) + + it('stays locked when the user cancels the prompt', async () => { + withCredentialsGet(async () => { + throw Object.assign(new Error('cancelled'), { name: 'NotAllowedError' }) + }) + await expect(requestLocalUserPresence(CREDENTIAL_ID)).resolves.toBe('dismissed') + }) + + it('reports unsupported when the authenticator rejects the request outright', async () => { + withCredentialsGet(async () => { + throw Object.assign(new Error('nope'), { name: 'NotSupportedError' }) + }) + await expect(requestLocalUserPresence(CREDENTIAL_ID)).resolves.toBe('unsupported') + }) + + it('pins the request to the stored credential and demands user verification', async () => { + let received: PublicKeyCredentialRequestOptions | undefined + withCredentialsGet(async (options?: CredentialRequestOptions) => { + received = options?.publicKey + return {} as Credential + }) + await requestLocalUserPresence(CREDENTIAL_ID) + expect(received?.userVerification).toBe('required') + expect(received?.allowCredentials).toHaveLength(1) + expect(new Uint8Array(received!.challenge as ArrayBufferView['buffer'])).not.toHaveLength(0) + }) +}) diff --git a/src/utils/app-lock.ts b/src/utils/app-lock.ts new file mode 100644 index 0000000000..4b25d2a6c4 --- /dev/null +++ b/src/utils/app-lock.ts @@ -0,0 +1,53 @@ +// Native app lock: a local user-presence gate shown when the app is opened +// cold or resumed after a spell in the background. +// +// This is deliberately a LOCAL check — the assertion is never sent to the API +// for verification. The threat it addresses is physical: someone holding an +// already-unlocked phone. The OS will not produce an assertion without the +// user's biometric or device passcode, which is exactly the property we want. +// Server-side proof of a fresh assertion is a separate concern (step-up auth on +// sensitive endpoints) and does not belong in a UI lock. + +import { base64URLToBytes } from './native-webauthn' + +/** How long the app may sit in the background before it relocks. */ +export const LOCK_AFTER_BACKGROUND_MS = 5 * 60 * 1000 + +export type UnlockOutcome = 'unlocked' | 'dismissed' | 'unsupported' + +/** + * Prompts for the device biometric/passcode via a WebAuthn assertion against + * the user's existing passkey. + * + * Returns 'unsupported' when we cannot prompt at all — no WebAuthn, or no + * stored credential id. Callers must treat that as "do not lock": a lock we + * can't lift would strand the user in their own app with no way back. + */ +export async function requestLocalUserPresence(credentialId?: string): Promise { + if (typeof window === 'undefined') return 'unsupported' + if (!credentialId) return 'unsupported' + if (!window.PublicKeyCredential || !navigator.credentials?.get) return 'unsupported' + + const challenge = new Uint8Array(32) + crypto.getRandomValues(challenge) + + try { + const assertion = await navigator.credentials.get({ + publicKey: { + challenge, + // Copy into a fresh buffer: BufferSource wants Uint8Array, + // and base64URLToBytes is typed over the wider ArrayBufferLike. + allowCredentials: [{ id: new Uint8Array(base64URLToBytes(credentialId)), type: 'public-key' }], + userVerification: 'required', + timeout: 60_000, + }, + }) + return assertion ? 'unlocked' : 'dismissed' + } catch (error) { + // A cancelled prompt and a genuinely broken authenticator are + // indistinguishable here; both leave the app locked with a retry, which + // is the safe direction. Only the pre-flight checks above fail open. + if (error instanceof Error && error.name === 'NotSupportedError') return 'unsupported' + return 'dismissed' + } +} From c0fbae41558a5a7d024b13ddcd86a77b29288fb6 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Tue, 21 Jul 2026 16:06:40 +0100 Subject: [PATCH 25/45] feat(security): report-only CSP with a script-src allow-list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , 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. --- next.config.js | 80 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/next.config.js b/next.config.js index 53c2ad6b51..41c7f1f361 100644 --- a/next.config.js +++ b/next.config.js @@ -5,6 +5,78 @@ const withBundleAnalyzer = const redirectsConfig = require('./redirects.json') +/** + * Sentry's CSP-report ingest endpoint, derived from the browser DSN + * (`https://@/`). Returns null when the DSN is + * absent or malformed, in which case the policy still ships — it just has + * nowhere to report, which is better than emitting a broken `report-uri`. + */ +function sentryCspReportUri() { + const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN + if (!dsn) return null + try { + const { host, username, pathname } = new URL(dsn) + const projectId = pathname.replace(/^\//, '') + if (!host || !username || !projectId) return null + return `https://${host}/api/${projectId}/security/?sentry_key=${username}` + } catch { + return null + } +} + +/** + * First-draft CSP, shipped REPORT-ONLY. + * + * Nothing here is enforced yet: the app currently has no script-src at all, so + * an XSS anywhere is unconstrained. Guessing the allow-list and enforcing it + * would blank the app; instead this collects violation reports from real + * traffic until the list is known to be complete, then it gets promoted to the + * enforcing `Content-Security-Policy` header. + * + * Known-loose parts, to tighten before promotion: + * - `'unsafe-inline'` / `'unsafe-eval'` in script-src: Next's inline bootstrap + * and the wallet SDKs need them today. Moving to nonces is its own change. + * - connect-src can't enumerate every chain RPC (they come from env and vary by + * network), so the report stream is what completes this list. + */ +function contentSecurityPolicyReportOnly() { + const reportUri = sentryCspReportUri() + const directives = [ + "default-src 'self'", + // PostHog is same-origin via the /relay rewrite, so it needs no entry here. + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://client.crisp.chat https://static.sumsub.com", + "style-src 'self' 'unsafe-inline' https://client.crisp.chat", + "img-src 'self' data: blob: https:", + "font-src 'self' data: https://client.crisp.chat", + [ + "connect-src 'self'", + 'https://api.peanut.me', + 'https://*.peanut.me', + 'https://*.ingest.sentry.io', + 'https://*.ingest.us.sentry.io', + 'https://www.google-analytics.com', + 'https://rpc.zerodev.app', + 'https://*.g.alchemy.com', + 'https://rpc.ankr.com', + 'https://assets.coingecko.com', + 'https://coin-images.coingecko.com', + 'https://api.frankfurter.app', + 'https://dolarapi.com', + 'https://client.crisp.chat', + 'wss://client.relay.crisp.chat', + 'https://*.sumsub.com', + 'https://widget.manteca.dev', + ].join(' '), + "frame-src 'self' https://client.crisp.chat https://*.sumsub.com https://widget.manteca.dev https://mpago.la", + "worker-src 'self' blob:", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + ] + if (reportUri) directives.push(`report-uri ${reportUri}`) + return directives.join('; ') +} + // Get git commit hash at build time let gitCommitHash = 'unknown' try { @@ -186,7 +258,13 @@ let nextConfig = { }, // Security headers - prevents clickjacking and other attacks // Using frame-ancestors instead of X-Frame-Options to allow specific domains - { key: 'Content-Security-Policy', value: "frame-ancestors 'self' https://hugo0.com" }, + // object-src/base-uri are safe to enforce today: the app embeds no + // plugins and sets no , so neither can break a working page. + { + key: 'Content-Security-Policy', + value: "frame-ancestors 'self' https://hugo0.com; object-src 'none'; base-uri 'self'", + }, + { key: 'Content-Security-Policy-Report-Only', value: contentSecurityPolicyReportOnly() }, { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, ], From 969d498a6b1fb4705e2d09f1f6b26adc8532b9a5 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Tue, 21 Jul 2026 16:44:13 +0100 Subject: [PATCH 26/45] feat(security): prove a fresh passkey assertion on sensitive actions 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. --- src/app/actions/users.ts | 2 + src/context/authContext.tsx | 5 ++ src/services/__tests__/step-up.test.ts | 103 +++++++++++++++++++++++++ src/services/rain.ts | 12 +++ src/services/step-up.ts | 77 ++++++++++++++++++ 5 files changed, 199 insertions(+) create mode 100644 src/services/__tests__/step-up.test.ts create mode 100644 src/services/step-up.ts diff --git a/src/app/actions/users.ts b/src/app/actions/users.ts index a57b505eca..0bed58670f 100644 --- a/src/app/actions/users.ts +++ b/src/app/actions/users.ts @@ -3,6 +3,7 @@ import { type AddBankAccountPayload, BridgeEndorsementType, type InitiateKycResp import { type CounterpartyUser } from '@/interfaces' import { type ContactsResponse } from '@/interfaces' import { serverFetch } from '@/utils/api-fetch' +import { withStepUpHeader } from '@/services/step-up' export const updateUserById = async (payload: Record): Promise<{ data?: ApiUser; error?: string }> => { try { @@ -51,6 +52,7 @@ export const addBankAccount = async (payload: AddBankAccountPayload): Promise<{ const response = await serverFetch('/users/accounts', { method: 'POST', body: JSON.stringify(payload), + headers: await withStepUpHeader({}), }) const responseJson = await response.json() diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx index 1c13495230..652be925d0 100644 --- a/src/context/authContext.tsx +++ b/src/context/authContext.tsx @@ -25,6 +25,7 @@ import { createContext, type ReactNode, useContext, useState, useEffect, useMemo import { captureException, setUser as setSentryUser } from '@sentry/nextjs' // import { PUBLIC_ROUTES_REGEX } from '@/constants/routes' import { USER_DATA_CACHE_PATTERNS } from '@/constants/cache.consts' +import { clearStepUpToken } from '@/services/step-up' interface AuthContextType { user: IUserProfile | null @@ -201,6 +202,10 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { // clear redirect url clearRedirectUrl() + // A cached step-up proof outliving the session would let the next user + // of this device skip verification on card and withdrawal screens. + clearStepUpToken() + // cancel + remove all queries to prevent refetches with cleared jwt try { queryClient.cancelQueries() diff --git a/src/services/__tests__/step-up.test.ts b/src/services/__tests__/step-up.test.ts new file mode 100644 index 0000000000..fadfaebe52 --- /dev/null +++ b/src/services/__tests__/step-up.test.ts @@ -0,0 +1,103 @@ +/** + * The cache is the risky part: too eager and one Face ID prompt unlocks + * sensitive routes indefinitely, too shy and a withdrawal prompts twice. + */ +import { clearStepUpToken, getStepUpToken, STEP_UP_HEADER, withStepUpHeader } from '../step-up' +import { apiFetch } from '@/utils/api-fetch' +import { startAuthentication } from '@simplewebauthn/browser' + +jest.mock('@/utils/api-fetch', () => ({ apiFetch: jest.fn() })) +jest.mock('@/utils/capacitor', () => ({ isCapacitor: () => false, getNativeRpId: () => 'peanut.me' })) + +const mockedFetch = apiFetch as jest.MockedFunction +const mockedAuth = startAuthentication as jest.MockedFunction + +function jsonResponse(body: unknown, ok = true, status = 200) { + return { ok, status, json: async () => body } as Response +} + +function happyPath(token = 'proof-token', expiresIn = 300) { + mockedFetch.mockReset() + mockedFetch + .mockResolvedValueOnce(jsonResponse({ challenge: 'abc' })) + .mockResolvedValueOnce(jsonResponse({ token, expiresIn })) +} + +describe('getStepUpToken', () => { + beforeEach(() => { + clearStepUpToken() + mockedAuth.mockReset() + mockedAuth.mockResolvedValue({ id: 'cred' } as never) + }) + + it('runs the ceremony and returns the proof token', async () => { + happyPath() + await expect(getStepUpToken()).resolves.toBe('proof-token') + expect(mockedFetch).toHaveBeenCalledTimes(2) + expect(mockedFetch.mock.calls[0][0]).toBe('/auth/step-up/options') + expect(mockedFetch.mock.calls[1][0]).toBe('/auth/step-up/verify') + }) + + it('reuses a live proof instead of prompting again', async () => { + happyPath() + await getStepUpToken() + await expect(getStepUpToken()).resolves.toBe('proof-token') + expect(mockedAuth).toHaveBeenCalledTimes(1) + }) + + it('re-prompts once the proof is close enough to expiry to be unusable', async () => { + happyPath('first', 20) // inside the 30s safety margin + await getStepUpToken() + happyPath('second') + await expect(getStepUpToken()).resolves.toBe('second') + expect(mockedAuth).toHaveBeenCalledTimes(2) + }) + + it('re-prompts after the cache is cleared (logout)', async () => { + happyPath('first') + await getStepUpToken() + clearStepUpToken() + happyPath('second') + await expect(getStepUpToken()).resolves.toBe('second') + }) + + it('explains the failure when the account has no passkey', async () => { + mockedFetch.mockReset() + mockedFetch.mockResolvedValueOnce(jsonResponse({ error: 'none' }, false, 404)) + await expect(getStepUpToken()).rejects.toThrow('No passkey is registered') + }) + + it('does not cache a proof when verification is rejected', async () => { + mockedFetch.mockReset() + mockedFetch + .mockResolvedValueOnce(jsonResponse({ challenge: 'abc' })) + .mockResolvedValueOnce(jsonResponse({ error: 'nope' }, false, 401)) + await expect(getStepUpToken()).rejects.toThrow('Could not confirm it is you') + + happyPath('after-retry') + await expect(getStepUpToken()).resolves.toBe('after-retry') + }) + + it('propagates a cancelled prompt rather than proceeding unverified', async () => { + mockedFetch.mockReset() + mockedFetch.mockResolvedValueOnce(jsonResponse({ challenge: 'abc' })) + mockedAuth.mockRejectedValueOnce(Object.assign(new Error('cancelled'), { name: 'NotAllowedError' })) + await expect(getStepUpToken()).rejects.toThrow('cancelled') + }) +}) + +describe('withStepUpHeader', () => { + beforeEach(() => { + clearStepUpToken() + mockedAuth.mockReset() + mockedAuth.mockResolvedValue({ id: 'cred' } as never) + }) + + it('adds the proof header while preserving existing ones', async () => { + happyPath() + await expect(withStepUpHeader({ 'Content-Type': 'application/json' })).resolves.toEqual({ + 'Content-Type': 'application/json', + [STEP_UP_HEADER]: 'proof-token', + }) + }) +}) diff --git a/src/services/rain.ts b/src/services/rain.ts index 9c7d5486f1..47d8c9a32f 100644 --- a/src/services/rain.ts +++ b/src/services/rain.ts @@ -11,6 +11,7 @@ import Cookies from 'js-cookie' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { PEANUT_API_KEY, PEANUT_API_URL } from '@/constants/general.consts' +import { getStepUpToken, STEP_UP_HEADER } from './step-up' import { fetchWithSentry } from '@/utils/sentry.utils' import type { SignedRainWithdrawal } from '@/hooks/wallet/useSignSpendBundle' @@ -322,6 +323,11 @@ interface RequestOpts { noStore?: boolean /** Override fetchWithSentry's default 10s timeout (e.g. UserOp submissions). */ timeoutMs?: number + /** + * Prove a fresh WebAuthn assertion alongside the session. Prompts for Face + * ID unless a proof from the last few minutes is still good. + */ + stepUp?: boolean } async function rainRequest(opts: RequestOpts): Promise { @@ -334,6 +340,7 @@ async function rainRequest(opts: RequestOpts): Promise { } if (opts.body !== undefined) headers['Content-Type'] = 'application/json' if (opts.noStore) headers['Cache-Control'] = 'no-store' + if (opts.stepUp) headers[STEP_UP_HEADER] = await getStepUpToken() const response = await fetchWithSentry( `${PEANUT_API_URL}${opts.path}`, @@ -430,6 +437,7 @@ export const rainApi = { await rainRequest<{ ok: boolean }>({ method: 'POST', path: '/rain/cards/withdraw/session-approve', + stepUp: true, body: input, }) }, @@ -443,6 +451,7 @@ export const rainApi = { return rainRequest({ method: 'POST', path: '/rain/cards/withdraw/prepare', + stepUp: true, body: input, }) }, @@ -646,6 +655,7 @@ export const rainApi = { return rainRequest({ method: 'GET', path: `/rain/cards/${cardId}/details`, + stepUp: true, rateLimitSensitive: true, noStore: true, }) @@ -678,6 +688,7 @@ export const rainApi = { const { pin } = await rainRequest<{ pin: string | null }>({ method: 'GET', path: `/rain/cards/${cardId}/pin`, + stepUp: true, rateLimitSensitive: true, noStore: true, }) @@ -689,6 +700,7 @@ export const rainApi = { await rainRequest<{ ok: boolean }>({ method: 'PUT', path: `/rain/cards/${cardId}/pin`, + stepUp: true, body: { pin }, noStore: true, }) diff --git a/src/services/step-up.ts b/src/services/step-up.ts new file mode 100644 index 0000000000..1c46a1c854 --- /dev/null +++ b/src/services/step-up.ts @@ -0,0 +1,77 @@ +/** + * Step-up authentication client. + * + * Sensitive routes (card PAN/CVV, PIN, withdrawal, bank-account add) want more + * than a week-old session cookie: they want proof the person is still holding + * the device. This runs a WebAuthn assertion against the user's own passkey and + * exchanges it for a short-lived proof token. + * + * The token is cached for its lifetime so a multi-step flow (approve → prepare) + * costs one Face ID prompt, not one per request. + */ + +import { startAuthentication } from '@simplewebauthn/browser' +import { apiFetch } from '@/utils/api-fetch' +import { getNativeRpId, isCapacitor } from '@/utils/capacitor' + +export const STEP_UP_HEADER = 'x-step-up-token' + +/** Retire the token early so a request never leaves with one about to expire. */ +const EXPIRY_MARGIN_MS = 30_000 + +let cached: { token: string; expiresAt: number } | null = null + +export class StepUpError extends Error { + constructor(message: string) { + super(message) + this.name = 'StepUpError' + } +} + +function currentRpId(): string { + return isCapacitor() ? getNativeRpId() : window.location.hostname.replace(/^www\./, '') +} + +/** Drops the cached proof. Call on logout, or after a 401 from a gated route. */ +export function clearStepUpToken(): void { + cached = null +} + +export async function getStepUpToken(): Promise { + if (cached && cached.expiresAt - EXPIRY_MARGIN_MS > Date.now()) return cached.token + cached = null + + const rpID = currentRpId() + + const optionsResponse = await apiFetch('/auth/step-up/options', { + method: 'POST', + body: JSON.stringify({ rpID }), + }) + if (!optionsResponse.ok) { + throw new StepUpError( + optionsResponse.status === 404 + ? 'No passkey is registered for this account.' + : 'Could not start verification.' + ) + } + const options = await optionsResponse.json() + + const cred = await startAuthentication(options) + + const verifyResponse = await apiFetch('/auth/step-up/verify', { + method: 'POST', + body: JSON.stringify({ cred, rpID }), + }) + if (!verifyResponse.ok) { + throw new StepUpError('Could not confirm it is you.') + } + + const { token, expiresIn } = (await verifyResponse.json()) as { token: string; expiresIn: number } + cached = { token, expiresAt: Date.now() + expiresIn * 1000 } + return token +} + +/** Adds the proof header, acquiring one if needed. */ +export async function withStepUpHeader(headers: Record): Promise> { + return { ...headers, [STEP_UP_HEADER]: await getStepUpToken() } +} From 331f20687be7efbade8f52ef22a542083f62fad7 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Tue, 21 Jul 2026 17:31:36 +0100 Subject: [PATCH 27/45] fix(review): preserve DSN protocol and path in the CSP report URI 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. --- next.config.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/next.config.js b/next.config.js index 41c7f1f361..6d19b3063c 100644 --- a/next.config.js +++ b/next.config.js @@ -7,18 +7,24 @@ const redirectsConfig = require('./redirects.json') /** * Sentry's CSP-report ingest endpoint, derived from the browser DSN - * (`https://@/`). Returns null when the DSN is - * absent or malformed, in which case the policy still ships — it just has - * nowhere to report, which is better than emitting a broken `report-uri`. + * (`://@/`). Returns null when the + * DSN is absent or malformed, in which case the policy still ships — it just + * has nowhere to report, which is better than emitting a broken `report-uri`. + * + * Protocol and any path prefix are preserved: self-hosted Sentry is commonly + * mounted under a sub-path, and flattening one would silently post reports to + * an endpoint that doesn't exist. */ function sentryCspReportUri() { const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN if (!dsn) return null try { - const { host, username, pathname } = new URL(dsn) - const projectId = pathname.replace(/^\//, '') + const { protocol, host, username, pathname } = new URL(dsn) + const segments = pathname.split('/').filter(Boolean) + const projectId = segments.pop() if (!host || !username || !projectId) return null - return `https://${host}/api/${projectId}/security/?sentry_key=${username}` + const prefix = segments.length ? `/${segments.join('/')}` : '' + return `${protocol}//${host}${prefix}/api/${projectId}/security/?sentry_key=${username}` } catch { return null } From e4db902937a01e7d89775000e2553cf5ad4e9c88 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Tue, 21 Jul 2026 17:37:53 +0100 Subject: [PATCH 28/45] fix(review): make the app lock a boundary, not an overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate started unlocked and was mounted beside the page, so protected content could paint before the lock engaged and stayed focusable behind the overlay. It now wraps children and closes on the first client paint on native — before the user query can resolve and render balances. A 'pending' state covers the window where auth hasn't settled yet, resolving to 'open' for a signed-out user or one with no usable credential. While locked, the protected tree is not rendered at all, so nothing sits behind the lock screen for tab or screen reader to reach. Tradeoff: unlocking remounts, losing in-flight component state. The lock only fires on cold start (no state yet) or after five minutes background, where iOS has usually discarded the webview anyway. Also assert the pinned credential's actual bytes — a descriptor for the wrong credential passed the old length-only check. --- src/app/ClientProviders.tsx | 7 +- src/components/Global/AppLock/index.tsx | 132 ++++++++++++++++-------- src/utils/__tests__/app-lock.test.ts | 9 +- 3 files changed, 99 insertions(+), 49 deletions(-) diff --git a/src/app/ClientProviders.tsx b/src/app/ClientProviders.tsx index 2f995a8c5a..6bb0f37aca 100644 --- a/src/app/ClientProviders.tsx +++ b/src/app/ClientProviders.tsx @@ -56,15 +56,14 @@ export function ClientProviders({ children }: { children: React.ReactNode }) { {/* Non-intrusive "badge unlocked" toast on /home (TASK-19791). Global so it surfaces wherever the user lands after earning. */} - {/* Biometric gate for the native app on cold start and - long background. Renders nothing on web. */} - {HarnessBootstrap && ( )} - {children} + {/* Wraps rather than sits beside the page: while the + native app is locked, nothing protected renders. */} + {children} diff --git a/src/components/Global/AppLock/index.tsx b/src/components/Global/AppLock/index.tsx index 74cc66f7a4..0d5b956140 100644 --- a/src/components/Global/AppLock/index.tsx +++ b/src/components/Global/AppLock/index.tsx @@ -1,13 +1,20 @@ 'use client' /** - * Native app lock. Covers the app with a biometric gate on cold start and - * whenever it returns from more than LOCK_AFTER_BACKGROUND_MS in the - * background, so a session that outlives the user's attention doesn't hand the - * account to whoever picks the phone up next. + * Native app lock. Wraps the app on cold start and whenever it returns from + * more than LOCK_AFTER_BACKGROUND_MS in the background, so a session that + * outlives the user's attention doesn't hand the account to whoever picks the + * phone up next. + * + * It is a gate, not an overlay: while locked, the protected tree is not + * rendered at all. Nothing paints behind the lock screen and nothing back + * there is focusable or reachable by assistive tech. The cost is that + * remounting on unlock loses in-flight component state — acceptable, since the + * lock only fires on cold start (no state yet) or after five minutes + * backgrounded (where iOS has often discarded the webview anyway). * * Web is unaffected — there is no OS-backed presence check to lean on there, - * and the browser tab has no equivalent of "resumed from background". + * and a browser tab has no equivalent of "resumed from background". */ import { useCallback, useEffect, useRef, useState } from 'react' @@ -17,39 +24,81 @@ import { isCapacitor } from '@/utils/capacitor' import { getUserPreferences } from '@/utils/general.utils' import { LOCK_AFTER_BACKGROUND_MS, requestLocalUserPresence } from '@/utils/app-lock' -export function AppLockGate() { - const { user, logoutUser } = useAuth() +/** + * `pending` is the state that makes this a boundary rather than a curtain: on + * native we enter it on the very first client paint, before the user query can + * resolve and render balances. Only once auth settles do we learn whether this + * becomes `locked` or, for a signed-out user or one with no usable credential, + * `open`. + */ +type GateState = 'open' | 'pending' | 'locked' + +function LockScreen({ + failed, + unlocking, + onUnlock, + onLogout, +}: { + failed: boolean + unlocking: boolean + onUnlock: () => void + onLogout: () => void +}) { + return ( +
+
+

Peanut is locked

+

+ {failed ? 'Could not confirm it is you. Try again to continue.' : 'Confirm it is you to continue.'} +

+
+
+ + +
+
+ ) +} + +export function AppLockGate({ children }: { children: React.ReactNode }) { + const { user, isFetchingUser, logoutUser } = useAuth() const userId = user?.user.userId - const [locked, setLocked] = useState(false) + const [state, setState] = useState('open') const [unlocking, setUnlocking] = useState(false) const [failed, setFailed] = useState(false) const backgroundedAt = useRef(null) - // Cold-start lock must fire once per launch, not on every login state change. - const coldStartHandled = useRef(false) const credentialId = userId ? getUserPreferences(userId)?.webAuthnKey?.authenticatorId : undefined + // Close the gate on the first client paint, before anything protected can + // render. Runs once — later transitions are driven by resume or unlock. + useEffect(() => { + if (isCapacitor()) setState('pending') + }, []) + + useEffect(() => { + if (state !== 'pending' || isFetchingUser) return + // Nothing to protect, or no credential we could ever prompt against — + // a gate we can't open would strand the user in their own app. + setState(userId && credentialId ? 'locked' : 'open') + }, [state, isFetchingUser, userId, credentialId]) + const attemptUnlock = useCallback(async () => { setUnlocking(true) const outcome = await requestLocalUserPresence(credentialId) setUnlocking(false) - // 'unsupported' means we can never raise this lock — never leave the - // user staring at a gate with no key. if (outcome === 'unlocked' || outcome === 'unsupported') { - setLocked(false) setFailed(false) + setState('open') return } setFailed(true) }, [credentialId]) - useEffect(() => { - if (!isCapacitor() || !userId || coldStartHandled.current) return - coldStartHandled.current = true - if (!credentialId) return - setLocked(true) - }, [userId, credentialId]) - useEffect(() => { if (!isCapacitor() || !userId || !credentialId) return @@ -66,8 +115,8 @@ export function AppLockGate() { const since = backgroundedAt.current backgroundedAt.current = null if (since !== null && Date.now() - since > LOCK_AFTER_BACKGROUND_MS) { - setLocked(true) setFailed(false) + setState('locked') } }) ) @@ -80,7 +129,7 @@ export function AppLockGate() { }) .catch(() => { // No @capacitor/app bridge (web bundle, or an old native shell): - // resume-locking is simply unavailable. Cold-start lock still works. + // resume-locking is unavailable. Cold-start locking still works. }) return () => { @@ -89,33 +138,28 @@ export function AppLockGate() { } }, [userId, credentialId]) - // Auto-prompt as soon as the gate goes up, so the common case is one Face ID + // Prompt as soon as the gate closes, so the common case is one Face ID // prompt and no taps at all. useEffect(() => { - if (locked && !unlocking && !failed) void attemptUnlock() - // attemptUnlock is stable for a given credential; re-running on every - // render would re-prompt in a loop. + if (state === 'locked' && !unlocking && !failed) void attemptUnlock() + // Deliberately keyed on `state` alone: including attemptUnlock or the + // transient flags would re-fire the prompt in a loop. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [locked]) + }, [state]) + + if (state === 'open') return <>{children} - if (!locked) return null + // 'pending': auth hasn't settled, so we don't yet know whether to prompt. + // Show the bare cover rather than the "locked" copy, which would be a lie + // for a signed-out user about to be let straight through. + if (state === 'pending') return
return ( -
-
-

Peanut is locked

-

- {failed ? 'Could not confirm it is you. Try again to continue.' : 'Confirm it is you to continue.'} -

-
-
- - -
-
+ void attemptUnlock()} + onLogout={() => void logoutUser()} + /> ) } diff --git a/src/utils/__tests__/app-lock.test.ts b/src/utils/__tests__/app-lock.test.ts index 28c863dc37..e5d8558fb2 100644 --- a/src/utils/__tests__/app-lock.test.ts +++ b/src/utils/__tests__/app-lock.test.ts @@ -6,6 +6,7 @@ import { webcrypto } from 'node:crypto' import { requestLocalUserPresence } from '../app-lock' +import { base64URLToBytes } from '../native-webauthn' if (!globalThis.crypto?.getRandomValues) { Object.defineProperty(globalThis, 'crypto', { value: webcrypto, configurable: true }) @@ -60,6 +61,12 @@ describe('requestLocalUserPresence', () => { await requestLocalUserPresence(CREDENTIAL_ID) expect(received?.userVerification).toBe('required') expect(received?.allowCredentials).toHaveLength(1) - expect(new Uint8Array(received!.challenge as ArrayBufferView['buffer'])).not.toHaveLength(0) + // Assert the actual bytes, not just the count: a descriptor for the + // WRONG credential would satisfy a length check while letting some + // other passkey on the device open the lock. + expect(Array.from(new Uint8Array(received!.allowCredentials![0].id as ArrayBuffer))).toEqual( + Array.from(base64URLToBytes(CREDENTIAL_ID)) + ) + expect(new Uint8Array(received!.challenge as ArrayBuffer)).not.toHaveLength(0) }) }) From b52b2bc6bb1b3f6f0cf61bc1a6a35a06db85030b Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 22 Jul 2026 10:42:13 +0100 Subject: [PATCH 29/45] fix(review): route rainRequest auth through apiFetch 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. --- src/services/rain.ts | 38 +++++++++++++++++--------------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/src/services/rain.ts b/src/services/rain.ts index 47d8c9a32f..2ebd1be8b1 100644 --- a/src/services/rain.ts +++ b/src/services/rain.ts @@ -7,12 +7,13 @@ * (via `js-cookie`) — matches the pattern in `services/manteca.ts`. */ -import Cookies from 'js-cookie' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import { PEANUT_API_KEY, PEANUT_API_URL } from '@/constants/general.consts' +import { PEANUT_API_KEY } from '@/constants/general.consts' import { getStepUpToken, STEP_UP_HEADER } from './step-up' -import { fetchWithSentry } from '@/utils/sentry.utils' +import { apiFetch } from '@/utils/api-fetch' +import { getAuthToken } from '@/utils/auth-token' +import { isCapacitor } from '@/utils/capacitor' import type { SignedRainWithdrawal } from '@/hooks/wallet/useSignSpendBundle' // ─── Types ────────────────────────────────────────────────────────────────── @@ -321,7 +322,7 @@ interface RequestOpts { rateLimitSensitive?: boolean /** Mirror PCI no-cache intent on the client fetch for secrets endpoints. */ noStore?: boolean - /** Override fetchWithSentry's default 10s timeout (e.g. UserOp submissions). */ + /** Override the default 10s fetch timeout (e.g. UserOp submissions). */ timeoutMs?: number /** * Prove a fresh WebAuthn assertion alongside the session. Prompts for Face @@ -331,27 +332,22 @@ interface RequestOpts { } async function rainRequest(opts: RequestOpts): Promise { - const jwt = Cookies.get('jwt-token') - if (!jwt) throw new Error('Authentication required') + // Auth rides apiFetch: Authorization header on web, the native cookie jar + // on Capacitor — reading the cookie here would wrongly 401 native, where + // JS never holds the token. + if (!isCapacitor() && !getAuthToken()) throw new Error('Authentication required') - const headers: Record = { - Authorization: `Bearer ${jwt}`, - 'api-key': PEANUT_API_KEY, - } - if (opts.body !== undefined) headers['Content-Type'] = 'application/json' + const headers: Record = { 'api-key': PEANUT_API_KEY } if (opts.noStore) headers['Cache-Control'] = 'no-store' if (opts.stepUp) headers[STEP_UP_HEADER] = await getStepUpToken() - const response = await fetchWithSentry( - `${PEANUT_API_URL}${opts.path}`, - { - method: opts.method, - headers, - body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, - cache: 'no-store', - }, - opts.timeoutMs - ) + const response = await apiFetch(opts.path, { + method: opts.method, + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + cache: 'no-store', + timeoutMs: opts.timeoutMs, + }) if (response.status === 429 && opts.rateLimitSensitive) { const err = await response.json().catch(() => ({})) From e3b07788e5a678ee421f002d675d4391027856a5 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 22 Jul 2026 10:42:40 +0100 Subject: [PATCH 30/45] feat(review): deliver CSP reports via report-to as well as report-uri 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. --- next.config.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/next.config.js b/next.config.js index 6d19b3063c..b48d2cd5a7 100644 --- a/next.config.js +++ b/next.config.js @@ -79,10 +79,23 @@ function contentSecurityPolicyReportOnly() { "base-uri 'self'", "form-action 'self'", ] - if (reportUri) directives.push(`report-uri ${reportUri}`) + // Both delivery mechanisms on purpose: `report-uri` is deprecated but what + // Firefox/Safari actually send today, `report-to` (backed by the + // Reporting-Endpoints header below) is what replaces it in Chromium. + // Shipping only one would undercount violations and promote the policy on + // a partial picture. + if (reportUri) directives.push(`report-uri ${reportUri}`, `report-to ${CSP_REPORT_GROUP}`) return directives.join('; ') } +const CSP_REPORT_GROUP = 'csp-endpoint' + +function reportingEndpointsHeader() { + const reportUri = sentryCspReportUri() + if (!reportUri) return [] + return [{ key: 'Reporting-Endpoints', value: `${CSP_REPORT_GROUP}="${reportUri}"` }] +} + // Get git commit hash at build time let gitCommitHash = 'unknown' try { @@ -271,6 +284,7 @@ let nextConfig = { value: "frame-ancestors 'self' https://hugo0.com; object-src 'none'; base-uri 'self'", }, { key: 'Content-Security-Policy-Report-Only', value: contentSecurityPolicyReportOnly() }, + ...reportingEndpointsHeader(), { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, ], From 2131b43956965b72ea7d19bf6a29f7fa99eafc6d Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 22 Jul 2026 10:43:56 +0100 Subject: [PATCH 31/45] docs(review): state the app lock's full fail-open surface honestly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/utils/app-lock.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/utils/app-lock.ts b/src/utils/app-lock.ts index 4b25d2a6c4..050f1750e2 100644 --- a/src/utils/app-lock.ts +++ b/src/utils/app-lock.ts @@ -7,6 +7,13 @@ // user's biometric or device passcode, which is exactly the property we want. // Server-side proof of a fresh assertion is a separate concern (step-up auth on // sensitive endpoints) and does not belong in a UI lock. +// +// It is a privacy screen and a deterrent, NOT account protection: the session +// token stays where it always was (webview storage / cookie jar), reachable by +// anything that can read the app's filesystem, and clearing the stored +// credential id disables the gate entirely. Making it a genuine control means +// moving the token into biometric-guarded Keychain/Keystore — tracked +// separately. import { base64URLToBytes } from './native-webauthn' @@ -19,9 +26,13 @@ export type UnlockOutcome = 'unlocked' | 'dismissed' | 'unsupported' * Prompts for the device biometric/passcode via a WebAuthn assertion against * the user's existing passkey. * - * Returns 'unsupported' when we cannot prompt at all — no WebAuthn, or no - * stored credential id. Callers must treat that as "do not lock": a lock we - * can't lift would strand the user in their own app with no way back. + * Returns 'unsupported' when we cannot prompt at all — no WebAuthn, no stored + * credential id, or the authenticator rejecting the request outright + * (NotSupportedError). Callers must treat that as "do not lock": a lock we + * can't lift would strand the user in their own app with no way back. That + * makes every 'unsupported' path fail OPEN — this gate is a presence check for + * an honest user, not a security boundary against someone who can strip the + * stored credential id (see the module comment). */ export async function requestLocalUserPresence(credentialId?: string): Promise { if (typeof window === 'undefined') return 'unsupported' @@ -46,7 +57,9 @@ export async function requestLocalUserPresence(credentialId?: string): Promise Date: Wed, 22 Jul 2026 13:32:23 +0100 Subject: [PATCH 32/45] fix(native): route push notification taps inside the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ and /?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. --- android/app/src/main/AndroidManifest.xml | 17 ++++++ ios/App/App/Info.plist | 6 ++ src/app/(mobile-ui)/notifications/page.tsx | 7 +++ src/app/(mobile-ui)/receipt/page.tsx | 65 ++++++++++++++++++++++ src/hooks/useNativePlugins.ts | 18 ++++++ src/utils/__tests__/native-routes.test.ts | 59 ++++++++++++++++++++ src/utils/native-routes.ts | 43 +++++++++++--- 7 files changed, 207 insertions(+), 8 deletions(-) create mode 100644 src/app/(mobile-ui)/receipt/page.tsx diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b9932357b4..aa03f6dede 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -59,6 +59,14 @@ + + + + + + + + @@ -78,6 +86,15 @@ + + + + OneSignal_suppress_launch_urls + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/src/app/(mobile-ui)/notifications/page.tsx b/src/app/(mobile-ui)/notifications/page.tsx index a471b20eb1..51cfdacb63 100644 --- a/src/app/(mobile-ui)/notifications/page.tsx +++ b/src/app/(mobile-ui)/notifications/page.tsx @@ -7,6 +7,7 @@ import NavHeader from '@/components/Global/NavHeader' import PeanutLoading from '@/components/Global/PeanutLoading' import { notificationsApi, type InAppItem } from '@/services/notifications' import { formatGroupHeaderDate, getDateGroup, getDateGroupKey } from '@/utils/dateGrouping.utils' +import { deepLinkToNativePath } from '@/utils/native-routes' import React, { useEffect, useMemo, useRef, useState } from 'react' import Image from 'next/image' import { PEANUTMAN } from '@/assets' @@ -157,7 +158,13 @@ export default function NotificationsPage() { if (group.items.length === 1) position = 'single' else if (idx === 0) position = 'first' else if (idx === group.items.length - 1) position = 'last' + // Rows minted from the OneSignal-dashboard webhook store the + // operator's `url` verbatim, so an absolute peanut.me link would + // navigate the webview off-app. Map those back to an in-app path; + // genuinely external links fall through untouched. const href = notif.ctaDeeplink + ? (deepLinkToNativePath(notif.ctaDeeplink) ?? notif.ctaDeeplink) + : notif.ctaDeeplink return ( ?kind=X onto this route's query params. +export default function NativeReceiptPage() { + const router = useRouter() + const searchParams = useSearchParams() + const entryId = searchParams.get('id') + const kind = resolveReceiptKind(searchParams.get('kind') ?? undefined, searchParams.get('t') ?? undefined) + + const { + data: entry, + isLoading, + isError, + } = useQuery({ + queryKey: [TRANSACTIONS, 'entry', entryId, kind], + enabled: Boolean(entryId && kind), + queryFn: async (): Promise => { + const response = await apiFetch(`/history/${encodeURIComponent(entryId!)}?kind=${kind}`) + if (!response.ok) throw new Error(`Failed to fetch history entry: ${response.status}`) + return completeHistoryEntry(await response.json()) + }, + // A pending transaction can still settle while the receipt is open. + refetchInterval: (query) => (query.state.data && !isFinalState(query.state.data) ? 15_000 : false), + }) + + useEffect(() => { + if (!entryId || !kind || isError) router.replace('/home') + }, [entryId, kind, isError, router]) + + if (!entryId || !kind || isError) return null + + return ( + +
+ +
+
+ {isLoading || !entry ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/src/hooks/useNativePlugins.ts b/src/hooks/useNativePlugins.ts index 557c94f92d..075baece92 100644 --- a/src/hooks/useNativePlugins.ts +++ b/src/hooks/useNativePlugins.ts @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation' import { isCapacitor, getPlatform } from '@/utils/capacitor' import { deepLinkToNativePath } from '@/utils/native-routes' import { sanitizeRedirectURL } from '@/utils/general.utils' +import { getOneSignalAdapter } from '@/services/onesignal' /** * initializes capacitor native plugins (back button, status bar, splash screen). @@ -50,6 +51,23 @@ export function useNativePlugins() { console.warn('failed to init app listeners:', e) } + try { + // Push taps: the OneSignal SDKs are configured not to open the + // launch URL themselves (suppressLaunchURLs / OneSignal_suppress_launch_urls), + // so routing is ours. `additionalData.deepLink` is the canonical + // relative path the API sends; the launch URL is the fallback for + // notifications sent before that field existed. + const adapter = await getOneSignalAdapter() + cleanups.push( + adapter.onNotificationClick(({ deepLink, additionalData }) => { + const target = additionalData.deepLink + openDeepLink(typeof target === 'string' ? target : deepLink) + }) + ) + } catch (e) { + console.warn('failed to init notification click listener:', e) + } + try { const { StatusBar, Style } = await import('@capacitor/status-bar') await StatusBar.setOverlaysWebView({ overlay: false }) diff --git a/src/utils/__tests__/native-routes.test.ts b/src/utils/__tests__/native-routes.test.ts index 2f995266fc..35c78d7baa 100644 --- a/src/utils/__tests__/native-routes.test.ts +++ b/src/utils/__tests__/native-routes.test.ts @@ -20,6 +20,7 @@ import { withdrawCountryUrl, withdrawBankUrl, rewriteMethodPath, + deepLinkToNativePath, } from '../native-routes' describe('native-routes', () => { @@ -290,4 +291,62 @@ describe('native-routes', () => { }) }) }) + + describe('deepLinkToNativePath', () => { + describe('capacitor mode', () => { + beforeEach(() => { + mockIsCapacitor.mockReturnValue(true) + }) + + it('accepts a bare path — push payloads carry the deep link without a host', () => { + expect(deepLinkToNativePath('/receipt/intent-1?kind=ONRAMP')).toBe('/receipt?id=intent-1&kind=ONRAMP') + }) + + it('accepts a full App-Links url', () => { + expect(deepLinkToNativePath('https://peanut.me/receipt/intent-1?kind=ONRAMP')).toBe( + '/receipt?id=intent-1&kind=ONRAMP' + ) + }) + + it('maps a charge deep link onto the pay-request stand-in for the disabled catch-all route', () => { + expect(deepLinkToNativePath('/alice?chargeId=charge-123')).toBe('/pay-request?chargeId=charge-123') + }) + + it('leaves a static in-app route untouched', () => { + expect(deepLinkToNativePath('https://peanut.me/history')).toBe('/history') + }) + + it('still rewrites dynamic routes to their query-param form', () => { + expect(deepLinkToNativePath('https://peanut.me/send/bob')).toBe('/send?recipient=bob') + expect(deepLinkToNativePath('https://peanut.me/qr/abc123')).toBe('/qr?code=abc123') + expect(deepLinkToNativePath('https://peanut.me/withdraw/be/bank')).toBe( + '/withdraw?country=be&view=bank' + ) + }) + + it('rejects an off-domain url rather than rewriting it into an in-app path', () => { + expect(deepLinkToNativePath('https://evil.com/receipt/intent-1')).toBeNull() + }) + + it('returns null for an unparseable link', () => { + expect(deepLinkToNativePath('http://')).toBeNull() + }) + }) + + describe('web mode', () => { + beforeEach(() => { + mockIsCapacitor.mockReturnValue(false) + }) + + it('keeps the path-based receipt url', () => { + expect(deepLinkToNativePath('https://peanut.me/receipt/intent-1?kind=ONRAMP')).toBe( + '/receipt/intent-1?kind=ONRAMP' + ) + }) + + it('keeps a profile charge link on the profile route', () => { + expect(deepLinkToNativePath('/alice?chargeId=charge-123')).toBe('/alice?chargeId=charge-123') + }) + }) + }) }) diff --git a/src/utils/native-routes.ts b/src/utils/native-routes.ts index 95e118a822..00cb79f554 100644 --- a/src/utils/native-routes.ts +++ b/src/utils/native-routes.ts @@ -4,6 +4,12 @@ import { isCapacitor } from './capacitor' +// Deep links are peanut.me links by definition — that's the host the Android +// App Links filter and the AASA are bound to. Deliberately not derived from +// NEXT_PUBLIC_BASE_URL: a build pointed at a preview origin must still route a +// real peanut.me notification link. +const APP_HOSTS = /^(.+\.)?peanut\.me$/ + export function profileUrl(username: string): string { return isCapacitor() ? `/send?recipient=${encodeURIComponent(username)}` : `/${username}` } @@ -64,21 +70,29 @@ export function withdrawBankUrl(countryPath: string, queryParams?: string): stri } /** - * Maps an incoming App-Links URL (https://peanut.me/) to the path the - * native static export can actually render. Dynamic web routes are funnelled - * through the same query-param helpers used to build outbound links, so - * `/send/` → `/send?recipient=`, `/qr/` → `/qr?code=`, - * `/add-money//bank` → `/add-money?country=&view=bank`, etc. - * Static routes (e.g. `/card`, `/pay-request`) pass through unchanged. - * Returns null for an unparseable URL. + * Maps an incoming deep link — an App-Links URL (https://peanut.me/) or a + * bare path from a push payload — to the path the native static export can + * actually render. Dynamic web routes are funnelled through the same query-param + * helpers used to build outbound links, so `/send/` → `/send?recipient=`, + * `/qr/` → `/qr?code=`, `/add-money//bank` → + * `/add-money?country=&view=bank`, etc. Static routes (e.g. `/card`, + * `/pay-request`) pass through unchanged. + * + * Returns null for an unparseable link or one pointing at another host — an + * off-domain URL must stay off-domain rather than be rewritten into a bogus + * in-app path. */ export function deepLinkToNativePath(url: string): string | null { let parsed: URL try { - parsed = new URL(url) + // Relative base: push payloads carry the canonical path (`/receipt/`), + // App Links carry the full URL. Both must resolve. + parsed = new URL(url, 'https://peanut.me') } catch { return null } + if (!APP_HOSTS.test(parsed.hostname)) return null + const path = parsed.pathname const extraParams = parsed.search.replace(/^\?/, '') const segments = path.split('/').filter(Boolean) @@ -96,6 +110,19 @@ export function deepLinkToNativePath(url: string): string | null { if (segments[0] === 'add-money' || segments[0] === 'withdraw') { return rewriteMethodPath(path, extraParams || undefined) } + // `/receipt/?kind=X` — the web receipt page is a server component and is + // stripped from the static export (scripts/native-build.js), so native routes + // to the client variant. `kind` rides along in extraParams. + if (segments[0] === 'receipt' && segments[1]) { + const id = decodeURIComponent(segments[1]) + return appendParams(isCapacitor() ? `/receipt?id=${encodeURIComponent(id)}` : path, extraParams) + } + // `/?chargeId=` — the catch-all profile route is disabled in + // native builds; /pay-request is its stand-in (see (mobile-ui)/pay-request). + if (isCapacitor() && segments.length === 1) { + const chargeId = parsed.searchParams.get('chargeId') + if (chargeId) return chargePayUrl(chargeId) + } return appendParams(path, extraParams) } From cf4ba72027d9e27a70d4b01d8419ead60064be63 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 22 Jul 2026 14:33:15 +0100 Subject: [PATCH 33/45] fix(native): harden deep-link mapping against malformed and reserved paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/(mobile-ui)/receipt/page.tsx | 11 +++++++--- src/utils/__tests__/native-routes.test.ts | 25 +++++++++++++++++++++++ src/utils/native-routes.ts | 19 ++++++++++++----- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/app/(mobile-ui)/receipt/page.tsx b/src/app/(mobile-ui)/receipt/page.tsx index 6227a53e86..4c64cccea0 100644 --- a/src/app/(mobile-ui)/receipt/page.tsx +++ b/src/app/(mobile-ui)/receipt/page.tsx @@ -39,11 +39,16 @@ export default function NativeReceiptPage() { refetchInterval: (query) => (query.state.data && !isFinalState(query.state.data) ? 15_000 : false), }) + // `isError` also trips on a failed background poll, so gate the bail-out on + // having no data at all — a flaky refetch must not yank the user off a + // receipt they're watching settle. + const isUnrecoverable = !entryId || !kind || (isError && !entry) + useEffect(() => { - if (!entryId || !kind || isError) router.replace('/home') - }, [entryId, kind, isError, router]) + if (isUnrecoverable) router.replace('/home') + }, [isUnrecoverable, router]) - if (!entryId || !kind || isError) return null + if (isUnrecoverable) return null return ( diff --git a/src/utils/__tests__/native-routes.test.ts b/src/utils/__tests__/native-routes.test.ts index 35c78d7baa..fca3e571e3 100644 --- a/src/utils/__tests__/native-routes.test.ts +++ b/src/utils/__tests__/native-routes.test.ts @@ -331,6 +331,31 @@ describe('native-routes', () => { it('returns null for an unparseable link', () => { expect(deepLinkToNativePath('http://')).toBeNull() }) + + // This runs during render in the notifications list, so a throw here + // would blank the whole page rather than drop one bad row. + it.each([ + ['receipt id', '/receipt/%E0%A4%A'], + ['send recipient', '/send/%E0%A4%A'], + ['qr code', '/qr/%'], + ['bare username', '/%'], + ])('never throws on malformed percent-encoding in the %s', (_label, link) => { + expect(() => deepLinkToNativePath(link)).not.toThrow() + }) + + it.each(['/receipt/%E0%A4%A', '/send/%E0%A4%A', '/qr/%'])( + 'degrades %s to null when the decode fails mid-mapping', + (link) => { + expect(deepLinkToNativePath(link)).toBeNull() + } + ) + + it.each(['/rewards', '/history', '/badges', '/profile'])( + 'leaves the reserved route %s alone even with a chargeId param', + (route) => { + expect(deepLinkToNativePath(`${route}?chargeId=charge-123`)).toBe(`${route}?chargeId=charge-123`) + } + ) }) describe('web mode', () => { diff --git a/src/utils/native-routes.ts b/src/utils/native-routes.ts index 00cb79f554..8a40b79c9e 100644 --- a/src/utils/native-routes.ts +++ b/src/utils/native-routes.ts @@ -2,6 +2,7 @@ // in capacitor (static export), dynamic routes don't work — use query params instead. // on web, use the normal path-based urls. +import { couldBeRecipient, isReservedRoute } from '@/constants/routes' import { isCapacitor } from './capacitor' // Deep links are peanut.me links by definition — that's the host the Android @@ -83,14 +84,20 @@ export function withdrawBankUrl(countryPath: string, queryParams?: string): stri * in-app path. */ export function deepLinkToNativePath(url: string): string | null { - let parsed: URL try { - // Relative base: push payloads carry the canonical path (`/receipt/`), - // App Links carry the full URL. Both must resolve. - parsed = new URL(url, 'https://peanut.me') + return mapDeepLink(url) } catch { + // The whole body is guarded, not just the URL parse: decodeURIComponent + // throws URIError on a stray `%`, and this runs during render in the + // notifications list — one malformed ctaDeeplink would blank the page. return null } +} + +function mapDeepLink(url: string): string | null { + // Relative base: push payloads carry the canonical path (`/receipt/`), + // App Links carry the full URL. Both must resolve. + const parsed = new URL(url, 'https://peanut.me') if (!APP_HOSTS.test(parsed.hostname)) return null const path = parsed.pathname @@ -119,7 +126,9 @@ export function deepLinkToNativePath(url: string): string | null { } // `/?chargeId=` — the catch-all profile route is disabled in // native builds; /pay-request is its stand-in (see (mobile-ui)/pay-request). - if (isCapacitor() && segments.length === 1) { + // Gated by the same reserved-route/recipient rules the web catch-all uses, so + // `/rewards?chargeId=x` stays on /rewards instead of landing on /pay-request. + if (isCapacitor() && segments.length === 1 && !isReservedRoute(path) && couldBeRecipient(segments[0])) { const chargeId = parsed.searchParams.get('chargeId') if (chargeId) return chargePayUrl(chargeId) } From 86e0766fbe1650a87be24f94a81f9584200646c5 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 22 Jul 2026 15:15:41 +0100 Subject: [PATCH 34/45] fix(native): buffer cold-start push clicks, open external links in browser 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. --- src/hooks/useNativePlugins.ts | 12 ++- src/services/onesignal/native.adapter.test.ts | 80 +++++++++++++++++++ src/services/onesignal/native.adapter.ts | 18 +++++ 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 src/services/onesignal/native.adapter.test.ts diff --git a/src/hooks/useNativePlugins.ts b/src/hooks/useNativePlugins.ts index 075baece92..d794a37545 100644 --- a/src/hooks/useNativePlugins.ts +++ b/src/hooks/useNativePlugins.ts @@ -61,7 +61,17 @@ export function useNativePlugins() { cleanups.push( adapter.onNotificationClick(({ deepLink, additionalData }) => { const target = additionalData.deepLink - openDeepLink(typeof target === 'string' ? target : deepLink) + const link = typeof target === 'string' ? target : deepLink + // Off-domain https links (operator sends) can't route in-app; + // with launch URLs suppressed, hand them to the system browser + // rather than silently swallowing the tap. + if (link && !deepLinkToNativePath(link) && /^https:\/\//i.test(link)) { + import('@capacitor/browser') + .then(({ Browser }) => Browser.open({ url: link })) + .catch((e) => console.warn('failed to open external push link:', e)) + return + } + openDeepLink(link) }) ) } catch (e) { diff --git a/src/services/onesignal/native.adapter.test.ts b/src/services/onesignal/native.adapter.test.ts new file mode 100644 index 0000000000..9dba3ac4ee --- /dev/null +++ b/src/services/onesignal/native.adapter.test.ts @@ -0,0 +1,80 @@ +import type { NotificationClickInfo } from './types' + +// virtual: the plugin ships ESM-only exports jest's resolver can't load; +// the adapter only ever runs in native builds where webpack resolves it. +jest.mock( + '@onesignal/capacitor-plugin', + () => ({ + __esModule: true, + default: { + initialize: jest.fn().mockResolvedValue(undefined), + login: jest.fn(), + logout: jest.fn(), + Notifications: { + addEventListener: jest.fn(), + hasPermission: jest.fn().mockResolvedValue(true), + canRequestPermission: jest.fn().mockResolvedValue(true), + requestPermission: jest.fn(), + }, + User: { + pushSubscription: { + addEventListener: jest.fn(), + getOptedInAsync: jest.fn().mockResolvedValue(true), + }, + }, + }, + }), + { virtual: true } +) + +import OneSignal from '@onesignal/capacitor-plugin' +import { nativeOneSignalAdapter } from './native.adapter' + +function getClickCallback(): (event: unknown) => void { + const call = (OneSignal.Notifications.addEventListener as jest.Mock).mock.calls.find(([name]) => name === 'click') + expect(call).toBeDefined() + return call![1] +} + +describe('nativeOneSignalAdapter cold-start click buffering', () => { + beforeAll(async () => { + process.env.NEXT_PUBLIC_ONESIGNAL_APP_ID = 'test-app-id' + await nativeOneSignalAdapter.init() + }) + + // The Capacitor bridge retains a cold-start click only until the first JS + // listener attaches — which init() does. If the event arrives before + // useNativePlugins has registered its routing callback, the adapter must + // hold it and replay it, or the tap is silently dropped. + it('replays a click that fired before any listener registered', () => { + const fireClick = getClickCallback() + fireClick({ + notification: { + launchURL: 'https://peanut.me/receipt/intent-1?kind=ONRAMP', + additionalData: { deepLink: '/receipt/intent-1?kind=ONRAMP' }, + }, + }) + + const seen: NotificationClickInfo[] = [] + const off = nativeOneSignalAdapter.onNotificationClick((info) => seen.push(info)) + expect(seen).toEqual([ + { + deepLink: 'https://peanut.me/receipt/intent-1?kind=ONRAMP', + additionalData: { deepLink: '/receipt/intent-1?kind=ONRAMP' }, + }, + ]) + + // one-shot: a second listener must not receive the same tap again + const seenLater: NotificationClickInfo[] = [] + const offLater = nativeOneSignalAdapter.onNotificationClick((info) => seenLater.push(info)) + expect(seenLater).toEqual([]) + + // once listeners exist, clicks are delivered live, not buffered + fireClick({ result: { url: 'https://peanut.me/rewards' }, notification: { additionalData: {} } }) + expect(seen).toHaveLength(2) + expect(seenLater).toHaveLength(1) + + off() + offLater() + }) +}) diff --git a/src/services/onesignal/native.adapter.ts b/src/services/onesignal/native.adapter.ts index de867004aa..fa6eb93140 100644 --- a/src/services/onesignal/native.adapter.ts +++ b/src/services/onesignal/native.adapter.ts @@ -14,6 +14,15 @@ let initPromise: Promise | null = null const permissionListeners = new Set<(state: NotificationPermissionState) => void>() const subscriptionListeners = new Set<(optedIn: boolean) => void>() const clickListeners = new Set<(info: NotificationClickInfo) => void>() +/** + * Cold-start tap buffer. Capacitor retains the click event only until the first + * JS listener attaches — which happens inside init() (attachUnderlyingListeners), + * driven by useNotifications. useNativePlugins registers its routing callback on + * a separate async path, so if init() wins that race the retained event would be + * consumed with an empty listener set and the tap silently dropped. Hold the + * last unconsumed click here and replay it to the next listener that registers. + */ +let pendingClick: NotificationClickInfo | null = null let underlyingListenersAttached = false function attachUnderlyingListeners() { @@ -34,6 +43,10 @@ function attachUnderlyingListeners() { deepLink: event?.result?.url ?? event?.notification?.launchURL, additionalData: (event?.notification?.additionalData ?? {}) as Record, } + if (clickListeners.size === 0) { + pendingClick = info + return + } clickListeners.forEach((cb) => cb(info)) }) } @@ -90,6 +103,11 @@ export const nativeOneSignalAdapter: OneSignalAdapter = { onNotificationClick(listener) { clickListeners.add(listener) + if (pendingClick) { + const buffered = pendingClick + pendingClick = null + listener(buffered) + } return () => clickListeners.delete(listener) }, } From c9a0d54dafb6d685783eb7cc17985e5878edbfe6 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 22 Jul 2026 15:54:09 +0100 Subject: [PATCH 35/45] fix(notifications): surface native OneSignal failures in Sentry 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 --- src/hooks/useNativePlugins.ts | 12 ++++++++++++ src/hooks/useNotifications.ts | 11 +++++++++-- src/services/onesignal/native.adapter.ts | 6 ++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/hooks/useNativePlugins.ts b/src/hooks/useNativePlugins.ts index 557c94f92d..ecff0be3a0 100644 --- a/src/hooks/useNativePlugins.ts +++ b/src/hooks/useNativePlugins.ts @@ -2,6 +2,7 @@ import { useEffect } from 'react' import { useRouter } from 'next/navigation' +import { captureMessage } from '@sentry/nextjs' import { isCapacitor, getPlatform } from '@/utils/capacitor' import { deepLinkToNativePath } from '@/utils/native-routes' import { sanitizeRedirectURL } from '@/utils/general.utils' @@ -12,6 +13,8 @@ import { sanitizeRedirectURL } from '@/utils/general.utils' * plugins are loaded via dynamic import with webpackIgnore since they only * exist in native builds (not on vercel/web ci). */ +let appListenersFailureCaptured = false + export function useNativePlugins() { const router = useRouter() @@ -48,6 +51,15 @@ export function useNativePlugins() { cleanups.push(() => urlListener.remove()) } catch (e) { console.warn('failed to init app listeners:', e) + // without these listeners push-tap deep links never route, so surface the failure + if (!appListenersFailureCaptured) { + appListenersFailureCaptured = true + captureMessage('failed to init native app listeners', { + level: 'warning', + tags: { feature: 'onesignal', source: 'native_app_listeners' }, + extra: { error: e instanceof Error ? e.message : String(e) }, + }) + } } try { diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index d9c75b3b00..16594670d2 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -1,7 +1,7 @@ 'use client' import { useEffect, useSyncExternalStore } from 'react' -import { captureException } from '@sentry/nextjs' +import { captureException, captureMessage } from '@sentry/nextjs' import { getOneSignalAdapter, type NotificationPermissionState } from '@/services/onesignal' import { getUserPreferences, updateUserPreferences } from '@/utils/general.utils' import { isDemoMode } from '@/utils/demo' @@ -62,8 +62,15 @@ function handleLoginError(err: unknown) { const msg = err instanceof Error ? err.message : String(err ?? '') // disable login on identity verification errors if (msg.toLowerCase().includes('identity') || msg.toLowerCase().includes('verify')) { + if (disableExternalIdLogin) return disableExternalIdLogin = true console.warn('OneSignal external_id login disabled due to identity verification error') + // silently disabling login unlinks the device from the user, so pushes target no one + captureMessage('OneSignal external_id login disabled after identity verification error', { + level: 'warning', + tags: { feature: 'onesignal', onesignal: 'login-disabled' }, + extra: { error: msg }, + }) } } @@ -211,7 +218,7 @@ async function ensureInitialized() { } catch (e) { // Surface Brave/Shields SDK-block failures; previously silent. console.warn('OneSignal init failed', e) - captureException(e, { tags: { source: 'onesignal_init' } }) + captureException(e, { level: 'warning', tags: { feature: 'onesignal', source: 'onesignal_init' } }) } } diff --git a/src/services/onesignal/native.adapter.ts b/src/services/onesignal/native.adapter.ts index de867004aa..d1526cb015 100644 --- a/src/services/onesignal/native.adapter.ts +++ b/src/services/onesignal/native.adapter.ts @@ -1,4 +1,5 @@ import OneSignal from '@onesignal/capacitor-plugin' +import { captureMessage } from '@sentry/nextjs' import type { NotificationClickEvent, PushSubscriptionChangedState } from '@onesignal/capacitor-plugin' import type { NotificationClickInfo, NotificationPermissionState, OneSignalAdapter } from './types' @@ -44,6 +45,11 @@ export const nativeOneSignalAdapter: OneSignalAdapter = { initPromise = (async () => { const appId = process.env.NEXT_PUBLIC_ONESIGNAL_APP_ID if (!appId) { + // captured here too so a swallowed init() rejection can't hide a broken build config + captureMessage('OneSignal init failed: NEXT_PUBLIC_ONESIGNAL_APP_ID is missing', { + level: 'warning', + tags: { feature: 'onesignal', onesignal: 'missing-app-id' }, + }) throw new Error('OneSignal configuration missing: NEXT_PUBLIC_ONESIGNAL_APP_ID is required') } await OneSignal.initialize(appId) From 6c463f5e7c02263f4b0c1c4d898c44133f876df0 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 23 Jul 2026 11:34:15 +0300 Subject: [PATCH 36/45] fix(withdraw): show non-EVM token selection in the selector button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting USDT-Tron / USDT+USDC-Solana in the withdraw token drawer updated context but the collapsed button resolved its label via areEvmAddressesEqual, which is always false for base58 addresses — so the button kept reading 'Select a token' and the selection looked like it never happened. Fall back to a case-insensitive literal match (keeping areEvmAddressesEqual for its native-proxy aliasing on EVM tokens). --- src/components/Global/TokenSelector/TokenSelector.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/Global/TokenSelector/TokenSelector.tsx b/src/components/Global/TokenSelector/TokenSelector.tsx index ba4538df10..a355f2b3d9 100644 --- a/src/components/Global/TokenSelector/TokenSelector.tsx +++ b/src/components/Global/TokenSelector/TokenSelector.tsx @@ -177,7 +177,15 @@ const TokenSelector: React.FC = ({ classNameButton, viewT if (selectedTokenAddress && selectedChainID) { const chainInfo = supportedChainsAndTokens[selectedChainID] - const tokenDetails = chainInfo?.tokens.find((t) => areEvmAddressesEqual(t.address, selectedTokenAddress)) + // areEvmAddressesEqual handles native-proxy aliasing for EVM tokens but is + // always false for non-EVM (base58 Tron/Solana) selections — without the + // literal fallback the button kept showing "Select a token" after picking + // USDT-Tron / USDT/USDC-Solana in the withdraw flow. + const tokenDetails = chainInfo?.tokens.find( + (t) => + areEvmAddressesEqual(t.address, selectedTokenAddress) || + t.address.toLowerCase() === selectedTokenAddress.toLowerCase() + ) if (tokenDetails && chainInfo) { buttonSymbol = tokenDetails.symbol buttonLogoURI = tokenDetails.logoURI From fcf7bb33b2b38c988664d381c6aecf28678823b6 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 23 Jul 2026 12:17:56 +0300 Subject: [PATCH 37/45] fix(withdraw): move the no-amount redirect out of render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit router.push during render is a React violation ('Cannot update Router while rendering WithdrawCryptoPage') — Next 16's dev overlay hard-errors on any direct entry or refresh of /withdraw/crypto, which blocked QA of the non-EVM withdraw flow. Same behavior, one frame later, via useEffect. --- src/app/(mobile-ui)/withdraw/crypto/page.tsx | 17 ++++++++++++----- src/content | 2 +- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index 61aad6eb70..3b492877be 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -503,11 +503,18 @@ export default function WithdrawCryptoPage() { [isCrossChainWithdrawal, payAmount, minDepositLimitUsd] ) - if (!amountToWithdraw && currentView !== 'STATUS') { - // Redirect to main withdraw page for amount input - // Guard against STATUS view: resetWithdrawFlow() clears amountToWithdraw, - // which would override the router.push('/home') in handleDone - router.push('/withdraw') + // Redirect to main withdraw page for amount input. The push must run in an + // effect — navigating during render is a React violation ("Cannot update + // Router while rendering WithdrawCryptoPage") that hard-errors the Next 16 + // dev overlay on direct entry/refresh of this route. + // Guard against STATUS view: resetWithdrawFlow() clears amountToWithdraw, + // which would override the router.push('/home') in handleDone + const needsAmountRedirect = !amountToWithdraw && currentView !== 'STATUS' + useEffect(() => { + if (needsAmountRedirect) router.push('/withdraw') + }, [needsAmountRedirect, router]) + + if (needsAmountRedirect) { return } diff --git a/src/content b/src/content index ca7f9ca5e8..e3a05f7771 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit ca7f9ca5e8a49336925570bcfb6b00e77db2b6c4 +Subproject commit e3a05f77716ca51da8e35880dca34db747a99969 From 209d01143e114277a02dfd3c6246eae72e2f6dce Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 23 Jul 2026 12:18:31 +0300 Subject: [PATCH 38/45] chore: revert unintended src/content submodule bump (restore main's pointer) --- src/content | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content b/src/content index e3a05f7771..ca7f9ca5e8 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit e3a05f77716ca51da8e35880dca34db747a99969 +Subproject commit ca7f9ca5e8a49336925570bcfb6b00e77db2b6c4 From 6d473c7224e1c3885b4fba3e54807a8dd1825060 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 23 Jul 2026 02:20:22 +0300 Subject: [PATCH 39/45] fix(withdraw): complete the user-facing charge on collateral-routed withdraws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same-chain crypto withdraws funded from Rain card collateral succeeded on-chain but never completed their charge intent — the page skipped recordPayment and never passed chargeId, so the charge rotted PENDING until the reaper failed it. History hides both rows (phantom + abandoned-draft filters), so users watched their balance drop with no Activity entry (98 charges / 55 users in 14 days). Mirror the direct-send contract: pass chargeDetails.uuid into sendMoney (the backend uses the charge as the prep and settles it in /submit's trusted completion) and always recordPayment — mixed spends rely on it, collateral-only re-enters trusted completion idempotently as the recovery net. recordPayment failures after a collateral-routed same-chain spend degrade to the success view instead of a false 'failed': the funds have already moved. Also refresh the three stale hook/service comments that instructed callers to SKIP recordPayment on collateral-only — the guidance that seeded this bug. --- .../crypto-withdraw-confirm.test.tsx | 345 ++++++++++++++++++ src/app/(mobile-ui)/withdraw/crypto/page.tsx | 64 +++- src/hooks/wallet/useSendMoney.ts | 5 +- src/hooks/wallet/useSpendBundle.ts | 15 +- src/services/rain.ts | 5 +- 5 files changed, 405 insertions(+), 29 deletions(-) create mode 100644 src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx diff --git a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx new file mode 100644 index 0000000000..9c5a4beb71 --- /dev/null +++ b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx @@ -0,0 +1,345 @@ +/** + * Crypto Withdraw — Confirm Flow Tests + * + * Regression net for the "successful withdrawal stuck PENDING / missing from + * Activity" bug: same-chain collateral-routed withdraws must pass the charge + * uuid into sendMoney (so the backend settles the charge server-side) and must + * ALWAYS call recordPayment (mixed spends rely on it; collateral-only uses it + * as the idempotent recovery net). A recordPayment failure after funds moved + * must degrade to the success view on collateral-routed paths, while + * smart-only keeps its historical hard-fail behavior. + * + * Strategy (same as withdraw-states.test.tsx): mock every hook/service at the + * module level; assert on the context-setter mocks rather than re-rendering. + */ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' + +// ---------- module-level mocks ---------- + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: jest.fn(), back: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }), + useSearchParams: () => ({ get: () => null }), + usePathname: () => '/withdraw/crypto', +})) + +const mockCaptureMessage = jest.fn() +jest.mock('@sentry/nextjs', () => ({ + captureMessage: (...args: any[]) => mockCaptureMessage(...args), +})) + +const mockPosthogCapture = jest.fn() +jest.mock('posthog-js', () => ({ + __esModule: true, + default: { capture: (...args: any[]) => mockPosthogCapture(...args), init: jest.fn() }, +})) + +jest.mock('use-haptic', () => ({ + useHaptic: () => ({ triggerHaptic: jest.fn() }), +})) + +jest.mock('@/hooks/useSafeBack', () => ({ + useSafeBack: () => jest.fn(), +})) + +jest.mock('@/context', () => { + const ReactActual = jest.requireActual('react') + return { + tokenSelectorContext: ReactActual.createContext({ resetTokenContextProvider: jest.fn() }), + } +}) + +jest.mock('@/constants/zerodev.consts', () => ({ + PEANUT_WALLET_CHAIN: { id: 42161 }, + PEANUT_WALLET_TOKEN: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + PEANUT_WALLET_TOKEN_DECIMALS: 6, +})) + +jest.mock('@/constants/general.consts', () => ({ + ROUTE_NOT_FOUND_ERROR: 'No route found', +})) + +jest.mock('@/constants/analytics.consts', () => ({ + ANALYTICS_EVENTS: { + WITHDRAW_CONFIRMED: 'withdraw_confirmed', + WITHDRAW_COMPLETED: 'withdraw_completed', + WITHDRAW_FAILED: 'withdraw_failed', + }, +})) + +jest.mock('@/utils/token.utils', () => ({ + NATIVE_TOKEN_ADDRESS: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', +})) + +jest.mock('@/utils/cross-chain-fee.utils', () => ({ + isWithdrawFeeDisproportionate: () => false, +})) + +jest.mock('@/utils/balance.utils', () => ({ + isAmountWithinBalance: () => true, +})) + +jest.mock('@/utils/withdraw.utils', () => ({ + isBelowRhinoMinDeposit: () => false, +})) + +jest.mock('@/utils/general.utils', () => ({ + isTxReverted: (receipt: any) => receipt?.status === 'reverted', +})) + +jest.mock('@/utils/url.utils', () => ({ + appBaseUrl: () => 'https://peanut.test', +})) + +jest.mock('@/utils/friendly-error.utils', () => ({ + ErrorHandler: (err: any) => err?.message ?? 'Something went wrong', +})) + +jest.mock('@/interfaces/peanut-sdk-types', () => ({ + EPeanutLinkType: { native: 0, erc20: 1 }, +})) + +jest.mock('@/services/charges', () => ({ + chargesApi: { create: jest.fn(), get: jest.fn() }, +})) + +jest.mock('@/services/requests', () => ({ + requestsApi: { create: jest.fn() }, +})) + +// ---------- view mocks ---------- + +jest.mock('@/components/Withdraw/views/Confirm.withdraw.view', () => ({ + __esModule: true, + default: (props: any) => ( + + ), +})) + +jest.mock('@/components/Withdraw/views/Initial.withdraw.view', () => ({ + __esModule: true, + default: () =>
, +})) + +jest.mock('@/features/payments/shared/components/PaymentSuccessView', () => ({ + __esModule: true, + default: () =>
, +})) + +jest.mock('@/components/Global/ActionModal', () => ({ + __esModule: true, + default: () => null, +})) + +jest.mock('@/components/Global/AddressLink', () => ({ + __esModule: true, + default: (props: any) => {props.address}, +})) + +jest.mock('@/components/Global/PeanutLoading', () => ({ + __esModule: true, + default: () =>
, +})) + +jest.mock('@/components/Slider', () => ({ + Slider: () =>
, +})) + +// ---------- flow hooks ---------- + +const CHARGE_UUID = 'charge-uuid-123' +const RECIPIENT = '0x1111111111111111111111111111111111111111' +const USER_ADDRESS = '0x2222222222222222222222222222222222222222' + +const mockSetCurrentView = jest.fn() +const mockSetPaymentDetails = jest.fn() +const mockSetTransactionHash = jest.fn() +const mockSetPaymentError = jest.fn() +const mockSetWithdrawError = jest.fn() + +const chargeDetails = { + uuid: CHARGE_UUID, + tokenAddress: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + tokenAmount: '50', + tokenDecimals: 6, + tokenType: '1', + chainId: '42161', + requestLink: { recipientAddress: RECIPIENT }, +} + +const withdrawData = { + address: RECIPIENT, + token: { address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', symbol: 'USDC', decimals: 6, price: 1 }, + chain: { chainId: 42161, name: 'Arbitrum' }, + amount: '50', +} + +const mockWithdrawFlow = { + amountToWithdraw: '50', + usdAmount: '50', + setAmountToWithdraw: jest.fn(), + currentView: 'CONFIRM', + setCurrentView: mockSetCurrentView, + withdrawData, + setWithdrawData: jest.fn(), + showCompatibilityModal: false, + setShowCompatibilityModal: jest.fn(), + isPreparingReview: false, + setIsPreparingReview: jest.fn(), + paymentError: null, + setPaymentError: mockSetPaymentError, + setError: mockSetWithdrawError, + chargeDetails, + setChargeDetails: jest.fn(), + setTransactionHash: mockSetTransactionHash, + paymentDetails: null, + setPaymentDetails: mockSetPaymentDetails, + resetWithdrawFlow: jest.fn(), +} + +jest.mock('@/context/WithdrawFlowContext', () => ({ + useWithdrawFlow: () => mockWithdrawFlow, +})) + +const mockSendMoney = jest.fn() +const mockSendTransactions = jest.fn() +jest.mock('@/hooks/wallet/useWallet', () => ({ + useWallet: () => ({ + isConnected: true, + address: USER_ADDRESS, + sendMoney: mockSendMoney, + sendTransactions: mockSendTransactions, + spendableBalance: 100n * 10n ** 6n, + }), +})) + +// Same-chain, same-token route: isXChain/isDiffToken false → sendMoney path. +const mockCrossChainTransfer = { + transactions: [{ to: RECIPIENT, value: 0n, data: '0x' }], + receiveAmount: '50', + payAmount: null, + feeUsd: 0, + minDepositLimitUsd: 0, + isCalculating: false, + isXChain: false, + isDiffToken: false, + error: null, + calculate: jest.fn(), + reset: jest.fn(), +} +jest.mock('@/features/payments/shared/hooks/useCrossChainTransfer', () => ({ + useCrossChainTransfer: () => mockCrossChainTransfer, +})) + +const mockRecordPayment = jest.fn() +jest.mock('@/features/payments/shared/hooks/usePaymentRecorder', () => ({ + usePaymentRecorder: () => ({ + isRecording: false, + error: null, + recordPayment: mockRecordPayment, + reset: jest.fn(), + }), +})) + +import WithdrawCryptoPage from '../page' + +// ---------- helpers ---------- + +const PAYMENT_RESULT = { uuid: 'payment-1' } + +const confirm = async () => { + render() + fireEvent.click(screen.getByTestId('confirm-withdraw')) +} + +beforeEach(() => { + jest.clearAllMocks() + mockRecordPayment.mockResolvedValue(PAYMENT_RESULT) +}) + +// ---------- tests ---------- + +describe('crypto withdraw confirm — charge completion', () => { + it('passes the charge uuid to sendMoney so collateral-only spends settle the charge server-side', async () => { + mockSendMoney.mockResolvedValue({ + txHash: '0xbetx', + userOpHash: undefined, + receipt: null, + strategy: 'collateral-only', + intentId: CHARGE_UUID, + }) + + await confirm() + + await waitFor(() => expect(mockSetCurrentView).toHaveBeenCalledWith('STATUS')) + expect(mockSendMoney).toHaveBeenCalledWith( + RECIPIENT, + '50', + expect.objectContaining({ kind: 'CRYPTO_WITHDRAW', chargeId: CHARGE_UUID }) + ) + // recordPayment fires as the idempotent recovery net (backend already + // settled the charge via /submit's trusted completion). + expect(mockRecordPayment).toHaveBeenCalledWith( + expect.objectContaining({ chargeId: CHARGE_UUID, txHash: '0xbetx', payerAddress: USER_ADDRESS }) + ) + expect(mockSetPaymentDetails).toHaveBeenCalledWith(PAYMENT_RESULT) + }) + + it('records the payment for mixed same-chain spends (used to be skipped → charge rotted PENDING)', async () => { + mockSendMoney.mockResolvedValue({ + txHash: undefined, + userOpHash: '0xuserop', + receipt: { transactionHash: '0xmined', status: 'success' }, + strategy: 'mixed', + intentId: 'prep-intent-1', + }) + + await confirm() + + await waitFor(() => expect(mockSetCurrentView).toHaveBeenCalledWith('STATUS')) + // The mined tx hash (not the userOp hash) must reach the validator. + expect(mockRecordPayment).toHaveBeenCalledWith( + expect.objectContaining({ chargeId: CHARGE_UUID, txHash: '0xmined' }) + ) + expect(mockPosthogCapture).toHaveBeenCalledWith('withdraw_completed', expect.anything()) + }) + + it('still shows success when recordPayment fails after a collateral-routed spend (funds already moved)', async () => { + mockSendMoney.mockResolvedValue({ + txHash: '0xbetx', + userOpHash: undefined, + receipt: null, + strategy: 'collateral-only', + intentId: CHARGE_UUID, + }) + mockRecordPayment.mockRejectedValue(new Error('network blip')) + + await confirm() + + await waitFor(() => expect(mockSetCurrentView).toHaveBeenCalledWith('STATUS')) + expect(mockSetPaymentDetails).toHaveBeenCalledWith(null) + expect(mockCaptureMessage).toHaveBeenCalled() + // No user-facing failure for a withdrawal that succeeded on-chain. + expect(mockPosthogCapture).not.toHaveBeenCalledWith('withdraw_failed', expect.anything()) + expect(mockSetWithdrawError).not.toHaveBeenCalledWith(expect.objectContaining({ showError: true })) + }) + + it('keeps surfacing recordPayment failures on the smart-only path (its only completion trigger)', async () => { + mockSendMoney.mockResolvedValue({ + txHash: undefined, + userOpHash: '0xuserop', + receipt: { transactionHash: '0xmined', status: 'success' }, + strategy: 'smart-only', + intentId: undefined, + }) + mockRecordPayment.mockRejectedValue(new Error('record failed')) + + await confirm() + + await waitFor(() => expect(mockPosthogCapture).toHaveBeenCalledWith('withdraw_failed', expect.anything())) + expect(mockSetCurrentView).not.toHaveBeenCalledWith('STATUS') + expect(mockSetWithdrawError).toHaveBeenCalledWith(expect.objectContaining({ showError: true })) + }) +}) diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index 61aad6eb70..68f504041d 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -326,9 +326,9 @@ export default function WithdrawCryptoPage() { // sendTransactions mixed path. let finalTxHash: Hex | undefined let receipt: TransactionReceipt | null = null - // 'collateral-only' | 'smart-only' | 'mixed' — drives whether we call - // recordPayment (smart-only) or rely on the Rain webhook → - // TransactionIntent reconciliation path (collateral-only / mixed). + // 'collateral-only' | 'smart-only' | 'mixed' — how the spend was + // funded; drives how strictly the recordPayment result is treated + // (see the recordPayment note below). let strategy: 'collateral-only' | 'smart-only' | 'mixed' | undefined // Backend TransactionIntent id — used to navigate to the unified // receipt page for collateral/mixed spends. @@ -341,7 +341,16 @@ export default function WithdrawCryptoPage() { receipt: r, strategy: s, intentId: i, - } = await sendMoney(withdrawData.address as Address, amountToWithdraw, { kind: 'CRYPTO_WITHDRAW' }) + } = await sendMoney(withdrawData.address as Address, amountToWithdraw, { + kind: 'CRYPTO_WITHDRAW', + // Lets the backend settle the charge directly when the spend + // routes through Rain card collateral (collateral-only): the + // charge intent becomes the withdrawal preparation and + // /submit completes it server-side. Without this the charge + // rots PENDING and the successful withdrawal never shows in + // Activity (same contract as direct-send / request-pay). + chargeId: chargeDetails.uuid, + }) receipt = r strategy = s intentId = i @@ -373,25 +382,27 @@ export default function WithdrawCryptoPage() { if (!finalTxHash) throw new Error('Withdrawal returned no transaction identifier') - // Skip recordPayment when funds moved via Rain collateral on a - // SAME-chain withdrawal — the charge indexer watches for - // smart-account-outgoing transfers, but a coordinator-driven - // withdraw moves USDC from the collateral proxy and would leave - // the Charge unmatched ("failed" in history). The Rain webhook + - // TransactionIntent reconciliation is the source of truth there. - // - // Cross-chain withdraws ALWAYS need recordPayment to fire — the - // BE validator's cross-chain branch transitions the charge intent - // to COMPLETED directly (trusts the source-chain submission since - // Rhino owns delivery downstream). Without this call the intent - // gets stuck at PENDING because nothing else triggers the - // transition for the bridge-path (depositWithId, mode='pay') - // flow we use for non-stable destinations. + // Record the payment against the charge on EVERY path — completing + // the user-facing charge is what makes the withdrawal appear in + // Activity: + // - collateral-only: /prepare tagged the charge (chargeId above) + // and /submit completed it server-side; recordPayment re-enters + // the same trusted-completion path (idempotent) — the designed + // recovery net when /submit's post-mining bookkeeping fails. + // - mixed: the kernel sent a plain usdc.transfer(recipient) that + // the on-chain validator matches — the normal recordPayment path. + // - smart-only / cross-chain: unchanged — recordPayment has always + // been their only charge-completion trigger. + // This used to be SKIPPED for collateral-routed same-chain + // withdraws, which left the charge PENDING forever: history hides + // never-paid charges as abandoned drafts and hides the rain-prepare + // intent as a phantom, so a successful withdrawal was completely + // invisible in Activity while the balance dropped. const routedThroughCollateral = strategy === 'collateral-only' || strategy === 'mixed' - const skipRecordPayment = routedThroughCollateral && !isCrossChainWithdrawal + const collateralRoutedSameChain = routedThroughCollateral && !isCrossChainWithdrawal let payment: Awaited> | null = null - if (!skipRecordPayment) { + try { payment = await recordPayment({ chargeId: chargeDetails.uuid, chainId: PEANUT_WALLET_CHAIN.id.toString(), @@ -399,6 +410,19 @@ export default function WithdrawCryptoPage() { tokenAddress: PEANUT_WALLET_TOKEN as Address, payerAddress: address as Address, }) + } catch (err) { + // Funds already moved on-chain. On collateral-routed same-chain + // paths a recordPayment hiccup must not read as a failed + // withdrawal (collateral-only is already settled server-side; + // mixed then just stays PENDING, exactly like pre-fix) — + // degrade to the success view without payment details. Other + // paths keep throwing, as they always have. + if (!collateralRoutedSameChain) throw err + console.error('recordPayment failed after collateral-routed withdrawal (funds moved):', err) + captureMessage('withdraw: recordPayment failed after collateral-routed spend', { + level: 'warning', + extra: { chargeId: chargeDetails.uuid, txHash: finalTxHash, strategy }, + }) } setTransactionHash(finalTxHash) diff --git a/src/hooks/wallet/useSendMoney.ts b/src/hooks/wallet/useSendMoney.ts index c86a4e97d5..733aa78032 100644 --- a/src/hooks/wallet/useSendMoney.ts +++ b/src/hooks/wallet/useSendMoney.ts @@ -22,8 +22,9 @@ type SendMoneyParams = { kind?: RainCollateralKind /** When this send pays a Peanut request/charge, the charge uuid. If the spend * routes entirely through Rain collateral the backend completes the charge - * itself — the result's `strategy === 'collateral-only'` is the caller's - * signal to skip `recordPayment`. Ignored for other strategies. */ + * itself; callers should still `recordPayment` afterwards — the backend + * treats that as an idempotent re-entry of the trusted-completion path, + * and every other strategy relies on it to complete the charge. */ chargeId?: string /** Optional UI hook — fires once routing is picked, before any signing prompt. */ onStrategyDecided?: (strategy: Exclude) => void diff --git a/src/hooks/wallet/useSpendBundle.ts b/src/hooks/wallet/useSpendBundle.ts index a7a2dbcd7d..5ce1614196 100644 --- a/src/hooks/wallet/useSpendBundle.ts +++ b/src/hooks/wallet/useSpendBundle.ts @@ -44,11 +44,16 @@ export interface SpendBundleInput { * the Rain collateral webhook can be reconciled and categorized correctly * in history (instead of showing up as a generic "card payment"). */ kind: RainCollateralKind - /** When this spend pays a Peanut request/charge AND it routes entirely through - * Rain collateral (`collateral-only`), the charge uuid. The backend uses the - * charge intent itself as the prep and marks it COMPLETED on confirm — so the - * caller MUST skip its own `recordPayment` when `strategy === 'collateral-only'`. - * Ignored for `smart-only` and `mixed` (those keep the recordPayment path). */ + /** When this spend pays a Peanut request/charge, the charge uuid. On the + * `collateral-only` strategy the backend uses the charge intent itself as + * the prep and completes it on confirm; a follow-up `recordPayment` is + * still safe — the backend routes it through the same trusted-completion + * path (idempotent), which doubles as the recovery net when /submit's + * post-mining bookkeeping fails. Ignored for `smart-only` and `mixed`, + * where `recordPayment` remains the charge-completion trigger — so + * charge-backed callers should ALWAYS record the payment afterwards + * (skipping it leaves the charge PENDING forever and the spend invisible + * in Activity). */ chargeId?: string /** Extra calls to include in the kernel UserOp (for approve+deposit-style flows). * If present, collateral-only routing is NOT eligible — calls must run from the kernel. */ diff --git a/src/services/rain.ts b/src/services/rain.ts index 2ebd1be8b1..946a845f81 100644 --- a/src/services/rain.ts +++ b/src/services/rain.ts @@ -96,8 +96,9 @@ export interface PrepareRainWithdrawalInput { * `amount` (which is only the collateral shortfall). History shows this. */ totalAmountCents?: string /** When this withdrawal pays a Peanut request/charge, the charge uuid. - * The backend then uses the charge intent itself as the prep and marks it - * COMPLETED on confirm — so the FE must NOT also call `recordPayment`. */ + * The backend then uses the charge intent itself as the prep and + * completes it on confirm; a follow-up `recordPayment` re-enters the + * same trusted-completion path (idempotent). */ chargeId?: string } From fcad26581f20094c3c5a26ed5b93a0f9d555178a Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 23 Jul 2026 02:25:02 +0300 Subject: [PATCH 40/45] fix(withdraw): only record mixed same-chain spends with a mined receipt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review find: on a receipt-wait timeout the kernel path returns only the userOp hash. Feeding that to the validator for an untagged mixed charge can never match a tx — retry exhaustion would flip the successful withdrawal to FAILED, worse than the pre-fix stuck PENDING. Skip the record with a Sentry breadcrumb in that case (collateral-only stays safe: backend-broadcast tx hash + trusted-path settlement). Also pin cross-chain rethrow + this skip in tests. --- .../crypto-withdraw-confirm.test.tsx | 40 ++++++++++++++ src/app/(mobile-ui)/withdraw/crypto/page.tsx | 55 ++++++++++++------- 2 files changed, 76 insertions(+), 19 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx index 9c5a4beb71..dedca60187 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx @@ -257,6 +257,7 @@ const confirm = async () => { beforeEach(() => { jest.clearAllMocks() mockRecordPayment.mockResolvedValue(PAYMENT_RESULT) + Object.assign(mockCrossChainTransfer, { isXChain: false, isDiffToken: false }) }) // ---------- tests ---------- @@ -319,6 +320,9 @@ describe('crypto withdraw confirm — charge completion', () => { await confirm() await waitFor(() => expect(mockSetCurrentView).toHaveBeenCalledWith('STATUS')) + // The failing call MUST have been attempted — pre-fix code never called + // recordPayment on this path, which is the bug. + expect(mockRecordPayment).toHaveBeenCalled() expect(mockSetPaymentDetails).toHaveBeenCalledWith(null) expect(mockCaptureMessage).toHaveBeenCalled() // No user-facing failure for a withdrawal that succeeded on-chain. @@ -326,6 +330,42 @@ describe('crypto withdraw confirm — charge completion', () => { expect(mockSetWithdrawError).not.toHaveBeenCalledWith(expect.objectContaining({ showError: true })) }) + it('skips recordPayment when a mixed spend has no mined receipt (a userOp hash would poison the validator)', async () => { + mockSendMoney.mockResolvedValue({ + txHash: undefined, + userOpHash: '0xuserop', + receipt: null, + strategy: 'mixed', + intentId: 'prep-intent-1', + }) + + await confirm() + + await waitFor(() => expect(mockSetCurrentView).toHaveBeenCalledWith('STATUS')) + expect(mockRecordPayment).not.toHaveBeenCalled() + expect(mockCaptureMessage).toHaveBeenCalled() + expect(mockSetPaymentDetails).toHaveBeenCalledWith(null) + expect(mockPosthogCapture).not.toHaveBeenCalledWith('withdraw_failed', expect.anything()) + }) + + it('keeps rethrowing recordPayment failures on cross-chain withdrawals (mixed funding included)', async () => { + Object.assign(mockCrossChainTransfer, { isXChain: true }) + mockSendTransactions.mockResolvedValue({ + userOpHash: '0xuserop', + receipt: { transactionHash: '0xmined', status: 'success' }, + strategy: 'mixed', + intentId: 'prep-intent-2', + }) + mockRecordPayment.mockRejectedValue(new Error('record failed')) + + await confirm() + + await waitFor(() => expect(mockPosthogCapture).toHaveBeenCalledWith('withdraw_failed', expect.anything())) + expect(mockSendMoney).not.toHaveBeenCalled() + expect(mockSetCurrentView).not.toHaveBeenCalledWith('STATUS') + expect(mockSetWithdrawError).toHaveBeenCalledWith(expect.objectContaining({ showError: true })) + }) + it('keeps surfacing recordPayment failures on the smart-only path (its only completion trigger)', async () => { mockSendMoney.mockResolvedValue({ txHash: undefined, diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index 68f504041d..ed6985dccb 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -401,28 +401,45 @@ export default function WithdrawCryptoPage() { const routedThroughCollateral = strategy === 'collateral-only' || strategy === 'mixed' const collateralRoutedSameChain = routedThroughCollateral && !isCrossChainWithdrawal + // An untagged mixed same-chain charge goes through the on-chain + // validator, which needs a MINED tx hash — a userOp hash can never + // match, and validator retry-exhaustion would flip the successful + // withdrawal to FAILED (worse than the pre-fix stuck-PENDING). If + // the receipt wait timed out, skip the record (pre-fix behavior) + // and leave a breadcrumb. collateral-only is safe regardless: its + // hash is a backend-broadcast EVM tx and the tagged charge settles + // via the trusted path. + const mixedWithoutMinedHash = strategy === 'mixed' && !isCrossChainWithdrawal && !receipt?.transactionHash + let payment: Awaited> | null = null - try { - payment = await recordPayment({ - chargeId: chargeDetails.uuid, - chainId: PEANUT_WALLET_CHAIN.id.toString(), - txHash: finalTxHash, - tokenAddress: PEANUT_WALLET_TOKEN as Address, - payerAddress: address as Address, - }) - } catch (err) { - // Funds already moved on-chain. On collateral-routed same-chain - // paths a recordPayment hiccup must not read as a failed - // withdrawal (collateral-only is already settled server-side; - // mixed then just stays PENDING, exactly like pre-fix) — - // degrade to the success view without payment details. Other - // paths keep throwing, as they always have. - if (!collateralRoutedSameChain) throw err - console.error('recordPayment failed after collateral-routed withdrawal (funds moved):', err) - captureMessage('withdraw: recordPayment failed after collateral-routed spend', { + if (mixedWithoutMinedHash) { + captureMessage('withdraw: skipping recordPayment — mixed spend without mined receipt', { level: 'warning', - extra: { chargeId: chargeDetails.uuid, txHash: finalTxHash, strategy }, + extra: { chargeId: chargeDetails.uuid, userOpOrTxHash: finalTxHash, strategy }, }) + } else { + try { + payment = await recordPayment({ + chargeId: chargeDetails.uuid, + chainId: PEANUT_WALLET_CHAIN.id.toString(), + txHash: finalTxHash, + tokenAddress: PEANUT_WALLET_TOKEN as Address, + payerAddress: address as Address, + }) + } catch (err) { + // Funds already moved on-chain. On collateral-routed same-chain + // paths a recordPayment hiccup must not read as a failed + // withdrawal (collateral-only is already settled server-side; + // mixed then just stays PENDING, exactly like pre-fix) — + // degrade to the success view without payment details. Other + // paths keep throwing, as they always have. + if (!collateralRoutedSameChain) throw err + console.error('recordPayment failed after collateral-routed withdrawal (funds moved):', err) + captureMessage('withdraw: recordPayment failed after collateral-routed spend', { + level: 'warning', + extra: { chargeId: chargeDetails.uuid, txHash: finalTxHash, strategy }, + }) + } } setTransactionHash(finalTxHash) From 255b083564ffdf5a5eb5f19322c715db0a4686ce Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 23 Jul 2026 02:31:19 +0300 Subject: [PATCH 41/45] test(withdraw): type the confirm-flow test mocks (keep new code eslint-clean) --- .../__tests__/crypto-withdraw-confirm.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx index dedca60187..a06b3457cf 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx @@ -25,13 +25,13 @@ jest.mock('next/navigation', () => ({ const mockCaptureMessage = jest.fn() jest.mock('@sentry/nextjs', () => ({ - captureMessage: (...args: any[]) => mockCaptureMessage(...args), + captureMessage: (...args: unknown[]) => mockCaptureMessage(...args), })) const mockPosthogCapture = jest.fn() jest.mock('posthog-js', () => ({ __esModule: true, - default: { capture: (...args: any[]) => mockPosthogCapture(...args), init: jest.fn() }, + default: { capture: (...args: unknown[]) => mockPosthogCapture(...args), init: jest.fn() }, })) jest.mock('use-haptic', () => ({ @@ -84,7 +84,7 @@ jest.mock('@/utils/withdraw.utils', () => ({ })) jest.mock('@/utils/general.utils', () => ({ - isTxReverted: (receipt: any) => receipt?.status === 'reverted', + isTxReverted: (receipt: { status?: string } | null) => receipt?.status === 'reverted', })) jest.mock('@/utils/url.utils', () => ({ @@ -92,7 +92,7 @@ jest.mock('@/utils/url.utils', () => ({ })) jest.mock('@/utils/friendly-error.utils', () => ({ - ErrorHandler: (err: any) => err?.message ?? 'Something went wrong', + ErrorHandler: (err: unknown) => (err instanceof Error ? err.message : 'Something went wrong'), })) jest.mock('@/interfaces/peanut-sdk-types', () => ({ @@ -111,7 +111,7 @@ jest.mock('@/services/requests', () => ({ jest.mock('@/components/Withdraw/views/Confirm.withdraw.view', () => ({ __esModule: true, - default: (props: any) => ( + default: (props: { onConfirm: () => void }) => ( @@ -135,7 +135,7 @@ jest.mock('@/components/Global/ActionModal', () => ({ jest.mock('@/components/Global/AddressLink', () => ({ __esModule: true, - default: (props: any) => {props.address}, + default: (props: { address: string }) => {props.address}, })) jest.mock('@/components/Global/PeanutLoading', () => ({ From 1c728a8fbd674899e6837d2f90e61ee24f3a05ed Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 23 Jul 2026 02:40:30 +0300 Subject: [PATCH 42/45] test(withdraw): pin cross-chain mixed-without-receipt keeps recording (CodeRabbit) --- .../crypto-withdraw-confirm.test.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx index a06b3457cf..588f59897b 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx @@ -348,6 +348,25 @@ describe('crypto withdraw confirm — charge completion', () => { expect(mockPosthogCapture).not.toHaveBeenCalledWith('withdraw_failed', expect.anything()) }) + it('still records cross-chain mixed spends without a mined receipt (skip guard is same-chain only)', async () => { + Object.assign(mockCrossChainTransfer, { isXChain: true }) + mockSendTransactions.mockResolvedValue({ + userOpHash: '0xuserop', + receipt: null, + strategy: 'mixed', + intentId: 'prep-intent-3', + }) + + await confirm() + + await waitFor(() => expect(mockSetCurrentView).toHaveBeenCalledWith('STATUS')) + // Cross-chain keeps recording whatever hash it has (pre-existing + // behavior): the BE validator's cross-chain branch completes from the + // source-chain submission and never runs same-chain tx matching, so + // the mixed-without-receipt skip must not apply here. + expect(mockRecordPayment).toHaveBeenCalledWith(expect.objectContaining({ txHash: '0xuserop' })) + }) + it('keeps rethrowing recordPayment failures on cross-chain withdrawals (mixed funding included)', async () => { Object.assign(mockCrossChainTransfer, { isXChain: true }) mockSendTransactions.mockResolvedValue({ From 2b967a5c6306e9caea62e67ae4bcb87be2b4c9ba Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 23 Jul 2026 02:42:57 +0300 Subject: [PATCH 43/45] review: purge two more skip-recordPayment comments, pin mixed tolerance + isDiffToken routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structured review pass: the bug-seeding 'skip recordPayment' guidance survived in two more comments (useSpendBundle prepare call-site, useWallet sendMoney return doc) — the exact rot this PR exists to purge. Also close the two coverage gaps a scoping regression could slip through: mixed same-chain tolerance and token-boundary-only routing. --- .../crypto-withdraw-confirm.test.tsx | 24 ++++++++++++++++++- src/hooks/wallet/useSpendBundle.ts | 6 +++-- src/hooks/wallet/useWallet.ts | 9 ++++--- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx index 588f59897b..f44f8cbb99 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx @@ -330,6 +330,24 @@ describe('crypto withdraw confirm — charge completion', () => { expect(mockSetWithdrawError).not.toHaveBeenCalledWith(expect.objectContaining({ showError: true })) }) + it('still shows success when recordPayment fails after a mixed same-chain spend (tolerance covers mixed too)', async () => { + mockSendMoney.mockResolvedValue({ + txHash: undefined, + userOpHash: '0xuserop', + receipt: { transactionHash: '0xmined', status: 'success' }, + strategy: 'mixed', + intentId: 'prep-intent-1', + }) + mockRecordPayment.mockRejectedValue(new Error('network blip')) + + await confirm() + + await waitFor(() => expect(mockSetCurrentView).toHaveBeenCalledWith('STATUS')) + expect(mockRecordPayment).toHaveBeenCalled() + expect(mockCaptureMessage).toHaveBeenCalled() + expect(mockPosthogCapture).not.toHaveBeenCalledWith('withdraw_failed', expect.anything()) + }) + it('skips recordPayment when a mixed spend has no mined receipt (a userOp hash would poison the validator)', async () => { mockSendMoney.mockResolvedValue({ txHash: undefined, @@ -368,7 +386,11 @@ describe('crypto withdraw confirm — charge completion', () => { }) it('keeps rethrowing recordPayment failures on cross-chain withdrawals (mixed funding included)', async () => { - Object.assign(mockCrossChainTransfer, { isXChain: true }) + // isDiffToken-only on purpose: same-chain USDC→ETH historically got + // silently downgraded to a plain transfer (see isCrossChainWithdrawal) + // — pin that token-boundary-only also routes through sendTransactions + // and keeps the strict rethrow. + Object.assign(mockCrossChainTransfer, { isDiffToken: true }) mockSendTransactions.mockResolvedValue({ userOpHash: '0xuserop', receipt: { transactionHash: '0xmined', status: 'success' }, diff --git a/src/hooks/wallet/useSpendBundle.ts b/src/hooks/wallet/useSpendBundle.ts index 5ce1614196..11b5021fb7 100644 --- a/src/hooks/wallet/useSpendBundle.ts +++ b/src/hooks/wallet/useSpendBundle.ts @@ -189,8 +189,10 @@ export const useSpendBundle = () => { recipientAddress: recipient!, directTransfer: true, kind, - // When set, the backend completes the charge directly on - // confirm — caller must skip recordPayment for this strategy. + // When set, the backend uses the charge as the prep and + // completes it directly on confirm; a follow-up + // recordPayment re-enters the same trusted-completion + // path idempotently (see SpendBundleInput.chargeId). chargeId, }) diff --git a/src/hooks/wallet/useWallet.ts b/src/hooks/wallet/useWallet.ts index 122db2eb10..9960296dc7 100644 --- a/src/hooks/wallet/useWallet.ts +++ b/src/hooks/wallet/useWallet.ts @@ -120,9 +120,12 @@ export const useWallet = () => { }) // `strategy` lets same-chain callers distinguish "funds left the smart // account" (smart-only) from "funds left Rain collateral" (collateral- - // only/mixed). The latter is reconciled server-side via Rain's webhook - // → TransactionIntent, so callers can skip legacy recordPayment paths - // that would otherwise leave an unmatched Charge in history. + // only/mixed). Charge-backed callers should still recordPayment on + // every strategy: collateral-only re-enters the backend's idempotent + // trusted-completion path, and the others rely on it to complete the + // charge — skipping it leaves the charge PENDING and the spend + // invisible in Activity. (Mixed callers: only record a MINED tx + // hash, never the bare userOp hash — see the withdraw page.) // `intentId` is the receipt handle for collateral/mixed spends — // `/receipt/?kind=`. return { From 0a44cd643dc2dc8c34902e2e3ce99d5e84378f3e Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 23 Jul 2026 14:38:05 +0300 Subject: [PATCH 44/45] fix(withdraw): exact-match fallback for non-EVM token selection (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base58 is case-significant and both operands come from the same registry — a case-insensitive compare could only mask an upstream case-corruption bug, never fix a legitimate mismatch. areEvmAddressesEqual keeps covering EVM checksum-case variance and native-proxy aliasing. --- .../Global/TokenSelector/TokenSelector.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/components/Global/TokenSelector/TokenSelector.tsx b/src/components/Global/TokenSelector/TokenSelector.tsx index a355f2b3d9..51074c27a9 100644 --- a/src/components/Global/TokenSelector/TokenSelector.tsx +++ b/src/components/Global/TokenSelector/TokenSelector.tsx @@ -177,14 +177,16 @@ const TokenSelector: React.FC = ({ classNameButton, viewT if (selectedTokenAddress && selectedChainID) { const chainInfo = supportedChainsAndTokens[selectedChainID] - // areEvmAddressesEqual handles native-proxy aliasing for EVM tokens but is - // always false for non-EVM (base58 Tron/Solana) selections — without the - // literal fallback the button kept showing "Select a token" after picking - // USDT-Tron / USDT/USDC-Solana in the withdraw flow. + // areEvmAddressesEqual handles EVM case variance (checksum casing) and + // native-proxy aliasing but is always false for non-EVM (base58 + // Tron/Solana) selections — without a fallback the button kept showing + // "Select a token" after picking USDT-Tron / USDT/USDC-Solana. The + // fallback is EXACT equality on purpose: base58 is case-significant and + // both operands come from the same registry, so a case-insensitive + // compare could only ever mask an upstream case-corruption bug, never + // fix a legitimate mismatch. const tokenDetails = chainInfo?.tokens.find( - (t) => - areEvmAddressesEqual(t.address, selectedTokenAddress) || - t.address.toLowerCase() === selectedTokenAddress.toLowerCase() + (t) => areEvmAddressesEqual(t.address, selectedTokenAddress) || t.address === selectedTokenAddress ) if (tokenDetails && chainInfo) { buttonSymbol = tokenDetails.symbol From 4d5f2f2202d5fc327533ddfbff097fccd432fc2b Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Fri, 24 Jul 2026 07:53:51 +0000 Subject: [PATCH 45/45] feat(footer): add Squirrel Labs Ltd legal-entity line Apple 5.1.1 rejected the iOS app because the seller name (Squirrel Labs Ltd) has no visible association with the Peanut brand. Terms already say 'trading as Peanut'; this puts the same disclosure on every landing page footer so the link is one click closer for reviewers (and standard UK practice anyway). --- src/components/LandingPage/Footer.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/LandingPage/Footer.tsx b/src/components/LandingPage/Footer.tsx index bc6630e888..7ca4edb94e 100644 --- a/src/components/LandingPage/Footer.tsx +++ b/src/components/LandingPage/Footer.tsx @@ -21,6 +21,9 @@ const Footer = ({ showSiteDirectory = true, locale = 'en' }: { showSiteDirectory Squirrel Labs

+

+ Peanut is a trading name of Squirrel Labs Ltd, registered in England & Wales (No. 14558823) +