From e903b4da11d797e6195890775483dee1e874b884 Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Wed, 27 May 2026 20:51:49 +0000 Subject: [PATCH 01/23] fix(seo): content-gate loaders + receive-from sources to kill live 404s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Followup to #2115. SEO loaders registered a slug whenever its directory existed, even with no en.md — the route then 404s at render. The live symptom: /{locale}/receive-money-from/{colombia,mexico} (10 URLs) — both are corridor origins but have no content/receive-from/{slug}/ article, and getReceiveSources() derived purely from CORRIDORS.from. - corridors.ts: centralize + gate receive sources as RECEIVE_SOURCES (corridor origins ∩ receive-from articles). receive-money-from page and sitemap.ts both consume it, so the route set, sitemap, and CI agree. Also gate loadCountries / loadCorridors on en.md presence. - exchanges.ts, payment-methods.ts, comparisons.ts: same en.md gate (the CodeRabbit fragility class). No-op on today's content; defends the next missing-en.md dir from shipping a 404. - utils.ts: displayNameFromContent returned the un-trimmed name despite validating with .trim() — whitespace leaked into breadcrumbs/links. - Footer.tsx: 4 hardcoded /en/{content,help,terms,privacy} bare tags → localized . The locale prop already existed; non-en visitors were bounced back to English. - verify-content.ts Pass 11: gate receive sources by receive-from/{slug}/ en.md in both the route index and the expected-sitemap set, so CI reflects runtime instead of agreeing with itself on a 404ing slug. Tests: utils trim + RECEIVE_SOURCES content gate (regression guard). --- scripts/verify-content.ts | 15 ++++++-- .../receive-money-from/[country]/page.tsx | 14 +++----- src/app/sitemap.ts | 15 +++++--- src/components/LandingPage/Footer.tsx | 16 ++++----- src/data/seo/comparisons.ts | 5 +-- src/data/seo/corridors.test.ts | 32 +++++++++++++++++ src/data/seo/corridors.ts | 34 +++++++++++++++++-- src/data/seo/exchanges.ts | 7 ++-- src/data/seo/index.ts | 2 +- src/data/seo/payment-methods.ts | 5 +-- src/data/seo/utils.test.ts | 28 +++++++++++++++ src/data/seo/utils.ts | 2 +- 12 files changed, 139 insertions(+), 36 deletions(-) create mode 100644 src/data/seo/corridors.test.ts create mode 100644 src/data/seo/utils.test.ts diff --git a/scripts/verify-content.ts b/scripts/verify-content.ts index 734bda315..7d9ed1e85 100644 --- a/scripts/verify-content.ts +++ b/scripts/verify-content.ts @@ -83,6 +83,17 @@ function listDirs(dir: string): string[] { .map((d) => d.name) } +/** + * Receive-money-from pages render only for corridor origins that actually have + * a receive-from article. Mirrors RECEIVE_SOURCES in src/data/seo/corridors.ts. + * Without this gate, both the route index and the sitemap "expected URLs" would + * agree with each other on a slug that 404s at runtime (e.g. colombia, mexico). + */ +function gateReceiveSources(corridors: Array<{ from: string; to: string }>): string[] { + const origins = [...new Set(corridors.map((c) => c.from))] + return origins.filter((slug) => fs.existsSync(path.join(CONTENT_DIR, 'receive-from', slug, 'en.md'))) +} + function getAllMdFiles(dir: string): string[] { const results: string[] = [] if (!fs.existsSync(dir)) return results @@ -192,7 +203,7 @@ function discoverRoutes(): Set { corridors.push({ to: dest, from: origin }) } } - const receiveSources = [...new Set(corridors.map((c) => c.from))] + const receiveSources = gateReceiveSources(corridors) // Check which routes actually have page.tsx files const hasRoute = (routePath: string) => { @@ -704,7 +715,7 @@ function expectedSitemapUrls(): string[] { const fromDir = path.join(CONTENT_DIR, 'send-to', dest, 'from') for (const origin of listDirs(fromDir)) corridors.push({ from: origin, to: dest }) } - const receiveSources = [...new Set(corridors.map((c) => c.from))] + const receiveSources = gateReceiveSources(corridors) for (const locale of SUPPORTED_LOCALES) { for (const slug of countrySlugs) { diff --git a/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx b/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx index a1dbb2a0d..82da4c851 100644 --- a/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx +++ b/src/app/[locale]/(marketing)/receive-money-from/[country]/page.tsx @@ -1,7 +1,7 @@ import { notFound } from 'next/navigation' import { type Metadata } from 'next' import { generateMetadata as metadataHelper } from '@/app/metadata' -import { CORRIDORS, getCountryName } from '@/data/seo' +import { RECEIVE_SOURCES, getCountryName } from '@/data/seo' import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config' import type { Locale } from '@/i18n/types' import { getTranslations, t } from '@/i18n' @@ -13,21 +13,15 @@ interface PageProps { params: Promise<{ locale: string; country: string }> } -/** Unique sending countries */ -function getReceiveSources(): string[] { - return [...new Set(CORRIDORS.map((c) => c.from))] -} - export async function generateStaticParams() { - const sources = getReceiveSources() - return SUPPORTED_LOCALES.flatMap((locale) => sources.map((country) => ({ locale, country }))) + return SUPPORTED_LOCALES.flatMap((locale) => RECEIVE_SOURCES.map((country) => ({ locale, country }))) } export const dynamicParams = false export async function generateMetadata({ params }: PageProps): Promise { const { locale, country } = await params if (!isValidLocale(locale)) return {} - if (!getReceiveSources().includes(country)) return {} + if (!RECEIVE_SOURCES.includes(country)) return {} const mdxContent = readPageContentLocalized('receive-from', country, locale) if (!mdxContent || mdxContent.frontmatter.published === false) return {} @@ -51,7 +45,7 @@ export async function generateMetadata({ params }: PageProps): Promise export default async function ReceiveMoneyPage({ params }: PageProps) { const { locale, country } = await params if (!isValidLocale(locale)) notFound() - if (!getReceiveSources().includes(country)) notFound() + if (!RECEIVE_SOURCES.includes(country)) notFound() const mdxSource = readPageContentLocalized('receive-from', country, locale) if (!mdxSource || mdxSource.frontmatter.published === false) notFound() diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index e0f736d9d..83d954b1d 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,6 +1,14 @@ import { type MetadataRoute } from 'next' import { BASE_URL } from '@/constants/general.consts' -import { COUNTRIES_SEO, CORRIDORS, COMPETITORS, EXCHANGES, DEPOSIT_RAILS, PAYMENT_METHOD_SLUGS } from '@/data/seo' +import { + COUNTRIES_SEO, + CORRIDORS, + RECEIVE_SOURCES, + COMPETITORS, + EXCHANGES, + DEPOSIT_RAILS, + PAYMENT_METHOD_SLUGS, +} from '@/data/seo' import { SUPPORTED_LOCALES } from '@/i18n/config' import { listContentSlugs, listPublishedSlugs } from '@/lib/content' @@ -62,9 +70,8 @@ async function generateSitemap(): Promise { }) } - // Receive money pages (unique sending countries from corridors) - const receiveSources = [...new Set(CORRIDORS.map((c) => c.from))] - for (const source of receiveSources) { + // Receive money pages — corridor origins that have a receive-from article + for (const source of RECEIVE_SOURCES) { pages.push({ path: `/${locale}/receive-money-from/${source}`, priority: 0.7 * basePriority, diff --git a/src/components/LandingPage/Footer.tsx b/src/components/LandingPage/Footer.tsx index 1497e3461..f66a24900 100644 --- a/src/components/LandingPage/Footer.tsx +++ b/src/components/LandingPage/Footer.tsx @@ -63,18 +63,18 @@ const Footer = ({ showSiteDirectory = true, locale = 'en' }: { showSiteDirectory Support - + Content - - + + Docs - - + + Terms - - + + Privacy - + { const result: Record = {} for (const slug of listContentSlugs('compare')) { + // Skip slug dirs with no en.md — /compare/peanut-vs-{slug} would 404. const content = readPageContent<{ name?: unknown; published?: boolean }>('compare', slug, 'en') - if (content && content.frontmatter.published === false) continue - result[slug] = { name: displayNameFromContent(slug, content?.frontmatter) } + if (!content || content.frontmatter.published === false) continue + result[slug] = { name: displayNameFromContent(slug, content.frontmatter) } } return result } diff --git a/src/data/seo/corridors.test.ts b/src/data/seo/corridors.test.ts new file mode 100644 index 000000000..3bc5335dd --- /dev/null +++ b/src/data/seo/corridors.test.ts @@ -0,0 +1,32 @@ +import fs from 'fs' +import path from 'path' +import { CORRIDORS, RECEIVE_SOURCES } from './corridors' + +const RECEIVE_FROM_DIR = path.join(process.cwd(), 'src/content/content/receive-from') + +// Regression guard for the May 2026 live 404s: receive-money-from rendered for +// every corridor origin, but origins lacking a receive-from article (colombia, +// mexico) 404ed. RECEIVE_SOURCES must be CORRIDORS.from gated by content. +describe('RECEIVE_SOURCES', () => { + it('only contains corridor origins', () => { + const origins = new Set(CORRIDORS.map((c) => c.from)) + for (const slug of RECEIVE_SOURCES) { + expect(origins.has(slug)).toBe(true) + } + }) + + it('only contains origins that have a receive-from article (no 404s)', () => { + for (const slug of RECEIVE_SOURCES) { + const enFile = path.join(RECEIVE_FROM_DIR, slug, 'en.md') + expect(fs.existsSync(enFile)).toBe(true) + } + }) + + it('drops corridor origins with no receive-from article', () => { + const origins = [...new Set(CORRIDORS.map((c) => c.from))] + for (const slug of origins) { + const hasArticle = fs.existsSync(path.join(RECEIVE_FROM_DIR, slug, 'en.md')) + expect(RECEIVE_SOURCES.includes(slug)).toBe(hasArticle) + } + }) +}) diff --git a/src/data/seo/corridors.ts b/src/data/seo/corridors.ts index b40a3f2fe..c1f14bfa4 100644 --- a/src/data/seo/corridors.ts +++ b/src/data/seo/corridors.ts @@ -16,7 +16,13 @@ // — now render the MDX body directly. We keep only the slug list and the // display-name resolver. -import { listContentSlugs, listCorridorOrigins, readPageContent, readPageContentLocalized } from '@/lib/content' +import { + listContentSlugs, + listCorridorOrigins, + readCorridorContent, + readPageContent, + readPageContentLocalized, +} from '@/lib/content' import type { Locale } from '@/i18n/types' import { displayNameFromContent } from './utils' @@ -32,9 +38,11 @@ export interface Corridor { function loadCountries(): Record { const result: Record = {} for (const slug of listContentSlugs('countries')) { + // A slug directory with no en.md has no render target — the route + // would 404. Gate on content presence, not just directory existence. const content = readPageContent<{ name?: unknown; published?: boolean }>('countries', slug, 'en') - if (content && content.frontmatter.published === false) continue - result[slug] = { name: displayNameFromContent(slug, content?.frontmatter) } + if (!content || content.frontmatter.published === false) continue + result[slug] = { name: displayNameFromContent(slug, content.frontmatter) } } return result } @@ -44,6 +52,9 @@ function loadCorridors(): Corridor[] { const corridors: Corridor[] = [] for (const dest of listContentSlugs('send-to')) { for (const origin of listCorridorOrigins(dest)) { + // Skip corridor dirs with no en.md — send-money-from would 404. + const content = readCorridorContent<{ published?: boolean }>(dest, origin, 'en') + if (!content || content.frontmatter.published === false) continue const key = `${origin}→${dest}` if (seen.has(key)) continue seen.add(key) @@ -53,8 +64,25 @@ function loadCorridors(): Corridor[] { return corridors } +/** + * Origins for the receive-money-from pages. The set is the corridor origins, + * but only those that actually have a receive-from article — an origin present + * in CORRIDORS.from but missing content/receive-from/{slug}/en.md would 404 + * (this is how colombia & mexico shipped as live 404s in May 2026). The + * receive-from content tree is authored independently of corridors, so the two + * sets don't line up automatically. + */ +function loadReceiveSources(corridors: Corridor[]): string[] { + const origins = [...new Set(corridors.map((c) => c.from))] + return origins.filter((slug) => { + const content = readPageContent<{ published?: boolean }>('receive-from', slug, 'en') + return content !== null && content.frontmatter.published !== false + }) +} + export const COUNTRIES_SEO: Record = loadCountries() export const CORRIDORS: Corridor[] = loadCorridors() +export const RECEIVE_SOURCES: string[] = loadReceiveSources(CORRIDORS) /** * Get the country display name for a slug at the given locale. Reads diff --git a/src/data/seo/exchanges.ts b/src/data/seo/exchanges.ts index e80f25e17..568ecbcec 100644 --- a/src/data/seo/exchanges.ts +++ b/src/data/seo/exchanges.ts @@ -53,11 +53,12 @@ function loadExchanges(): Record { const result: Record = {} for (const slug of listContentSlugs('deposit')) { if (RAIL_SLUGS.has(slug)) continue + // Skip slug dirs with no en.md — /deposit/from-{slug} would 404. const content = readPageContent('deposit', slug, 'en') - if (content && content.frontmatter.published === false) continue + if (!content || content.frontmatter.published === false) continue result[slug] = { - name: displayNameFromContent(slug, content?.frontmatter), - recommendedNetwork: pickRecommendedNetwork(content?.frontmatter), + name: displayNameFromContent(slug, content.frontmatter), + recommendedNetwork: pickRecommendedNetwork(content.frontmatter), } } return result diff --git a/src/data/seo/index.ts b/src/data/seo/index.ts index a17b2ebd8..82a122ea2 100644 --- a/src/data/seo/index.ts +++ b/src/data/seo/index.ts @@ -1,4 +1,4 @@ -export { COUNTRIES_SEO, CORRIDORS, getCountryName } from './corridors' +export { COUNTRIES_SEO, CORRIDORS, RECEIVE_SOURCES, getCountryName } from './corridors' export { COMPETITORS } from './comparisons' diff --git a/src/data/seo/payment-methods.ts b/src/data/seo/payment-methods.ts index 5666f59bb..dcdc66046 100644 --- a/src/data/seo/payment-methods.ts +++ b/src/data/seo/payment-methods.ts @@ -20,9 +20,10 @@ export interface PaymentMethod { function loadPaymentMethods(): Record { const result: Record = {} for (const slug of listContentSlugs('pay-with')) { + // Skip slug dirs with no en.md — /pay-with/{slug} would 404. const content = readPageContent<{ name?: unknown; published?: boolean }>('pay-with', slug, 'en') - if (content && content.frontmatter.published === false) continue - result[slug] = { slug, name: displayNameFromContent(slug, content?.frontmatter) } + if (!content || content.frontmatter.published === false) continue + result[slug] = { slug, name: displayNameFromContent(slug, content.frontmatter) } } return result } diff --git a/src/data/seo/utils.test.ts b/src/data/seo/utils.test.ts new file mode 100644 index 000000000..5f5e620b2 --- /dev/null +++ b/src/data/seo/utils.test.ts @@ -0,0 +1,28 @@ +import { displayNameFromContent, titleCaseSlug } from './utils' + +describe('displayNameFromContent', () => { + it('returns the frontmatter name when present', () => { + expect(displayNameFromContent('wise', { name: 'Wise' })).toBe('Wise') + }) + + it('trims surrounding whitespace from the frontmatter name', () => { + // Validated with .trim() but used to return the raw value — whitespace + // leaked into breadcrumbs / link text. + expect(displayNameFromContent('wise', { name: ' Wise ' })).toBe('Wise') + expect(displayNameFromContent('wise', { name: 'Wise\n' })).toBe('Wise') + }) + + it('falls back to title-cased slug for missing / blank / non-string names', () => { + expect(displayNameFromContent('western-union', undefined)).toBe('Western Union') + expect(displayNameFromContent('western-union', { name: ' ' })).toBe('Western Union') + expect(displayNameFromContent('western-union', { name: 42 })).toBe('Western Union') + expect(displayNameFromContent('western-union', null)).toBe('Western Union') + }) +}) + +describe('titleCaseSlug', () => { + it('title-cases a kebab slug', () => { + expect(titleCaseSlug('united-kingdom')).toBe('United Kingdom') + expect(titleCaseSlug('binance-p2p')).toBe('Binance P2p') + }) +}) diff --git a/src/data/seo/utils.ts b/src/data/seo/utils.ts index d95d1f3c6..1862a2a76 100644 --- a/src/data/seo/utils.ts +++ b/src/data/seo/utils.ts @@ -16,7 +16,7 @@ interface Named { */ export function displayNameFromContent(slug: string, frontmatter: Named | null | undefined): string { const name = frontmatter?.name - if (typeof name === 'string' && name.trim().length > 0) return name + if (typeof name === 'string' && name.trim().length > 0) return name.trim() return titleCaseSlug(slug) } From 32699f17112c863166f2550c2650d961fcdef423 Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Mon, 1 Jun 2026 15:30:09 +0000 Subject: [PATCH 02/23] fix(badges): restore event badge catalog entries dropped in BADGES refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The May 29 event-badge hotfix shipped ethfloripa_hub.svg + token_nation_2026.svg to main, but the catalog entries never reached the single BADGES record — they lived in the pre-refactor parallel maps on the hotfix branch. With no entry, getBadgeIcon() falls back to the Peanutman logo and getBadgeDisplayName() returns the raw backend name, so in prod the EthFloripa badge renders the wrong art under the wrong name. Re-add both codes. ETHFLORIPA_HUB gets a displayName override so it reads "Ethereum Hub Floripa" regardless of the backend-seeded name. --- src/components/Badges/badge.utils.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/components/Badges/badge.utils.ts b/src/components/Badges/badge.utils.ts index 596168908..fc5b9bb85 100644 --- a/src/components/Badges/badge.utils.ts +++ b/src/components/Badges/badge.utils.ts @@ -94,6 +94,18 @@ export const BADGES: Record = { description: '$1K swiped. They put their money where their card is.', // TODO(card-launch): award on cumulative card spend ≥ $1K }, + // Event badges — assets shipped to main via the May 29 hotfix but the catalog + // entries were dropped when the parallel maps collapsed into this single BADGES + // record, so the backend codes fell back to the Peanutman logo + raw backend name. + TOKEN_NATION_SP_2026: { + path: '/badges/token_nation_2026.svg', + description: 'São Paulo, baby. They came, they claimed, they tagged the wall.', + }, + ETHFLORIPA_HUB: { + path: '/badges/ethfloripa_hub.svg', + description: 'Ilha da Magia, baby. Coconuts and consensus.', + displayName: 'Ethereum Hub Floripa', + }, } /** All known badge codes — derived from BADGES so we never duplicate the From d18819d66786a7cabea23eef29877785b8f3dc33 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 1 Jun 2026 16:09:33 +0100 Subject: [PATCH 03/23] feat(observability): attach user to Sentry alongside posthog.identify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the long-standing FE Sentry visibility gap: events from authenticated sessions were anonymous, so triaging a JS error meant cross-referencing PostHog's $sentry_url tag to figure out which user hit it. Now Sentry.setUser({ id, username, email }) fires inside the same useEffect that already runs gtag('user_id', …) and posthog.identify(…) — right after the user query resolves. Every subsequent error captured by: - the existing fetchWithSentry wrapper - captureConsoleIntegration ('error' + 'warn') - explicit Sentry.captureException calls in error boundaries - the Replay integration on error inherits user.id + user.username + user.email as searchable Sentry tags — facetable in the Sentry UI dropdown without writing JQL, and usable as conditions in alert rules. On logout / unauthenticated transitions, the effect clears the scope via setUser(null) so anonymous-session errors don't get misattributed to the prior user. Purely additive — no control-flow change. Same useEffect, three more function calls inside it. --- src/context/authContext.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx index 7d9f8d4e5..1a349f8ed 100644 --- a/src/context/authContext.tsx +++ b/src/context/authContext.tsx @@ -21,7 +21,7 @@ import posthog from 'posthog-js' import { useQueryClient } from '@tanstack/react-query' import { useRouter } from 'next/navigation' import { createContext, type ReactNode, useContext, useState, useEffect, useMemo, useCallback } from 'react' -import { captureException } from '@sentry/nextjs' +import { captureException, setUser as setSentryUser } from '@sentry/nextjs' // import { PUBLIC_ROUTES_REGEX } from '@/constants/routes' import { USER_DATA_CACHE_PATTERNS } from '@/constants/cache.consts' @@ -90,6 +90,19 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { posthog.identify(user.user.userId, { username: user.user.username, }) + // Sentry: every error captured from here on inherits user context + // as searchable Sentry tags. Closes the historical gap where FE + // errors were anonymous and had to be cross-referenced via the + // posthog $sentry_url field to figure out which user hit them. + setSentryUser({ + id: user.user.userId, + username: user.user.username ?? undefined, + email: user.user.email, + }) + } else { + // Logout / unauthenticated: clear Sentry user so subsequent + // anonymous-session errors don't get misattributed. + setSentryUser(null) } }, [user]) From 3f0f757fed9ce1783ccf8427edf6a57a41a632cc Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 1 Jun 2026 16:23:35 +0100 Subject: [PATCH 04/23] feat(observability): scrubber + Sentry beforeSend backstop + setup-flow visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes: 1. Sensitive-data scrubber in sentry.utils.ts + src/utils/sentry.utils.ts - Expand BODY_SENSITIVE_URLS from just /rain/cards/.../pin to cover card secrets (PIN, CVV, details), card creation/holder PII, send-link passwords, auth (login/signup/password reset), KYC submissions (Bridge customers, Manteca KYC widget, Sumsub applicant), and send-link claim - Expand sensitive headers list to include set-cookie, x-api-key, md-api-key (Manteca), x-app-token + x-app-access-sig (Sumsub) - Add scrubObject + sanitizeResponseBody that recursively redact known sensitive keys (passwords, card data, gov IDs, bank accounts, names, addresses, DOB, phone, 2FA secrets, wallet secrets) - Apply sanitizeResponseBody to the response body in fetchWithSentry's non-2xx capture (was previously full body, now key-scrubbed) - Extend the Sentry beforeSend hook (cleanSensitiveHeaders) to also scrub event.extra, event.contexts.*, event.request.data, and breadcrumb data recursively — defense in depth for anything that slipped past the call sites 2. Whitelist identity fields. userId, username, email, inviteCode are intentionally NOT in the sensitive-keys set — Hugo wants them queryable in Sentry the same way they already are in PostHog. 3. Setup-flow analytics. The SIGNUP_USERNAME_VALIDATED and INVITE_CODE_VALIDATED PostHog events were firing without the actual value, so we couldn't see WHICH username someone tried or WHICH invite code they entered. Adding `username` to the former and `invite_code` to the latter — same identity tier as the rest of PostHog, not sensitive per Hugo's call. --- sentry.utils.ts | 177 ++++++++++++++++- src/components/Setup/Views/JoinWaitlist.tsx | 12 +- src/components/Setup/Views/Signup.tsx | 4 +- src/utils/sentry.utils.ts | 201 ++++++++++++++++++-- 4 files changed, 376 insertions(+), 18 deletions(-) diff --git a/sentry.utils.ts b/sentry.utils.ts index 31b89dfea..9c47feab6 100644 --- a/sentry.utils.ts +++ b/sentry.utils.ts @@ -82,13 +82,182 @@ export function shouldIgnoreError(event: ErrorEvent): boolean { } /** - * Clean sensitive headers from events + * Defense-in-depth: even when call-site code (fetchWithSentry, etc) has + * already scrubbed payloads, walk every Sentry event one more time and + * redact known sensitive headers, fields, and breadcrumb data before it + * leaves the browser. Catches the long tail of errors that come from + * places we don't control (third-party SDKs, error boundaries, console + * spam) and might carry PII / card data / passwords in `extra`. + * + * What stays unredacted: userId, username, email, inviteCode — identity + * fields already shared with PostHog and intentionally queryable in + * Sentry too. + */ +const SENSITIVE_HEADERS = [ + 'authorization', + 'cookie', + 'set-cookie', + 'x-auth-token', + 'api-key', + 'x-api-key', + 'apikey', + 'md-api-key', + 'x-app-token', + 'x-app-access-sig', + 'x-app-access-ts', +] + +const SENSITIVE_KEYS = new Set([ + // Secrets + 'password', + 'pwd', + 'passphrase', + 'secret', + 'secretkey', + 'apikey', + 'apitoken', + 'bearer', + 'authtoken', + 'jwt', + 'token', + 'sessiontoken', + 'refreshtoken', + 'accesstoken', + 'idtoken', + 'privatekey', + 'mnemonic', + 'seed', + 'seedphrase', + 'recoveryphrase', + // Card data + 'pan', + 'cardnumber', + 'cvv', + 'cvc', + 'securitycode', + 'cardpin', + 'pin', + 'cardholdername', + 'expirydate', + 'expirymonth', + 'expiryyear', + 'expmonth', + 'expyear', + // Government IDs + 'ssn', + 'socialsecurity', + 'taxid', + 'tin', + 'dni', + 'cuit', + 'cuil', + 'rfc', + 'curp', + 'nif', + 'governmentid', + 'documentnumber', + 'passport', + 'passportnumber', + 'driverslicense', + 'licensenumber', + 'idnumber', + 'nationalid', + // Bank accounts + 'iban', + 'swift', + 'bic', + 'sortcode', + 'routingnumber', + 'accountnumber', + 'bankaccountnumber', + 'cbu', + 'cvu', + 'clabe', + // PII — names + 'firstname', + 'lastname', + 'fullname', + 'givenname', + 'familyname', + 'surname', + 'middlename', + 'mothername', + 'mothersmaidenname', + 'maidenname', + // PII — address / DOB / contact + 'streetaddress', + 'street1', + 'street2', + 'addressline1', + 'addressline2', + 'postalcode', + 'zipcode', + 'zip', + 'postcode', + 'dob', + 'dateofbirth', + 'birthdate', + 'birthday', + 'phonenumber', + 'mobilenumber', + 'telephone', + // 2FA + 'otp', + 'verificationcode', + 'totpsecret', + 'twofactor', + 'twofactorsecret', +]) + +function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEYS.has(key.toLowerCase().replace(/[_-]/g, '')) +} + +function scrubObject(value: unknown, depth = 0): unknown { + if (depth > 10) return '[REDACTED: max depth]' + if (value === null || value === undefined) return value + if (typeof value !== 'object') return value + if (Array.isArray(value)) return value.map((item) => scrubObject(item, depth + 1)) + const out: Record = {} + for (const [key, val] of Object.entries(value as Record)) { + out[key] = isSensitiveKey(key) ? '[REDACTED]' : scrubObject(val, depth + 1) + } + return out +} + +/** + * Clean sensitive headers, extras, request data, and breadcrumbs from events + * before they leave the browser. */ export function cleanSensitiveHeaders(event: ErrorEvent): void { if (event.request?.headers) { - delete event.request.headers['Authorization'] - delete event.request.headers['api-key'] - delete event.request.headers['cookie'] + for (const key of Object.keys(event.request.headers)) { + if (SENSITIVE_HEADERS.includes(key.toLowerCase())) { + event.request.headers[key] = '[REDACTED]' + } + } + } + if (event.request?.data) { + event.request.data = scrubObject(event.request.data) + } + if (event.extra) { + event.extra = scrubObject(event.extra) as Record + } + if (event.contexts) { + for (const [key, value] of Object.entries(event.contexts)) { + if (key === 'trace') continue + ;(event.contexts as Record)[key] = scrubObject(value) + } + } + if (event.breadcrumbs) { + event.breadcrumbs = event.breadcrumbs.map((crumb) => ({ + ...crumb, + data: crumb.data + ? (Object.fromEntries( + Object.entries(crumb.data).map(([k, v]) => [k, isSensitiveKey(k) ? '[REDACTED]' : scrubObject(v)]) + ) as Record) + : crumb.data, + })) } } diff --git a/src/components/Setup/Views/JoinWaitlist.tsx b/src/components/Setup/Views/JoinWaitlist.tsx index 2bb6c78c2..76c3e88e3 100644 --- a/src/components/Setup/Views/JoinWaitlist.tsx +++ b/src/components/Setup/Views/JoinWaitlist.tsx @@ -37,13 +37,21 @@ const JoinWaitlist = () => { setisLoading(true) const res = await invitesApi.validateInviteCode(inviteCode) const isValid = res.success - posthog.capture(ANALYTICS_EVENTS.INVITE_CODE_VALIDATED, { valid: isValid, source: 'setup' }) + posthog.capture(ANALYTICS_EVENTS.INVITE_CODE_VALIDATED, { + valid: isValid, + source: 'setup', + invite_code: inviteCode, + }) if (!isValid) { setError("We couldn't find that user") } return isValid } catch { - posthog.capture(ANALYTICS_EVENTS.INVITE_CODE_VALIDATED, { valid: false, source: 'setup' }) + posthog.capture(ANALYTICS_EVENTS.INVITE_CODE_VALIDATED, { + valid: false, + source: 'setup', + invite_code: inviteCode, + }) setError("We couldn't find that user") return false } finally { diff --git a/src/components/Setup/Views/Signup.tsx b/src/components/Setup/Views/Signup.tsx index a631e22bc..b0abdf204 100644 --- a/src/components/Setup/Views/Signup.tsx +++ b/src/components/Setup/Views/Signup.tsx @@ -60,6 +60,7 @@ const SignupStep = () => { posthog.capture(ANALYTICS_EVENTS.SIGNUP_USERNAME_VALIDATED, { is_valid: false, error_type: 'taken', + username, }) return false case 400: @@ -67,12 +68,13 @@ const SignupStep = () => { posthog.capture(ANALYTICS_EVENTS.SIGNUP_USERNAME_VALIDATED, { is_valid: false, error_type: 'invalid', + username, }) return false case 404: // handle is available setError('') - posthog.capture(ANALYTICS_EVENTS.SIGNUP_USERNAME_VALIDATED, { is_valid: true }) + posthog.capture(ANALYTICS_EVENTS.SIGNUP_USERNAME_VALIDATED, { is_valid: true, username }) return true default: // we dont expect any other status code diff --git a/src/utils/sentry.utils.ts b/src/utils/sentry.utils.ts index bbfa21193..c63f28fa0 100644 --- a/src/utils/sentry.utils.ts +++ b/src/utils/sentry.utils.ts @@ -24,23 +24,190 @@ const SKIP_REPORTING: Array<{ pattern: string | RegExp; statuses: number[] }> = ] /** - * URLs whose request body carries a card secret (PIN, etc). For these, the - * raw body is replaced with '[REDACTED]' before being attached to Sentry — - * any non-2xx, timeout, or network failure on /rain/cards/{id}/pin would - * otherwise ship the user's 4-digit PIN to Sentry as {"pin":"1234"}. - * (SKIP_REPORTING above filters the 429 rate-limit case; this catches the - * 400/401/403/409/500/502/504/timeout/network branches that still report.) + * URLs whose request OR response body carries sensitive data wholesale. + * For these, the body is replaced with '[REDACTED]' before being attached + * to Sentry — covers card secrets, KYC submissions, send-link passwords, + * auth credentials. */ -const BODY_SENSITIVE_URLS: RegExp[] = [/\/rain\/cards\/[^/]+\/pin(?:[/?]|$)/] +const BODY_SENSITIVE_URLS: RegExp[] = [ + // Card secrets — PIN, CVV, details + /\/rain\/cards\/[^/]+\/(?:pin|cvv|details)(?:[/?]|$)/, + // Card creation/update — Rain backend, holder PII + /\/rain\/cards(?:\?|$)/, + /\/rain\/cardholders/, + // Send-link passwords + /\/send-link\/(?:create|verify-password|claim|set-password)/, + /\/verify-password/, + // Auth — login, signup, password set/reset + /\/(?:login|signup|register|set-password|reset-password|change-password)/, + // KYC — Bridge, Sumsub, Manteca + /\/kyc\/(?:start|submit|update)/, + /\/bridge\/customers/, + /\/manteca\/(?:user|widgets)/, + /\/sumsub\/(?:applicant|token)/, +] + +/** + * Lowercased + underscore/hyphen-stripped field names whose values should + * be redacted recursively. Identity fields (userId, username, email, + * inviteCode) are intentionally NOT in this set — they're already in + * PostHog and Hugo wants them queryable in Sentry too. + */ +const SENSITIVE_KEYS = new Set([ + // Passwords + secrets + 'password', + 'pwd', + 'passphrase', + 'secret', + 'secretkey', + 'apikey', + 'apitoken', + 'bearer', + 'authtoken', + 'jwt', + 'token', + 'sessiontoken', + 'refreshtoken', + 'accesstoken', + 'idtoken', + 'privatekey', + 'mnemonic', + 'seed', + 'seedphrase', + 'recoveryphrase', + // Card data + 'pan', + 'cardnumber', + 'cvv', + 'cvc', + 'securitycode', + 'cardpin', + 'pin', + 'cardholdername', + 'expirydate', + 'expirymonth', + 'expiryyear', + 'expmonth', + 'expyear', + // Government IDs + 'ssn', + 'socialsecurity', + 'taxid', + 'tin', + 'dni', + 'cuit', + 'cuil', + 'rfc', + 'curp', + 'nif', + 'governmentid', + 'documentnumber', + 'passport', + 'passportnumber', + 'driverslicense', + 'licensenumber', + 'idnumber', + 'nationalid', + // Bank account numbers + 'iban', + 'swift', + 'bic', + 'sortcode', + 'routingnumber', + 'accountnumber', + 'bankaccountnumber', + 'cbu', + 'cvu', + 'clabe', + // PII — names + 'firstname', + 'lastname', + 'fullname', + 'givenname', + 'familyname', + 'surname', + 'middlename', + 'mothername', + 'mothersmaidenname', + 'maidenname', + // PII — address + 'streetaddress', + 'street1', + 'street2', + 'addressline1', + 'addressline2', + 'postalcode', + 'zipcode', + 'zip', + 'postcode', + // PII — DOB / contact + 'dob', + 'dateofbirth', + 'birthdate', + 'birthday', + 'phonenumber', + 'mobilenumber', + 'telephone', + // 2FA / OTP + 'otp', + 'verificationcode', + 'totpsecret', + 'twofactor', + 'twofactorsecret', +]) + +function normalizeKey(key: string): string { + return key.toLowerCase().replace(/[_-]/g, '') +} + +function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEYS.has(normalizeKey(key)) +} + +function isSensitiveUrl(url: string | undefined): boolean { + if (!url) return false + return BODY_SENSITIVE_URLS.some((pattern) => pattern.test(url)) +} + +/** + * Recursively redacts sensitive keys in any object — applied to both + * request bodies AND response bodies before they ship to Sentry. + */ +export function scrubObject(value: unknown, depth = 0): unknown { + if (depth > 10) return '[REDACTED: max depth]' + if (value === null || value === undefined) return value + if (typeof value !== 'object') return value + if (Array.isArray(value)) return value.map((item) => scrubObject(item, depth + 1)) + const out: Record = {} + for (const [key, val] of Object.entries(value as Record)) { + out[key] = isSensitiveKey(key) ? '[REDACTED]' : scrubObject(val, depth + 1) + } + return out +} export const sanitizeRequestBody = (url: string, body: BodyInit | null | undefined): BodyInit | string | null => { if (body == null) return null - for (const pattern of BODY_SENSITIVE_URLS) { - if (pattern.test(url)) return '[REDACTED]' + if (isSensitiveUrl(url)) return '[REDACTED: sensitive endpoint]' + // String bodies — try JSON parse, scrub, re-stringify; otherwise pass through. + if (typeof body === 'string') { + try { + return JSON.stringify(scrubObject(JSON.parse(body))) + } catch { + return body + } } return body } +/** + * Sanitize response bodies before they land in Sentry `extra`. Same + * URL + key-scrubbing as request bodies. + */ +export const sanitizeResponseBody = (url: string, body: unknown): unknown => { + if (isSensitiveUrl(url)) return '[REDACTED: sensitive endpoint]' + return scrubObject(body) +} + /** * Map URL → feature tag so Sentry issues can be filtered by product surface * without wrapping every call site. Add new entries here as features grow. @@ -88,7 +255,19 @@ const sanitizeHeaders = (headers: any): any => { if (!headers) return headers const sanitized = { ...headers } - const sensitiveHeaders = ['authorization', 'cookie', 'x-auth-token', 'api-key'] + const sensitiveHeaders = [ + 'authorization', + 'cookie', + 'set-cookie', + 'x-auth-token', + 'api-key', + 'x-api-key', + 'apikey', + 'md-api-key', // Manteca + 'x-app-token', // Sumsub + 'x-app-access-sig', + 'x-app-access-ts', + ] for (const key of Object.keys(sanitized)) { if (sensitiveHeaders.includes(key.toLowerCase())) { @@ -157,7 +336,7 @@ export const fetchWithSentry = async ( requestHeaders: sanitizeHeaders(options.headers || {}), requestBody: sanitizeRequestBody(url, options.body), status: response.status, - response: errorContent, + response: sanitizeResponseBody(url, errorContent), }, }) }) From 994bed34cc97bec17944b55c808ee1f051eda8b3 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 1 Jun 2026 16:43:07 +0100 Subject: [PATCH 05/23] chore(observability): plug PII gaps, lock onchain addresses unredacted, add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tighten the scrubber for the real provider field shapes we see in prod: - Bridge long-form: street_line_1/2/3, tax_identification_number, social_security_number, government_id_number, national_id_number, customer_first_name, customer_last_name - Manteca Spanish: nombre, apellido, direccion, domicilio, telefono, documento, numero_documento - English variants we'd missed: billingAddress, mailingAddress, homeAddress, residentialAddress, permanentAddress, addressLine3, street3 Critically — `address` alone stays OUT of the scrubber, with a top-of-file comment explaining why. Peanut has first-class onchain addresses (walletAddress, recipientAddress, depositAddress, sdaAddress, tokenAddress, contractAddress, …) and substring-matching on `address` would clobber every one of them. Pinned by a regression test that verifies all 9 onchain address fields pass through unredacted. Test parity with BE — 19 colocated unit tests covering the contract (identity stays, PII redacted, onchain visible, KYC URLs wholesale, arrays + nesting, cycle defense). --- sentry.utils.ts | 41 +++++- src/utils/__tests__/sentry.utils.test.ts | 174 ++++++++++++++++++++++- src/utils/sentry.utils.ts | 42 +++++- 3 files changed, 244 insertions(+), 13 deletions(-) diff --git a/sentry.utils.ts b/sentry.utils.ts index 9c47feab6..c1e748ef5 100644 --- a/sentry.utils.ts +++ b/sentry.utils.ts @@ -107,6 +107,14 @@ const SENSITIVE_HEADERS = [ 'x-app-access-ts', ] +/** + * IMPORTANT: EXACT-MATCH set. We deliberately do NOT substring-match + * because Peanut has first-class onchain addresses everywhere (walletAddress, + * recipientAddress, tokenAddress, sdaAddress, depositAddress, …) and those + * are public chain data that must stay visible. Substring on `address` + * would clobber every one. Same for `pin` / `token` / `seed` — share names + * with non-sensitive concepts. + */ const SENSITIVE_KEYS = new Set([ // Secrets 'password', @@ -143,10 +151,12 @@ const SENSITIVE_KEYS = new Set([ 'expiryyear', 'expmonth', 'expyear', - // Government IDs + // Government IDs (English + Bridge long-form) 'ssn', 'socialsecurity', + 'socialsecuritynumber', 'taxid', + 'taxidentificationnumber', 'tin', 'dni', 'cuit', @@ -155,6 +165,7 @@ const SENSITIVE_KEYS = new Set([ 'curp', 'nif', 'governmentid', + 'governmentidnumber', 'documentnumber', 'passport', 'passportnumber', @@ -162,6 +173,11 @@ const SENSITIVE_KEYS = new Set([ 'licensenumber', 'idnumber', 'nationalid', + 'nationalidnumber', + // Manteca (Spanish) + 'documento', + 'numerodocumento', + 'numerodedocumento', // Bank accounts 'iban', 'swift', @@ -173,7 +189,7 @@ const SENSITIVE_KEYS = new Set([ 'cbu', 'cvu', 'clabe', - // PII — names + // PII — names (English + Manteca Spanish) 'firstname', 'lastname', 'fullname', @@ -184,12 +200,30 @@ const SENSITIVE_KEYS = new Set([ 'mothername', 'mothersmaidenname', 'maidenname', - // PII — address / DOB / contact + 'customerfirstname', + 'customerlastname', + 'nombre', + 'apellido', + // PII — address. NOTE: `address` alone is NOT here — onchain addresses + // (walletAddress, recipientAddress, etc.) must stay visible for + // debugging onchain flows. 'streetaddress', 'street1', 'street2', + 'street3', + 'streetline1', + 'streetline2', + 'streetline3', 'addressline1', 'addressline2', + 'addressline3', + 'billingaddress', + 'homeaddress', + 'mailingaddress', + 'residentialaddress', + 'permanentaddress', + 'direccion', + 'domicilio', 'postalcode', 'zipcode', 'zip', @@ -201,6 +235,7 @@ const SENSITIVE_KEYS = new Set([ 'phonenumber', 'mobilenumber', 'telephone', + 'telefono', // 2FA 'otp', 'verificationcode', diff --git a/src/utils/__tests__/sentry.utils.test.ts b/src/utils/__tests__/sentry.utils.test.ts index bbf5b7f46..1c739b50c 100644 --- a/src/utils/__tests__/sentry.utils.test.ts +++ b/src/utils/__tests__/sentry.utils.test.ts @@ -1,19 +1,26 @@ -import { sanitizeRequestBody } from '../sentry.utils' +import { sanitizeRequestBody, sanitizeResponseBody, scrubObject } from '../sentry.utils' describe('sanitizeRequestBody', () => { - it('redacts the PIN-set endpoint body', () => { + it('redacts the PIN-set endpoint body wholesale (sensitive URL)', () => { const body = JSON.stringify({ pin: '1234' }) - expect(sanitizeRequestBody('https://api.peanut.me/rain/cards/abc-123/pin', body)).toBe('[REDACTED]') + expect(sanitizeRequestBody('https://api.peanut.me/rain/cards/abc-123/pin', body)).toBe( + '[REDACTED: sensitive endpoint]' + ) }) it('redacts regardless of cardId shape', () => { const body = JSON.stringify({ pin: '0000' }) - expect(sanitizeRequestBody('/rain/cards/00000000-0000-0000-0000-000000000000/pin', body)).toBe('[REDACTED]') + expect(sanitizeRequestBody('/rain/cards/00000000-0000-0000-0000-000000000000/pin', body)).toBe( + '[REDACTED: sensitive endpoint]' + ) }) - it('passes non-sensitive bodies through unchanged', () => { - const body = JSON.stringify({ amount: 1000 }) - expect(sanitizeRequestBody('https://api.peanut.me/rain/cards/abc-123/withdraw/submit', body)).toBe(body) + it('key-scrubs non-sensitive URL bodies (preserves shape, redacts PII keys)', () => { + const body = JSON.stringify({ amount: 1000, firstName: 'Hugo' }) + const out = sanitizeRequestBody('https://api.peanut.me/rain/cards/abc-123/withdraw/submit', body) as string + const parsed = JSON.parse(out) + expect(parsed.amount).toBe(1000) + expect(parsed.firstName).toBe('[REDACTED]') }) it('returns null for null/undefined bodies', () => { @@ -25,4 +32,157 @@ describe('sanitizeRequestBody', () => { const body = JSON.stringify({ thing: 'pin-pad' }) expect(sanitizeRequestBody('/rain/cards/abc-123/pinpad-config', body)).toBe(body) }) + + it('wholesale-redacts send-link password endpoints', () => { + const body = JSON.stringify({ password: 'hunter2' }) + expect(sanitizeRequestBody('/api/send-link/create', body)).toBe('[REDACTED: sensitive endpoint]') + expect(sanitizeRequestBody('/api/send-link/verify-password', body)).toBe('[REDACTED: sensitive endpoint]') + }) + + it('wholesale-redacts auth endpoints', () => { + const body = JSON.stringify({ email: 'x@example.com', password: 'hunter2' }) + expect(sanitizeRequestBody('/api/login', body)).toBe('[REDACTED: sensitive endpoint]') + expect(sanitizeRequestBody('/api/signup', body)).toBe('[REDACTED: sensitive endpoint]') + }) + + it('wholesale-redacts KYC endpoints', () => { + const body = JSON.stringify({ firstName: 'Hugo', dni: '12345678' }) + expect(sanitizeRequestBody('/api/kyc/start', body)).toBe('[REDACTED: sensitive endpoint]') + expect(sanitizeRequestBody('/api/bridge/customers', body)).toBe('[REDACTED: sensitive endpoint]') + }) +}) + +describe('sanitizeResponseBody', () => { + it('wholesale-redacts sensitive URL responses', () => { + expect(sanitizeResponseBody('/api/rain/cards/abc/pin', { ok: false })).toBe('[REDACTED: sensitive endpoint]') + }) + + it('key-scrubs non-sensitive URL responses (preserves shape, redacts PII keys)', () => { + const out = sanitizeResponseBody('/api/users/me', { + user: { email: 'hugo@peanut.me', firstName: 'Hugo', userId: 'u-1' }, + }) as { user: { email: string; firstName: string; userId: string } } + expect(out.user.email).toBe('hugo@peanut.me') + expect(out.user.userId).toBe('u-1') + expect(out.user.firstName).toBe('[REDACTED]') + }) +}) + +describe('scrubObject — exact-match contract', () => { + it('identity fields stay unredacted (already in PostHog)', () => { + const result = scrubObject({ + userId: 'u-1', + username: 'hugo', + email: 'hugo@peanut.me', + inviteCode: 'PEANUT42', + }) as Record + expect(result.userId).toBe('u-1') + expect(result.username).toBe('hugo') + expect(result.email).toBe('hugo@peanut.me') + expect(result.inviteCode).toBe('PEANUT42') + }) + + it('CRITICAL: onchain addresses stay visible (debugging onchain flows)', () => { + // If this test fails, someone substring-matched on `address` and broke + // debugging for every onchain flow in the app. + const result = scrubObject({ + address: '0xabc', + walletAddress: '0xdef', + recipientAddress: '0x123', + payerAddress: '0x456', + depositAddress: '0x789', + destinationAddress: 'TCNRtkx', + sdaAddress: '0xsda', + tokenAddress: '0xtoken', + contractAddress: '0xcontract', + }) as Record + expect(result.address).toBe('0xabc') + expect(result.walletAddress).toBe('0xdef') + expect(result.recipientAddress).toBe('0x123') + expect(result.payerAddress).toBe('0x456') + expect(result.depositAddress).toBe('0x789') + expect(result.destinationAddress).toBe('TCNRtkx') + expect(result.sdaAddress).toBe('0xsda') + expect(result.tokenAddress).toBe('0xtoken') + expect(result.contractAddress).toBe('0xcontract') + }) + + it('PII names get redacted (English + Bridge customer_* + Manteca Spanish)', () => { + const result = scrubObject({ + firstName: 'Hugo', + lastName: 'Monte', + customerFirstName: 'Hugo', + customerLastName: 'Monte', + nombre: 'Hugo', + apellido: 'Monte', + }) as Record + Object.values(result).forEach((v) => expect(v).toBe('[REDACTED]')) + }) + + it('home address variants redacted; crypto addresses NOT', () => { + const result = scrubObject({ + billingAddress: 'X', + mailingAddress: 'X', + homeAddress: 'X', + street_line_1: 'X', + direccion: 'X', + walletAddress: '0xstay', + }) as Record + expect(result.billingAddress).toBe('[REDACTED]') + expect(result.mailingAddress).toBe('[REDACTED]') + expect(result.homeAddress).toBe('[REDACTED]') + expect(result.street_line_1).toBe('[REDACTED]') + expect(result.direccion).toBe('[REDACTED]') + expect(result.walletAddress).toBe('0xstay') + }) + + it('card data', () => { + const result = scrubObject({ + pan: '4111', + cvv: '123', + cardPin: '1234', + pin: '1234', + expiryMonth: 12, + cardholderName: 'Hugo', + }) as Record + Object.values(result).forEach((v) => expect(v).toBe('[REDACTED]')) + }) + + it('government IDs (English + Bridge long-form + Manteca)', () => { + const result = scrubObject({ + ssn: '111', + social_security_number: '111', + tax_identification_number: 'X', + government_id_number: 'Z', + dni: '12345678', + documento: '12345678', + }) as Record + Object.values(result).forEach((v) => expect(v).toBe('[REDACTED]')) + }) + + it('bank accounts', () => { + const result = scrubObject({ + iban: 'ES12', + cbu: '0123', + clabe: '012345', + routingNumber: '021000021', + accountNumber: '9876', + }) as Record + Object.values(result).forEach((v) => expect(v).toBe('[REDACTED]')) + }) + + it('handles arrays + nested objects', () => { + const result = scrubObject({ + items: [{ pan: '4111' }], + nested: { profile: { firstName: 'Hugo', userId: 'u-1' } }, + }) as { items: Array<{ pan: string }>; nested: { profile: { firstName: string; userId: string } } } + expect(result.items[0].pan).toBe('[REDACTED]') + expect(result.nested.profile.firstName).toBe('[REDACTED]') + expect(result.nested.profile.userId).toBe('u-1') + }) + + it('depth-caps to defend against cycles (no throw)', () => { + const cyclic: Record = { name: 'root' } + cyclic.self = cyclic + expect(() => scrubObject(cyclic)).not.toThrow() + }) }) diff --git a/src/utils/sentry.utils.ts b/src/utils/sentry.utils.ts index c63f28fa0..1cfce4502 100644 --- a/src/utils/sentry.utils.ts +++ b/src/utils/sentry.utils.ts @@ -52,6 +52,15 @@ const BODY_SENSITIVE_URLS: RegExp[] = [ * be redacted recursively. Identity fields (userId, username, email, * inviteCode) are intentionally NOT in this set — they're already in * PostHog and Hugo wants them queryable in Sentry too. + * + * IMPORTANT: this is an EXACT-MATCH set. We deliberately do NOT + * substring-match because Peanut has first-class onchain addresses + * everywhere — `walletAddress`, `recipientAddress`, `tokenAddress`, + * `sdaAddress`, `depositAddress`, `destinationAddress`, `payerAddress`. + * Those are public chain data that MUST stay visible for debugging + * onchain flows. Substring-matching on `address` would clobber every + * one of them. Same for `pin`, `token`, `seed` — share names with + * non-sensitive concepts. */ const SENSITIVE_KEYS = new Set([ // Passwords + secrets @@ -89,10 +98,12 @@ const SENSITIVE_KEYS = new Set([ 'expiryyear', 'expmonth', 'expyear', - // Government IDs + // Government IDs (English + Bridge long-form) 'ssn', 'socialsecurity', + 'socialsecuritynumber', 'taxid', + 'taxidentificationnumber', 'tin', 'dni', 'cuit', @@ -101,6 +112,7 @@ const SENSITIVE_KEYS = new Set([ 'curp', 'nif', 'governmentid', + 'governmentidnumber', 'documentnumber', 'passport', 'passportnumber', @@ -108,6 +120,11 @@ const SENSITIVE_KEYS = new Set([ 'licensenumber', 'idnumber', 'nationalid', + 'nationalidnumber', + // Manteca (Spanish) + 'documento', + 'numerodocumento', + 'numerodedocumento', // Bank account numbers 'iban', 'swift', @@ -119,7 +136,7 @@ const SENSITIVE_KEYS = new Set([ 'cbu', 'cvu', 'clabe', - // PII — names + // PII — names (English + Manteca Spanish) 'firstname', 'lastname', 'fullname', @@ -130,12 +147,30 @@ const SENSITIVE_KEYS = new Set([ 'mothername', 'mothersmaidenname', 'maidenname', - // PII — address + 'customerfirstname', + 'customerlastname', + 'nombre', + 'apellido', + // PII — address. NOTE: `address` alone is NOT here — onchain addresses + // (walletAddress, recipientAddress, etc.) must stay visible for + // debugging onchain flows. 'streetaddress', 'street1', 'street2', + 'street3', + 'streetline1', + 'streetline2', + 'streetline3', 'addressline1', 'addressline2', + 'addressline3', + 'billingaddress', + 'homeaddress', + 'mailingaddress', + 'residentialaddress', + 'permanentaddress', + 'direccion', + 'domicilio', 'postalcode', 'zipcode', 'zip', @@ -148,6 +183,7 @@ const SENSITIVE_KEYS = new Set([ 'phonenumber', 'mobilenumber', 'telephone', + 'telefono', // 2FA / OTP 'otp', 'verificationcode', From e4bdcbdc266415e9a2a45af349971f7ed12b647b Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 1 Jun 2026 16:44:24 +0100 Subject: [PATCH 06/23] style: null-coalesce email in setSentryUser for consistency (CodeRabbit nit) --- src/context/authContext.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx index 1a349f8ed..9d95cd6fe 100644 --- a/src/context/authContext.tsx +++ b/src/context/authContext.tsx @@ -97,7 +97,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { setSentryUser({ id: user.user.userId, username: user.user.username ?? undefined, - email: user.user.email, + email: user.user.email ?? undefined, }) } else { // Logout / unauthenticated: clear Sentry user so subsequent From 983d3c203ba280c7527bb85ab0d979f9121a2d7c Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 1 Jun 2026 17:07:31 +0100 Subject: [PATCH 07/23] security(observability): prototype-pollution defense in scrubObject (CodeQL fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged src/utils/sentry.utils.ts:219 as "Remote property injection". The accumulator `{}` plus `out[key] = …` with a key derived from user-controlled JSON walks into prototype pollution when the input contains `__proto__` as an OWN property (JSON.parse creates this). Fix on both copies of scrubObject (src/utils/sentry.utils.ts and root sentry.utils.ts): 1. Switch accumulator to `Object.create(null)` — prototype-less, so `out['__proto__'] = …` is a regular property with no chain to pollute. 2. Explicitly skip `__proto__` / `constructor` / `prototype` keys — redundant given (1) but documents intent + belt-and-braces. Pinned by a regression test that parses the malicious JSON and asserts none of the dangerous keys make it into the output, and that global Object.prototype is not mutated. --- sentry.utils.ts | 6 +++++- src/utils/__tests__/sentry.utils.test.ts | 16 ++++++++++++++++ src/utils/sentry.utils.ts | 9 ++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/sentry.utils.ts b/sentry.utils.ts index c1e748ef5..1f3539702 100644 --- a/sentry.utils.ts +++ b/sentry.utils.ts @@ -253,8 +253,12 @@ function scrubObject(value: unknown, depth = 0): unknown { if (value === null || value === undefined) return value if (typeof value !== 'object') return value if (Array.isArray(value)) return value.map((item) => scrubObject(item, depth + 1)) - const out: Record = {} + // Prototype-pollution defense: see src/utils/sentry.utils.ts for the same + // pattern. Null-proto accumulator + explicit skip of __proto__ / + // constructor / prototype keys. + const out: Record = Object.create(null) for (const [key, val] of Object.entries(value as Record)) { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue out[key] = isSensitiveKey(key) ? '[REDACTED]' : scrubObject(val, depth + 1) } return out diff --git a/src/utils/__tests__/sentry.utils.test.ts b/src/utils/__tests__/sentry.utils.test.ts index 1c739b50c..6f61a0a56 100644 --- a/src/utils/__tests__/sentry.utils.test.ts +++ b/src/utils/__tests__/sentry.utils.test.ts @@ -185,4 +185,20 @@ describe('scrubObject — exact-match contract', () => { cyclic.self = cyclic expect(() => scrubObject(cyclic)).not.toThrow() }) + + it('PROTO-POLLUTION DEFENSE: __proto__ / constructor / prototype dropped (CodeQL)', () => { + // JSON.parse creates __proto__ as an OWN property. Without the + // defense, scrubObject's `out[key] = …` walks into prototype + // pollution. Pinned because CodeQL flagged the equivalent shape. + const malicious = JSON.parse( + '{"__proto__":{"polluted":true},"constructor":"x","prototype":"y","clean":"z"}' + ) + const out = scrubObject(malicious) as Record + expect(Object.prototype.hasOwnProperty.call(out, '__proto__')).toBe(false) + expect(Object.prototype.hasOwnProperty.call(out, 'constructor')).toBe(false) + expect(Object.prototype.hasOwnProperty.call(out, 'prototype')).toBe(false) + expect(out.clean).toBe('z') + // Global Object.prototype must NOT have been mutated + expect(({} as Record).polluted).toBeUndefined() + }) }) diff --git a/src/utils/sentry.utils.ts b/src/utils/sentry.utils.ts index 1cfce4502..551bacc89 100644 --- a/src/utils/sentry.utils.ts +++ b/src/utils/sentry.utils.ts @@ -214,8 +214,15 @@ export function scrubObject(value: unknown, depth = 0): unknown { if (value === null || value === undefined) return value if (typeof value !== 'object') return value if (Array.isArray(value)) return value.map((item) => scrubObject(item, depth + 1)) - const out: Record = {} + // `Object.create(null)` produces a prototype-less object so `out[key] = …` + // with a user-controlled `key` like `__proto__` / `constructor` / + // `prototype` can't pollute the prototype chain. Without this, a JSON + // body shaped `{"__proto__": {...}}` (own property after JSON.parse) + // would walk into prototype pollution. CodeQL flagged the original + // accumulator (`{}`); switching to a null-proto record closes it. + const out: Record = Object.create(null) for (const [key, val] of Object.entries(value as Record)) { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue out[key] = isSensitiveKey(key) ? '[REDACTED]' : scrubObject(val, depth + 1) } return out From 729bce2d0d64769e28f15bbb945ce03b58c4189c Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 1 Jun 2026 17:10:18 +0100 Subject: [PATCH 08/23] style: prettier --write src/utils/__tests__/sentry.utils.test.ts --- src/utils/__tests__/sentry.utils.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/utils/__tests__/sentry.utils.test.ts b/src/utils/__tests__/sentry.utils.test.ts index 6f61a0a56..28d8c2796 100644 --- a/src/utils/__tests__/sentry.utils.test.ts +++ b/src/utils/__tests__/sentry.utils.test.ts @@ -190,9 +190,7 @@ describe('scrubObject — exact-match contract', () => { // JSON.parse creates __proto__ as an OWN property. Without the // defense, scrubObject's `out[key] = …` walks into prototype // pollution. Pinned because CodeQL flagged the equivalent shape. - const malicious = JSON.parse( - '{"__proto__":{"polluted":true},"constructor":"x","prototype":"y","clean":"z"}' - ) + const malicious = JSON.parse('{"__proto__":{"polluted":true},"constructor":"x","prototype":"y","clean":"z"}') const out = scrubObject(malicious) as Record expect(Object.prototype.hasOwnProperty.call(out, '__proto__')).toBe(false) expect(Object.prototype.hasOwnProperty.call(out, 'constructor')).toBe(false) From 0543fac76f7939ebfcba9176bbcf1a0914458d1f Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 1 Jun 2026 17:16:54 +0100 Subject: [PATCH 09/23] security(observability): use Object.defineProperty in scrubObject (CodeQL re-flag) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous fix (Object.create(null) + skip dangerous keys) is correct at runtime but CodeQL still flagged it — its taint analysis only sees the direct assignment `out[key] = …` and can't prove the runtime key check is exhaustive. Switch the write to Object.defineProperty with an explicit descriptor. That's the form CodeQL's data-flow recognises as a sanitizer. Defense layers now: 1. Object.create(null) — no prototype chain to pollute 2. Explicit skip of __proto__ / constructor / prototype keys 3. Object.defineProperty — CodeQL-recognised safe write 20/20 unit tests still pass, including the proto-pollution regression test that verifies global Object.prototype is never mutated. --- sentry.utils.ts | 15 +++++++++++---- src/utils/sentry.utils.ts | 22 +++++++++++++++------- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/sentry.utils.ts b/sentry.utils.ts index 1f3539702..8daf91052 100644 --- a/sentry.utils.ts +++ b/sentry.utils.ts @@ -253,13 +253,20 @@ function scrubObject(value: unknown, depth = 0): unknown { if (value === null || value === undefined) return value if (typeof value !== 'object') return value if (Array.isArray(value)) return value.map((item) => scrubObject(item, depth + 1)) - // Prototype-pollution defense: see src/utils/sentry.utils.ts for the same - // pattern. Null-proto accumulator + explicit skip of __proto__ / - // constructor / prototype keys. + // Prototype-pollution defense — see src/utils/sentry.utils.ts for the + // full rationale. Object.create(null) + Object.defineProperty + explicit + // dangerous-key skip. The defineProperty form is what CodeQL recognises + // as a sanitizer; direct `out[key] = …` triggers the alert even when + // keys are validated at runtime. const out: Record = Object.create(null) for (const [key, val] of Object.entries(value as Record)) { if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue - out[key] = isSensitiveKey(key) ? '[REDACTED]' : scrubObject(val, depth + 1) + Object.defineProperty(out, key, { + value: isSensitiveKey(key) ? '[REDACTED]' : scrubObject(val, depth + 1), + writable: true, + enumerable: true, + configurable: true, + }) } return out } diff --git a/src/utils/sentry.utils.ts b/src/utils/sentry.utils.ts index 551bacc89..11b4547ee 100644 --- a/src/utils/sentry.utils.ts +++ b/src/utils/sentry.utils.ts @@ -214,16 +214,24 @@ export function scrubObject(value: unknown, depth = 0): unknown { if (value === null || value === undefined) return value if (typeof value !== 'object') return value if (Array.isArray(value)) return value.map((item) => scrubObject(item, depth + 1)) - // `Object.create(null)` produces a prototype-less object so `out[key] = …` - // with a user-controlled `key` like `__proto__` / `constructor` / - // `prototype` can't pollute the prototype chain. Without this, a JSON - // body shaped `{"__proto__": {...}}` (own property after JSON.parse) - // would walk into prototype pollution. CodeQL flagged the original - // accumulator (`{}`); switching to a null-proto record closes it. + // Prototype-pollution defense — two layers, both required: + // 1. `Object.create(null)` so `out` has no prototype to pollute. + // 2. `Object.defineProperty` with explicit descriptor instead of + // `out[key] = …`. The former is recognised by CodeQL's taint + // analysis as a sanitizer; the latter triggers + // js/prototype-polluting-assignment even when keys are validated + // because CodeQL can't prove the runtime check is complete. + // 3. Explicit skip of __proto__ / constructor / prototype — belt + // and braces; redundant with (1) but documents intent. const out: Record = Object.create(null) for (const [key, val] of Object.entries(value as Record)) { if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue - out[key] = isSensitiveKey(key) ? '[REDACTED]' : scrubObject(val, depth + 1) + Object.defineProperty(out, key, { + value: isSensitiveKey(key) ? '[REDACTED]' : scrubObject(val, depth + 1), + writable: true, + enumerable: true, + configurable: true, + }) } return out } From e5fd6e4eb157862e02d90dd03fa13f7a797d08d6 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 1 Jun 2026 18:13:47 +0100 Subject: [PATCH 10/23] fix(capabilities): hoist `ready` above blocked/accept-tos/fixable in deriveGate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If any in-scope rail can transact NOW, the gate returns `ready` — period. A stuck sibling rail in a different currency or with KYC remediation pending is not the user's problem when they have a working path. The 2026-06-01 Alexandre incident was the latest customer-visible expression of the prior "gate any sibling blocker first" order. Alex (BR user, xandovsk) had Manteca × PIX_BR ENABLED but four Bridge rails (ACH_US / FASTER_PAYMENTS_GB / SEPA_EU / SPEI_MX) stuck on `terms_of_service_v2`. On `/add-money/brazil` he saw a Bridge "Accept Terms of Service" modal — even though PIX_BR was right there ready. Jota's #2145 narrowed the gate scope to `country` when the URL carried one, which closes the cross-country case. This change closes the same bug at the within-scope layer: even if a blocked Bridge × BR rail existed in scope, a ready Manteca × PIX_BR alongside should win. Also fixes the picker case (no country in URL) where #2145 intentionally left the scope unchanged. 6 new tests pinning the new order — Alexandre's exact prod state, plus ready-beats-blocked / ready-beats-fixable-rejection / ready-beats-restart-identity / no-ready-falls-through-to-old-order / per-op-status-wins. All 14 prior tests still pass (the 'priority: ready beats waiting-on-provider' one already documented this intent for the wait branch; this PR makes it the rule, not the exception). --- src/utils/capability-gate.test.ts | 94 +++++++++++++++++++++++++++++++ src/utils/capability-gate.ts | 32 +++++++---- 2 files changed, 114 insertions(+), 12 deletions(-) diff --git a/src/utils/capability-gate.test.ts b/src/utils/capability-gate.test.ts index 28e60be53..c3b84f030 100644 --- a/src/utils/capability-gate.test.ts +++ b/src/utils/capability-gate.test.ts @@ -315,3 +315,97 @@ describe('deriveGate — restart-identity vs contact-support split for blocked r expect(getKycModalVariant(gate.kind)).toBe('blocked') }) }) + +describe('deriveGate — ready-first ordering (Alexandre fix)', () => { + const tosAction: NextAction = { key: 'bridge.tos', kind: 'accept-tos', purpose: 'bridge-tos' } + const sumsubAction: NextAction = { key: 'bridge.rfi', kind: 'sumsub', purpose: 'bridge-rfi' } + + test('Alexandre case: PIX_BR ENABLED + Bridge × US/GB/EU/MX stuck on ToS → ready (no modal)', () => { + const pixBr = bankRail({ + id: 'manteca.pix_br', + provider: 'manteca', + method: 'PIX_BR', + country: 'BR', + currency: 'BRL', + status: 'enabled', + }) + const stuck = (id: `bridge.${string}`, country: string, currency: string, method: string): RailCapability => + bankRail({ + id, + provider: 'bridge', + method, + country, + currency, + status: 'requires-info', + blockingActions: ['bridge.tos'], + reason: { code: 'bridge_tos_v2_required', userMessage: 'Accept terms' }, + }) + const rails = [ + pixBr, + stuck('bridge.ach_us', 'US', 'USD', 'ACH_US'), + stuck('bridge.faster_payments_gb', 'GB', 'GBP', 'FASTER_PAYMENTS_GB'), + stuck('bridge.sepa_eu', 'EU', 'EUR', 'SEPA_EU'), + stuck('bridge.spei_mx', 'MX', 'MXN', 'SPEI_MX'), + ] + + const gate = deriveGate(state(rails, [tosAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + }) + + test('ready beats blocked-rejection — working rail trumps unrelated blocked one', () => { + const ready = bankRail({ id: 'manteca.pix_br', provider: 'manteca', country: 'BR', status: 'enabled' }) + const blockedTerminal = bankRail({ + id: 'bridge.ach_us', + status: 'blocked', + reason: { code: 'kyc_rejected_terminal', userMessage: 'Verification failed' }, + }) + + const gate = deriveGate(state([ready, blockedTerminal]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + }) + + test('ready beats fixable-rejection — RFI on another rail does not gate', () => { + const ready = bankRail({ id: 'manteca.pix_br', provider: 'manteca', country: 'BR', status: 'enabled' }) + const rfi = bankRail({ id: 'bridge.ach_us', status: 'requires-info', blockingActions: ['bridge.rfi'] }) + + const gate = deriveGate(state([ready, rfi], [sumsubAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + }) + + test('ready beats restart-identity — self-fixable blocked rail does not gate', () => { + const ready = bankRail({ id: 'manteca.pix_br', provider: 'manteca', country: 'BR', status: 'enabled' }) + const restartable = bankRail({ + id: 'bridge.ach_us', + status: 'blocked', + blockingActions: ['bridge.restart'], + }) + const restartAction: NextAction = { key: 'bridge.restart', kind: 'restart-identity', purpose: 'restart' } + + const gate = deriveGate(state([ready, restartable], [restartAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + }) + + test('no ready rail → falls through to existing blocked / accept-tos / fixable order', () => { + const tosRail = bankRail({ + id: 'bridge.ach_us', + status: 'requires-info', + blockingActions: ['bridge.tos'], + reason: { code: 'bridge_tos_required', userMessage: 'Accept terms' }, + }) + + const gate = deriveGate(state([tosRail], [tosAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('accept-tos') + }) + + test('per-op enabled status (not rail-level) is what matters', () => { + const railWithEnabledOp = bankRail({ + id: 'bridge.ach_us', + status: 'requires-info', + operations: { deposit: 'enabled', withdraw: 'requires-info' }, + blockingActions: ['bridge.rfi'], + }) + + const gate = deriveGate(state([railWithEnabledOp], [sumsubAction]), 'deposit', { channel: 'bank' }) + expect(gate.kind).toBe('ready') + }) +}) diff --git a/src/utils/capability-gate.ts b/src/utils/capability-gate.ts index 829f1ca57..5e3804187 100644 --- a/src/utils/capability-gate.ts +++ b/src/utils/capability-gate.ts @@ -112,13 +112,18 @@ export interface CapabilityState { * * Priority (highest first): * 1. loading — capability state not yet settled - * 2. blocked-rejection — any in-scope rail status: 'blocked' - * 3. accept-tos — requires-info + a `kind: 'accept-tos'` action + * 2. ready — at least one in-scope rail has operationStatus(op) === 'enabled'. + * Hoisted to position 2 so a working rail (e.g. Manteca PIX_BR + * ENABLED) wins over stuck sibling rails (e.g. Bridge ACH_US + * terms_of_service_v2). The 2026-06-01 Alexandre incident + * (BR user blocked by Bridge ToS modal while their Manteca + * PIX_BR was ENABLED) was the latest customer-visible failure + * of the prior 'gate any sibling blocker first' order. + * 3. blocked-rejection — any in-scope rail status: 'blocked', and no ready rail + * 4. accept-tos — requires-info + a `kind: 'accept-tos'` action * (user-actionable, unblocks the scope) - * 4. fixable-rejection — requires-info + a `kind: 'sumsub'` action + * 5. fixable-rejection — requires-info + a `kind: 'sumsub'` action * (user-actionable, unblocks the scope) - * 5. ready — at least one in-scope rail has operationStatus(op) === 'enabled' - * (user can transact NOW) * 6. pending — at least one in-scope rail status === 'pending' (we're provisioning) * 7. waiting-on-provider — only requires-info rails AND every one has only * `kind: 'wait'` actions (e.g. Bridge internal KYC @@ -138,7 +143,14 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat const candidates = filterRailsByScope(state.rails, scope) const actionByKey = new Map(state.nextActions.map((action) => [action.key, action])) - // 2. blocked — split: if the rail carries a `restart-identity` action the + // 2. ready — per-op refinement wins over rail-level status. Hoisted + // above blocked / accept-tos / fixable-rejection because the user has + // a working path; a blocked sibling rail (different currency, KYC + // remediation pending) is not the user's problem right now. + const hasReady = candidates.some((rail) => operationStatus(rail, op) === 'enabled') + if (hasReady) return { kind: 'ready' } + + // 3. blocked — split: if the rail carries a `restart-identity` action the // user can self-fix by re-verifying with a different document; otherwise // the only path is contact-support. const blocked = candidates.find((rail) => rail.status === 'blocked') @@ -160,7 +172,7 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat const requiresInfoRails = candidates.filter((rail) => rail.status === 'requires-info') - // 3. accept-tos + // 4. accept-tos const tosRail = requiresInfoRails.find((rail) => railActions(rail, actionByKey).some((action) => action.kind === 'accept-tos') ) @@ -174,7 +186,7 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat } } - // 4. fixable-rejection (Sumsub RFI / self-heal) + // 5. fixable-rejection (Sumsub RFI / self-heal) const fixableRail = requiresInfoRails.find((rail) => railActions(rail, actionByKey).some((action) => action.kind === 'sumsub') ) @@ -186,10 +198,6 @@ export function deriveGate(state: CapabilityState, op: RailOperation, scope: Gat } } - // 5. ready — per-op refinement wins over rail-level status - const hasReady = candidates.some((rail) => operationStatus(rail, op) === 'enabled') - if (hasReady) return { kind: 'ready' } - // 6. pending — BE is provisioning, no user action needed const hasPending = candidates.some((rail) => rail.status === 'pending') if (hasPending) return { kind: 'pending' } From 6e358af9d93ba145944301662bd7e86d3026063a Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Wed, 27 May 2026 21:03:00 +0000 Subject: [PATCH 11/23] =?UTF-8?q?feat(badge):=20Skip=20Pass=20=E2=80=94=20?= =?UTF-8?q?bypass=20the=20waitlist=20via=20/invite=3Fcampaign=3Dskip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Friends-of-Peanut get a /invite?campaign=skip link (no invite code) that drops the Skip Pass badge and springs them from 'Peanut jail'. A logged-in visitor is auto-claimed; a new visitor signs up first and useZeroDev awards the skip once the account exists. Backend /badge/award grants hasAppAccess alongside the badge. Adds the WAITLIST_SKIP asset + badge.utils mapping. --- public/badges/skip_pass.svg | 20 +++++++ src/components/Badges/badge.utils.ts | 6 +++ src/components/Invites/InvitesPage.tsx | 74 ++++++++++++++++++++++++++ src/hooks/useZeroDev.ts | 10 ++++ 4 files changed, 110 insertions(+) create mode 100644 public/badges/skip_pass.svg diff --git a/public/badges/skip_pass.svg b/public/badges/skip_pass.svg new file mode 100644 index 000000000..1aadc08b8 --- /dev/null +++ b/public/badges/skip_pass.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/Badges/badge.utils.ts b/src/components/Badges/badge.utils.ts index fc5b9bb85..2df10241b 100644 --- a/src/components/Badges/badge.utils.ts +++ b/src/components/Badges/badge.utils.ts @@ -106,6 +106,12 @@ export const BADGES: Record = { description: 'Ilha da Magia, baby. Coconuts and consensus.', displayName: 'Ethereum Hub Floripa', }, + // Skip Pass — friends-of-Peanut who bypassed the waitlist via /invite?campaign=skip. + // Awarded by the backend /badge/award endpoint, which also flips hasAppAccess. + WAITLIST_SKIP: { + path: '/badges/skip_pass.svg', + description: 'They skipped the waitlist. A friend handed them the key and they walked right in.', + }, } /** All known badge codes — derived from BADGES so we never duplicate the diff --git a/src/components/Invites/InvitesPage.tsx b/src/components/Invites/InvitesPage.tsx index 6592c9b6b..60b00156c 100644 --- a/src/components/Invites/InvitesPage.tsx +++ b/src/components/Invites/InvitesPage.tsx @@ -39,6 +39,10 @@ const UTM_CAMPAIGN_TO_BADGE_MAP: Record = { ethfloripa: 'ETHFLORIPA_HUB', } +// Campaign that bypasses the waitlist with no invite code: /invite?campaign=skip. +// Awarding the Skip Pass badge (backend /badge/award) also flips hasAppAccess. +const SKIP_CAMPAIGN = 'skip' + function InvitePageContent() { const searchParams = useSearchParams() // trim trailing '?' from invite code to handle qr codes with ? at the end @@ -57,11 +61,15 @@ function InvitePageContent() { (inviteCode ? INVITE_CODE_TO_CAMPAIGN_MAP[inviteCode] : undefined) || (utmCampaignParam ? UTM_CAMPAIGN_TO_BADGE_MAP[utmCampaignParam] : undefined) + // skip-the-waitlist link: no invite code required, handled by its own flow below + const isSkipCampaign = !inviteCode && campaign?.toLowerCase() === SKIP_CAMPAIGN + const dispatch = useAppDispatch() const router = useRouter() const { handleLoginClick, isLoggingIn } = useLogin() const [isAwardingBadge, setIsAwardingBadge] = useState(false) const hasStartedAwardingRef = useRef(false) + const hasStartedSkipRef = useRef(false) // Track if we should show content (prevents flash) const [shouldShowContent, setShouldShowContent] = useState(false) @@ -154,6 +162,30 @@ function InvitePageContent() { } }, [user, inviteCodeData, isLoading, isFetchingUser, router, campaign, redirectUri, fetchUser]) + // skip-the-waitlist: a logged-in visitor (even one still in jail) claims immediately — + // awardBadge('skip') grants app access + the Skip Pass badge on the backend. + useEffect(() => { + if (!isSkipCampaign || isFetchingUser || hasStartedSkipRef.current) return + if (!user?.user) return // not logged in — handled by the claim CTA below + + hasStartedSkipRef.current = true + setIsAwardingBadge(true) + invitesApi + .awardBadge(SKIP_CAMPAIGN) + .catch((e) => console.error('Error claiming skip pass', e)) + .finally(async () => { + await fetchUser() + router.push('/home') + }) + }, [isSkipCampaign, user, isFetchingUser, fetchUser, router]) + + const handleClaimSkip = () => { + posthog.capture(ANALYTICS_EVENTS.INVITE_CLAIM_CLICKED, { invite_code: SKIP_CAMPAIGN }) + // carry the campaign through signup; useZeroDev awards it once the account exists + saveToCookie('campaignTag', SKIP_CAMPAIGN) + router.push('/setup?step=signup') + } + const handleClaimInvite = async () => { if (inviteCode) { posthog.capture(ANALYTICS_EVENTS.INVITE_CLAIM_CLICKED, { @@ -174,6 +206,48 @@ function InvitePageContent() { } } + // skip-the-waitlist link (?campaign=skip, no invite code) — its own flow + if (isSkipCampaign) { + // logged-in visitors are auto-claimed by the effect above; show loading while it runs + if (user?.user || isAwardingBadge || isFetchingUser) { + return + } + // not logged in — create an account, then useZeroDev awards the skip on signup + return ( + +
+
+
+

You're skipping the waitlist

+

+ Someone at Peanut wants you in. Create your wallet and walk straight past the line — no + invite code, no queue. +

+ + +
+
+
+ +
+ ) + } + // show loading if: // 1. badge is being awarded // 2. we determined content shouldn't be shown yet (covers user fetching + invite validation) diff --git a/src/hooks/useZeroDev.ts b/src/hooks/useZeroDev.ts index 8dab4e0f4..72c664434 100644 --- a/src/hooks/useZeroDev.ts +++ b/src/hooks/useZeroDev.ts @@ -120,6 +120,16 @@ export const useZeroDev = () => { }) console.error('Error accepting invite', e) } + } else if (campaignTag?.toLowerCase() === 'skip') { + // skip-the-waitlist campaign (no invite code): awarding grants app access + the Skip Pass badge + try { + await invitesApi.awardBadge(campaignTag) + posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, { campaign_tag: campaignTag }) + } catch (e) { + console.error('Error claiming skip pass', e) + } finally { + removeFromCookie('campaignTag') + } } setWebAuthnKey(webAuthnKey) From 7911b3d01573b6a497740a6cd9efb285edbed2ed Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Thu, 28 May 2026 16:15:32 +0000 Subject: [PATCH 12/23] =?UTF-8?q?chore(invite):=20TODO-flag=20the=20WET=20?= =?UTF-8?q?skip=20flow=20for=20card-beta=E2=86=92open=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skip path parallels the existing invite-claim flow (handleClaim, the auto-claim effect, the render branch) instead of folding into it. Kept intentionally WET — invite signup is a hot, FE-test-less code path, and the refactor blast radius outweighs the duplication while Card Beta is live. Markers point future readers at the collapse point so the cleanup happens when public launch removes the urgency around touching this flow. --- src/components/Invites/InvitesPage.tsx | 5 +++++ src/hooks/useZeroDev.ts | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/Invites/InvitesPage.tsx b/src/components/Invites/InvitesPage.tsx index 60b00156c..dda16c94b 100644 --- a/src/components/Invites/InvitesPage.tsx +++ b/src/components/Invites/InvitesPage.tsx @@ -41,6 +41,11 @@ const UTM_CAMPAIGN_TO_BADGE_MAP: Record = { // Campaign that bypasses the waitlist with no invite code: /invite?campaign=skip. // Awarding the Skip Pass badge (backend /badge/award) also flips hasAppAccess. +// +// TODO(card-beta-open): collapse the skip flow (this constant, isSkipCampaign, the +// auto-claim effect, handleClaimSkip, and the skip render branch) into the existing +// invite-claim path. Kept WET on purpose — invite signup is a hot, untested code path; +// duplication < refactor blast radius while Card Beta is live. const SKIP_CAMPAIGN = 'skip' function InvitePageContent() { diff --git a/src/hooks/useZeroDev.ts b/src/hooks/useZeroDev.ts index 72c664434..30a13611e 100644 --- a/src/hooks/useZeroDev.ts +++ b/src/hooks/useZeroDev.ts @@ -121,7 +121,9 @@ export const useZeroDev = () => { console.error('Error accepting invite', e) } } else if (campaignTag?.toLowerCase() === 'skip') { - // skip-the-waitlist campaign (no invite code): awarding grants app access + the Skip Pass badge + // skip-the-waitlist campaign (no invite code): awarding grants app access + the Skip Pass badge. + // TODO(card-beta-open): generalize to `else if (campaignTag)` once the skip path is folded + // into the invite-claim path (see InvitesPage SKIP_CAMPAIGN TODO). try { await invitesApi.awardBadge(campaignTag) posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, { campaign_tag: campaignTag }) From d314a307ab08314a79b96760d413787eaf33a490 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 1 Jun 2026 19:40:18 +0100 Subject: [PATCH 13/23] fix(invite): drop unused useCallback import (eslint) --- src/components/Invites/InvitesPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Invites/InvitesPage.tsx b/src/components/Invites/InvitesPage.tsx index dda16c94b..f148e3b92 100644 --- a/src/components/Invites/InvitesPage.tsx +++ b/src/components/Invites/InvitesPage.tsx @@ -1,5 +1,5 @@ 'use client' -import { Suspense, useEffect, useRef, useState, useCallback } from 'react' +import { Suspense, useEffect, useRef, useState } from 'react' import PeanutLoading from '../Global/PeanutLoading' import ValidationErrorView from '../Payment/Views/Error.validation.view' import InvitesPageLayout from './InvitesPageLayout' From ad9162226a785882d6526b46791eeeb9a5eb1b14 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 1 Jun 2026 19:57:37 +0100 Subject: [PATCH 14/23] refactor(invite): fold the Skip Pass path into the shared claim flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DRYs the 79-line parallel branch Konrad shipped in #2120 with an explicit TODO(card-beta-open). With Skip Pass live on prod and the invite signup hot-path validated, the duplication has paid off — collapse it: - Drop SKIP_CAMPAIGN constant's WET TODO comment. - Drop hasStartedSkipRef ref (one auto-claim ref now serves both paths). - Drop dedicated skip useEffect — extend the existing auto-claim effect so it fires for isSkipCampaign too (badge GRANTS hasAppAccess on the BE, so the existing effect's hasAppAccess gate is bypassed for that case). - Drop handleClaimSkip — generalize handleClaimInvite → handleClaim that handles the no-invite-code skip case (saves campaignTag cookie, pushes to signup; useZeroDev picks up the cookie post-signup). - Drop the dedicated `if (isSkipCampaign)` render branch — branch the existing render's title / description / CTA copy on isSkipCampaign. - Generalize useZeroDev's `else if (campaignTag?.toLowerCase() === 'skip')` to `else if (campaignTag)`. BE whitelists which campaigns are claimable; passing other values is safe (non-whitelisted → 400). InvitesPage is the only path that sets campaignTag-without-inviteCode today, so behavior is unchanged. Net: -67 LoC in InvitesPage.tsx, no behavior change across all five paths (logged-out skip, logged-in skip, logged-in valid invite + campaign, logged-in valid invite no campaign, logged-out valid invite + campaign). Unit suite green (10 invite-related tests pass), eslint clean on the touched files, typecheck clean. --- src/components/Invites/InvitesPage.tsx | 223 +++++++++---------------- src/hooks/useZeroDev.ts | 11 +- 2 files changed, 84 insertions(+), 150 deletions(-) diff --git a/src/components/Invites/InvitesPage.tsx b/src/components/Invites/InvitesPage.tsx index f148e3b92..52cb1e61b 100644 --- a/src/components/Invites/InvitesPage.tsx +++ b/src/components/Invites/InvitesPage.tsx @@ -39,13 +39,9 @@ const UTM_CAMPAIGN_TO_BADGE_MAP: Record = { ethfloripa: 'ETHFLORIPA_HUB', } -// Campaign that bypasses the waitlist with no invite code: /invite?campaign=skip. -// Awarding the Skip Pass badge (backend /badge/award) also flips hasAppAccess. -// -// TODO(card-beta-open): collapse the skip flow (this constant, isSkipCampaign, the -// auto-claim effect, handleClaimSkip, and the skip render branch) into the existing -// invite-claim path. Kept WET on purpose — invite signup is a hot, untested code path; -// duplication < refactor blast radius while Card Beta is live. +// /invite?campaign=skip — bypasses the waitlist with no invite code. The backend +// /badge/award endpoint flips hasAppAccess + cardFlowEarlyAccessAt and adds the +// Skip Pass badge (which is also in SKIP_BADGE_CODES, so card-waitlist is bypassed). const SKIP_CAMPAIGN = 'skip' function InvitePageContent() { @@ -66,7 +62,9 @@ function InvitePageContent() { (inviteCode ? INVITE_CODE_TO_CAMPAIGN_MAP[inviteCode] : undefined) || (utmCampaignParam ? UTM_CAMPAIGN_TO_BADGE_MAP[utmCampaignParam] : undefined) - // skip-the-waitlist link: no invite code required, handled by its own flow below + // Skip-the-waitlist link: no invite code, ?campaign=skip. The auto-claim + // effect below treats it specially (fires even without hasAppAccess) since + // awarding the badge IS what grants access. const isSkipCampaign = !inviteCode && campaign?.toLowerCase() === SKIP_CAMPAIGN const dispatch = useAppDispatch() @@ -74,7 +72,6 @@ function InvitePageContent() { const { handleLoginClick, isLoggingIn } = useLogin() const [isAwardingBadge, setIsAwardingBadge] = useState(false) const hasStartedAwardingRef = useRef(false) - const hasStartedSkipRef = useRef(false) // Track if we should show content (prevents flash) const [shouldShowContent, setShouldShowContent] = useState(false) @@ -103,164 +100,97 @@ function InvitePageContent() { // determine if we should show content based on user state useEffect(() => { - // if still fetching user, don't show content yet if (isFetchingUser) { setShouldShowContent(false) return } - - // if invite validation is still loading, don't show content yet - if (isLoading) { + // wait for the invite query if there is one + if (inviteCode && isLoading) { setShouldShowContent(false) return } - - // if user has app access AND invite is valid, they'll be redirected - // don't show content in this case (show loading instead) - if (!redirectUri && user?.user?.hasAppAccess && inviteCodeData?.success) { + // a logged-in visitor on either claim path will be auto-routed by the + // effect below — keep the loading spinner so they don't see the CTA flash. + if (user?.user && (isSkipCampaign || (!redirectUri && user.user.hasAppAccess && inviteCodeData?.success))) { setShouldShowContent(false) return } - - // otherwise, safe to show content (either error view or invite screen) setShouldShowContent(true) - }, [user, isFetchingUser, redirectUri, inviteCodeData, isLoading]) + }, [user, isFetchingUser, redirectUri, inviteCodeData, isLoading, isSkipCampaign, inviteCode]) - // redirect logged-in users who already have app access - // users without app access should stay on this page to claim the invite and get access + // Logged-in auto-claim: award the campaign badge then push /home, or fall + // back to the inviter's profile when there's a valid invite but no campaign. + // Fires on both the invite-code path (needs hasAppAccess) and the Skip Pass + // path (badge GRANTS access, so no hasAppAccess gate). useEffect(() => { - // wait for both user and invite data to be loaded - if (!user?.user || !inviteCodeData || isLoading || isFetchingUser) { + if (!user?.user || isFetchingUser || hasStartedAwardingRef.current) return + // wait for invite-code validation when there is one + if (inviteCode && (isLoading || !inviteCodeData)) return + + const hasValidInvite = !!inviteCodeData?.success && !!inviteCodeData.username + const isInviteAutoClaim = !redirectUri && user.user.hasAppAccess && hasValidInvite + if (!isInviteAutoClaim && !isSkipCampaign) return + + hasStartedAwardingRef.current = true + + if (campaign) { + setIsAwardingBadge(true) + invitesApi + .awardBadge(campaign) + .catch((e) => console.error('Error awarding campaign badge', e)) + .finally(async () => { + await fetchUser() + setIsAwardingBadge(false) + router.push('/home') + }) return } - // prevent running the effect multiple times (ref doesn't trigger re-renders) - if (hasStartedAwardingRef.current) { - return + // No campaign on a validated invite → route to inviter profile. + if (hasValidInvite) { + router.push(profileUrl(inviteCodeData!.username!)) } + }, [ + user, + inviteCodeData, + isLoading, + isFetchingUser, + router, + campaign, + redirectUri, + fetchUser, + isSkipCampaign, + inviteCode, + ]) + + const handleClaim = () => { + const eventTag = inviteCode || (isSkipCampaign ? SKIP_CAMPAIGN : undefined) + posthog.capture(ANALYTICS_EVENTS.INVITE_CLAIM_CLICKED, { invite_code: eventTag }) - // if user has app access and invite is valid, handle redirect - if (!redirectUri && user.user.hasAppAccess && inviteCodeData.success && inviteCodeData.username) { - // if campaign is present, award the badge and redirect to home - if (campaign) { - hasStartedAwardingRef.current = true - setIsAwardingBadge(true) - invitesApi - .awardBadge(campaign) - .then(async () => { - // refetch user data to get the newly awarded badge - await fetchUser() - router.push('/home') - }) - .catch(async () => { - // if badge awarding fails, still refetch and redirect - await fetchUser() - router.push('/home') - }) - .finally(() => { - setIsAwardingBadge(false) - }) - } else { - // no campaign, just redirect to inviter's profile - router.push(profileUrl(inviteCodeData.username)) - } - } - }, [user, inviteCodeData, isLoading, isFetchingUser, router, campaign, redirectUri, fetchUser]) - - // skip-the-waitlist: a logged-in visitor (even one still in jail) claims immediately — - // awardBadge('skip') grants app access + the Skip Pass badge on the backend. - useEffect(() => { - if (!isSkipCampaign || isFetchingUser || hasStartedSkipRef.current) return - if (!user?.user) return // not logged in — handled by the claim CTA below - - hasStartedSkipRef.current = true - setIsAwardingBadge(true) - invitesApi - .awardBadge(SKIP_CAMPAIGN) - .catch((e) => console.error('Error claiming skip pass', e)) - .finally(async () => { - await fetchUser() - router.push('/home') - }) - }, [isSkipCampaign, user, isFetchingUser, fetchUser, router]) - - const handleClaimSkip = () => { - posthog.capture(ANALYTICS_EVENTS.INVITE_CLAIM_CLICKED, { invite_code: SKIP_CAMPAIGN }) - // carry the campaign through signup; useZeroDev awards it once the account exists - saveToCookie('campaignTag', SKIP_CAMPAIGN) - router.push('/setup?step=signup') - } - - const handleClaimInvite = async () => { if (inviteCode) { - posthog.capture(ANALYTICS_EVENTS.INVITE_CLAIM_CLICKED, { - invite_code: inviteCode, - }) dispatch(setupActions.setInviteCode(inviteCode)) dispatch(setupActions.setInviteType(EInviteType.PAYMENT_LINK)) - saveToCookie('inviteCode', inviteCode) // Save to cookies as well, so that if user installs PWA, they can still use the invite code - if (campaign) { - saveToCookie('campaignTag', campaign) - } - if (redirectUri) { - const encodedRedirectUri = encodeURIComponent(redirectUri) - router.push('/setup?step=signup&redirect_uri=' + encodedRedirectUri) - } else { - router.push('/setup?step=signup') - } + // Save to cookie so PWA-install + later signup still see the invite. + saveToCookie('inviteCode', inviteCode) } - } - - // skip-the-waitlist link (?campaign=skip, no invite code) — its own flow - if (isSkipCampaign) { - // logged-in visitors are auto-claimed by the effect above; show loading while it runs - if (user?.user || isAwardingBadge || isFetchingUser) { - return + if (campaign) { + // useZeroDev reads `campaignTag` post-signup and calls /badge/award. + saveToCookie('campaignTag', campaign) } - // not logged in — create an account, then useZeroDev awards the skip on signup - return ( - -
-
-
-

You're skipping the waitlist

-

- Someone at Peanut wants you in. Create your wallet and walk straight past the line — no - invite code, no queue. -

- - -
-
-
- -
- ) + + const signupUrl = redirectUri + ? `/setup?step=signup&redirect_uri=${encodeURIComponent(redirectUri)}` + : '/setup?step=signup' + router.push(signupUrl) } - // show loading if: - // 1. badge is being awarded - // 2. we determined content shouldn't be shown yet (covers user fetching + invite validation) if (isAwardingBadge || !shouldShowContent) { return } - if (isError || !inviteCodeData?.success) { + // Invalid invite code (only reachable when an invite code was supplied). + // The skip path has no invite code so it never falls here. + if (!isSkipCampaign && (isError || !inviteCodeData?.success)) { return (
-

{inviteCodeData?.username} invited you to Peanut

-

- Members-only access. Use this invite to open your wallet and start sending and receiving - money globally. -

- {!user?.user && ( diff --git a/src/hooks/useZeroDev.ts b/src/hooks/useZeroDev.ts index 30a13611e..1222a3894 100644 --- a/src/hooks/useZeroDev.ts +++ b/src/hooks/useZeroDev.ts @@ -120,15 +120,16 @@ export const useZeroDev = () => { }) console.error('Error accepting invite', e) } - } else if (campaignTag?.toLowerCase() === 'skip') { - // skip-the-waitlist campaign (no invite code): awarding grants app access + the Skip Pass badge. - // TODO(card-beta-open): generalize to `else if (campaignTag)` once the skip path is folded - // into the invite-claim path (see InvitesPage SKIP_CAMPAIGN TODO). + } else if (campaignTag) { + // No invite code but a campaign tag — only InvitesPage's skip-path + // CTA reaches here today (it sets the cookie without an inviteCode). + // The BE whitelists which campaigns are claimable, so passing other + // values through is safe — anything not on the whitelist 400s. try { await invitesApi.awardBadge(campaignTag) posthog.capture(ANALYTICS_EVENTS.INVITE_ACCEPTED, { campaign_tag: campaignTag }) } catch (e) { - console.error('Error claiming skip pass', e) + console.error('Error awarding campaign badge', e) } finally { removeFromCookie('campaignTag') } From d8b7fe10ee7e06b01db7ee34422c23150a15b4f3 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 1 Jun 2026 20:07:48 +0100 Subject: [PATCH 15/23] fix(saved-accounts): include Manteca accounts in useSavedAccounts The claim / send-link bank flows filtered saved accounts to IBAN/US/CLABE/GB only, dropping Manteca (AR/BR) accounts that the backend projects as type 'manteca'. AR/BR users saw an empty saved-accounts list and had to re-enter their CBU/CVU/PIX every time. The withdraw router already included MANTECA; this brings the claim flows to parity. --- src/hooks/__tests__/useSavedAccounts.test.ts | 46 ++++++++++++++++++++ src/hooks/useSavedAccounts.tsx | 11 +++-- 2 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 src/hooks/__tests__/useSavedAccounts.test.ts diff --git a/src/hooks/__tests__/useSavedAccounts.test.ts b/src/hooks/__tests__/useSavedAccounts.test.ts new file mode 100644 index 000000000..2a01560b3 --- /dev/null +++ b/src/hooks/__tests__/useSavedAccounts.test.ts @@ -0,0 +1,46 @@ +import { renderHook } from '@testing-library/react' +import useSavedAccounts from '@/hooks/useSavedAccounts' +import { AccountType } from '@/interfaces' + +const mockUseAuth = jest.fn() +jest.mock('@/context/authContext', () => ({ + useAuth: () => mockUseAuth(), +})) + +const acct = (type: AccountType, identifier: string) => ({ type, identifier, id: identifier }) + +beforeEach(() => mockUseAuth.mockReset()) + +describe('useSavedAccounts', () => { + it('returns [] when there is no user', () => { + mockUseAuth.mockReturnValue({ user: undefined }) + const { result } = renderHook(() => useSavedAccounts()) + expect(result.current).toEqual([]) + }) + + it('includes Manteca (AR CBU/CVU, BR PIX) accounts alongside Bridge bank types', () => { + // Regression: Manteca accounts (projected by the backend as type + // 'manteca') were dropped, so AR/BR users saw an empty saved-accounts + // list and had to re-enter their bank account every time. + mockUseAuth.mockReturnValue({ + user: { + accounts: [ + acct(AccountType.PEANUT_WALLET, '0xabc'), + acct(AccountType.EVM_ADDRESS, '0xdef'), + acct(AccountType.IBAN, 'DE00'), + acct(AccountType.MANTECA, '0070075730004135153296'), + ], + }, + }) + const { result } = renderHook(() => useSavedAccounts()) + expect(result.current.map((a) => a.type)).toEqual([AccountType.IBAN, AccountType.MANTECA]) + }) + + it('excludes wallets and other non-bank account types', () => { + mockUseAuth.mockReturnValue({ + user: { accounts: [acct(AccountType.PEANUT_WALLET, '0xabc'), acct(AccountType.EVM_ADDRESS, '0xdef')] }, + }) + const { result } = renderHook(() => useSavedAccounts()) + expect(result.current).toEqual([]) + }) +}) diff --git a/src/hooks/useSavedAccounts.tsx b/src/hooks/useSavedAccounts.tsx index f34fb4a2b..bcf60fe2a 100644 --- a/src/hooks/useSavedAccounts.tsx +++ b/src/hooks/useSavedAccounts.tsx @@ -5,14 +5,18 @@ import { AccountType } from '@/interfaces' import { useMemo } from 'react' /** - * Used to get the user's saved accounts, for now limited to bank accounts with (IBAN, US, and CLABE) + * Used to get the user's saved bank accounts (IBAN, US/ACH, CLABE, GB sort-code, + * and Manteca LATAM accounts — AR CBU/CVU, BR PIX — which the backend projects + * under the single 'manteca' type). * NOTE: This hook can be extended to support more account types in the future based on requirements * @returns {array} An array of the user's saved bank accounts */ export default function useSavedAccounts() { const { user } = useAuth() - // filter out accounts that are not IBAN, US, or CLABE + // keep only saved bank accounts. Manteca (AR/BR) was previously omitted, so + // Manteca users' saved bank accounts never appeared in the claim/send-link + // bank flows — they had to re-enter their CBU/CVU/PIX every time. const savedAccounts = useMemo(() => { return ( user?.accounts.filter( @@ -20,7 +24,8 @@ export default function useSavedAccounts() { acc.type === AccountType.IBAN || acc.type === AccountType.US || acc.type === AccountType.CLABE || - acc.type === AccountType.GB + acc.type === AccountType.GB || + acc.type === AccountType.MANTECA ) ?? [] ) }, [user]) From 697be265b64462ee1ad8540f19e3e299845c3875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Tue, 2 Jun 2026 10:58:59 -0300 Subject: [PATCH 16/23] hotfix(kyc): derive region intent from destination country in bank flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #913 (BE) + #2136 (FE) replaced resolveLevelName with the 4-bucket verificationLevelForIntent map: EU/NA → bridge-requirements, LATAM/ROW/ STANDARD/undefined → general. The Bridge bank-flow call sites kept the hardcoded literal 'STANDARD' as the intent, which now falls through to the general-bucket fallback. Every Bridge bank user (add-money OR withdraw) gets routed to general instead of bridge-requirements, producing a confused KYC level mismatch. Fix: pass the destination country's region through getRegionIntent so /add-money/usa and /withdraw/usa land on NA, /add-money/germany on EU, etc. Four files, six call sites: - app/(mobile-ui)/withdraw/[country]/bank/page.tsx - app/(mobile-ui)/add-money/[country]/bank/page.tsx - components/AddWithdraw/AddWithdrawCountriesList.tsx (two) - components/Claim/Link/views/BankFlowManager.view.tsx (two) Out of scope (filed as follow-up): UnlockedRegions.view.tsx line 92 still returns 'STANDARD' for a Bridge functional rail as a legacy cross-region marker; providerForRegionIntent maps STANDARD → manteca, so a Bridge-to-Bridge re-verification path is at risk of misclassifying as cross-region. Separate fix, not bank-flow-specific. --- src/app/(mobile-ui)/add-money/[country]/bank/page.tsx | 6 ++++-- src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx | 4 ++-- src/components/AddWithdraw/AddWithdrawCountriesList.tsx | 7 ++++--- src/components/Claim/Link/views/BankFlowManager.view.tsx | 7 ++++--- 4 files changed, 14 insertions(+), 10 deletions(-) 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 5b8677652..3d4c2ff78 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -38,6 +38,7 @@ import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import { addMoneyCountryUrl } from '@/utils/native-routes' import { useSafeBack } from '@/hooks/useSafeBack' +import { getRegionIntent } from '@/utils/regions.utils' // Step type for URL state type BridgeBankStep = 'inputAmount' | 'showDetails' @@ -71,7 +72,8 @@ export default function OnrampBankPage() { // inline sumsub kyc flow for bridge bank onramp // regionIntent is NOT passed here to avoid creating a backend record on mount. - // intent is passed at call time: handleInitiateKyc('STANDARD') + // intent is passed at call time, derived from the destination country + // (e.g. /add-money/usa → NA → bridge-requirements). const sumsubFlow = useMultiPhaseKycFlow({ onKycSuccess: () => { setUrlState({ step: 'inputAmount' }) @@ -418,7 +420,7 @@ export default function OnrampBankPage() { await sumsubFlow.handleSelfHealResubmit('BRIDGE') } else { await sumsubFlow.handleInitiateKyc( - 'STANDARD', + getRegionIntent(selectedCountry?.region ?? 'rest-of-the-world'), undefined, gate.kind === 'needs-enrollment' || undefined ) diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 59fd34bce..568ba377b 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -38,7 +38,7 @@ import { getKycModalVariant, getGateUserMessage } from '@/utils/capability-gate' import { useModalsContext } from '@/context/ModalsContext' import ExchangeRate from '@/components/ExchangeRate' import countryCurrencyMappings, { isNonEuroSepaCountry } from '@/constants/countryCurrencyMapping' -import { isBridgeSupportedCountry } from '@/utils/regions.utils' +import { isBridgeSupportedCountry, getRegionIntent } from '@/utils/regions.utils' import { PointsAction } from '@/services/services.types' import { usePointsCalculation } from '@/hooks/usePointsCalculation' import { parseUnits } from 'viem' @@ -533,7 +533,7 @@ export default function WithdrawBankPage() { await sumsubFlow.handleSelfHealResubmit('BRIDGE') } else { await sumsubFlow.handleInitiateKyc( - 'STANDARD', + getRegionIntent(getCountryFromPath(country)?.region ?? 'rest-of-the-world'), undefined, gate.kind === 'needs-enrollment' || undefined ) diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index cd3245c24..c287f5517 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -32,6 +32,7 @@ import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' import { useCapabilities } from '@/hooks/useCapabilities' import { getKycModalVariant, getGateUserMessage } from '@/utils/capability-gate' import { railJurisdictionForBank } from '@/utils/bridge.utils' +import { getRegionIntent } from '@/utils/regions.utils' import { useTosGuard } from '@/hooks/useTosGuard' import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { useModalsContext } from '@/context/ModalsContext' @@ -59,7 +60,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { // inline sumsub kyc flow for bridge bank users who need verification // regionIntent is NOT passed here to avoid creating a backend record on mount. - // intent is passed at call time: handleInitiateKyc('STANDARD') + // intent is passed at call time, derived from the destination country. const sumsubFlow = useMultiPhaseKycFlow({ onKycSuccess: () => { setIsKycModalOpen(false) @@ -235,7 +236,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { // scenario (2): if the user hasn't completed kyc yet // name and email are now collected by sumsub sdk — no need to save them beforehand if (!isUserKycApproved) { - await sumsubFlow.handleInitiateKyc('STANDARD') + await sumsubFlow.handleInitiateKyc(getRegionIntent(currentCountry?.region ?? 'rest-of-the-world')) } return {} @@ -342,7 +343,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { await sumsubFlow.handleSelfHealResubmit('BRIDGE') } else { await sumsubFlow.handleInitiateKyc( - 'STANDARD', + getRegionIntent(currentCountry?.region ?? 'rest-of-the-world'), undefined, gate.kind === 'needs-enrollment' || undefined ) diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index 60fbb46c7..12c210953 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -32,6 +32,7 @@ import { bankFormActions } from '@/redux/slices/bank-form-slice' import { sendLinksApi } from '@/services/sendLinks' import { useSearchParams } from 'next/navigation' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' +import { getRegionIntent } from '@/utils/regions.utils' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { useCapabilities } from '@/hooks/useCapabilities' import { getKycModalVariant, getGateUserMessage } from '@/utils/capability-gate' @@ -93,7 +94,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => { // inline sumsub kyc flow for users who need verification // regionIntent is NOT passed here to avoid creating a backend record on mount. - // intent is passed at call time: handleInitiateKyc('STANDARD') + // intent is passed at call time, derived from the destination country. const sumsubFlow = useMultiPhaseKycFlow({ onKycSuccess: async () => { if (justCompletedKyc) return @@ -296,7 +297,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => { // scenario 1: receiver needs KYC // name and email are now collected by sumsub sdk — no need to save them beforehand if (bankClaimType === BankClaimType.ReceiverKycNeeded && !justCompletedKyc) { - await sumsubFlow.handleInitiateKyc('STANDARD') + await sumsubFlow.handleInitiateKyc(getRegionIntent(selectedCountry?.region ?? 'rest-of-the-world')) return {} } @@ -564,7 +565,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => { await sumsubFlow.handleSelfHealResubmit('BRIDGE') } else { await sumsubFlow.handleInitiateKyc( - 'STANDARD', + getRegionIntent(selectedCountry?.region ?? 'rest-of-the-world'), undefined, gate.kind === 'needs-enrollment' || undefined ) From 69ddaa5d4ce34ae69806b6033da80890d2bcce91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Tue, 2 Jun 2026 11:01:54 -0300 Subject: [PATCH 17/23] fix(kyc): drop legacy 'STANDARD' Bridge-rail marker in cross-region check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UnlockedRegions used to return 'STANDARD' as the sumsubVerificationRegionIntent for users with a functional Bridge rail. providerForRegionIntent maps STANDARD → manteca (the general-bucket fallback after PR #913), so a Bridge → Bridge re-verification path would have evaluated 'manteca' !== 'bridge' = true and misclassified as cross-region — opening the wrong KYC modal copy + bypassing the same-provider flow. Capture the provider directly instead. Same semantics for Manteca (manteca rail → 'manteca' → matches LATAM-bucket clicked region), correct semantics for Bridge (bridge rail → 'bridge' → matches EU/NA clicked region). No round-trip through the deprecated STANDARD bucket. Local refactor, single file. --- .../Profile/views/UnlockedRegions.view.tsx | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/components/Profile/views/UnlockedRegions.view.tsx b/src/components/Profile/views/UnlockedRegions.view.tsx index 35eff07a1..e191a5ff4 100644 --- a/src/components/Profile/views/UnlockedRegions.view.tsx +++ b/src/components/Profile/views/UnlockedRegions.view.tsx @@ -79,17 +79,20 @@ const UnlockedRegions = () => { const mantecaRejection = useMemo(() => deriveProviderRejection(rails, 'MANTECA'), [rails]) const isSumsubApproved = isKycApproved // MIGRATION-REVIEW: the existing verification's region intent (was - // useUnifiedKycStatus.sumsubVerificationRegionIntent, read off raw kycVerifications - // metadata) is proxied from which provider the user already holds a functional rail - // for: a Manteca rail ⇒ they verified for LATAM, a Bridge rail ⇒ STANDARD. Used only + // Which provider the user already holds a functional rail for, used only // to detect a cross-region switch when starting a new verification. - const sumsubVerificationRegionIntent: string | null = useMemo(() => { + // We deliberately capture the PROVIDER directly rather than round-tripping + // through KYCRegionIntent: the legacy 'STANDARD' value used to stand in + // for "Bridge rail" here, but providerForRegionIntent maps STANDARD → + // manteca (it's in the general-bucket fallback), so a Bridge-→Bridge + // re-verification would have misclassified as cross-region. + const existingVerificationProvider: ProviderCode | null = useMemo(() => { const hasFunctional = (provider: ProviderCode) => railsForProvider(provider).some( (rail) => rail.status === 'enabled' || rail.status === 'pending' || rail.status === 'requires-info' ) - if (hasFunctional('manteca')) return 'LATAM' - if (hasFunctional('bridge')) return 'STANDARD' + if (hasFunctional('manteca')) return 'manteca' + if (hasFunctional('bridge')) return 'bridge' return null }, [railsForProvider]) const { setIsSupportModalOpen } = useModalsContext() @@ -165,21 +168,17 @@ const UnlockedRegions = () => { const intent = selectedRegion ? getRegionIntent(selectedRegion.path) : undefined if (intent) setActiveRegionIntent(intent) // Cross-region means "user has an identity for one provider, clicked a - // region served by a DIFFERENT provider". Compare PROVIDERS not raw - // intent strings: in the 4-bucket model both 'EU' and 'NA' route to - // Bridge, and both 'LATAM' / 'ROW' route to Manteca (`general` level), - // so 'STANDARD' !== 'EU' alone would misclassify a Bridge→Bridge flow - // as cross-region. + // region served by a DIFFERENT provider". Compare PROVIDERS directly: + // in the 4-bucket model both 'EU' and 'NA' route to Bridge and both + // 'LATAM' / 'ROW' route to Manteca, so an intent-string compare would + // miss Bridge-EU → Bridge-NA same-provider flows. const crossRegion = - sumsubVerificationRegionIntent && - intent && - providerForRegionIntent(sumsubVerificationRegionIntent as KYCRegionIntent) !== - providerForRegionIntent(intent) + existingVerificationProvider && intent && existingVerificationProvider !== providerForRegionIntent(intent) ? true : undefined setSelectedRegion(null) await flow.handleInitiateKyc(intent, undefined, crossRegion) - }, [flow.handleInitiateKyc, selectedRegion, sumsubVerificationRegionIntent]) + }, [flow.handleInitiateKyc, selectedRegion, existingVerificationProvider]) // re-submission: skip StartVerificationView since user already consented const handleResubmitKyc = useCallback(async () => { From 53398d1ec9649028738ebb05aa5638bdcd4a13a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Tue, 2 Jun 2026 11:09:14 -0300 Subject: [PATCH 18/23] test(add-money): mock new transitive imports from regions.utils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bank-flow hotfix in this PR adds a regions.utils import to add-money/[country]/bank/page.tsx. That pulls in module-level calls to getFlagUrl and BRIDGE_ALPHA3_TO_ALPHA2 when the test loads the page, so the existing add-money-states suite errored at module-load time without running any of its 40 tests. Mock both names with sensible shapes — getFlagUrl returns a fake URL, BRIDGE_ALPHA3_TO_ALPHA2 carries the Bridge-served countries the suite already exercises. No behavioural test changes. --- .../(mobile-ui)/add-money/__tests__/add-money-states.test.tsx | 2 ++ 1 file changed, 2 insertions(+) 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 6f6cb3bfd..3fc6ac842 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 @@ -256,6 +256,7 @@ jest.mock('@/constants/countryCurrencyMapping', () => ({ ], isNonEuroSepaCountry: jest.fn(() => false), isUKCountry: jest.fn(() => false), + getFlagUrl: jest.fn((iso2: string) => `/flags/${iso2}.svg`), })) // Rhino consts @@ -681,6 +682,7 @@ jest.mock('@/components/AddMoney/consts', () => ({ { type: 'country', id: 'XX', path: 'unknown', currency: 'USD', iso3: 'XXX' }, ], ALL_COUNTRIES_ALPHA3_TO_ALPHA2: { ARG: 'AR', BRA: 'BR', USA: 'US', DEU: 'DE', MEX: 'MX', GBR: 'GB' }, + BRIDGE_ALPHA3_TO_ALPHA2: { USA: 'US', DEU: 'DE', MEX: 'MX', GBR: 'GB' }, })) jest.mock('@/components/TransactionDetails/transactionTransformer', () => ({})) From b33141288097d86b44322d1e9e16ea58b9a5dd0b Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 2 Jun 2026 17:28:49 +0100 Subject: [PATCH 19/23] fix(saved-accounts): import AccountType from @/interfaces/interfaces (no barrel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @/interfaces barrel import is banned by no-restricted-imports (build perf). The pre-existing import in useSavedAccounts surfaced once eslint ran on the touched file, and the new test added another — point both at the concrete module, matching AddWithdrawRouterView. --- src/hooks/__tests__/useSavedAccounts.test.ts | 2 +- src/hooks/useSavedAccounts.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/__tests__/useSavedAccounts.test.ts b/src/hooks/__tests__/useSavedAccounts.test.ts index 2a01560b3..a77d32d3a 100644 --- a/src/hooks/__tests__/useSavedAccounts.test.ts +++ b/src/hooks/__tests__/useSavedAccounts.test.ts @@ -1,6 +1,6 @@ import { renderHook } from '@testing-library/react' import useSavedAccounts from '@/hooks/useSavedAccounts' -import { AccountType } from '@/interfaces' +import { AccountType } from '@/interfaces/interfaces' const mockUseAuth = jest.fn() jest.mock('@/context/authContext', () => ({ diff --git a/src/hooks/useSavedAccounts.tsx b/src/hooks/useSavedAccounts.tsx index bcf60fe2a..de53407a9 100644 --- a/src/hooks/useSavedAccounts.tsx +++ b/src/hooks/useSavedAccounts.tsx @@ -1,7 +1,7 @@ 'use client' import { useAuth } from '@/context/authContext' -import { AccountType } from '@/interfaces' +import { AccountType } from '@/interfaces/interfaces' import { useMemo } from 'react' /** From 56e5c08821cf1b6ad5798f28e126efaf9780fff1 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 1 Jun 2026 12:12:26 +0100 Subject: [PATCH 20/23] feat(card): host Rain card legal pages + wire onboarding links Rain requires the card terms, account-opening privacy notice, and prohibited- activities policy to be hosted at stable URLs and surfaced in onboarding. Adds 4 marketing legal routes (content authored in mono content/legal/, served via the peanut-content mirror) and points CardTermsScreen at them, replacing the '#' placeholders. US and international cardholders accept different card-terms documents, so the card-terms link is now split by residency. --- .../(marketing)/card-privacy/page.tsx | 83 +++++++++++++++++ .../card-prohibited-activities/page.tsx | 83 +++++++++++++++++ .../card-terms-international/page.tsx | 83 +++++++++++++++++ .../(marketing)/card-terms-us/page.tsx | 83 +++++++++++++++++ src/components/Card/CardTermsScreen.tsx | 93 ++++++++++--------- src/i18n/config.ts | 4 + 6 files changed, 385 insertions(+), 44 deletions(-) create mode 100644 src/app/[locale]/(marketing)/card-privacy/page.tsx create mode 100644 src/app/[locale]/(marketing)/card-prohibited-activities/page.tsx create mode 100644 src/app/[locale]/(marketing)/card-terms-international/page.tsx create mode 100644 src/app/[locale]/(marketing)/card-terms-us/page.tsx diff --git a/src/app/[locale]/(marketing)/card-privacy/page.tsx b/src/app/[locale]/(marketing)/card-privacy/page.tsx new file mode 100644 index 000000000..0920f9e05 --- /dev/null +++ b/src/app/[locale]/(marketing)/card-privacy/page.tsx @@ -0,0 +1,83 @@ +import { notFound } from 'next/navigation' +import { type Metadata } from 'next' +import { generateMetadata as metadataHelper } from '@/app/metadata' +import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config' +import { getTranslations } from '@/i18n' +import { ContentPage } from '@/components/Marketing/ContentPage' +import { readPageContentLocalized } from '@/lib/content' +import { renderContent } from '@/lib/mdx' + +interface PageProps { + params: Promise<{ locale: string }> +} + +interface LegalFrontmatter { + title: string + description: string + slug: string + published?: boolean + last_updated?: string +} + +const SLUG = 'card-privacy' + +export async function generateStaticParams() { + return SUPPORTED_LOCALES.map((locale) => ({ locale })) +} +export const dynamicParams = false + +export async function generateMetadata({ params }: PageProps): Promise { + const { locale } = await params + if (!isValidLocale(locale)) return {} + + const mdxContent = readPageContentLocalized('legal', SLUG, locale) + if (!mdxContent || mdxContent.frontmatter.published === false) return {} + + return { + ...metadataHelper({ + title: mdxContent.frontmatter.title, + description: mdxContent.frontmatter.description, + canonical: `/${locale}/${SLUG}`, + }), + alternates: { + canonical: `/${locale}/${SLUG}`, + languages: getAlternates(SLUG), + }, + } +} + +export default async function CardPrivacyPage({ params }: PageProps) { + const { locale } = await params + if (!isValidLocale(locale)) notFound() + + const mdxSource = readPageContentLocalized('legal', SLUG, locale) + if (!mdxSource || mdxSource.frontmatter.published === false) notFound() + + const { content } = await renderContent(mdxSource.body) + const i18n = getTranslations(locale) + + const displayTitle = mdxSource.frontmatter.title.replace(/\s*\|\s*Peanut$/, '') + const url = `/${locale}/${SLUG}` + + return ( + + {content} + + ) +} diff --git a/src/app/[locale]/(marketing)/card-prohibited-activities/page.tsx b/src/app/[locale]/(marketing)/card-prohibited-activities/page.tsx new file mode 100644 index 000000000..cf7c83552 --- /dev/null +++ b/src/app/[locale]/(marketing)/card-prohibited-activities/page.tsx @@ -0,0 +1,83 @@ +import { notFound } from 'next/navigation' +import { type Metadata } from 'next' +import { generateMetadata as metadataHelper } from '@/app/metadata' +import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config' +import { getTranslations } from '@/i18n' +import { ContentPage } from '@/components/Marketing/ContentPage' +import { readPageContentLocalized } from '@/lib/content' +import { renderContent } from '@/lib/mdx' + +interface PageProps { + params: Promise<{ locale: string }> +} + +interface LegalFrontmatter { + title: string + description: string + slug: string + published?: boolean + last_updated?: string +} + +const SLUG = 'card-prohibited-activities' + +export async function generateStaticParams() { + return SUPPORTED_LOCALES.map((locale) => ({ locale })) +} +export const dynamicParams = false + +export async function generateMetadata({ params }: PageProps): Promise { + const { locale } = await params + if (!isValidLocale(locale)) return {} + + const mdxContent = readPageContentLocalized('legal', SLUG, locale) + if (!mdxContent || mdxContent.frontmatter.published === false) return {} + + return { + ...metadataHelper({ + title: mdxContent.frontmatter.title, + description: mdxContent.frontmatter.description, + canonical: `/${locale}/${SLUG}`, + }), + alternates: { + canonical: `/${locale}/${SLUG}`, + languages: getAlternates(SLUG), + }, + } +} + +export default async function CardProhibitedActivitiesPage({ params }: PageProps) { + const { locale } = await params + if (!isValidLocale(locale)) notFound() + + const mdxSource = readPageContentLocalized('legal', SLUG, locale) + if (!mdxSource || mdxSource.frontmatter.published === false) notFound() + + const { content } = await renderContent(mdxSource.body) + const i18n = getTranslations(locale) + + const displayTitle = mdxSource.frontmatter.title.replace(/\s*\|\s*Peanut$/, '') + const url = `/${locale}/${SLUG}` + + return ( + + {content} + + ) +} diff --git a/src/app/[locale]/(marketing)/card-terms-international/page.tsx b/src/app/[locale]/(marketing)/card-terms-international/page.tsx new file mode 100644 index 000000000..87b3fcc9d --- /dev/null +++ b/src/app/[locale]/(marketing)/card-terms-international/page.tsx @@ -0,0 +1,83 @@ +import { notFound } from 'next/navigation' +import { type Metadata } from 'next' +import { generateMetadata as metadataHelper } from '@/app/metadata' +import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config' +import { getTranslations } from '@/i18n' +import { ContentPage } from '@/components/Marketing/ContentPage' +import { readPageContentLocalized } from '@/lib/content' +import { renderContent } from '@/lib/mdx' + +interface PageProps { + params: Promise<{ locale: string }> +} + +interface LegalFrontmatter { + title: string + description: string + slug: string + published?: boolean + last_updated?: string +} + +const SLUG = 'card-terms-international' + +export async function generateStaticParams() { + return SUPPORTED_LOCALES.map((locale) => ({ locale })) +} +export const dynamicParams = false + +export async function generateMetadata({ params }: PageProps): Promise { + const { locale } = await params + if (!isValidLocale(locale)) return {} + + const mdxContent = readPageContentLocalized('legal', SLUG, locale) + if (!mdxContent || mdxContent.frontmatter.published === false) return {} + + return { + ...metadataHelper({ + title: mdxContent.frontmatter.title, + description: mdxContent.frontmatter.description, + canonical: `/${locale}/${SLUG}`, + }), + alternates: { + canonical: `/${locale}/${SLUG}`, + languages: getAlternates(SLUG), + }, + } +} + +export default async function CardTermsInternationalPage({ params }: PageProps) { + const { locale } = await params + if (!isValidLocale(locale)) notFound() + + const mdxSource = readPageContentLocalized('legal', SLUG, locale) + if (!mdxSource || mdxSource.frontmatter.published === false) notFound() + + const { content } = await renderContent(mdxSource.body) + const i18n = getTranslations(locale) + + const displayTitle = mdxSource.frontmatter.title.replace(/\s*\|\s*Peanut$/, '') + const url = `/${locale}/${SLUG}` + + return ( + + {content} + + ) +} diff --git a/src/app/[locale]/(marketing)/card-terms-us/page.tsx b/src/app/[locale]/(marketing)/card-terms-us/page.tsx new file mode 100644 index 000000000..6a8aeab3b --- /dev/null +++ b/src/app/[locale]/(marketing)/card-terms-us/page.tsx @@ -0,0 +1,83 @@ +import { notFound } from 'next/navigation' +import { type Metadata } from 'next' +import { generateMetadata as metadataHelper } from '@/app/metadata' +import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config' +import { getTranslations } from '@/i18n' +import { ContentPage } from '@/components/Marketing/ContentPage' +import { readPageContentLocalized } from '@/lib/content' +import { renderContent } from '@/lib/mdx' + +interface PageProps { + params: Promise<{ locale: string }> +} + +interface LegalFrontmatter { + title: string + description: string + slug: string + published?: boolean + last_updated?: string +} + +const SLUG = 'card-terms-us' + +export async function generateStaticParams() { + return SUPPORTED_LOCALES.map((locale) => ({ locale })) +} +export const dynamicParams = false + +export async function generateMetadata({ params }: PageProps): Promise { + const { locale } = await params + if (!isValidLocale(locale)) return {} + + const mdxContent = readPageContentLocalized('legal', SLUG, locale) + if (!mdxContent || mdxContent.frontmatter.published === false) return {} + + return { + ...metadataHelper({ + title: mdxContent.frontmatter.title, + description: mdxContent.frontmatter.description, + canonical: `/${locale}/${SLUG}`, + }), + alternates: { + canonical: `/${locale}/${SLUG}`, + languages: getAlternates(SLUG), + }, + } +} + +export default async function CardTermsUsPage({ params }: PageProps) { + const { locale } = await params + if (!isValidLocale(locale)) notFound() + + const mdxSource = readPageContentLocalized('legal', SLUG, locale) + if (!mdxSource || mdxSource.frontmatter.published === false) notFound() + + const { content } = await renderContent(mdxSource.body) + const i18n = getTranslations(locale) + + const displayTitle = mdxSource.frontmatter.title.replace(/\s*\|\s*Peanut$/, '') + const url = `/${locale}/${SLUG}` + + return ( + + {content} + + ) +} diff --git a/src/components/Card/CardTermsScreen.tsx b/src/components/Card/CardTermsScreen.tsx index 2cc410c70..481689b7f 100644 --- a/src/components/Card/CardTermsScreen.tsx +++ b/src/components/Card/CardTermsScreen.tsx @@ -17,12 +17,13 @@ interface Props { // stands in for Peanut. Swap if the partnership agreement renames the card. const CARD_PARTNER_NAME = 'Peanut' +// US and international cardholders accept different card-terms documents. const LINKS = { eSign: 'https://legal.raincards.xyz/legal/electronic-communications-notice', issuerPrivacy: 'https://www.third-national.com/privacypolicy', - // TODO: swap placeholders with the real links when Rain/partner share them. - cardTerms: '#', - accountOpeningPrivacy: '#', + cardTermsUs: 'https://peanut.me/en/card-terms-us', + cardTermsInternational: 'https://peanut.me/en/card-terms-international', + accountOpeningPrivacy: 'https://peanut.me/en/card-privacy', } interface Term { @@ -36,49 +37,53 @@ const ExternalLink: FC<{ href: string; children: ReactNode }> = ({ href, childre
) -const INT_TERMS: Term[] = [ - { - id: 'esign', - label: ( - <> - I accept the E-Sign Consent - - ), - }, - { - id: 'cardTermsIssuer', - label: ( - <> - I accept the {CARD_PARTNER_NAME} Card Terms - {', and the '} - Issuer Privacy Policy - - ), - }, - { - id: 'accuracy', - label: `I certify that the information I have provided is accurate and that I will abide by all the rules and requirements related to my ${CARD_PARTNER_NAME} Spend Card.`, - }, - { - id: 'solicitation', - label: `I acknowledge that applying for the ${CARD_PARTNER_NAME} Spend Card does not constitute unauthorized solicitation.`, - }, -] +const esignTerm: Term = { + id: 'esign', + label: ( + <> + I accept the E-Sign Consent + + ), +} + +const cardTermsIssuerTerm = (cardTermsHref: string): Term => ({ + id: 'cardTermsIssuer', + label: ( + <> + I accept the {CARD_PARTNER_NAME} Card Terms + {', and the '} + Issuer Privacy Policy + + ), +}) + +const accuracyTerm: Term = { + id: 'accuracy', + label: `I certify that the information I have provided is accurate and that I will abide by all the rules and requirements related to my ${CARD_PARTNER_NAME} Spend Card.`, +} + +const solicitationTerm: Term = { + id: 'solicitation', + label: `I acknowledge that applying for the ${CARD_PARTNER_NAME} Spend Card does not constitute unauthorized solicitation.`, +} + +const accountOpeningPrivacyTerm: Term = { + id: 'accountOpeningPrivacy', + label: ( + <> + I accept the Account Opening Privacy Notice + + ), +} + +const INT_TERMS: Term[] = [esignTerm, cardTermsIssuerTerm(LINKS.cardTermsInternational), accuracyTerm, solicitationTerm] const US_TERMS: Term[] = [ - INT_TERMS[0], - { - id: 'accountOpeningPrivacy', - label: ( - <> - I accept the{' '} - Account Opening Privacy Notice - - ), - }, - INT_TERMS[1], - INT_TERMS[2], - INT_TERMS[3], + esignTerm, + accountOpeningPrivacyTerm, + cardTermsIssuerTerm(LINKS.cardTermsUs), + accuracyTerm, + solicitationTerm, ] const CardTermsScreen: FC = ({ isUsResident, onAccept, onPrev, submitError }) => { diff --git a/src/i18n/config.ts b/src/i18n/config.ts index 30ddd5c58..fa97becbf 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -20,6 +20,10 @@ export const ROUTE_SLUGS = [ 'supported-networks', 'terms', 'privacy', + 'card-terms-us', + 'card-terms-international', + 'card-privacy', + 'card-prohibited-activities', ] as const export type RouteSlug = (typeof ROUTE_SLUGS)[number] From 81b4653e7cf3c1835446dc997ff1457ac2a6c6cd Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 2 Jun 2026 21:12:11 +0100 Subject: [PATCH 21/23] chore(content): bump content submodule to publish card legal pages Bumps src/content fd82a94 -> 014418b (peanut-content HEAD) so the new /card-terms-us, /card-terms-international, /card-privacy, /card-prohibited-activities pages render. Pairs with #2143 (routes). --- src/content | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content b/src/content index fd82a94fe..400925caa 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit fd82a94fe5c174849e2c30ace83a1a19bc3281dc +Subproject commit 400925caae5c0a39191ebc70529401fbb2fc2ebc From 5897fa578793a843659242b8517fe67fcd593ccf Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 2 Jun 2026 22:03:47 +0100 Subject: [PATCH 22/23] fix(exchange-rate): short-circuit non-Bridge AccountTypes (PEANUT-UI-QHR) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `useGetExchangeRate` was passing the raw `AccountType` enum value to the `/bridge/exchange-rate` endpoint. The BE schema validates against the Bridge enum {iban,us,clabe,gb}; passing any other value (manteca, evm-address, peanut-wallet) returns 400. PEANUT-UI-QHR — 2 users hit this today via Manteca bank-account withdraw flows. The hook already short-circuited AccountType.US to return '1' (USD↔USD). Generalise: short-circuit ANY accountType not on the Bridge FX set {IBAN, CLABE, GB}. US still returns '1'; MANTECA/EVM/wallet types now also return '1' instead of leaking to the 400. '1' is a safe display fallback — the only consumer that needs a real Manteca FX rate routes through `getCachedCurrencyPrice` in `app/actions/currency.ts` (Manteca-aware), which is unaffected. Manteca display rates ideally would route through that same helper (rather than displaying as 1:1); marked as a TODO. Includes regression test asserting non-Bridge types skip the network call. --- .../__tests__/useGetExchangeRate.test.ts | 63 +++++++++++++++++++ src/hooks/useGetExchangeRate.tsx | 16 ++++- 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 src/hooks/__tests__/useGetExchangeRate.test.ts diff --git a/src/hooks/__tests__/useGetExchangeRate.test.ts b/src/hooks/__tests__/useGetExchangeRate.test.ts new file mode 100644 index 000000000..83ce77a2e --- /dev/null +++ b/src/hooks/__tests__/useGetExchangeRate.test.ts @@ -0,0 +1,63 @@ +import { renderHook, waitFor } from '@testing-library/react' +import React from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import useGetExchangeRate from '@/hooks/useGetExchangeRate' +import { AccountType } from '@/interfaces' + +// `getExchangeRate` performs a server fetch in real use. We mock it to capture +// whether it was called — the regression test for PEANUT-UI-QHR is that +// non-Bridge AccountType values must NOT cause a network call. +const getExchangeRateMock = jest.fn() +jest.mock('@/app/actions/exchange-rate', () => ({ + getExchangeRate: (...args: unknown[]) => getExchangeRateMock(...args), +})) + +const wrapper = ({ children }: { children: React.ReactNode }) => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } }, + }) + return React.createElement(QueryClientProvider, { client }, children) +} + +describe('useGetExchangeRate', () => { + beforeEach(() => { + getExchangeRateMock.mockReset() + }) + + it('returns 1 and skips the network call for AccountType.US (USD↔USD)', async () => { + const { result } = renderHook(() => useGetExchangeRate({ accountType: AccountType.US }), { wrapper }) + await waitFor(() => expect(result.current.exchangeRate).toBe('1')) + expect(getExchangeRateMock).not.toHaveBeenCalled() + }) + + // PEANUT-UI-QHR regression: the BE `/bridge/exchange-rate` enum is + // {iban,us,clabe,gb}. Any other AccountType (MANTECA, EVM_ADDRESS, + // PEANUT_WALLET) would 400 — the hook must short-circuit instead of + // making the network call. + it.each([AccountType.MANTECA, AccountType.EVM_ADDRESS, AccountType.PEANUT_WALLET])( + 'returns 1 and skips the network call for non-Bridge type: %s', + async (accountType) => { + const { result } = renderHook(() => useGetExchangeRate({ accountType }), { wrapper }) + await waitFor(() => expect(result.current.exchangeRate).toBe('1')) + expect(getExchangeRateMock).not.toHaveBeenCalled() + } + ) + + it.each([AccountType.IBAN, AccountType.CLABE, AccountType.GB])( + 'calls the network for Bridge FX type: %s and returns its sell_rate', + async (accountType) => { + getExchangeRateMock.mockResolvedValue({ data: { sell_rate: '1.05' } }) + const { result } = renderHook(() => useGetExchangeRate({ accountType }), { wrapper }) + await waitFor(() => expect(result.current.exchangeRate).toBe('1.05')) + expect(getExchangeRateMock).toHaveBeenCalledWith(accountType) + } + ) + + it('falls back to 1 when the network returns an error', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + getExchangeRateMock.mockResolvedValue({ error: 'upstream 500' }) + const { result } = renderHook(() => useGetExchangeRate({ accountType: AccountType.IBAN }), { wrapper }) + await waitFor(() => expect(result.current.exchangeRate).toBe('1')) + consoleErrorSpy.mockRestore() + }) +}) diff --git a/src/hooks/useGetExchangeRate.tsx b/src/hooks/useGetExchangeRate.tsx index 72f4af574..ec7f79567 100644 --- a/src/hooks/useGetExchangeRate.tsx +++ b/src/hooks/useGetExchangeRate.tsx @@ -11,12 +11,24 @@ export interface IExchangeRate { * Used to get exchange rate for a given account type using TanStack Query * @returns {string, boolean} The exchange rate for the given account type and a boolean indicating if the rate is being fetched */ +// `/bridge/exchange-rate` only serves Bridge bank-account types with a +// non-trivial FX rate: IBAN (EUR), CLABE (MXN), GB (GBP). US is USD↔USD = 1 +// by definition. Other AccountType values (MANTECA, EVM_ADDRESS, PEANUT_WALLET) +// aren't on the Bridge enum and 400 the endpoint (PEANUT-UI-QHR, 2026-06-02). +// Treat those like US — '1' is a safe display fallback; consumers already +// degrade gracefully on '1' (and the only consumer that needs a real Manteca +// FX rate routes through `getCachedCurrencyPrice` in actions/currency.ts). +// TODO: Manteca-currency display rates should route through that helper too +// instead of returning '1'; separate PR. +const BRIDGE_FX_ACCOUNT_TYPES: ReadonlySet = new Set([AccountType.IBAN, AccountType.CLABE, AccountType.GB]) + export default function useGetExchangeRate({ accountType, enabled = true }: IExchangeRate) { const { data: exchangeRate, isFetching: isFetchingRate } = useQuery({ queryKey: ['exchangeRate', accountType], queryFn: async () => { - // US accounts have 1:1 exchange rate - if (accountType === AccountType.US) { + // Anything not on the Bridge FX set returns the passthrough rate. + // Includes AccountType.US (USD↔USD = 1). + if (!BRIDGE_FX_ACCOUNT_TYPES.has(accountType)) { return '1' } From 66600a19cc78bdd9c9f533288e6b1501b8cbc2e5 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 3 Jun 2026 11:56:36 +0100 Subject: [PATCH 23/23] chore(content): bump content submodule to publish polished card legal pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/content 400925ca -> 177594605b (peanut-content HEAD) — ships the polished card legal pages (Hero + clean tables, mono#27) + latest content. Clean single-pointer bump (the auto-bump PRs are dirty/conflicting). --- src/content | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content b/src/content index 400925caa..177594605 160000 --- a/src/content +++ b/src/content @@ -1 +1 @@ -Subproject commit 400925caae5c0a39191ebc70529401fbb2fc2ebc +Subproject commit 177594605b74ab42f90897da1d12a2abf13a925f