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/next.config.js b/next.config.js index 53c2ad6b51..b48d2cd5a7 100644 --- a/next.config.js +++ b/next.config.js @@ -5,6 +5,97 @@ const withBundleAnalyzer = const redirectsConfig = require('./redirects.json') +/** + * Sentry's CSP-report ingest endpoint, derived from the browser DSN + * (`://@/`). 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 { protocol, host, username, pathname } = new URL(dsn) + const segments = pathname.split('/').filter(Boolean) + const projectId = segments.pop() + if (!host || !username || !projectId) return null + const prefix = segments.length ? `/${segments.join('/')}` : '' + return `${protocol}//${host}${prefix}/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'", + ] + // 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 { @@ -186,7 +277,14 @@ 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() }, + ...reportingEndpointsHeader(), { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, ], diff --git a/package.json b/package.json index a5e33a3edb..7f1941c902 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,7 @@ "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^16.1.0", "@types/canvas-confetti": "^1.9.0", + "@types/d3-force": "^3.0.10", "@types/jest": "^29.5.12", "@types/js-cookie": "^3.0.6", "@types/node": "20.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be7eac86b8..26bf8de33d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -304,6 +304,9 @@ importers: '@types/canvas-confetti': specifier: ^1.9.0 version: 1.9.0 + '@types/d3-force': + specifier: ^3.0.10 + version: 3.0.10 '@types/jest': specifier: ^29.5.12 version: 29.5.14 @@ -3109,6 +3112,9 @@ packages: '@types/connect@3.4.36': resolution: {integrity: sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==} + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -11823,6 +11829,8 @@ snapshots: dependencies: '@types/node': 20.4.2 + '@types/d3-force@3.0.10': {} + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 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 8be16debd0..5346dbf899 100644 --- a/scripts/native-build.js +++ b/scripts/native-build.js @@ -85,7 +85,7 @@ export default function RootRedirect() { { path: '(mobile-ui)/claim/page.tsx', // strip generateMetadata + force-dynamic, keep component render (SEO irrelevant in native) - replacement: `import { Claim } from '@/components' + replacement: `import { Claim } from '@/components/Claim/Claim' export default function ClaimPage() { return @@ -253,6 +253,14 @@ function isCoveredByDisableList(relPath) { return P0_TRANSFORMS.some((item) => relPath === item.path) } +// 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 @@ -263,7 +271,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 diff --git a/sentry.utils.test.ts b/sentry.utils.test.ts new file mode 100644 index 0000000000..0700ab1ee5 --- /dev/null +++ b/sentry.utils.test.ts @@ -0,0 +1,19 @@ +import type { ErrorEvent } from '@sentry/nextjs' +import { shouldIgnoreError } from './sentry.utils' + +function eventWith(partial: { message?: string; type?: string; value?: string }): ErrorEvent { + return { + message: partial.message, + exception: { values: [{ type: partial.type, value: partial.value }] }, + } as unknown as ErrorEvent +} + +describe('shouldIgnoreError — alreadyReported (fetchWithSentry wrapper)', () => { + it('ignores a re-thrown ServiceUnavailableError (already captured at the fetch site)', () => { + expect(shouldIgnoreError(eventWith({ type: 'ServiceUnavailableError', value: 'upstream 503' }))).toBe(true) + }) + + it('does not ignore an unrelated application error', () => { + expect(shouldIgnoreError(eventWith({ type: 'TypeError', value: 'x is not a function' }))).toBe(false) + }) +}) diff --git a/sentry.utils.ts b/sentry.utils.ts index 2a72d7990c..51f805d312 100644 --- a/sentry.utils.ts +++ b/sentry.utils.ts @@ -38,6 +38,15 @@ const IGNORED_ERRORS = { // Third-party scripts we don't control thirdParty: ['googletagmanager', 'gtag', 'analytics', 'hotjar', 'clarity', 'intercom', 'crisp'], + // fetchWithSentry wrapper errors: the underlying timeout/network/HTTP + // failure is already captured at the fetch site with full context, so the + // re-thrown ServiceUnavailableError bubbling to global handlers (or being + // console.error'd by a consumer) would only double-count it (PEANUT-UI-QDJ). + // Substring-matching this pattern is safe only because ServiceUnavailableError + // is our own internal fetchWithSentry wrapper name, not a generic string that + // could appear in an unrelated third-party error message. + alreadyReported: ['ServiceUnavailableError'], + // Third-party SDK internal errors (not actionable) thirdPartySdkErrors: [ 'IndexedDB:Set:InternalError', // Vercel Analytics storage - fails in private browsing, not actionable @@ -58,12 +67,14 @@ export function shouldIgnoreError(event: ErrorEvent): boolean { const exceptionType = event.exception?.values?.[0]?.type || '' const culprit = (event as any).culprit || '' - const searchText = `${message} ${exceptionValue} ${exceptionType} ${culprit}`.toLowerCase() + // Match each field independently — concatenating them would let a pattern + // match across unrelated fields and suppress a legitimate event. + const searchTexts = [message, exceptionValue, exceptionType, culprit] // Check all ignore patterns for (const patterns of Object.values(IGNORED_ERRORS)) { for (const pattern of patterns) { - if (searchText.includes(pattern.toLowerCase())) { + if (searchTexts.some((text) => text.toLowerCase().includes(pattern.toLowerCase()))) { return true } } diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index ab1554816f..7e6fb19f14 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -11,7 +11,7 @@ import { formatAmount } from '@/utils/general.utils' import { countryData } from '@/components/AddMoney/consts' import { useAuth } from '@/context/authContext' import { useCapabilities } from '@/hooks/useCapabilities' -import { getKycModalVariant, getGateUserMessage } from '@/utils/capability-gate' +import { resolveKycModalVariant, getGateUserMessage } from '@/utils/capability-gate' import { useModalsContext } from '@/context/ModalsContext' import { useCreateOnramp, GENERIC_ONRAMP_ERROR } from '@/hooks/useCreateOnramp' import { useParams, useSearchParams } from 'next/navigation' @@ -505,7 +505,7 @@ export default function OnrampBankPage() { }} isLoading={sumsubFlow.isLoading} error={sumsubFlow.error} - variant={getKycModalVariant(gate.kind)} + variant={resolveKycModalVariant(gate)} providerMessage={getGateUserMessage(gate)} regionName={selectedCountry?.title} /> diff --git a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx index 77bca03b9a..58894d859e 100644 --- a/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx +++ b/src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx @@ -13,7 +13,7 @@ * Strategy: mock every hook and service at the module level, then configure * per-test via mockReturnValue / mockImplementation. */ -/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars, @typescript-eslint/no-require-imports, react/display-name, @next/next/no-img-element */ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars, react/display-name */ import React from 'react' import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' diff --git a/src/app/(mobile-ui)/card/page.tsx b/src/app/(mobile-ui)/card/page.tsx index 6baa0be4d3..16c846fdbe 100644 --- a/src/app/(mobile-ui)/card/page.tsx +++ b/src/app/(mobile-ui)/card/page.tsx @@ -22,6 +22,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' @@ -72,7 +73,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') @@ -215,6 +216,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. @@ -598,12 +655,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} /> ) @@ -632,14 +693,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} /> ) @@ -656,6 +721,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 + }} + /> { if (isLoading) { return [] } - const entries: Array = [...allEntries] + const entries: Array = [ + ...allEntries, + ] // inject badge items from user profile, placed by earnedAt const badges = user?.user?.badges ?? [] @@ -171,7 +177,7 @@ const HistoryPage = () => { if (!b.earnedAt) return entries.push({ isBadge: true, - uuid: b.id, + uuid: b.id ?? b.code, timestamp: new Date(b.earnedAt).toISOString(), code: b.code, name: b.name, diff --git a/src/app/(mobile-ui)/notifications/page.tsx b/src/app/(mobile-ui)/notifications/page.tsx index f2477443a0..dc104fe6bd 100644 --- a/src/app/(mobile-ui)/notifications/page.tsx +++ b/src/app/(mobile-ui)/notifications/page.tsx @@ -7,9 +7,10 @@ import NavHeader from '@/components/Global/NavHeader' import PeanutLoading from '@/components/Global/PeanutLoading' import { notificationsApi, type InAppItem } from '@/services/notifications' import { DateGroup, 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' +import { PEANUTMAN } from '@/assets/mascot' import Link from 'next/link' import EmptyState from '@/components/Global/EmptyStates/EmptyState' import { Button } from '@/components/0_Bruddle/Button' @@ -170,7 +171,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 ( ({ jest.mock('@/components/Global/Card', () => ({ __esModule: true, - default: React.forwardRef((props: any, ref: any) => ( -
- {props.children} -
- )), + default: React.forwardRef(function Card(props: any, ref: any) { + return ( +
+ {props.children} +
+ ) + }), })) jest.mock('@/components/0_Bruddle/Button', () => ({ diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 3af4d8d932..1b504bc110 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -32,7 +32,7 @@ import { useCardMarkupRate } from '@/hooks/useCardMarkupRate' import ErrorAlert from '@/components/Global/ErrorAlert' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' import { PERK_HOLD_DURATION_MS } from '@/constants/general.consts' -import { MANTECA_DEPOSIT_ADDRESS } from '@/constants/manteca.consts' +import { MANTECA_QR_DEPOSIT_ADDRESS_AR, MANTECA_QR_DEPOSIT_ADDRESS_NON_AR } from '@/constants/manteca.consts' import { MIN_MANTECA_QR_PAYMENT_AMOUNT, MIN_PIX_AMOUNT_BRL } from '@/constants/payment.consts' import { isPixRecurringCode } from '@/utils/withdraw.utils' import { formatUnits, parseUnits } from 'viem' @@ -273,6 +273,10 @@ export default function QRPayPage() { if (sumsubFlow.showWrapper || sumsubFlow.isModalOpen) { sumsubFlow.completeFlow() } + // Field-level deps are complete for this body. useMultiPhaseKycFlow returns a fresh + // object each render, so depending on sumsubFlow itself would re-fire every render + // and call completeFlow() repeatedly. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [kycGateState, sumsubFlow.showWrapper, sumsubFlow.isModalOpen, sumsubFlow.completeFlow]) const queryClient = useQueryClient() @@ -384,7 +388,7 @@ export default function QRPayPage() { if (isSuccess || !!errorMessage) { setLoadingState('Idle') } - }, [isSuccess, errorMessage]) + }, [isSuccess, errorMessage, setLoadingState]) // First fetch for qrcode info — only after KYC gating allows proceeding useEffect(() => { @@ -403,6 +407,11 @@ export default function QRPayPage() { } setIsFirstLoad(false) + // Keyed on the scan (timestamp/processor/qrCode) on purpose: this resets payment + // state for each new QR. resetState is a render-body function and t/ + // pixRecurringErrorMessage derive from it, so including them would re-run the + // reset on every render and wipe the amount mid-entry. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [timestamp, paymentProcessor, qrCode]) // Get amount from payment lock (Manteca) @@ -418,7 +427,7 @@ export default function QRPayPage() { setAmount(paymentLock.paymentAgainstAmount) setCurrencyAmount(paymentLock.paymentAssetAmount) } - }, [paymentLock?.code, paymentProcessor]) + }, [paymentLock, paymentProcessor]) // Get currency object from payment lock (Manteca) useEffect(() => { @@ -440,7 +449,7 @@ export default function QRPayPage() { } } getCurrencyObject().then(setCurrency) - }, [paymentLock?.code, paymentProcessor]) + }, [paymentLock, paymentProcessor]) const isBlockingError = useMemo(() => { // The settling failure says "try again in a few seconds" — keep the Pay @@ -458,7 +467,7 @@ export default function QRPayPage() { // For dynamic QR codes, backend provides the USD amount return paymentLock.paymentAgainstAmount } - }, [paymentLock?.code, paymentLock?.paymentAgainstAmount, amount]) + }, [paymentLock, amount]) // Live card-vs-local-rail markup, driven by Manteca's rate + (for ARS) // BCRA's official rate. Used by both the confirm-screen "Save vs card" @@ -541,7 +550,7 @@ export default function QRPayPage() { !isPixRecurringCode(qrCode) && !paymentLock && !shouldBlockPay, - retry: (failureCount, error: any) => { + retry: (failureCount, error) => { // Don't retry provider-specific errors if (NON_RETRYABLE_QR_PAY_ERRORS.some((code) => error?.message?.includes(code))) { return false @@ -569,8 +578,8 @@ export default function QRPayPage() { setLoadingState('Idle') } - if (paymentLockError || paymentLockFailureReason) { - const error = paymentLockError ?? paymentLockFailureReason + const error = paymentLockError ?? paymentLockFailureReason + if (error) { setLoadingState('Idle') // Provider-specific errors: show appropriate message @@ -665,7 +674,9 @@ export default function QRPayPage() { const requiredUsdcAmount = parseUnits(finalPaymentLock.paymentAgainstAmount, PEANUT_WALLET_TOKEN_DECIMALS) signedArtifact = await signSpend({ requiredUsdcAmount, - recipient: MANTECA_DEPOSIT_ADDRESS, + // Per-rail Manteca QR funding wallet: Pix → non-AR, everything else → AR + // (same binary heuristic as the backend's getQrReceiveAddress). + recipient: qrType === EQrType.PIX ? MANTECA_QR_DEPOSIT_ADDRESS_NON_AR : MANTECA_QR_DEPOSIT_ADDRESS_AR, rainSpendingPower: rainCentsToUsdcUnits(rainCardOverview?.balance?.spendingPower), kind: 'QR_PAY', }) @@ -770,7 +781,6 @@ export default function QRPayPage() { }, [ paymentLock, signSpend, - balance, rainCardOverview, qrCode, currencyAmount, diff --git a/src/app/(mobile-ui)/qr/[code]/page.tsx b/src/app/(mobile-ui)/qr/[code]/page.tsx index f4da917cc1..e4ce42452e 100644 --- a/src/app/(mobile-ui)/qr/[code]/page.tsx +++ b/src/app/(mobile-ui)/qr/[code]/page.tsx @@ -110,7 +110,7 @@ export default function RedirectQrClaimPage() { // Success! Show success page, then redirect to invite (which goes to profile for logged-in users) router.push(qrSuccessUrl(code)) - } catch (err: any) { + } catch (err) { console.error('Error claiming QR:', err) // Always show generic error message (don't expose backend details) setError(t('claim.claimFailed')) diff --git a/src/app/(mobile-ui)/qr/[code]/success/page.tsx b/src/app/(mobile-ui)/qr/[code]/success/page.tsx index 255cc53e44..4699b96a8e 100644 --- a/src/app/(mobile-ui)/qr/[code]/success/page.tsx +++ b/src/app/(mobile-ui)/qr/[code]/success/page.tsx @@ -102,9 +102,9 @@ export default function RedirectQrSuccessPage() { url: qrUrl, }) } - } catch (error: any) { + } catch (error) { // Ignore user cancellation - if (error.name !== 'AbortError') { + if (!(error instanceof Error) || error.name !== 'AbortError') { console.error('Share error:', error) } } diff --git a/src/app/(mobile-ui)/receipt/page.tsx b/src/app/(mobile-ui)/receipt/page.tsx new file mode 100644 index 0000000000..4c64cccea0 --- /dev/null +++ b/src/app/(mobile-ui)/receipt/page.tsx @@ -0,0 +1,70 @@ +'use client' + +import { useEffect } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import { useQuery } from '@tanstack/react-query' +import PageContainer from '@/components/0_Bruddle/PageContainer' +import NavHeader from '@/components/Global/NavHeader' +import PeanutLoading from '@/components/Global/PeanutLoading' +import { TransactionDetailsReceipt } from '@/components/TransactionDetails/TransactionDetailsReceipt' +import { mapTransactionDataForDrawer } from '@/components/TransactionDetails/transactionTransformer' +import { resolveReceiptKind } from '@/components/TransactionDetails/strategies/registry' +import { TRANSACTIONS } from '@/constants/query.consts' +import { apiFetch } from '@/utils/api-fetch' +import { completeHistoryEntry, isFinalState, type HistoryEntry } from '@/utils/history.utils' + +// Client twin of the web /receipt/[entryId] page. That one is a server component +// and is stripped from the static export (scripts/native-build.js), so a receipt +// deep link — the most common push destination — had nothing to render natively. +// deepLinkToNativePath maps /receipt/?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), + }) + + // `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 (isUnrecoverable) router.replace('/home') + }, [isUnrecoverable, router]) + + if (isUnrecoverable) return null + + return ( + +
+ +
+
+ {isLoading || !entry ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/src/app/(mobile-ui)/recover-funds/page.tsx b/src/app/(mobile-ui)/recover-funds/page.tsx index efcefcbaef..194cb0bfd5 100644 --- a/src/app/(mobile-ui)/recover-funds/page.tsx +++ b/src/app/(mobile-ui)/recover-funds/page.tsx @@ -3,7 +3,7 @@ import NavHeader from '@/components/Global/NavHeader' import ScrollableList from '@/components/Global/TokenSelector/Components/ScrollableList' import TokenListItem from '@/components/Global/TokenSelector/Components/TokenListItem' -import { type IUserBalance } from '@/interfaces' +import { type IUserBalance } from '@/interfaces/interfaces' import { useState, useEffect, useCallback, useContext } from 'react' import { useWallet } from '@/hooks/wallet/useWallet' import { fetchWalletBalances } from '@/services/tokens-price' @@ -21,7 +21,7 @@ import PeanutLoading from '@/components/Global/PeanutLoading' import { erc20Abi, parseUnits, encodeFunctionData, formatUnits } from 'viem' import type { Address, Hash, TransactionReceipt } from 'viem' import { useRouter } from 'next/navigation' -import { loadingStateContext } from '@/context' +import { loadingStateContext } from '@/context/loadingStates.context' import { captureException } from '@sentry/nextjs' import { mainnet, base, linea } from 'viem/chains' import { getPublicClient, type ChainId } from '@/app/actions/clients' diff --git a/src/app/(mobile-ui)/rewards/invites/page.tsx b/src/app/(mobile-ui)/rewards/invites/page.tsx index 254a64f852..8a950af447 100644 --- a/src/app/(mobile-ui)/rewards/invites/page.tsx +++ b/src/app/(mobile-ui)/rewards/invites/page.tsx @@ -12,7 +12,7 @@ import { invitesApi } from '@/services/invites' import { useQuery } from '@tanstack/react-query' import { useRouter } from 'next/navigation' import { useSafeBack } from '@/hooks/useSafeBack' -import { STAR_STRAIGHT_ICON } from '@/assets' +import STAR_STRAIGHT_ICON from '@/assets/icons/starStraight.svg' import Image from 'next/image' import EmptyState from '@/components/Global/EmptyStates/EmptyState' import { getInitialsFromName } from '@/utils/general.utils' diff --git a/src/app/(mobile-ui)/rewards/page.tsx b/src/app/(mobile-ui)/rewards/page.tsx index e65c3f121b..8f2d00a795 100644 --- a/src/app/(mobile-ui)/rewards/page.tsx +++ b/src/app/(mobile-ui)/rewards/page.tsx @@ -15,7 +15,11 @@ import { getInitialsFromName } from '@/utils/general.utils' import { useQuery } from '@tanstack/react-query' import { useRouter } from 'next/navigation' import { useSafeBack } from '@/hooks/useSafeBack' -import { STAR_STRAIGHT_ICON, TIER_0_BADGE, TIER_1_BADGE, TIER_2_BADGE, TIER_3_BADGE } from '@/assets' +import STAR_STRAIGHT_ICON from '@/assets/icons/starStraight.svg' +import TIER_0_BADGE from '@/assets/badges/tier0.svg' +import TIER_1_BADGE from '@/assets/badges/tier1.svg' +import TIER_2_BADGE from '@/assets/badges/tier2.svg' +import TIER_3_BADGE from '@/assets/badges/tier3.svg' import Image from 'next/image' import { pointsApi } from '@/services/points' import EmptyState from '@/components/Global/EmptyStates/EmptyState' diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 43f0870652..23d17f7310 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -12,7 +12,7 @@ import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants/zer import { useWithdrawFlow } from '@/context/WithdrawFlowContext' import { useWallet } from '@/hooks/wallet/useWallet' import { usePendingTransactions } from '@/hooks/wallet/usePendingTransactions' -import { AccountType, type Account } from '@/interfaces' +import { AccountType, type Account } from '@/interfaces/interfaces' import { formatIban, shortenStringLong, isTxReverted } from '@/utils/general.utils' import { useParams, useRouter, useSearchParams } from 'next/navigation' import { useEffect, useMemo, useState } from 'react' @@ -37,7 +37,7 @@ import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt' import { useEeaUpliftFunnel } from '@/hooks/useEeaUpliftFunnel' import { upliftTriggerFromGate, upliftTriggerFromAdvisory } from '@/utils/eea-uplift.utils' import { useCapabilities } from '@/hooks/useCapabilities' -import { getKycModalVariant, getGateUserMessage } from '@/utils/capability-gate' +import { resolveKycModalVariant, getGateUserMessage } from '@/utils/capability-gate' import { useModalsContext } from '@/context/ModalsContext' import ExchangeRate from '@/components/ExchangeRate' import countryCurrencyMappings, { isNonEuroSepaCountry } from '@/constants/countryCurrencyMapping' @@ -150,7 +150,7 @@ export default function WithdrawBankPage() { router.replace('/withdraw') } } - }, [country, isBridgeSupportedCountry, router]) + }, [country, router]) // check if we came from send flow - using method param to detect (only bank goes through this page) const methodParam = searchParams.get('method') @@ -349,14 +349,14 @@ export default function WithdrawBankPage() { method_type: 'bridge', country, }) - } catch (e: any) { + } catch (e) { const error = toFriendlyError(e) posthog.capture(ANALYTICS_EVENTS.WITHDRAW_FAILED, { method_type: 'bridge', error_message: error, }) if (error.includes('Something failed. Please try again.')) { - setError({ showError: true, errorMessage: e.message }) + setError({ showError: true, errorMessage: e instanceof Error ? e.message : String(e) }) } else { setError({ showError: true, errorMessage: error }) } @@ -598,7 +598,7 @@ export default function WithdrawBankPage() { }} isLoading={sumsubFlow.isLoading} error={sumsubFlow.error} - variant={getKycModalVariant(gate.kind)} + variant={resolveKycModalVariant(gate)} providerMessage={getGateUserMessage(gate)} regionName={getCountryFromPath(country)?.title} /> diff --git a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx index 25b77a4ee5..289a25f3e9 100644 --- a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx +++ b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx @@ -114,15 +114,6 @@ jest.mock('@/hooks/useGetExchangeRate', () => ({ default: () => mockUseGetExchangeRate(), })) -jest.mock('@/interfaces', () => ({ - AccountType: { - IBAN: 'iban', - US: 'us', - GB: 'gb', - CLABE: 'clabe', - }, -})) - const mockUseLimitsValidation = jest.fn() jest.mock('@/features/limits/hooks/useLimitsValidation', () => ({ useLimitsValidation: (...args: any[]) => mockUseLimitsValidation(...args), @@ -399,12 +390,14 @@ describe('GROUP 3: Amount Validation', () => { expect(screen.getByTestId('limits-warning-card')).toBeInTheDocument() }) - test('Crypto withdrawal allows sub-$1 amounts (no fiat-rail minimum)', () => { + test('Crypto withdrawal has no amount-step minimum (parity with send-via-link)', () => { // Regression: the shared amount step applied the bank $1 minimum to // crypto (getMinimumAmount('') → 1), blocking sub-$1 on-chain sends - // that send-via-link already allows. + // that send-via-link already allows. Same-chain Arbitrum withdrawals + // have no minimum at all; Rhino's per-network bridge minimums are + // enforced at review time, once the destination is known. mockWithdrawFlow.selectedMethod = { type: 'crypto' } - mockWithdrawFlow.amountToWithdraw = '0.5' + mockWithdrawFlow.amountToWithdraw = '0.4' renderWithdraw() diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index ab623c0863..3350dc9ee8 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -17,7 +17,7 @@ import type { TRequestResponse, } from '@/services/services.types' import { NATIVE_TOKEN_ADDRESS } from '@/utils/token.utils' -import { isWithdrawFeeDisproportionate } from '@/utils/cross-chain-fee.utils' +import { isWithdrawFeeDisproportionate, getMinWithdrawUsdForChain } from '@/utils/cross-chain-fee.utils' import { isAmountWithinBalance } from '@/utils/balance.utils' import { isBelowRhinoMinDeposit } from '@/utils/withdraw.utils' import * as peanutInterfaces from '@/interfaces/peanut-sdk-types' @@ -27,7 +27,7 @@ import { useSafeBack } from '@/hooks/useSafeBack' import type { Address, Hex, TransactionReceipt } from 'viem' import { parseUnits } from 'viem' import { Slider } from '@/components/Slider' -import { tokenSelectorContext } from '@/context' +import { tokenSelectorContext } from '@/context/tokenSelector.context' import { useHaptic } from 'use-haptic' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN, PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' import { useCrossChainTransfer } from '@/features/payments/shared/hooks/useCrossChainTransfer' @@ -172,6 +172,26 @@ export default function WithdrawCryptoPage() { return } + // Same-chain USDC is a direct transfer — no Rhino, no minimum + // (parity with send-via-link). Every other destination/token rides + // Rhino, which parks (doesn't auto-refund) deposits below the route + // minimum — block those before any request/charge is created. + // amountToWithdraw is USD. + const isSameChainUsdc = + data.chain.chainId.toString() === PEANUT_WALLET_CHAIN.id.toString() && + data.token.address.toLowerCase() === PEANUT_WALLET_TOKEN.toLowerCase() + if (!isSameChainUsdc) { + const usdToWithdraw = parseFloat(amountToWithdraw) + const minUsd = getMinWithdrawUsdForChain(data.chain.chainId) + if (!Number.isFinite(usdToWithdraw) || usdToWithdraw < minUsd) { + const minDisplay = minUsd % 1 === 0 ? `$${minUsd}` : `$${minUsd.toFixed(2)}` + setError( + `Withdrawals to ${data.chain.networkName} need at least ${minDisplay}. Increase the amount or pick a different network.` + ) + return + } + } + clearErrors() setChargeDetails(null) setIsPreparingReview(true) @@ -237,9 +257,9 @@ export default function WithdrawCryptoPage() { setChargeDetails(fullChargeDetails) setShowCompatibilityModal(true) - } catch (err: any) { + } catch (err) { console.error('Error during setup review (request/charge creation):', err) - const errorMessage = err.message || t('errors.prepareFailed') + const errorMessage = err instanceof Error && err.message ? err.message : t('errors.prepareFailed') setError(errorMessage) } finally { setIsPreparingReview(false) diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index 502f27d656..6f8dfee691 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -8,12 +8,11 @@ import AmountInput from '@/components/Global/AmountInput' import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' import { useWallet } from '@/hooks/wallet/useWallet' -import { tokenSelectorContext } from '@/context/tokenSelector.context' import { getCountryFromAccount, getCountryFromPath, getMinimumAmount } from '@/utils/bridge.utils' import useGetExchangeRate from '@/hooks/useGetExchangeRate' -import { AccountType } from '@/interfaces' +import { AccountType } from '@/interfaces/interfaces' import { useRouter, useSearchParams } from 'next/navigation' -import React, { useCallback, useEffect, useMemo, useState, useRef, useContext } from 'react' +import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react' import { formatUnits } from 'viem' import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation' import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard' @@ -32,7 +31,6 @@ export default function WithdrawPage() { const tNav = useTranslations('navigation') const tCommon = useTranslations('common') const tErrors = useTranslations('errors') - const { selectedTokenData } = useContext(tokenSelectorContext) // check if coming from send flow based on method query param const methodParam = searchParams.get('method') @@ -133,7 +131,12 @@ export default function WithdrawPage() { // compute minimum withdrawal in USD using the exchange rate const minUsdAmount = useMemo(() => { - if (isCryptoWithdraw) return 0 // any amount > 0 is valid, same as send-via-link + // no amount-step minimum for crypto: same-chain (Arbitrum) withdrawals + // are direct transfers with no floor, matching send-via-link. Rhino's + // per-network bridge minimums ($0.50, ETH $5, Tron $10) are enforced + // chain-aware at review time (see withdraw/crypto), once the + // destination is known. + if (isCryptoWithdraw) return 0 const localMin = getMinimumAmount(countryIso2) // for US or unknown, minimum is already in USD if (!countryIso2 || countryIso2 === 'US') return localMin @@ -199,9 +202,10 @@ export default function WithdrawPage() { return false } - // convert the entered token amount to USD - const price = selectedTokenData?.price ?? 0 // 0 for safety; will fail below - const usdEquivalent = price ? amount * price : amount // if no price assume token pegged 1 USD + // AmountInput is USD-pinned on this page (price: 1), so the typed + // value IS the USD value — scaling by the app-wide token price let + // a stale non-USD price loosen or false-trip the minimums. + const usdEquivalent = amount // While the balance is still loading, maxDecimalAmount is 0 — skip the // balance check so a pre-filled amount isn't false-blocked; the effect @@ -227,7 +231,7 @@ export default function WithdrawPage() { setError({ showError: true, errorMessage: message }) return false }, - [balance, maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount, t, tErrors] + [balance, maxDecimalAmount, setError, isFromSendFlow, minUsdAmount, t, tErrors] ) const handleTokenAmountChange = useCallback( @@ -278,7 +282,7 @@ export default function WithdrawPage() { const handleAmountContinue = () => { if (validateAmount(rawTokenAmount) && selectedMethod) { setAmountToWithdraw(rawTokenAmount) - const usdVal = (selectedTokenData?.price ?? 1) * parseFloat(rawTokenAmount) + const usdVal = parseFloat(rawTokenAmount) setUsdAmount(usdVal.toString()) posthog.capture(ANALYTICS_EVENTS.WITHDRAW_AMOUNT_ENTERED, { amount_usd: usdVal, @@ -356,13 +360,12 @@ export default function WithdrawPage() { const numericAmount = parseFloat(rawTokenAmount) if (!Number.isFinite(numericAmount) || numericAmount <= 0) return true - const usdEq = (selectedTokenData?.price ?? 1) * numericAmount - if (usdEq < minUsdAmount) return true // below country-specific minimum + if (numericAmount < minUsdAmount) return true // below the method's USD minimum // only apply the balance ceiling once it has loaded (maxDecimalAmount is 0 // while spendableBalance is undefined) — else Continue is disabled during load return (balance !== undefined && numericAmount > maxDecimalAmount) || error.showError - }, [rawTokenAmount, balance, maxDecimalAmount, error.showError, selectedTokenData?.price, minUsdAmount]) + }, [rawTokenAmount, balance, maxDecimalAmount, error.showError, minUsdAmount]) // native app: render country-specific views when ?country= is present const viewFromQuery = searchParams.get('view') diff --git a/src/app/(setup)/layout.tsx b/src/app/(setup)/layout.tsx index 29695bf7a5..49634e6e0a 100644 --- a/src/app/(setup)/layout.tsx +++ b/src/app/(setup)/layout.tsx @@ -64,7 +64,7 @@ function SetupLayoutContent({ children }: { children?: React.ReactNode }) { } else { dispatch(setupActions.setShowIosPwaInstallScreen(false)) } - }, [isPWA, deviceType]) + }, [isPWA, deviceType, dispatch]) usePullToRefresh() diff --git a/src/app/ClientProviders.tsx b/src/app/ClientProviders.tsx index 308fae7f90..a43875de8c 100644 --- a/src/app/ClientProviders.tsx +++ b/src/app/ClientProviders.tsx @@ -10,6 +10,7 @@ import { ConsoleGreeting } from '@/components/Global/ConsoleGreeting' import RainCooldownIntroModal from '@/components/Global/RainCooldown/IntroModal' import StaleCardApprovalReEnableModal from '@/components/Global/StaleCardApproval/ReEnableModal' import BadgeEarnToast from '@/components/Badges/BadgeEarnToast' +import { AppLockGate } from '@/components/Global/AppLock' import { ScreenOrientationLocker } from '@/components/Global/ScreenOrientationLocker' import { TranslationSafeWrapper } from '@/components/Global/TranslationSafeWrapper' import { AppIntlProvider } from '@/i18n/app/AppIntlProvider' @@ -65,7 +66,9 @@ export function ClientProviders({ children }: { children: React.ReactNode }) { )} - {children} + {/* Wraps rather than sits beside the page: while the + native app is locked, nothing protected renders. */} + {children} diff --git a/src/app/[...recipient]/page.tsx b/src/app/[...recipient]/page.tsx index af5c7de1a9..c67c992029 100644 --- a/src/app/[...recipient]/page.tsx +++ b/src/app/[...recipient]/page.tsx @@ -13,9 +13,10 @@ import { couldBeRecipient, isReservedRoute } from '@/constants/routes' type PageProps = { params: Promise<{ recipient?: string[] }> + searchParams: Promise<{ chargeId?: string }> } -export async function generateMetadata({ params, searchParams }: any) { +export async function generateMetadata({ params, searchParams }: PageProps) { const resolvedSearchParams = await searchParams const resolvedParams = await params diff --git a/src/app/[locale]/(marketing)/team/page.tsx b/src/app/[locale]/(marketing)/team/page.tsx index 32b19267f8..ef693253de 100644 --- a/src/app/[locale]/(marketing)/team/page.tsx +++ b/src/app/[locale]/(marketing)/team/page.tsx @@ -1,5 +1,6 @@ import { notFound } from 'next/navigation' import { type Metadata } from 'next' +import Image from 'next/image' import { generateMetadata as metadataHelper } from '@/app/metadata' import { MarketingHero } from '@/components/Marketing/MarketingHero' import { MarketingShell } from '@/components/Marketing/MarketingShell' @@ -108,7 +109,7 @@ export default async function TeamPage({ params }: PageProps) { return ( {member.image ? ( - {member.name} = (() => { const map: Record = {} for (const c of countryData) { - const iso2 = (c as any).iso2 || c.id - const iso3 = (c as any).iso3 + const iso2 = c.iso2 || c.id + const iso3 = c.iso3 if (iso2 && iso3) { map[String(iso3).toUpperCase()] = String(iso2).toUpperCase() } diff --git a/src/app/actions/card.ts b/src/app/actions/card.ts index 2fdffe2f13..70d256356f 100644 --- a/src/app/actions/card.ts +++ b/src/app/actions/card.ts @@ -51,8 +51,8 @@ export const getCardInfo = async (): Promise<{ data?: CardInfoResponse; error?: const data = await response.json() return { data } - } catch (e: any) { - return { error: e.message || 'An unexpected error occurred' } + } catch (e) { + return { error: e instanceof Error ? e.message : 'An unexpected error occurred' } } } @@ -76,7 +76,7 @@ export const purchaseCard = async (): Promise<{ data?: CardPurchaseResponse; err const data = await response.json() return { data } - } catch (e: any) { - return { error: e.message || 'An unexpected error occurred' } + } catch (e) { + return { error: e instanceof Error ? e.message : 'An unexpected error occurred' } } } diff --git a/src/app/actions/currency.ts b/src/app/actions/currency.ts index c694b7a4ee..654fac5c14 100644 --- a/src/app/actions/currency.ts +++ b/src/app/actions/currency.ts @@ -1,5 +1,5 @@ import { getExchangeRate } from './exchange-rate' -import { AccountType } from '@/interfaces' +import { AccountType } from '@/interfaces/interfaces' import { mantecaApi } from '@/services/manteca' import { unstable_cache } from '@/utils/no-cache' diff --git a/src/app/actions/exchange-rate.ts b/src/app/actions/exchange-rate.ts index d7642d1ee5..2ee4eab8ae 100644 --- a/src/app/actions/exchange-rate.ts +++ b/src/app/actions/exchange-rate.ts @@ -1,4 +1,4 @@ -import { AccountType } from '@/interfaces' +import { AccountType } from '@/interfaces/interfaces' import { serverFetch } from '@/utils/api-fetch' export interface ExchangeRateResponse { diff --git a/src/app/actions/external-accounts.ts b/src/app/actions/external-accounts.ts index 60445fd9f7..efad0fb477 100644 --- a/src/app/actions/external-accounts.ts +++ b/src/app/actions/external-accounts.ts @@ -1,5 +1,5 @@ import { type AddBankAccountPayload } from './types/users.types' -import { type IBridgeAccount } from '@/interfaces' +import { type IBridgeAccount } from '@/interfaces/interfaces' import { serverFetch } from '@/utils/api-fetch' export async function createBridgeExternalAccountForGuest( diff --git a/src/app/actions/onramp-quote.ts b/src/app/actions/onramp-quote.ts index 7ac69c6a7e..5692501ffe 100644 --- a/src/app/actions/onramp-quote.ts +++ b/src/app/actions/onramp-quote.ts @@ -1,5 +1,5 @@ import { fetchWithSentry } from '@/utils/sentry.utils' -import { AccountType } from '@/interfaces' +import { AccountType } from '@/interfaces/interfaces' import { PEANUT_API_URL } from '@/constants/general.consts' import { getAuthHeaders } from '@/utils/auth-token' diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts index 519e8f43ca..017bde0d66 100644 --- a/src/app/actions/sumsub.ts +++ b/src/app/actions/sumsub.ts @@ -48,7 +48,7 @@ export interface SelfHealResubmissionResponse { applicantId: string actionId: string externalActionId: string - requiredAction: 'REUPLOAD_ID' | 'REUPLOAD_ADDRESS_PROOF' | 'CONTACT_SUPPORT' + requiredAction: 'REUPLOAD_ID' | 'REUPLOAD_ADDRESS_PROOF' | 'CONTACT_SUPPORT' | 'RAIN_DOCUMENT' userMessage: string attempt: number maxAttempts: number @@ -86,7 +86,7 @@ export const restartIdentityVerification = async (): Promise<{ // initiate self-heal document resubmission for a provider-rejected user export const initiateSelfHealResubmission = async ( - provider: 'BRIDGE' | 'MANTECA', + provider: 'BRIDGE' | 'MANTECA' | 'RAIN', // Optional — target a specific (e.g. future-dated advisory) Bridge requirement // by key. Omitted for the legacy blocking flow (current nextAction). requirementKey?: string diff --git a/src/app/actions/users.ts b/src/app/actions/users.ts index a57b505eca..0d2e99de9e 100644 --- a/src/app/actions/users.ts +++ b/src/app/actions/users.ts @@ -1,10 +1,11 @@ import { type ApiUser } from '@/services/users' import { type AddBankAccountPayload, BridgeEndorsementType, type InitiateKycResponse } from './types/users.types' -import { type CounterpartyUser } from '@/interfaces' -import { type ContactsResponse } from '@/interfaces' +import { type CounterpartyUser } from '@/interfaces/interfaces' +import { type ContactsResponse } from '@/interfaces/interfaces' import { serverFetch } from '@/utils/api-fetch' +import { withStepUpHeader } from '@/services/step-up' -export const updateUserById = async (payload: Record): Promise<{ data?: ApiUser; error?: string }> => { +export const updateUserById = async (payload: Record): Promise<{ data?: ApiUser; error?: string }> => { try { const response = await serverFetch('/update-user', { method: 'POST', @@ -16,8 +17,8 @@ export const updateUserById = async (payload: Record): Promise<{ da return { error: responseJson.message || responseJson.error || 'Failed to update user' } } return { data: responseJson } - } catch (e: any) { - return { error: e.message || 'An unexpected error occurred' } + } catch (e) { + return { error: e instanceof Error ? e.message : 'An unexpected error occurred' } } } @@ -41,16 +42,33 @@ export const getKycDetails = async (params?: { } } return { data: responseJson } - } catch (e: any) { - return { error: e.message || 'An unexpected error occurred' } + } catch (e) { + return { error: e instanceof Error ? e.message : 'An unexpected error occurred' } } } -export const addBankAccount = async (payload: AddBankAccountPayload): Promise<{ data?: any; error?: string }> => { +// shape of the account returned by POST /users/accounts, as consumed by callers +export interface AddedBankAccount { + id: string + type: string + identifier?: string + bridgeAccountId?: string + bic?: string + routingNumber?: string + sortCode?: string + firstName?: string + lastName?: string + details: { accountOwnerName?: string; countryCode?: string } +} + +export const addBankAccount = async ( + payload: AddBankAccountPayload +): Promise<{ data?: AddedBankAccount; error?: string }> => { try { const response = await serverFetch('/users/accounts', { method: 'POST', body: JSON.stringify(payload), + headers: await withStepUpHeader({}), }) const responseJson = await response.json() @@ -63,8 +81,8 @@ export const addBankAccount = async (payload: AddBankAccountPayload): Promise<{ } } return { data: responseJson } - } catch (e: any) { - return { error: e.message || 'An unexpected error occurred' } + } catch (e) { + return { error: e instanceof Error ? e.message : 'An unexpected error occurred' } } } diff --git a/src/app/api/health/justaname/route.ts b/src/app/api/health/justaname/route.ts index c10f0ec78d..739fde0fb4 100644 --- a/src/app/api/health/justaname/route.ts +++ b/src/app/api/health/justaname/route.ts @@ -40,7 +40,13 @@ export async function GET() { // Test a second endpoint - ENS name lookup (if available) const lookupTestStart = Date.now() - let lookupHealth: any = { status: 'not_tested', message: 'Lookup endpoint not tested' } + let lookupHealth: { + status: string + message?: string + responseTime?: number + httpStatus?: number + error?: string + } = { status: 'not_tested', message: 'Lookup endpoint not tested' } try { // Test reverse ENS lookup if the API supports it diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index e2529b8e8e..1e9cfcb0e5 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -22,10 +22,29 @@ export const fetchCache = 'force-no-store' let lastNotificationTime = 0 const NOTIFICATION_COOLDOWN_MS = 30 * 60 * 1000 // 30 minutes +type ServiceHealth = { + status?: string + responseTime?: number + timestamp?: string + details?: Record + error?: string +} + +type HealthSummary = { total: number; healthy: number; degraded: number; unhealthy: number } + +type HealthReport = { + status: string + healthScore: number + timestamp: string + summary: HealthSummary + services: Record + systemInfo?: { environment?: string; version?: string; region?: string } +} + /** * Send Discord notification when system is unhealthy (with cooldown). */ -async function sendDiscordNotification(healthData: any) { +async function sendDiscordNotification(healthData: HealthReport) { try { const webhookUrl = process.env.DISCORD_WEBHOOK_URL if (!webhookUrl) { @@ -44,12 +63,12 @@ async function sendDiscordNotification(healthData: any) { lastNotificationTime = now const failedServices = Object.entries(healthData.services) - .filter(([_, service]: [string, any]) => service.status === 'unhealthy') - .map(([name, service]: [string, any]) => `• ${name}: ${service.error || 'unhealthy'}`) + .filter(([_, service]) => service.status === 'unhealthy') + .map(([name, service]) => `• ${name}: ${service.error || 'unhealthy'}`) const degradedServices = Object.entries(healthData.services) - .filter(([_, service]: [string, any]) => service.status === 'degraded') - .map(([name, service]: [string, any]) => `• ${name}: ${service.error || 'degraded'}`) + .filter(([_, service]) => service.status === 'degraded') + .map(([name, service]) => `• ${name}: ${service.error || 'degraded'}`) // Only @mention the role in production const isProduction = process.env.NODE_ENV === 'production' @@ -123,7 +142,7 @@ export async function GET() { clearTimeout(timeoutId) - const data = await response.json() + const data: ServiceHealth = await response.json() // Pass through the sub-check's own status rather than only trusting HTTP status. // Sub-checks now return degraded (HTTP 200) for non-critical partial failures. @@ -135,7 +154,7 @@ export async function GET() { }) ) - const results: any = { + const results: { services: Record; summary: HealthSummary } = { services: {}, summary: { total: services.length, @@ -150,7 +169,7 @@ export async function GET() { const serviceName = services[index] if (result.status === 'fulfilled') { - const serviceData = result.value as any + const serviceData = result.value const serviceStatus = serviceData.status || 'unhealthy' results.services[serviceName] = { diff --git a/src/app/api/health/rpc/route.ts b/src/app/api/health/rpc/route.ts index 59a57691c2..95749b9a15 100644 --- a/src/app/api/health/rpc/route.ts +++ b/src/app/api/health/rpc/route.ts @@ -17,6 +17,23 @@ const ALCHEMY_API_KEY = process.env.NEXT_PUBLIC_ALCHEMY_API_KEY // If a critical chain has zero healthy providers, overall status = unhealthy. const CRITICAL_CHAINS = new Set([1, 42161]) // Ethereum, Arbitrum +type ProviderHealth = { + status: 'healthy' | 'degraded' | 'unhealthy' + responseTime: number + blockNumber?: number | null + httpStatus?: number + error?: string + url: string +} + +type ChainHealth = { + chainId: number + critical: boolean + providers: Record + overallStatus: string + summary?: { total: number; healthy: number; degraded: number; unhealthy: number } +} + export async function GET() { const startTime = Date.now() @@ -34,7 +51,7 @@ export async function GET() { ) } - const chainResults: any = {} + const chainResults: Record = {} const chainsToTest = [ { id: 1, name: 'ethereum' }, @@ -117,7 +134,7 @@ export async function GET() { ) // Determine chain overall status - const chainProviders = Object.values(chainResults[chain.name].providers) as any[] + const chainProviders = Object.values(chainResults[chain.name].providers) const healthyCount = chainProviders.filter((p) => p.status === 'healthy').length const degradedCount = chainProviders.filter((p) => p.status === 'degraded').length const unhealthyCount = chainProviders.length - healthyCount - degradedCount diff --git a/src/app/api/health/zerodev/route.ts b/src/app/api/health/zerodev/route.ts index c360c8f1a2..6c28246337 100644 --- a/src/app/api/health/zerodev/route.ts +++ b/src/app/api/health/zerodev/route.ts @@ -34,7 +34,20 @@ export async function GET() { ) } - const results: any = { + type ProbeResult = { + status?: string + responseTime?: number + httpStatus?: number + entryPoints?: unknown + chainId?: unknown + message?: string + error?: string + } + + const results: { + arbitrum: { bundler: ProbeResult; paymaster: ProbeResult } + configuration: Record + } = { arbitrum: { bundler: {}, paymaster: {} }, configuration: { projectId: 'configured', diff --git a/src/app/api/og/route.tsx b/src/app/api/og/route.tsx index 190cb7e334..1202ea41ac 100644 --- a/src/app/api/og/route.tsx +++ b/src/app/api/og/route.tsx @@ -1,7 +1,7 @@ import { ImageResponse } from 'next/og' import { PaymentCardOG } from '@/components/og/PaymentCardOG' import { NextResponse, type NextRequest } from 'next/server' -import { type PaymentLink } from '@/interfaces' +import { type PaymentLink } from '@/interfaces/interfaces' import { promises as fs } from 'fs' import path from 'path' import getOrigin from '@/lib/hosting/get-origin' diff --git a/src/app/api/recent-transactions/route.ts b/src/app/api/recent-transactions/route.ts index 53ee649595..12cff58fbb 100644 --- a/src/app/api/recent-transactions/route.ts +++ b/src/app/api/recent-transactions/route.ts @@ -93,7 +93,7 @@ export async function POST(request: NextRequest, _context: { params: Promise
- Peanut + Peanut
diff --git a/src/app/kyc/success/page.tsx b/src/app/kyc/success/page.tsx index 7e050b722b..81c99a7cca 100644 --- a/src/app/kyc/success/page.tsx +++ b/src/app/kyc/success/page.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react' import Image from 'next/image' -import { HandThumbsUp } from '@/assets' +import HandThumbsUp from '@/assets/illustrations/hand-thumbs-up.svg' /* This page is just to let users know that their KYC was successful. Incase there's some issue with webosckets closing the modal, ideally this should not happen but added this as fallback guide diff --git a/src/app/lp/card/CardLandingPage.tsx b/src/app/lp/card/CardLandingPage.tsx index 401b45398d..6444eefc27 100644 --- a/src/app/lp/card/CardLandingPage.tsx +++ b/src/app/lp/card/CardLandingPage.tsx @@ -8,7 +8,8 @@ import PioneerCard3D from '@/components/LandingPage/PioneerCard3D' import { Marquee } from '@/components/LandingPage' import { useAuth } from '@/context/authContext' import { useRouter } from 'next/navigation' -import { Star, HandThumbsUp } from '@/assets' +import Star from '@/assets/illustrations/star.svg' +import HandThumbsUp from '@/assets/illustrations/hand-thumbs-up.svg' import { useEffect } from 'react' import underMaintenanceConfig from '@/config/underMaintenance.config' diff --git a/src/app/quests/[questId]/page.tsx b/src/app/quests/[questId]/page.tsx index 88b8654e09..679f3eb689 100644 --- a/src/app/quests/[questId]/page.tsx +++ b/src/app/quests/[questId]/page.tsx @@ -6,7 +6,7 @@ import { useRouter, useSearchParams } from 'next/navigation' import { motion } from 'framer-motion' import Image from 'next/image' import borderCloud from '@/assets/illustrations/border-cloud.svg' -import { Star } from '@/assets' +import Star from '@/assets/illustrations/star.svg' import { useEffect, useState, useCallback } from 'react' import { Button } from '@/components/0_Bruddle/Button' import { QuestLeaderboard } from '../components/QuestLeaderboard' diff --git a/src/app/quests/components/QuestsHero.tsx b/src/app/quests/components/QuestsHero.tsx index 4fbc2434a5..644abc7af9 100644 --- a/src/app/quests/components/QuestsHero.tsx +++ b/src/app/quests/components/QuestsHero.tsx @@ -5,7 +5,7 @@ import { motion } from 'framer-motion' import { QuestCard } from './QuestCard' import borderCloud from '@/assets/illustrations/border-cloud.svg' import handPointing from '@/assets/illustrations/got-it-hand.svg' -import { Star } from '@/assets' +import Star from '@/assets/illustrations/star.svg' import { useRouter, useSearchParams } from 'next/navigation' import Image from 'next/image' import { Button } from '@/components/0_Bruddle/Button' diff --git a/src/app/quests/explore/page.tsx b/src/app/quests/explore/page.tsx index cfaa2e94ca..08ef266bbc 100644 --- a/src/app/quests/explore/page.tsx +++ b/src/app/quests/explore/page.tsx @@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation' import { motion } from 'framer-motion' import Image from 'next/image' import borderCloud from '@/assets/illustrations/border-cloud.svg' -import { Star } from '@/assets' +import Star from '@/assets/illustrations/star.svg' import { useEffect, useState, useMemo, useCallback } from 'react' import { Button } from '@/components/0_Bruddle/Button' import { QuestLeaderboard } from '../components/QuestLeaderboard' @@ -144,7 +144,13 @@ export default function QuestsExplorePage() {
- DevConnect + DevConnect

QUESTS

diff --git a/src/components/AddMoney/components/AddMoneyBankDetails.tsx b/src/components/AddMoney/components/AddMoneyBankDetails.tsx index ab6141b0d6..97e5f39e71 100644 --- a/src/components/AddMoney/components/AddMoneyBankDetails.tsx +++ b/src/components/AddMoney/components/AddMoneyBankDetails.tsx @@ -450,6 +450,12 @@ export default function AddMoneyBankDetails(props: AddMoneyBankDetailsProps) { shortDepositReference(onrampData?.depositInstructions?.depositMessage) || tCommon('loading'), }), + // name mismatch is the top cause of returned deposits (Bridge BE01). the + // own-name rule holds for ACH/SEPA/wire; MX SPEI supports paying from a + // third-party/client account, so this item is omitted there. + ...(currentCountryDetails?.id !== 'MX' + ? [t('bankDetails.doubleCheckSenderName'), t('bankDetails.doubleCheckRecipientName')] + : []), ]} /> diff --git a/src/components/AddMoney/components/MantecaAddMoney.tsx b/src/components/AddMoney/components/MantecaAddMoney.tsx index f34a7fef3a..696ee67c4c 100644 --- a/src/components/AddMoney/components/MantecaAddMoney.tsx +++ b/src/components/AddMoney/components/MantecaAddMoney.tsx @@ -263,8 +263,8 @@ const MantecaAddMoney: FC = () => { onVerify={async () => { if (mantecaRejection.state === 'blocked') { // blocked users cannot self-heal — route to support - if (typeof window !== 'undefined' && (window as any).$crisp) { - ;(window as any).$crisp.push(['do', 'chat:open']) + if (typeof window !== 'undefined' && window.$crisp) { + window.$crisp.push(['do', 'chat:open']) } setShowKycModal(false) return diff --git a/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx b/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx index bd06d013f6..0a80826169 100644 --- a/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx +++ b/src/components/AddMoney/components/__tests__/MantecaPixQrDeposit.test.tsx @@ -41,7 +41,7 @@ jest.mock('@/components/0_Bruddle/Button', () => ({ ), })) -// eslint-disable-next-line import/first -- must come after jest.mock +// must come after the jest.mock calls above import MantecaPixQrDeposit from '../MantecaPixQrDeposit' const PIX_CODE = '00020126-COPIA-E-COLA' diff --git a/src/components/AddMoney/hooks/__tests__/useMantecaDepositPolling.test.tsx b/src/components/AddMoney/hooks/__tests__/useMantecaDepositPolling.test.tsx index f6ff2041d2..4bbf456ca8 100644 --- a/src/components/AddMoney/hooks/__tests__/useMantecaDepositPolling.test.tsx +++ b/src/components/AddMoney/hooks/__tests__/useMantecaDepositPolling.test.tsx @@ -12,7 +12,7 @@ jest.mock('@/services/manteca', () => ({ mantecaApi: { getDepositStatus: mockGetDepositStatus }, })) -// eslint-disable-next-line import/first -- must come after jest.mock +// must come after the jest.mock calls above import { useMantecaDepositPolling } from '../useMantecaDepositPolling' describe('useMantecaDepositPolling', () => { diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 836f5eec18..6f406085b9 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -18,7 +18,7 @@ import { DynamicBankAccountForm, type IBankAccountDetails } from './DynamicBankA import { addBankAccount } from '@/app/actions/users' import { type AddBankAccountPayload } from '@/app/actions/types/users.types' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' -import { type Account } from '@/interfaces' +import { type Account } from '@/interfaces/interfaces' import { getCountryCodeForWithdraw } from '@/utils/withdraw.utils' import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' import { useAppDispatch } from '@/redux/hooks' @@ -29,7 +29,7 @@ import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' import { useCapabilities } from '@/hooks/useCapabilities' -import { getKycModalVariant, getGateUserMessage } from '@/utils/capability-gate' +import { resolveKycModalVariant, getGateUserMessage } from '@/utils/capability-gate' import { railJurisdictionForBank } from '@/utils/bridge.utils' import { getRegionIntent } from '@/utils/regions.utils' import { useTosGuard } from '@/hooks/useTosGuard' @@ -382,7 +382,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { }} isLoading={sumsubFlow.isLoading} error={sumsubFlow.error} - variant={getKycModalVariant(gate.kind)} + variant={resolveKycModalVariant(gate)} providerMessage={getGateUserMessage(gate)} regionName={currentCountry?.title} /> diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx index 53cd38777d..3bfd39ee35 100644 --- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx +++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx @@ -1,11 +1,11 @@ 'use client' import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react' -import { useForm, Controller } from 'react-hook-form' +import { useForm, Controller, type ControllerRenderProps, type FieldPath, type RegisterOptions } from 'react-hook-form' import { useAuth } from '@/context/authContext' import { Button } from '@/components/0_Bruddle/Button' import { type AddBankAccountPayload, BridgeAccountOwnerType, BridgeAccountType } from '@/app/actions/types/users.types' import BaseInput from '@/components/0_Bruddle/BaseInput' -import BaseSelect from '@/components/0_Bruddle/BaseSelect' +import BaseSelect, { type BaseSelectOption } from '@/components/0_Bruddle/BaseSelect' import { BRIDGE_ALPHA3_TO_ALPHA2, ALL_COUNTRIES_ALPHA3_TO_ALPHA2 } from '@/components/AddMoney/consts' import { useParams, useRouter, useSearchParams } from 'next/navigation' import { @@ -304,8 +304,8 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D dispatch(bankFormActions.setFormData(formDataToSave)) setIsSubmitting(false) } - } catch (error: any) { - setSubmissionError(error.message) + } catch (error) { + setSubmissionError(error instanceof Error ? error.message : String(error)) } finally { setIsSubmitting(false) } @@ -328,13 +328,13 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D } } - const renderInput = ( - name: keyof IBankAccountDetails, + const renderInput = >( + name: TName, placeholder: string, - rules: any, + rules: RegisterOptions, type: string = 'text', rightAdornment?: React.ReactNode, - onBlur?: (field: any) => Promise | void, + onBlur?: (field: ControllerRenderProps) => Promise | void, showCharCount?: boolean, maxLength?: number ) => { @@ -391,7 +391,12 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D ) } - const renderSelect = (name: keyof IBankAccountDetails, placeholder: string, options: any[], rules: any) => ( + const renderSelect = ( + name: keyof IBankAccountDetails, + placeholder: string, + options: BaseSelectOption[], + rules: RegisterOptions + ) => (

void }, D
{renderInput('accountOwnerName', t('accountOwnerName'), { required: t('accountOwnerNameRequired'), - validate: (value: string) => { - const trimmed = value.trim() + validate: (value: string | undefined) => { + const trimmed = value?.trim() ?? '' const parts = trimmed.split(/\s+/) if (parts.length < 2) { return t('accountOwnerNameFull') @@ -464,8 +469,8 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D
{renderInput('accountOwnerName', t('accountOwnerName'), { required: t('accountOwnerNameRequired'), - validate: (value: string) => { - const trimmed = value.trim() + validate: (value: string | undefined) => { + const trimmed = value?.trim() ?? '' const parts = trimmed.split(/\s+/) if (parts.length < 2) { return t('accountOwnerNameFull') @@ -488,8 +493,8 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D
{renderInput('accountOwnerName', t('accountOwnerName'), { required: t('accountOwnerNameRequired'), - validate: (value: string) => { - const trimmed = value.trim() + validate: (value: string | undefined) => { + const trimmed = value?.trim() ?? '' const parts = trimmed.split(/\s+/) if (parts.length < 2) { return t('accountOwnerNameFull') diff --git a/src/components/AddWithdraw/__tests__/AddWithdrawRouterView.test.tsx b/src/components/AddWithdraw/__tests__/AddWithdrawRouterView.test.tsx index 1f2e3d1083..bc14d9c9bc 100644 --- a/src/components/AddWithdraw/__tests__/AddWithdrawRouterView.test.tsx +++ b/src/components/AddWithdraw/__tests__/AddWithdrawRouterView.test.tsx @@ -146,10 +146,12 @@ function SelectedMethodProbe() { function Harness({ user }: { user: MockUser }) { mockUser = user return ( - - - - + + + + + + ) } diff --git a/src/components/Badges/BadgeStatusDrawer.tsx b/src/components/Badges/BadgeStatusDrawer.tsx index d35306370b..abd33dcaa3 100644 --- a/src/components/Badges/BadgeStatusDrawer.tsx +++ b/src/components/Badges/BadgeStatusDrawer.tsx @@ -1,4 +1,4 @@ -import { Drawer, DrawerContent } from '@/components/Global/Drawer' +import { Drawer, DrawerContent, DrawerTitle } from '@/components/Global/Drawer' import Image from 'next/image' import { useState } from 'react' import { useFormatter, useTranslations } from 'next-intl' @@ -76,7 +76,9 @@ export const BadgeStatusDrawer = ({ isOpen, onClose, badge }: BadgeStatusDrawerP

{t('unlocked')}

-

{displayName}

+ + {displayName} +
diff --git a/src/components/Badges/BadgesRow.tsx b/src/components/Badges/BadgesRow.tsx index f2ea4bbb34..02269d5818 100644 --- a/src/components/Badges/BadgesRow.tsx +++ b/src/components/Badges/BadgesRow.tsx @@ -40,7 +40,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..4e739d9c6c --- /dev/null +++ b/src/components/Badges/__tests__/BadgesRow.test.tsx @@ -0,0 +1,43 @@ +import { render } from '@testing-library/react' +import type { ComponentProps } from 'react' +import { NextIntlClientProvider } from 'next-intl' +import en from '@/i18n/app/messages/en.json' +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']) + }) +}) diff --git a/src/components/Badges/badge.types.ts b/src/components/Badges/badge.types.ts index dacd661557..9c1254b261 100644 --- a/src/components/Badges/badge.types.ts +++ b/src/components/Badges/badge.types.ts @@ -8,4 +8,5 @@ export type BadgeHistoryEntry = { iconUrl?: string | null } -export const isBadgeHistoryItem = (entry: any): entry is BadgeHistoryEntry => !!entry?.isBadge +export const isBadgeHistoryItem = (entry: unknown): entry is BadgeHistoryEntry => + typeof entry === 'object' && entry !== null && !!(entry as { isBadge?: unknown }).isBadge diff --git a/src/components/Card/ApplicationStatusScreen.tsx b/src/components/Card/ApplicationStatusScreen.tsx index f3d66c358c..c9ee329f98 100644 --- a/src/components/Card/ApplicationStatusScreen.tsx +++ b/src/components/Card/ApplicationStatusScreen.tsx @@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl' import { PeanutCrying } from '@/assets/mascot' import NavHeader from '@/components/Global/NavHeader' import Loading from '@/components/Global/Loading' +import { Button } from '@/components/0_Bruddle/Button' type Variant = 'pending' | 'manual-review' | 'requires-info' | 'requires-support' | 'rejected' @@ -15,6 +16,13 @@ interface Props { * user sees what specifically is missing. Provider-neutral by contract. */ reasonMessage?: string onContactSupport?: () => 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 } @@ -32,7 +40,14 @@ const COPY_KEYS = { /** 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 t = useTranslations('card') const tCommon = useTranslations('common') const copyKeys = COPY_KEYS[variant] @@ -57,6 +72,14 @@ const ApplicationStatusScreen: FC = ({ variant, reasonMessage, onContactS {reasonMessage &&

{reasonMessage}

}

{t(copyKeys.body)}

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

{uploadError}

} +
+ )} {SUPPORT_VARIANTS.has(variant) && onContactSupport && ( )}
- - ) : ( - <> - •••• {last4} -
- {isVirtual ? ( + {isVirtual && ( +
{t('virtual')} - ) : ( - - )} +
+ )} + + ) : ( + <> + {/* Eye sits inline with the number — the bottom-right corner + * is where the hand's arm rests when masked, so the reveal + * toggle lives in the hand-free left zone instead. */} +
+ •••• {last4} {onToggleReveal && ( )}
+ {isVirtual && ( +
+ + {t('virtual')} + +
+ )} )}
diff --git a/src/components/Card/CardLimitEditModal.tsx b/src/components/Card/CardLimitEditModal.tsx index d9d83a969c..7fccdafdd6 100644 --- a/src/components/Card/CardLimitEditModal.tsx +++ b/src/components/Card/CardLimitEditModal.tsx @@ -9,6 +9,7 @@ import { Button } from '@/components/0_Bruddle/Button' import { Icon } from '@/components/Global/Icons/Icon' import { rainApi, type RainCardLimit, type RainLimitFrequency } from '@/services/rain' import { RAIN_CARD_OVERVIEW_QUERY_KEY } from '@/hooks/useRainCardOverview' +import { useReturnExcessCollateral } from '@/hooks/wallet/useReturnExcessCollateral' export const CARD_LIMITS_QUERY_KEY = 'rain-card-limits' @@ -24,6 +25,7 @@ interface Props { const CardLimitEditModal: FC = ({ cardId, frequency, label, initialAmountCents, isOpen, onClose }) => { const t = useTranslations('card.limits') const queryClient = useQueryClient() + const { returnExcess } = useReturnExcessCollateral() const [value, setValue] = useState(initialAmountCents != null ? (initialAmountCents / 100).toFixed(2) : '') const [saving, setSaving] = useState(false) const [error, setError] = useState(null) @@ -51,6 +53,33 @@ const CardLimitEditModal: FC = ({ cardId, frequency, label, initialAmount try { const payload: RainCardLimit[] = [{ amount: amountCents, frequency }] await rainApi.updateCardLimits(cardId, payload) + // The card's backing tracks the per-transaction limit. If it now + // holds more than the new limit, return the difference to the + // user's wallet — surfaced only as a passkey prompt. Ordering + // matters: the PATCH above must land first so the auto-balancer's + // target is already lowered and can't race the withdrawal by + // topping the collateral back up. + // Non-fatal: the limit change itself succeeded, the unified + // displayed balance is identical either way, and re-saving the + // limit retries the return — so a cancelled passkey or withdrawal + // cooldown never blocks the modal. + if (frequency === 'perAuthorization') { + try { + const returnedCents = await returnExcess(amountCents) + if (returnedCents > 0) { + posthog.capture(ANALYTICS_EVENTS.CARD_LIMIT_EXCESS_RETURNED, { + returned_cents: returnedCents, + new_limit_cents: amountCents, + }) + } + } catch (excessError) { + posthog.capture(ANALYTICS_EVENTS.CARD_LIMIT_EXCESS_RETURN_FAILED, { + new_limit_cents: amountCents, + error_kind: (excessError as Error)?.name ?? 'unknown', + error_message: (excessError as Error)?.message, + }) + } + } await Promise.all([ queryClient.invalidateQueries({ queryKey: [CARD_LIMITS_QUERY_KEY, cardId] }), queryClient.invalidateQueries({ queryKey: [RAIN_CARD_OVERVIEW_QUERY_KEY] }), diff --git a/src/components/Card/PhysicalCardScreen.tsx b/src/components/Card/PhysicalCardScreen.tsx index f87fdaeffd..8c33aab8bd 100644 --- a/src/components/Card/PhysicalCardScreen.tsx +++ b/src/components/Card/PhysicalCardScreen.tsx @@ -77,7 +77,13 @@ const PhysicalCardScreen: FC = ({ cardId, last4, onPrev }) => {

{t('onListTitle')}

-

{t('onListBody', { position: String(data.position) })}

+

+ {/* position is nullable — without the branch a user with no + queue position is told they are "#null" on the list. */} + {data.position === null + ? t('onListBodyNoPosition') + : t('onListBody', { position: data.position.toLocaleString() })} +

) : (
diff --git a/src/components/Card/__tests__/ApplicationStatusScreen.test.tsx b/src/components/Card/__tests__/ApplicationStatusScreen.test.tsx index fc74a63540..2f3bddf37c 100644 --- a/src/components/Card/__tests__/ApplicationStatusScreen.test.tsx +++ b/src/components/Card/__tests__/ApplicationStatusScreen.test.tsx @@ -27,7 +27,6 @@ jest.mock('@/context/authContext', () => ({ // next/image → plain img so jsdom doesn't choke on the optimizer. 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, })) @@ -66,3 +65,56 @@ describe('ApplicationStatusScreen — rejected', () => { expect(onContactSupport).toHaveBeenCalledTimes(1) }) }) + +describe('ApplicationStatusScreen — proof-of-address upload CTA', () => { + it('renders the upload CTA as primary when the rail carries a PoA action', () => { + const onUpload = jest.fn() + render( + + ) + fireEvent.click(screen.getByText('Upload proof of address')) + expect(onUpload).toHaveBeenCalledTimes(1) + // Contact support stays available as the fallback path. + expect(screen.getByText('Contact support')).toBeInTheDocument() + }) + + it('renders the upload CTA on the rejected variant too (denied + fixable PoA)', () => { + render( + + ) + expect(screen.getByText('Upload proof of address')).toBeInTheDocument() + }) + + it('omits the upload CTA when no PoA action exists', () => { + render() + expect(screen.queryByText('Upload proof of address')).not.toBeInTheDocument() + }) + + it('never renders the upload CTA on non-support variants', () => { + render() + expect(screen.queryByText('Upload proof of address')).not.toBeInTheDocument() + }) +}) + +describe('ApplicationStatusScreen — upload error', () => { + it('renders the inline error under the upload CTA', () => { + render( + + ) + expect(screen.getByText('Could not start the upload. Please try again.')).toBeInTheDocument() + }) +}) diff --git a/src/components/Card/__tests__/PhysicalCardScreen.test.tsx b/src/components/Card/__tests__/PhysicalCardScreen.test.tsx new file mode 100644 index 0000000000..33f3a4f969 --- /dev/null +++ b/src/components/Card/__tests__/PhysicalCardScreen.test.tsx @@ -0,0 +1,67 @@ +/** + * 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 { NextIntlClientProvider } from 'next-intl' +import en from '@/i18n/app/messages/en.json' +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(/#/) + }) + + // 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() + const body = await screen.findByText(/on the list\./) + expect(body.textContent).toContain(`You are #${(1234).toLocaleString()} on the list.`) + }) +}) diff --git a/src/components/Card/share-asset/RejectionAssetD3.tsx b/src/components/Card/share-asset/RejectionAssetD3.tsx index 6dc3cbd0a3..9a399a3541 100644 --- a/src/components/Card/share-asset/RejectionAssetD3.tsx +++ b/src/components/Card/share-asset/RejectionAssetD3.tsx @@ -87,7 +87,6 @@ const RejectionAssetD3: FC = ({ fontFamily: 'var(--font-roboto), system-ui, sans-serif', }} > - {/* eslint-disable-next-line react/no-unknown-property -- styled-jsx `jsx` attr, same pattern as ShareAssetD3 */}