From 5fa013fcc1735f727976c784d40d401ce7bef0bc Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Wed, 5 Aug 2026 09:31:20 +0000 Subject: [PATCH 01/14] refactor(fx): consume shared backend rate policy --- docs/api-types.md | 19 + next.config.js | 1 - src/__tests__/proxy.test.ts | 30 + .../add-money/[country]/bank/page.tsx | 2 +- .../api/exchange-rate/__tests__/route.test.ts | 30 +- src/app/api/exchange-rate/route.ts | 5 +- src/app/m/[slug]/MerchantLandingPage.tsx | 4 +- src/hooks/useExchangeRate.ts | 22 +- src/proxy.ts | 5 +- src/types/api.generated.ts | 342 ++++++++++- src/types/api.openapi.json | 555 ++++++++++++++++++ src/utils/__tests__/demo-api.test.ts | 23 + src/utils/__tests__/fx.utils.test.ts | 171 +++--- src/utils/__tests__/sentry.utils.test.ts | 10 + src/utils/demo-api.ts | 12 +- src/utils/fx.utils.ts | 120 ++-- src/utils/sentry.utils.ts | 3 + 17 files changed, 1204 insertions(+), 150 deletions(-) create mode 100644 src/__tests__/proxy.test.ts diff --git a/docs/api-types.md b/docs/api-types.md index 9a0781278f..0af8b82fd4 100644 --- a/docs/api-types.md +++ b/docs/api-types.md @@ -59,6 +59,25 @@ pulled from the BE schema, so a BE shape change shows up as a TS error. - When CI's typecheck flags a stale type — pull `main`, run `pnpm gen:api`, commit - Before opening a PR that touches a BE route +## Cross-repo FX rollout + +Peanut API owns the `/fx/rate` and `/fx/rates` contracts. Its OpenAPI spec is the canonical reference. + +Peanut UI calls `/fx/rate` directly from first-party browsers and native clients, +so the backend rate limiter sees each real client IP. Its +`/api/exchange-rate` route remains a compatibility proxy that returns +`{ rate: number }`, but normal UI traffic no longer traverses it. + +Peanut Split calls `/fx/rates` from its server. Peanut UI does not use that batch route. + +Deploy Peanut API first. Smoke-test both FX routes, refresh this snapshot, and then deploy Peanut UI and Peanut Split. + +The backend snapshot applies provider precedence independently to each USD leg. +That deliberately changes mixed pairs from the old UI behavior: PLN→EUR, for +example, now combines reference PLN with Bridge EUR instead of falling back to +reference data for both legs. The API labels that provenance as `mixed`. This +policy migration needs product/CTO approval before the consumer PRs ship. + ## Limitations - The generator only sees what's in TypeBox `schema`. Routes that don't declare a response schema appear as `unknown` content. Fixing that is per-route and incremental. diff --git a/next.config.js b/next.config.js index adf45c0375..2092284c81 100644 --- a/next.config.js +++ b/next.config.js @@ -108,7 +108,6 @@ function contentSecurityPolicyReportOnly() { // Token metadata lookup in TransactionDetailsReceipt — a different // CoinGecko host from the two image CDNs above. 'https://api.coingecko.com', - 'https://api.frankfurter.app', 'https://dolarapi.com', 'https://ipapi.co', 'https://api.justaname.id', diff --git a/src/__tests__/proxy.test.ts b/src/__tests__/proxy.test.ts new file mode 100644 index 0000000000..c7ef4796e7 --- /dev/null +++ b/src/__tests__/proxy.test.ts @@ -0,0 +1,30 @@ +/** @jest-environment node */ +import { NextRequest } from 'next/server' +import { proxy } from '@/proxy' + +function runProxy(path: string) { + return proxy(new NextRequest(`https://peanut.me${path}`)) +} + +describe('API cache policy', () => { + it('lets the exact exchange-rate route preserve its route-owned cache headers', () => { + const response = runProxy('/api/exchange-rate?from=PLN&to=EUR') + + expect(response.headers.get('Cache-Control')).toBeNull() + expect(response.headers.get('Pragma')).toBeNull() + expect(response.headers.get('Expires')).toBeNull() + expect(response.headers.get('Surrogate-Control')).toBeNull() + }) + + it.each(['/api/rooms', '/api/exchange-rate/', '/api/exchange-rate-history'])( + 'keeps no-store on every other API path: %s', + (path) => { + const response = runProxy(path) + + expect(response.headers.get('Cache-Control')).toBe('no-store, no-cache, must-revalidate, proxy-revalidate') + expect(response.headers.get('Pragma')).toBe('no-cache') + expect(response.headers.get('Expires')).toBe('0') + expect(response.headers.get('Surrogate-Control')).toBe('no-store') + } + ) +}) 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 bcf293499a..509a4ac4a5 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -182,7 +182,7 @@ export default function OnrampBankPage() { // deposit-side price (localCurrency per USD) for limits validation — deposits // execute at buy, so the USD equivalent must derive from buy, not the sell-side - // display quote served by /api/exchange-rate (useCurrency handles USD as 1:1) + // display quote served by /fx/rate (useCurrency handles USD as 1:1) const { price: localPrice, isLoading: isRateLoading, isError: isRateError } = useCurrency(localCurrency) // convert input amount to USD for limits validation diff --git a/src/app/api/exchange-rate/__tests__/route.test.ts b/src/app/api/exchange-rate/__tests__/route.test.ts index 7011e4c3d8..75e615e31c 100644 --- a/src/app/api/exchange-rate/__tests__/route.test.ts +++ b/src/app/api/exchange-rate/__tests__/route.test.ts @@ -1,11 +1,18 @@ /** @jest-environment node */ import { GET } from '../route' -import { fetchDisplayRate } from '@/utils/fx.utils' +import { fetchDisplayRate, FxApiError } from '@/utils/fx.utils' import type { NextRequest } from 'next/server' -// Rate math (sell-side both orientations, cross pairs, same-currency) is pinned -// in src/utils/__tests__/fx.utils.test.ts — this file only covers route wiring. -jest.mock('@/utils/fx.utils', () => ({ fetchDisplayRate: jest.fn() })) +// The shared backend contract is pinned in src/utils/__tests__/fx.utils.test.ts. +// This file only covers compatibility-route validation and response wiring. +jest.mock('@/utils/fx.utils', () => { + class MockFxApiError extends Error { + constructor(readonly status: number) { + super(`FX API returned ${status}`) + } + } + return { fetchDisplayRate: jest.fn(), FxApiError: MockFxApiError } +}) const mockFetchDisplayRate = fetchDisplayRate as jest.Mock @@ -38,8 +45,23 @@ describe('GET /api/exchange-rate — thin wrapper over fetchDisplayRate', () => it('returns 500 when every rate source fails', async () => { mockFetchDisplayRate.mockRejectedValue(new Error('all sources down')) + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) const response = await get('from=USD&to=EUR') expect(response.status).toBe(500) expect(await response.json()).toEqual({ error: 'Failed to fetch exchange rates' }) + expect(errorSpy).toHaveBeenCalledTimes(1) + errorSpy.mockRestore() + }) + + it('preserves expected backend pair misses without logging them as server failures', async () => { + mockFetchDisplayRate.mockRejectedValue(new FxApiError(404, 'ZZZ', 'EUR')) + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + const response = await get('from=ZZZ&to=EUR') + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: 'Exchange rate unavailable' }) + expect(errorSpy).not.toHaveBeenCalled() + errorSpy.mockRestore() }) }) diff --git a/src/app/api/exchange-rate/route.ts b/src/app/api/exchange-rate/route.ts index e6a27bb9f1..a4ecf83eea 100644 --- a/src/app/api/exchange-rate/route.ts +++ b/src/app/api/exchange-rate/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { fetchDisplayRate } from '@/utils/fx.utils' +import { fetchDisplayRate, FxApiError } from '@/utils/fx.utils' export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams @@ -25,6 +25,9 @@ export async function GET(request: NextRequest) { } ) } catch (error) { + if (error instanceof FxApiError && (error.status === 400 || error.status === 404)) { + return NextResponse.json({ error: 'Exchange rate unavailable' }, { status: error.status }) + } console.error(`Exchange rate API error for ${from}-${to}:`, error) return NextResponse.json({ error: 'Failed to fetch exchange rates' }, { status: 500 }) } diff --git a/src/app/m/[slug]/MerchantLandingPage.tsx b/src/app/m/[slug]/MerchantLandingPage.tsx index 69288aa28a..9409b0bd13 100644 --- a/src/app/m/[slug]/MerchantLandingPage.tsx +++ b/src/app/m/[slug]/MerchantLandingPage.tsx @@ -296,8 +296,8 @@ function Polaroids({ items }: { items: NonNullable }) { function MenuFold({ fold }: { fold: Extract }) { const [currency, setCurrency] = useState('USD') - // Live ARS rates from the same source as the currency widget (/api/exchange-rate - // via useExchangeRate). Both pairs are prefetched so the USD/EUR toggle is instant. + // Live ARS rates from the same source as the currency widget (/fx/rate via + // useExchangeRate). Both pairs are prefetched so the USD/EUR toggle is instant. // exchangeRate is 0 until loaded — we show a loading dash rather than a fake rate. const { exchangeRate: usdArs } = useExchangeRate({ sourceCurrency: 'USD', diff --git a/src/hooks/useExchangeRate.ts b/src/hooks/useExchangeRate.ts index 522f1a8403..011a7acabe 100644 --- a/src/hooks/useExchangeRate.ts +++ b/src/hooks/useExchangeRate.ts @@ -1,8 +1,7 @@ import { useState, useEffect, useCallback } from 'react' import { useDebounce } from './useDebounce' import { useQuery } from '@tanstack/react-query' -import { isCapacitor } from '@/utils/capacitor' -import { fetchDisplayRate } from '@/utils/fx.utils' +import { fetchDisplayRate, FxApiError } from '@/utils/fx.utils' type InputValue = number | '' @@ -85,20 +84,19 @@ export function useExchangeRate({ isError, } = useQuery<{ rate: number }>({ queryKey: ['exchangeRate', sourceCurrency, destinationCurrency], - queryFn: async () => { - if (isCapacitor()) { - // no /api/ routes exist in the static native build — run the shared - // implementation directly (the same code the route runs on the server) - return { rate: await fetchDisplayRate(sourceCurrency, destinationCurrency) } - } - const res = await fetch(`/api/exchange-rate?from=${sourceCurrency}&to=${destinationCurrency}`) - if (!res.ok) throw new Error('Failed to fetch exchange rate') - return res.json() - }, + // First-party browsers and native clients both call api.peanut.me + // directly. This preserves the real client IP at the API rate limiter; + // proxying normal web traffic through Vercel collapses every user onto + // one egress address and lets one noisy client throttle everyone. + queryFn: async () => ({ rate: await fetchDisplayRate(sourceCurrency, destinationCurrency) }), staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, // garbage collect after 10 minutes refetchOnWindowFocus: true, // Refresh rates when user returns to tab refetchInterval: 5 * 60 * 1000, // Auto-refresh every 5 minutes + // Invalid or unsupported pairs are deterministic client outcomes. Do + // not turn one selection into four identical rate-limited requests. + retry: (failureCount, error) => + !(error instanceof FxApiError && (error.status === 400 || error.status === 404)) && failureCount < 3, enabled: enabled && !!sourceCurrency && !!destinationCurrency, }) diff --git a/src/proxy.ts b/src/proxy.ts index 3471941261..7fe9a95a13 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -54,9 +54,10 @@ export function proxy(request: NextRequest) { return NextResponse.redirect(redirectUrl) } - // Set headers to disable caching for specified paths + // Set headers to disable caching for API paths. The exchange-rate route is + // intentionally cacheable and owns its narrower CDN policy. const response = NextResponse.next() - if (url.pathname.startsWith('/api/')) { + if (url.pathname.startsWith('/api/') && url.pathname !== '/api/exchange-rate') { response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate') response.headers.set('Pragma', 'no-cache') response.headers.set('Expires', '0') diff --git a/src/types/api.generated.ts b/src/types/api.generated.ts index eedd903884..92db696003 100644 --- a/src/types/api.generated.ts +++ b/src/types/api.generated.ts @@ -235,7 +235,9 @@ export interface paths { }; get: { parameters: { - query?: never; + query?: { + chainId?: number; + }; header?: never; path: { ensName: string; @@ -261,6 +263,41 @@ export interface paths { patch?: never; trace?: never; }; + "/ens/reverse/{address}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + address: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/add-account": { parameters: { query?: never; @@ -1273,6 +1310,130 @@ export interface paths { patch?: never; trace?: never; }; + "/users/consent/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + documents: { + slug: string; + currentVersion: string; + acceptedVersion: string | null; + acceptedAt: string | null; + needsAcceptance: boolean; + }[]; + needsReConsent: boolean; + }; + }; + }; + /** @description Default Response */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users/consent/accept": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + documents: { + slug: string; + version: string; + hash?: string; + }[]; + }; + }; + }; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + recorded: number; + }; + }; + }; + /** @description Default Response */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Default Response */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/validate-bank-account-number": { parameters: { query?: never; @@ -1523,6 +1684,11 @@ export interface paths { username: string; cred: unknown; rpID?: string; + acceptedLegal?: { + slug: string; + version: string; + hash?: string; + }[]; }; }; }; @@ -6288,6 +6454,11 @@ export interface paths { termsAccepted?: boolean; serializedApproval?: string; confirmedResidenceCountry?: string; + acceptedDocuments?: { + slug: string; + version: string; + hash?: string; + }[]; }; }; }; @@ -8839,7 +9010,7 @@ export interface paths { content: { "application/json": { userId: string; - code: "BETA_TESTER" | "DEVCONNECT_BA_2025" | "PRODUCT_HUNT" | "OG_2025_10_12" | "SEEDLING_DEVCONNECT_BA_2025" | "ARBIVERSE_DEVCONNECT_BA_2025" | "CARD_PIONEER" | "FOUNDER_HOUSE" | "BUG_WHISPERER" | "SHHHHH" | "NOT_SO_SHHHH" | "CARD_FIRST_SWIPE" | "CARD_SPENT_1K" | "CARD_ALPHA" | "TOKEN_NATION_SP_2026" | "ETHFLORIPA_HUB" | "IRL_NOMADS" | "EVENT_ALUMNI" | "TOUCHED_GRASS" | "OFFRAMP_USER" | "PSYOPS_DIVISION" | "WAITLIST_SKIP" | "FESTA_JUNINA_2026" | "MANICERO"; + code: "BETA_TESTER" | "DEVCONNECT_BA_2025" | "PRODUCT_HUNT" | "OG_2025_10_12" | "SEEDLING_DEVCONNECT_BA_2025" | "ARBIVERSE_DEVCONNECT_BA_2025" | "CARD_PIONEER" | "FOUNDER_HOUSE" | "BUG_WHISPERER" | "SHHHHH" | "NOT_SO_SHHHH" | "CARD_FIRST_SWIPE" | "CARD_SPENT_1K" | "CARD_ALPHA" | "TOKEN_NATION_SP_2026" | "ETHFLORIPA_HUB" | "IRL_NOMADS" | "EVENT_ALUMNI" | "TOUCHED_GRASS" | "OFFRAMP_USER" | "PSYOPS_DIVISION" | "WAITLIST_SKIP" | "FESTA_JUNINA_2026" | "MANICERO" | "NITA"; revoke?: boolean; }; }; @@ -10564,6 +10735,173 @@ export interface paths { patch?: never; trace?: never; }; + "/fx/rates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Public, indicative display-sell FX snapshot. unitsPerBase is quote-currency units per one base unit. */ + get: { + parameters: { + query?: { + /** @description ISO-style currency code or supported four-letter internal ticker */ + base?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @enum {string} */ + base: "USD"; + /** @enum {string} */ + basis: "display_sell"; + /** @enum {boolean} */ + indicative: true; + /** Format: date-time */ + generatedAt: string; + rates: { + code: string; + unitsPerBase: string; + source: "identity" | "bridge" | "manteca" | "reference"; + effectiveAt: string | null; + }[]; + }; + }; + }; + /** @description Default Response */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + message: string; + }; + }; + }; + /** @description Default Response */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + message: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/fx/rate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description Public indicative display-sell rate for one currency pair. */ + get: { + parameters: { + query: { + /** @description ISO-style currency code or supported four-letter internal ticker */ + from: string; + /** @description ISO-style currency code or supported four-letter internal ticker */ + to: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Default Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + from: string; + to: string; + rate: string; + /** @enum {string} */ + basis: "display_sell"; + /** @enum {boolean} */ + indicative: true; + source: ("identity" | "bridge" | "manteca" | "reference") | "mixed"; + effectiveAt: string | null; + /** Format: date-time */ + generatedAt: string; + }; + }; + }; + /** @description Default Response */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + message: string; + }; + }; + }; + /** @description Default Response */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + message: string; + }; + }; + }; + /** @description Default Response */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + message: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { diff --git a/src/types/api.openapi.json b/src/types/api.openapi.json index 0dd8008360..f4f2cca192 100644 --- a/src/types/api.openapi.json +++ b/src/types/api.openapi.json @@ -153,6 +153,15 @@ "/ens/{ensName}": { "get": { "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 1 + }, + "in": "query", + "name": "chainId", + "required": false + }, { "schema": { "type": "string" @@ -169,6 +178,26 @@ } } }, + "/ens/reverse/{address}": { + "get": { + "parameters": [ + { + "schema": { + "pattern": "^0x[a-fA-F0-9]{40}$", + "type": "string" + }, + "in": "path", + "name": "address", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + } + }, "/add-account": { "post": { "requestBody": { @@ -1819,6 +1848,175 @@ } } }, + "/users/consent/status": { + "get": { + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "documents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "currentVersion": { + "type": "string" + }, + "acceptedVersion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "acceptedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "needsAcceptance": { + "type": "boolean" + } + }, + "required": [ + "slug", + "currentVersion", + "acceptedVersion", + "acceptedAt", + "needsAcceptance" + ] + } + }, + "needsReConsent": { + "type": "boolean" + } + }, + "required": ["documents", "needsReConsent"] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + } + } + } + }, + "/users/consent/accept": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "documents": { + "minItems": 1, + "maxItems": 8, + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "version": { + "type": "string" + }, + "hash": { + "type": "string" + } + }, + "required": ["slug", "version"] + } + } + }, + "required": ["documents"] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "recorded": { + "type": "number" + } + }, + "required": ["recorded"] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + } + } + } + }, "/validate-bank-account-number": { "post": { "requestBody": { @@ -1967,6 +2165,25 @@ "cred": {}, "rpID": { "type": "string" + }, + "acceptedLegal": { + "maxItems": 4, + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "version": { + "type": "string" + }, + "hash": { + "type": "string" + } + }, + "required": ["slug", "version"] + } } }, "required": ["userId", "username", "cred"] @@ -8190,6 +8407,25 @@ "minLength": 2, "maxLength": 2, "type": "string" + }, + "acceptedDocuments": { + "maxItems": 8, + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "version": { + "type": "string" + }, + "hash": { + "type": "string" + } + }, + "required": ["slug", "version"] + } } } } @@ -11874,6 +12110,10 @@ { "type": "string", "enum": ["MANICERO"] + }, + { + "type": "string", + "enum": ["NITA"] } ] }, @@ -14154,6 +14394,321 @@ } } } + }, + "/fx/rates": { + "get": { + "description": "Public, indicative display-sell FX snapshot. unitsPerBase is quote-currency units per one base unit.", + "parameters": [ + { + "schema": { + "pattern": "^[A-Za-z]{3,4}$", + "type": "string" + }, + "in": "query", + "name": "base", + "required": false, + "description": "ISO-style currency code or supported four-letter internal ticker" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object", + "properties": { + "base": { + "type": "string", + "enum": ["USD"] + }, + "basis": { + "type": "string", + "enum": ["display_sell"] + }, + "indicative": { + "type": "boolean", + "enum": [true] + }, + "generatedAt": { + "format": "date-time", + "type": "string" + }, + "rates": { + "minItems": 1, + "maxItems": 512, + "type": "array", + "items": { + "additionalProperties": false, + "type": "object", + "properties": { + "code": { + "pattern": "^[A-Z]{3,4}$", + "type": "string" + }, + "unitsPerBase": { + "pattern": "^(?:0|[1-9]\\d*)(?:\\.\\d{1,18})?$", + "type": "string" + }, + "source": { + "anyOf": [ + { + "type": "string", + "enum": ["identity"] + }, + { + "type": "string", + "enum": ["bridge"] + }, + { + "type": "string", + "enum": ["manteca"] + }, + { + "type": "string", + "enum": ["reference"] + } + ] + }, + "effectiveAt": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["code", "unitsPerBase", "source", "effectiveAt"] + } + } + }, + "required": ["base", "basis", "indicative", "generatedAt", "rates"] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["error", "message"] + } + } + } + }, + "503": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["error", "message"] + } + } + } + } + } + } + }, + "/fx/rate": { + "get": { + "description": "Public indicative display-sell rate for one currency pair.", + "parameters": [ + { + "schema": { + "pattern": "^[A-Za-z]{3,4}$", + "type": "string" + }, + "in": "query", + "name": "from", + "required": true, + "description": "ISO-style currency code or supported four-letter internal ticker" + }, + { + "schema": { + "pattern": "^[A-Za-z]{3,4}$", + "type": "string" + }, + "in": "query", + "name": "to", + "required": true, + "description": "ISO-style currency code or supported four-letter internal ticker" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object", + "properties": { + "from": { + "pattern": "^[A-Z]{3,4}$", + "type": "string" + }, + "to": { + "pattern": "^[A-Z]{3,4}$", + "type": "string" + }, + "rate": { + "pattern": "^(?:0|[1-9]\\d*)(?:\\.\\d{1,18})?$", + "type": "string" + }, + "basis": { + "type": "string", + "enum": ["display_sell"] + }, + "indicative": { + "type": "boolean", + "enum": [true] + }, + "source": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "enum": ["identity"] + }, + { + "type": "string", + "enum": ["bridge"] + }, + { + "type": "string", + "enum": ["manteca"] + }, + { + "type": "string", + "enum": ["reference"] + } + ] + }, + { + "type": "string", + "enum": ["mixed"] + } + ] + }, + "effectiveAt": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "generatedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "from", + "to", + "rate", + "basis", + "indicative", + "source", + "effectiveAt", + "generatedAt" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["error", "message"] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["error", "message"] + } + } + } + }, + "503": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["error", "message"] + } + } + } + } + } + } } }, "servers": [ diff --git a/src/utils/__tests__/demo-api.test.ts b/src/utils/__tests__/demo-api.test.ts index a8bd53711a..7c9900b299 100644 --- a/src/utils/__tests__/demo-api.test.ts +++ b/src/utils/__tests__/demo-api.test.ts @@ -5,6 +5,7 @@ // but stripped by jsdom. demo-api uses only web-standard APIs, so node is faithful. import { demoRespond } from '@/utils/demo-api' import { DEMO_CONTACTS, DEMO_HISTORY_ENTRIES, DEMO_USER } from '@/constants/demo-data' +import { PEANUT_API_URL } from '@/constants/general.consts' const body = async (path: string, options?: RequestInit) => { const res = await demoRespond(path, options) @@ -12,6 +13,28 @@ const body = async (path: string, options?: RequestInit) => { } describe('demoRespond — routing', () => { + it('bounds and forwards the shared FX passthrough', async () => { + const originalFetch = global.fetch + global.fetch = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ rate: '1' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) + + try { + const { data } = await body('/fx/rate?from=USD&to=USD') + + expect(data).toEqual({ rate: '1' }) + expect(global.fetch).toHaveBeenCalledWith( + `${PEANUT_API_URL}/fx/rate?from=USD&to=USD`, + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ) + } finally { + global.fetch = originalFetch + } + }) + it('returns the synthetic user for GET /users/me', async () => { const { res, data } = await body('/users/me') expect(res.status).toBe(200) diff --git a/src/utils/__tests__/fx.utils.test.ts b/src/utils/__tests__/fx.utils.test.ts index 1357b2501a..0d6fa5cb60 100644 --- a/src/utils/__tests__/fx.utils.test.ts +++ b/src/utils/__tests__/fx.utils.test.ts @@ -1,106 +1,119 @@ -import { displayRateFromPrices, fetchDisplayRate } from '../fx.utils' -import { getCachedCurrencyPrice } from '@/app/actions/currency' +import { fetchDisplayRate } from '../fx.utils' +import { apiFetch } from '@/utils/api-fetch' -jest.mock('@/app/actions/currency', () => ({ getCachedCurrencyPrice: jest.fn() })) +jest.mock('@/utils/api-fetch', () => ({ apiFetch: jest.fn() })) -const mockGetCachedCurrencyPrice = getCachedCurrencyPrice as jest.Mock +const mockApiFetch = apiFetch as jest.Mock -// Provider prices are "currency units per USD": buy = deposit side, sell = withdrawal side -const USD = { buy: 1, sell: 1 } -const EUR = { buy: 0.8699, sell: 0.8614 } -const BRL = { buy: 5.61, sell: 5.43 } +const validResponse = { + from: 'PLN', + to: 'EUR', + rate: '0.2322191619648635', + basis: 'display_sell', + indicative: true, + source: 'reference', + effectiveAt: '2026-08-04T00:00:00.000Z', + generatedAt: '2026-08-05T08:00:00.000Z', +} -describe('displayRateFromPrices — every display orientation derives from the sell rate', () => { - it('quotes USD→EUR at the sell rate (what a withdrawal delivers)', () => { - expect(displayRateFromPrices(USD, EUR)).toBeCloseTo(EUR.sell, 10) +describe('fetchDisplayRate — shared backend contract', () => { + beforeEach(() => { + mockApiFetch.mockReset() + jest.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-08-05T08:00:00.000Z')) }) - it('quotes EUR→USD off sell too, so both orientations imply the same price', () => { - expect(displayRateFromPrices(EUR, USD)).toBeCloseTo(1 / EUR.sell, 10) - expect(displayRateFromPrices(EUR, USD) * displayRateFromPrices(USD, EUR)).toBeCloseTo(1, 10) - }) + afterEach(() => jest.restoreAllMocks()) - it('quotes cross pairs off sell on both legs', () => { - expect(displayRateFromPrices(EUR, BRL)).toBeCloseTo((1 / EUR.sell) * BRL.sell, 10) - }) + it('normalizes the pair and converts a valid decimal-string rate to a number', async () => { + mockApiFetch.mockResolvedValue({ ok: true, status: 200, json: async () => validResponse }) - it('never reads the buy side', () => { - expect(displayRateFromPrices({ sell: EUR.sell }, { sell: BRL.sell })).toBe(displayRateFromPrices(EUR, BRL)) + await expect(fetchDisplayRate('pln', 'eur')).resolves.toBeCloseTo(0.2322191619648635, 15) + expect(mockApiFetch).toHaveBeenCalledWith('/fx/rate?from=PLN&to=EUR', { method: 'GET' }) }) -}) -describe('fetchDisplayRate — provider prices first, Frankfurter fallback', () => { - beforeEach(() => { - mockGetCachedCurrencyPrice.mockReset() - mockGetCachedCurrencyPrice.mockImplementation(async (code: string) => { - if (code === 'USD') return USD - if (code === 'EUR') return EUR - if (code === 'BRL') return BRL - throw new Error('Invalid currency code') + it('asks the backend to validate same-currency identity pairs', async () => { + mockApiFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + ...validResponse, + from: 'EUR', + to: 'EUR', + rate: '1', + source: 'identity', + effectiveAt: null, + }), }) - global.fetch = jest.fn().mockRejectedValue(new Error('network disabled in tests')) as typeof fetch - }) - - it('returns 1 for same-currency pairs without hitting providers or the network', async () => { - await expect(fetchDisplayRate('EUR', 'eur')).resolves.toBe(1) - expect(mockGetCachedCurrencyPrice).not.toHaveBeenCalled() - expect(global.fetch).not.toHaveBeenCalled() - }) - it('uppercases inputs and returns the sell-side rate for provider-covered pairs', async () => { - await expect(fetchDisplayRate('usd', 'eur')).resolves.toBeCloseTo(EUR.sell, 10) - expect(mockGetCachedCurrencyPrice).toHaveBeenCalledWith('USD') - expect(mockGetCachedCurrencyPrice).toHaveBeenCalledWith('EUR') - expect(global.fetch).not.toHaveBeenCalled() + await expect(fetchDisplayRate('eur', 'EUR')).resolves.toBe(1) + expect(mockApiFetch).toHaveBeenCalledWith('/fx/rate?from=EUR&to=EUR', { method: 'GET' }) }) - it('computes cross pairs from both sell sides', async () => { - await expect(fetchDisplayRate('EUR', 'BRL')).resolves.toBeCloseTo((1 / EUR.sell) * BRL.sell, 10) + it.each([ + ['numeric rate', { ...validResponse, rate: 0.23 }], + ['zero rate', { ...validResponse, rate: '0' }], + ['scientific rate', { ...validResponse, rate: '2.3e-1' }], + ['leading-zero rate', { ...validResponse, rate: '00.23' }], + ['over-precise rate', { ...validResponse, rate: '0.1234567890123456789' }], + ['mismatched pair', { ...validResponse, from: 'USD' }], + ['wrong basis', { ...validResponse, basis: 'midmarket' }], + ['non-canonical timestamp', { ...validResponse, generatedAt: '2026-08-05' }], + ['missing generation time', { ...validResponse, generatedAt: undefined }], + ['stale generation time', { ...validResponse, generatedAt: '2026-08-04T05:59:59.999Z' }], + ['future generation time', { ...validResponse, generatedAt: '2026-08-05T08:05:00.001Z' }], + ['future effective time', { ...validResponse, effectiveAt: '2026-08-05T08:05:00.001Z' }], + ['stale effective time', { ...validResponse, effectiveAt: '2026-07-06T07:59:59.999Z' }], + ['implausibly small rate', { ...validResponse, rate: '0.0000000000000000001' }], + ['implausibly large rate', { ...validResponse, rate: '10000000000000000000' }], + ['identity source on a cross pair', { ...validResponse, source: 'identity', effectiveAt: null }], + ['missing effective time on a cross pair', { ...validResponse, effectiveAt: null }], + ])('rejects an unusable backend contract: %s', async (_label, body) => { + mockApiFetch.mockResolvedValue({ ok: true, status: 200, json: async () => body }) + + await expect(fetchDisplayRate('PLN', 'EUR')).rejects.toThrow('invalid rate contract') }) - it('falls back to a single Frankfurter call with the spread on the USD leg', async () => { - global.fetch = jest.fn().mockResolvedValue({ + it.each([ + ['non-one rate', { rate: '2' }], + ['non-identity source', { source: 'reference' }], + ['non-null effective time', { effectiveAt: '2026-08-04T00:00:00.000Z' }], + ])('rejects invalid identity cross-fields: %s', async (_label, overrides) => { + mockApiFetch.mockResolvedValue({ ok: true, - json: async () => ({ rates: { JPY: 150 } }), - }) as unknown as typeof fetch - await expect(fetchDisplayRate('USD', 'JPY')).resolves.toBeCloseTo(150 * 0.995, 10) - expect(global.fetch).toHaveBeenCalledTimes(1) - expect(String((global.fetch as jest.Mock).mock.calls[0][0])).toContain('from=USD&to=JPY') - }) + status: 200, + json: async () => ({ + ...validResponse, + from: 'EUR', + to: 'EUR', + rate: '1', + source: 'identity', + effectiveAt: null, + ...overrides, + }), + }) - it('keeps fallback orientations reciprocal — the spread is per currency, not per request', async () => { - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - json: async () => ({ rates: { JPY: 150 } }), - }) as unknown as typeof fetch - const usdToJpy = await fetchDisplayRate('USD', 'JPY') - const jpyToUsd = await fetchDisplayRate('JPY', 'USD') - expect(jpyToUsd).toBeCloseTo(1 / (150 * 0.995), 10) - expect(usdToJpy * jpyToUsd).toBeCloseTo(1, 10) + await expect(fetchDisplayRate('EUR', 'EUR')).rejects.toThrow('invalid rate contract') }) - it('falls back when the provider returns an unusable price instead of throwing', async () => { - mockGetCachedCurrencyPrice.mockResolvedValue({ buy: 0, sell: 0 }) - global.fetch = jest.fn().mockResolvedValue({ + it('rejects malformed JSON from the backend', async () => { + mockApiFetch.mockResolvedValue({ ok: true, - json: async () => ({ rates: { EUR: 0.87 } }), - }) as unknown as typeof fetch - await expect(fetchDisplayRate('USD', 'EUR')).resolves.toBeCloseTo(0.87 * 0.995, 10) + status: 200, + json: async () => Promise.reject(new SyntaxError('bad JSON')), + }) + + await expect(fetchDisplayRate('PLN', 'EUR')).rejects.toThrow('invalid JSON') }) - it('rejects when both the providers and Frankfurter fail', async () => { - mockGetCachedCurrencyPrice.mockRejectedValue(new Error('provider down')) - global.fetch = jest.fn().mockResolvedValue({ ok: false }) as unknown as typeof fetch - await expect(fetchDisplayRate('USD', 'EUR')).rejects.toThrow('Failed to fetch exchange rate') + it('rejects backend error responses', async () => { + mockApiFetch.mockResolvedValue({ ok: false, status: 503 }) + + await expect(fetchDisplayRate('PLN', 'EUR')).rejects.toThrow('FX API returned 503') }) - it('rejects when Frankfurter does not know the requested currency', async () => { - // e.g. ARS is not ECB-covered: rates object lacks the key → NaN → guard trips - mockGetCachedCurrencyPrice.mockRejectedValue(new Error('provider down')) - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - json: async () => ({ rates: {} }), - }) as unknown as typeof fetch - await expect(fetchDisplayRate('USD', 'ARS')).rejects.toThrow('Failed to fetch exchange rate') + it('propagates backend transport errors', async () => { + mockApiFetch.mockRejectedValue(new Error('backend unavailable')) + + await expect(fetchDisplayRate('PLN', 'EUR')).rejects.toThrow('backend unavailable') }) }) diff --git a/src/utils/__tests__/sentry.utils.test.ts b/src/utils/__tests__/sentry.utils.test.ts index 1be17a7b4b..eb3d74a43b 100644 --- a/src/utils/__tests__/sentry.utils.test.ts +++ b/src/utils/__tests__/sentry.utils.test.ts @@ -70,6 +70,16 @@ describe('fetchWithSentry — expected-response suppression', () => { ) }) + it('does not report an unknown public FX pair, but still returns the 404', async () => { + global.fetch = jest.fn().mockResolvedValue(mockResponse(404, { error: 'FX_RATE_UNAVAILABLE' })) + + const res = await fetchWithSentry('https://api.peanut.me/fx/rate?from=ZZZ&to=EUR', { method: 'GET' }) + + expect(res.status).toBe(404) + expect(Sentry.captureMessage).not.toHaveBeenCalled() + expect(warnSpy).not.toHaveBeenCalled() + }) + it('still reports 400s from endpoints without a skip rule', async () => { global.fetch = jest.fn().mockResolvedValue(mockResponse(400, { error: 'bad request' })) diff --git a/src/utils/demo-api.ts b/src/utils/demo-api.ts index e329e689b4..1adbb1b1f0 100644 --- a/src/utils/demo-api.ts +++ b/src/utils/demo-api.ts @@ -14,10 +14,11 @@ import { PEANUT_API_URL } from '@/constants/general.consts' const CHAIN_ID = PEANUT_WALLET_CHAIN.id.toString() const CREATED_AT = '2026-01-01T00:00:00.000Z' +const PASSTHROUGH_TIMEOUT_MS = 10_000 // Public read-only rate endpoints proxied to the real backend so demo shows live // FX rates. Best-effort: any failure falls through to the canned handler below. -const PASSTHROUGH_GET = new Set(['/bridge/exchange-rate', '/manteca/prices']) +const PASSTHROUGH_GET = new Set(['/bridge/exchange-rate', '/manteca/prices', '/fx/rate']) const EMPTY_GRAPH = { nodes: [] as unknown[], @@ -628,11 +629,18 @@ export async function demoRespond(path: string, options?: RequestInit): Promise< // Live-rate passthrough to the real backend (best-effort). if (method === 'GET' && PASSTHROUGH_GET.has(pathname)) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), PASSTHROUGH_TIMEOUT_MS) try { - const res = await fetch(`${PEANUT_API_URL}${path}`, { headers: { accept: 'application/json' } }) + const res = await fetch(`${PEANUT_API_URL}${path}`, { + headers: { accept: 'application/json' }, + signal: controller.signal, + }) if (res.ok) return res } catch { // fall through to the canned handler below + } finally { + clearTimeout(timeout) } } diff --git a/src/utils/fx.utils.ts b/src/utils/fx.utils.ts index c80ec9a59f..f1631531d3 100644 --- a/src/utils/fx.utils.ts +++ b/src/utils/fx.utils.ts @@ -1,61 +1,93 @@ -import { getCachedCurrencyPrice } from '@/app/actions/currency' +import { apiFetch } from '@/utils/api-fetch' +import type { paths } from '@/types/api.generated' // This module is imported by the /api/exchange-rate route (a React Server // module) — it must stay free of client-only imports (react hooks). That is // why it lives apart from utils/currency.ts, which pulls in useCurrency. -/** - * Display-policy conversion between two provider-normalized prices (each - * expressed as currency units per USD). Both orientations of a pair are quoted - * off the withdrawal-execution (sell) side so one pair implies one price; USD - * normalizes to `{ buy: 1, sell: 1 }`, so this single expression covers - * direct (USD→X), reverse (X→USD), and cross (X→Y) pairs. - */ -export const displayRateFromPrices = (from: { sell: number }, to: { sell: number }): number => (1 / from.sell) * to.sell +type FxRateResponse = paths['/fx/rate']['get']['responses'][200]['content']['application/json'] +const FX_SOURCES = new Set(['identity', 'bridge', 'manteca', 'reference', 'mixed']) +const PLAIN_DECIMAL = /^(?:0|[1-9]\d*)(?:\.\d{1,18})?$/ +const MAX_GENERATED_AGE_MS = 26 * 60 * 60 * 1000 +const MAX_EFFECTIVE_AGE_MS = 30 * 24 * 60 * 60 * 1000 +const MAX_FUTURE_CLOCK_SKEW_MS = 5 * 60 * 1000 +// The backend constrains each USD leg to [1e-9, 1e9]. A cross-rate is a +// quotient of two legs, so its corresponding safe envelope is [1e-18, 1e18]. +const MIN_DISPLAY_RATE = 1e-18 +const MAX_DISPLAY_RATE = 1e18 + +export class FxApiError extends Error { + constructor( + readonly status: number, + from: string, + to: string + ) { + super(`FX API returned ${status} for ${from}→${to}`) + this.name = 'FxApiError' + } +} + +function timestamp(value: unknown): number | null { + if (typeof value !== 'string') return null + const parsed = Date.parse(value) + return Number.isFinite(parsed) && new Date(parsed).toISOString() === value ? parsed : null +} + +function parseFxRateResponse(value: unknown, from: string, to: string): number | null { + if (!value || typeof value !== 'object') return null + + const data = value as Partial + if (data.from !== from || data.to !== to) return null + if (data.basis !== 'display_sell' || data.indicative !== true) return null + if (typeof data.source !== 'string' || !FX_SOURCES.has(data.source)) return null + if (typeof data.rate !== 'string' || !PLAIN_DECIMAL.test(data.rate)) return null + + const generatedAt = timestamp(data.generatedAt) + if (generatedAt === null) return null + const generatedAge = Date.now() - generatedAt + if (generatedAge > MAX_GENERATED_AGE_MS || generatedAge < -MAX_FUTURE_CLOCK_SKEW_MS) return null + + const isIdentity = from === to + if (isIdentity) { + if (data.rate !== '1' || data.source !== 'identity' || data.effectiveAt !== null) return null + } else { + if (data.source === 'identity' || data.effectiveAt === null) return null + const effectiveAt = timestamp(data.effectiveAt) + if (effectiveAt === null) return null + const effectiveAge = Date.now() - effectiveAt + if (effectiveAge > MAX_EFFECTIVE_AGE_MS || effectiveAge < -MAX_FUTURE_CLOCK_SKEW_MS) return null + } + + const rate = Number(data.rate) + return Number.isFinite(rate) && rate >= MIN_DISPLAY_RATE && rate <= MAX_DISPLAY_RATE ? rate : null +} /** - * Display exchange rate for any currency pair — the single implementation - * behind both the /api/exchange-rate route (web) and the Capacitor branch of - * useExchangeRate (native, where no Next.js server exists). Provider prices - * come from getCachedCurrencyPrice; pairs no provider covers fall back to - * Frankfurter mid-market with a ×0.995 spread approximation so the fallback - * doesn't overstate what a transfer delivers. Display surfaces only — commit - * paths quote their own executed side via the uncached getCurrencyPrice. + * Reads the backend's shared indicative display rate. This is the common + * implementation for first-party browser/native clients and the web + * compatibility route. Commit paths still use getCurrencyPrice to fetch an + * execution-side quote. */ export async function fetchDisplayRate(fromCurrency: string, toCurrency: string): Promise { const from = fromCurrency.toUpperCase() const to = toCurrency.toUpperCase() - // exact 1 for same-currency pairs, without provider calls (and without - // float noise from (1/sell)*sell) - if (from === to) return 1 + const query = new URLSearchParams({ from, to }) + const response = await apiFetch(`/fx/rate?${query.toString()}`, { method: 'GET' }) + if (!response.ok) { + throw new FxApiError(response.status, from, to) + } + + let data: unknown try { - const [fromPrice, toPrice] = await Promise.all([getCachedCurrencyPrice(from), getCachedCurrencyPrice(to)]) - const rate = displayRateFromPrices(fromPrice, toPrice) - if (isFinite(rate) && rate > 0) return rate - } catch (error) { - // lands here for provider outages AND for currencies no provider serves - // ('Invalid currency code') — both continue to the Frankfurter fallback - console.warn(`No provider price for ${from}→${to}, falling back to Frankfurter:`, error) + data = await response.json() + } catch { + throw new Error(`FX API returned invalid JSON for ${from}→${to}`) } - // Fallback: synthesize a sell-side price per currency from Frankfurter - // mid-market — the ×0.995 spread is applied once, on each currency's - // USD-leg price, then converted through the same displayRateFromPrices - // policy as the provider path. Applying the spread per-request instead - // (the old behavior) made the two orientations of a pair multiply to - // 0.995² rather than 1, breaking the one-pair-one-price contract on - // every fallback-served pair. - // `next.revalidate` is a 5-min data cache on the server, a no-op in the - // browser (Capacitor static build). - const options: RequestInit & { next?: { revalidate?: number } } = { next: { revalidate: 300 } } - const targets = [from, to].filter((code) => code !== 'USD').join(',') - const res = await fetch(`https://api.frankfurter.app/latest?from=USD&to=${targets}`, options) - if (res.ok) { - const data = await res.json() - const syntheticSellPrice = (code: string) => ({ sell: code === 'USD' ? 1 : data?.rates?.[code] * 0.995 }) - const rate = displayRateFromPrices(syntheticSellPrice(from), syntheticSellPrice(to)) - if (isFinite(rate) && rate > 0) return rate + const rate = parseFxRateResponse(data, from, to) + if (rate === null) { + throw new Error(`FX API returned an invalid rate contract for ${from}→${to}`) } - throw new Error(`Failed to fetch exchange rate for ${from}→${to}`) + return rate } diff --git a/src/utils/sentry.utils.ts b/src/utils/sentry.utils.ts index 15569f2256..9b3c12ecbf 100644 --- a/src/utils/sentry.utils.ts +++ b/src/utils/sentry.utils.ts @@ -16,6 +16,9 @@ const SKIP_REPORTING: Array<{ pattern: string | RegExp; statuses: number[] }> = // /invites/validate 400 = "Invalid Invite": the user mistyped an invite code. // Expected input validation, surfaced inline to the user — not a server bug. { pattern: /\/invites\/validate/, statuses: [400] }, + // Public FX pair misses and validation failures are expected user/input + // outcomes, not backend incidents. + { pattern: /\/fx\/rate(?:\?|$)/, statuses: [400, 404] }, // qr-payment/init: 400 = open QR awaiting merchant amount; 422 = a QR the // provider can't decode (bad/expired/unsupported) — both are user-input // outcomes shown to the user, not server bugs. (BE peanut-api-ts #1041.) From 5f95fadefaddff68d7a79f5725f1b6f4bcde54b1 Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Wed, 5 Aug 2026 09:55:45 +0000 Subject: [PATCH 02/14] fix(fx): omit credentials from public rate reads --- src/utils/__tests__/api-fetch.test.ts | 20 ++++++++++++++++++++ src/utils/__tests__/fx.utils.test.ts | 12 ++++++++++-- src/utils/api-fetch.ts | 10 +++++++--- src/utils/fx.utils.ts | 6 +++++- 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/utils/__tests__/api-fetch.test.ts b/src/utils/__tests__/api-fetch.test.ts index 24ea746f69..792169a578 100644 --- a/src/utils/__tests__/api-fetch.test.ts +++ b/src/utils/__tests__/api-fetch.test.ts @@ -56,6 +56,26 @@ describe('apiFetch', () => { await apiFetch('/users/me', { headers: { 'X-Custom': 'value' } }) expect(getAuthHeaders).toHaveBeenCalledWith({ 'X-Custom': 'value' }) }) + + it('can omit credentials for a public endpoint without forwarding the control option', async () => { + await apiFetch('/fx/rate?from=PLN&to=EUR', { + method: 'GET', + includeAuth: false, + credentials: 'omit', + headers: { Accept: 'application/json' }, + }) + + expect(getAuthHeaders).not.toHaveBeenCalled() + expect(mockFetchWithSentry).toHaveBeenCalledWith( + 'https://api.test.com/fx/rate?from=PLN&to=EUR', + expect.objectContaining({ + method: 'GET', + credentials: 'omit', + headers: { Accept: 'application/json' }, + }) + ) + expect(mockFetchWithSentry.mock.calls[0][1]).not.toHaveProperty('includeAuth') + }) }) describe('content-type header', () => { diff --git a/src/utils/__tests__/fx.utils.test.ts b/src/utils/__tests__/fx.utils.test.ts index 0d6fa5cb60..68aaf435da 100644 --- a/src/utils/__tests__/fx.utils.test.ts +++ b/src/utils/__tests__/fx.utils.test.ts @@ -28,7 +28,11 @@ describe('fetchDisplayRate — shared backend contract', () => { mockApiFetch.mockResolvedValue({ ok: true, status: 200, json: async () => validResponse }) await expect(fetchDisplayRate('pln', 'eur')).resolves.toBeCloseTo(0.2322191619648635, 15) - expect(mockApiFetch).toHaveBeenCalledWith('/fx/rate?from=PLN&to=EUR', { method: 'GET' }) + expect(mockApiFetch).toHaveBeenCalledWith('/fx/rate?from=PLN&to=EUR', { + method: 'GET', + includeAuth: false, + credentials: 'omit', + }) }) it('asks the backend to validate same-currency identity pairs', async () => { @@ -46,7 +50,11 @@ describe('fetchDisplayRate — shared backend contract', () => { }) await expect(fetchDisplayRate('eur', 'EUR')).resolves.toBe(1) - expect(mockApiFetch).toHaveBeenCalledWith('/fx/rate?from=EUR&to=EUR', { method: 'GET' }) + expect(mockApiFetch).toHaveBeenCalledWith('/fx/rate?from=EUR&to=EUR', { + method: 'GET', + includeAuth: false, + credentials: 'omit', + }) }) it.each([ diff --git a/src/utils/api-fetch.ts b/src/utils/api-fetch.ts index 0a2c8d3ceb..a21505acb5 100644 --- a/src/utils/api-fetch.ts +++ b/src/utils/api-fetch.ts @@ -7,7 +7,11 @@ import { fetchWithSentry } from './sentry.utils' import { PEANUT_API_URL } from '@/constants/general.consts' import { isDemoMode } from './demo' -type FetchOptions = RequestInit & { timeoutMs?: number } +type FetchOptions = RequestInit & { + timeoutMs?: number + /** Public endpoints can opt out of sending the web bearer token. */ + includeAuth?: boolean +} function callApi(path: string, options?: FetchOptions): Promise { // Native-only demo mode: route to synthetic data before any header/network @@ -16,7 +20,7 @@ function callApi(path: string, options?: FetchOptions): Promise { // web bundle and out of every api-fetch importer's module graph. if (isDemoMode()) return import('./demo-api').then((m) => m.demoRespond(path, options)) - const { timeoutMs, ...fetchOptions } = options ?? {} + const { timeoutMs, includeAuth = true, ...fetchOptions } = options ?? {} const callerHeaders = (fetchOptions.headers as Record) ?? {} const headers: Record = {} @@ -24,7 +28,7 @@ function callApi(path: string, options?: FetchOptions): Promise { if (fetchOptions.body && !hasContentType) { headers['Content-Type'] = 'application/json' } - Object.assign(headers, getAuthHeaders(callerHeaders)) + Object.assign(headers, includeAuth ? getAuthHeaders(callerHeaders) : callerHeaders) const args: Parameters = [`${PEANUT_API_URL}${path}`, { ...fetchOptions, headers }] if (timeoutMs !== undefined) args[2] = timeoutMs diff --git a/src/utils/fx.utils.ts b/src/utils/fx.utils.ts index f1631531d3..dddf7176fc 100644 --- a/src/utils/fx.utils.ts +++ b/src/utils/fx.utils.ts @@ -73,7 +73,11 @@ export async function fetchDisplayRate(fromCurrency: string, toCurrency: string) const to = toCurrency.toUpperCase() const query = new URLSearchParams({ from, to }) - const response = await apiFetch(`/fx/rate?${query.toString()}`, { method: 'GET' }) + const response = await apiFetch(`/fx/rate?${query.toString()}`, { + method: 'GET', + includeAuth: false, + credentials: 'omit', + }) if (!response.ok) { throw new FxApiError(response.status, from, to) } From 5ade3d45ea358eee6d4b9cead1fb58dfe8adda63 Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Wed, 5 Aug 2026 10:04:40 +0000 Subject: [PATCH 03/14] fix(fx): bound requests and avoid caching errors --- src/app/api/exchange-rate/__tests__/route.test.ts | 3 +++ src/app/api/exchange-rate/route.ts | 14 +++++++++++--- src/utils/__tests__/fx.utils.test.ts | 4 +++- src/utils/fx.utils.ts | 1 + 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/app/api/exchange-rate/__tests__/route.test.ts b/src/app/api/exchange-rate/__tests__/route.test.ts index 75e615e31c..70f303e3b3 100644 --- a/src/app/api/exchange-rate/__tests__/route.test.ts +++ b/src/app/api/exchange-rate/__tests__/route.test.ts @@ -39,6 +39,7 @@ describe('GET /api/exchange-rate — thin wrapper over fetchDisplayRate', () => for (const query of ['from=USD', 'to=EUR', 'from=US%0Ad&to=EUR', 'from=TOOLONG&to=EUR']) { const response = await get(query) expect(response.status).toBe(400) + expect(response.headers.get('Cache-Control')).toBe('no-store') } expect(mockFetchDisplayRate).not.toHaveBeenCalled() }) @@ -49,6 +50,7 @@ describe('GET /api/exchange-rate — thin wrapper over fetchDisplayRate', () => const response = await get('from=USD&to=EUR') expect(response.status).toBe(500) expect(await response.json()).toEqual({ error: 'Failed to fetch exchange rates' }) + expect(response.headers.get('Cache-Control')).toBe('no-store') expect(errorSpy).toHaveBeenCalledTimes(1) errorSpy.mockRestore() }) @@ -61,6 +63,7 @@ describe('GET /api/exchange-rate — thin wrapper over fetchDisplayRate', () => expect(response.status).toBe(404) expect(await response.json()).toEqual({ error: 'Exchange rate unavailable' }) + expect(response.headers.get('Cache-Control')).toBe('no-store') expect(errorSpy).not.toHaveBeenCalled() errorSpy.mockRestore() }) diff --git a/src/app/api/exchange-rate/route.ts b/src/app/api/exchange-rate/route.ts index a4ecf83eea..b2f0a8d4e0 100644 --- a/src/app/api/exchange-rate/route.ts +++ b/src/app/api/exchange-rate/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from 'next/server' import { fetchDisplayRate, FxApiError } from '@/utils/fx.utils' +const NO_STORE = { 'Cache-Control': 'no-store' } + export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams const from = searchParams.get('from') @@ -11,7 +13,10 @@ export async function GET(request: NextRequest) { // other control characters from arbitrary query input. const ISO_CODE = /^[A-Za-z]{3,4}$/ if (!from || !to || !ISO_CODE.test(from) || !ISO_CODE.test(to)) { - return NextResponse.json({ error: 'Missing or invalid parameters: from and to' }, { status: 400 }) + return NextResponse.json( + { error: 'Missing or invalid parameters: from and to' }, + { status: 400, headers: NO_STORE } + ) } try { @@ -26,9 +31,12 @@ export async function GET(request: NextRequest) { ) } catch (error) { if (error instanceof FxApiError && (error.status === 400 || error.status === 404)) { - return NextResponse.json({ error: 'Exchange rate unavailable' }, { status: error.status }) + return NextResponse.json( + { error: 'Exchange rate unavailable' }, + { status: error.status, headers: NO_STORE } + ) } console.error(`Exchange rate API error for ${from}-${to}:`, error) - return NextResponse.json({ error: 'Failed to fetch exchange rates' }, { status: 500 }) + return NextResponse.json({ error: 'Failed to fetch exchange rates' }, { status: 500, headers: NO_STORE }) } } diff --git a/src/utils/__tests__/fx.utils.test.ts b/src/utils/__tests__/fx.utils.test.ts index 68aaf435da..94c248b63f 100644 --- a/src/utils/__tests__/fx.utils.test.ts +++ b/src/utils/__tests__/fx.utils.test.ts @@ -32,6 +32,7 @@ describe('fetchDisplayRate — shared backend contract', () => { method: 'GET', includeAuth: false, credentials: 'omit', + timeoutMs: 10_000, }) }) @@ -54,6 +55,7 @@ describe('fetchDisplayRate — shared backend contract', () => { method: 'GET', includeAuth: false, credentials: 'omit', + timeoutMs: 10_000, }) }) @@ -71,7 +73,7 @@ describe('fetchDisplayRate — shared backend contract', () => { ['future generation time', { ...validResponse, generatedAt: '2026-08-05T08:05:00.001Z' }], ['future effective time', { ...validResponse, effectiveAt: '2026-08-05T08:05:00.001Z' }], ['stale effective time', { ...validResponse, effectiveAt: '2026-07-06T07:59:59.999Z' }], - ['implausibly small rate', { ...validResponse, rate: '0.0000000000000000001' }], + ['implausibly small rate', { ...validResponse, rate: '0.000000000000000000' }], ['implausibly large rate', { ...validResponse, rate: '10000000000000000000' }], ['identity source on a cross pair', { ...validResponse, source: 'identity', effectiveAt: null }], ['missing effective time on a cross pair', { ...validResponse, effectiveAt: null }], diff --git a/src/utils/fx.utils.ts b/src/utils/fx.utils.ts index dddf7176fc..8bf202475b 100644 --- a/src/utils/fx.utils.ts +++ b/src/utils/fx.utils.ts @@ -77,6 +77,7 @@ export async function fetchDisplayRate(fromCurrency: string, toCurrency: string) method: 'GET', includeAuth: false, credentials: 'omit', + timeoutMs: 10_000, }) if (!response.ok) { throw new FxApiError(response.status, from, to) From 312f595198e474a83f4d827d8d679ffc45ba0202 Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Wed, 5 Aug 2026 15:17:11 +0000 Subject: [PATCH 04/14] fix(fx): consume shared pair contract --- docs/api-types.md | 10 +- .../api/exchange-rate/__tests__/route.test.ts | 26 +- src/app/api/exchange-rate/route.ts | 16 +- src/hooks/__tests__/useExchangeRate.test.tsx | 35 +++ src/hooks/useExchangeRate.ts | 2 +- src/types/api.generated.ts | 69 +++++- src/types/api.openapi.json | 227 +++++++++++++++--- src/utils/__tests__/fx.utils.test.ts | 141 ++++++++--- src/utils/__tests__/sentry.utils.test.ts | 10 + src/utils/fx.utils.ts | 71 ++++-- src/utils/sentry.utils.ts | 2 +- 11 files changed, 511 insertions(+), 98 deletions(-) create mode 100644 src/hooks/__tests__/useExchangeRate.test.tsx diff --git a/docs/api-types.md b/docs/api-types.md index 0af8b82fd4..371640095f 100644 --- a/docs/api-types.md +++ b/docs/api-types.md @@ -72,11 +72,11 @@ Peanut Split calls `/fx/rates` from its server. Peanut UI does not use that batc Deploy Peanut API first. Smoke-test both FX routes, refresh this snapshot, and then deploy Peanut UI and Peanut Split. -The backend snapshot applies provider precedence independently to each USD leg. -That deliberately changes mixed pairs from the old UI behavior: PLN→EUR, for -example, now combines reference PLN with Bridge EUR instead of falling back to -reference data for both legs. The API labels that provenance as `mixed`. This -policy migration needs product/CTO approval before the consumer PRs ship. +The backend preserves the UI's established pair policy as one atomic choice: +use `provider_pair` only when both legs have provider coverage; otherwise use +`reference_pair` for both legs. For example, PLN→EUR remains reference/reference +even though EUR has a Bridge quote. Responses expose the selection and both leg +sources, and consumers reject provider/reference hybrids. ## Limitations diff --git a/src/app/api/exchange-rate/__tests__/route.test.ts b/src/app/api/exchange-rate/__tests__/route.test.ts index 70f303e3b3..78698b3c41 100644 --- a/src/app/api/exchange-rate/__tests__/route.test.ts +++ b/src/app/api/exchange-rate/__tests__/route.test.ts @@ -7,7 +7,12 @@ import type { NextRequest } from 'next/server' // This file only covers compatibility-route validation and response wiring. jest.mock('@/utils/fx.utils', () => { class MockFxApiError extends Error { - constructor(readonly status: number) { + constructor( + readonly status: number, + _from?: string, + _to?: string, + readonly retryAfter: string | null = null + ) { super(`FX API returned ${status}`) } } @@ -32,7 +37,7 @@ describe('GET /api/exchange-rate — thin wrapper over fetchDisplayRate', () => const response = await get('from=USD&to=EUR') expect(await response.json()).toEqual({ rate: 0.8614 }) expect(mockFetchDisplayRate).toHaveBeenCalledWith('USD', 'EUR') - expect(response.headers.get('Cache-Control')).toBe('s-maxage=300, stale-while-revalidate=600') + expect(response.headers.get('Cache-Control')).toBe('s-maxage=300') }) it('rejects malformed currency codes with 400 before touching any provider', async () => { @@ -67,4 +72,21 @@ describe('GET /api/exchange-rate — thin wrapper over fetchDisplayRate', () => expect(errorSpy).not.toHaveBeenCalled() errorSpy.mockRestore() }) + + it.each([503, 429])( + 'preserves upstream %s availability status without logging it as a wrapper failure', + async (status) => { + mockFetchDisplayRate.mockRejectedValue(new FxApiError(status, 'PLN', 'EUR', status === 429 ? '30' : null)) + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + const response = await get('from=PLN&to=EUR') + + expect(response.status).toBe(status) + expect(await response.json()).toEqual({ error: 'Exchange rate temporarily unavailable' }) + expect(response.headers.get('Cache-Control')).toBe('no-store') + expect(response.headers.get('Retry-After')).toBe(status === 429 ? '30' : null) + expect(errorSpy).not.toHaveBeenCalled() + errorSpy.mockRestore() + } + ) }) diff --git a/src/app/api/exchange-rate/route.ts b/src/app/api/exchange-rate/route.ts index b2f0a8d4e0..8b17fc1813 100644 --- a/src/app/api/exchange-rate/route.ts +++ b/src/app/api/exchange-rate/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { fetchDisplayRate, FxApiError } from '@/utils/fx.utils' const NO_STORE = { 'Cache-Control': 'no-store' } +const PASSTHROUGH_STATUSES = new Set([400, 404, 429, 503]) export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams @@ -25,15 +26,22 @@ export async function GET(request: NextRequest) { { rate }, { headers: { - 'Cache-Control': 's-maxage=300, stale-while-revalidate=600', + 'Cache-Control': 's-maxage=300', }, } ) } catch (error) { - if (error instanceof FxApiError && (error.status === 400 || error.status === 404)) { + if (error instanceof FxApiError && PASSTHROUGH_STATUSES.has(error.status)) { + const headers: Record = { ...NO_STORE } + if (error.status === 429 && error.retryAfter) headers['Retry-After'] = error.retryAfter return NextResponse.json( - { error: 'Exchange rate unavailable' }, - { status: error.status, headers: NO_STORE } + { + error: + error.status >= 500 || error.status === 429 + ? 'Exchange rate temporarily unavailable' + : 'Exchange rate unavailable', + }, + { status: error.status, headers } ) } console.error(`Exchange rate API error for ${from}-${to}:`, error) diff --git a/src/hooks/__tests__/useExchangeRate.test.tsx b/src/hooks/__tests__/useExchangeRate.test.tsx new file mode 100644 index 0000000000..fc12ede08c --- /dev/null +++ b/src/hooks/__tests__/useExchangeRate.test.tsx @@ -0,0 +1,35 @@ +import { renderHook, waitFor } from '@testing-library/react' +import React from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useExchangeRate } from '@/hooks/useExchangeRate' +import { fetchDisplayRate, FxApiError } from '@/utils/fx.utils' + +jest.mock('@/utils/fx.utils', () => { + class MockFxApiError extends Error { + constructor(readonly status: number) { + super(`FX API returned ${status}`) + } + } + return { fetchDisplayRate: jest.fn(), FxApiError: MockFxApiError } +}) + +const mockFetchDisplayRate = fetchDisplayRate as jest.Mock + +describe('useExchangeRate retries', () => { + it('does not amplify a public FX rate-limit response', async () => { + mockFetchDisplayRate.mockRejectedValue(new FxApiError(429, 'PLN', 'EUR')) + const client = new QueryClient({ + defaultOptions: { queries: { gcTime: 0, retryDelay: 0 } }, + }) + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client }, children) + + const { result } = renderHook(() => useExchangeRate({ sourceCurrency: 'PLN', destinationCurrency: 'EUR' }), { + wrapper, + }) + + await waitFor(() => expect(result.current.isError).toBe(true)) + expect(mockFetchDisplayRate).toHaveBeenCalledTimes(1) + client.clear() + }) +}) diff --git a/src/hooks/useExchangeRate.ts b/src/hooks/useExchangeRate.ts index 011a7acabe..4f50319eb6 100644 --- a/src/hooks/useExchangeRate.ts +++ b/src/hooks/useExchangeRate.ts @@ -96,7 +96,7 @@ export function useExchangeRate({ // Invalid or unsupported pairs are deterministic client outcomes. Do // not turn one selection into four identical rate-limited requests. retry: (failureCount, error) => - !(error instanceof FxApiError && (error.status === 400 || error.status === 404)) && failureCount < 3, + !(error instanceof FxApiError && [400, 404, 429].includes(error.status)) && failureCount < 3, enabled: enabled && !!sourceCurrency && !!destinationCurrency, }) diff --git a/src/types/api.generated.ts b/src/types/api.generated.ts index 92db696003..23e284dfd8 100644 --- a/src/types/api.generated.ts +++ b/src/types/api.generated.ts @@ -439,7 +439,7 @@ export interface paths { }; nextAction?: { key: string; - kind: "sumsub" | "accept-tos" | "wait" | "contact-support" | "provide-email"; + kind: "sumsub" | "accept-tos" | "wait" | "contact-support" | "provide-email" | "bridge-hosted"; purpose: string; levelKey?: string; tosUrl?: string; @@ -450,7 +450,7 @@ export interface paths { }[]; nextActions: { key: string; - kind: "sumsub" | "accept-tos" | "wait" | "contact-support" | "provide-email"; + kind: "sumsub" | "accept-tos" | "wait" | "contact-support" | "provide-email" | "bridge-hosted"; purpose: string; levelKey?: string; tosUrl?: string; @@ -1152,7 +1152,7 @@ export interface paths { }; nextAction?: { key: string; - kind: "sumsub" | "accept-tos" | "wait" | "contact-support" | "provide-email"; + kind: "sumsub" | "accept-tos" | "wait" | "contact-support" | "provide-email" | "bridge-hosted"; purpose: string; levelKey?: string; tosUrl?: string; @@ -1163,7 +1163,7 @@ export interface paths { }[]; nextActions: { key: string; - kind: "sumsub" | "accept-tos" | "wait" | "contact-support" | "provide-email"; + kind: "sumsub" | "accept-tos" | "wait" | "contact-support" | "provide-email" | "bridge-hosted"; purpose: string; levelKey?: string; tosUrl?: string; @@ -1224,9 +1224,10 @@ export interface paths { }; content: { "application/json": { - sumsubAccessToken: string; - levelName: string; + sumsubAccessToken?: string; + levelName?: string; externalActionId?: string; + verificationUrl?: string; }; }; }; @@ -1259,7 +1260,12 @@ export interface paths { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + tosLink: string; + endorsement: string; + }; + }; }; }; }; @@ -9010,7 +9016,7 @@ export interface paths { content: { "application/json": { userId: string; - code: "BETA_TESTER" | "DEVCONNECT_BA_2025" | "PRODUCT_HUNT" | "OG_2025_10_12" | "SEEDLING_DEVCONNECT_BA_2025" | "ARBIVERSE_DEVCONNECT_BA_2025" | "CARD_PIONEER" | "FOUNDER_HOUSE" | "BUG_WHISPERER" | "SHHHHH" | "NOT_SO_SHHHH" | "CARD_FIRST_SWIPE" | "CARD_SPENT_1K" | "CARD_ALPHA" | "TOKEN_NATION_SP_2026" | "ETHFLORIPA_HUB" | "IRL_NOMADS" | "EVENT_ALUMNI" | "TOUCHED_GRASS" | "OFFRAMP_USER" | "PSYOPS_DIVISION" | "WAITLIST_SKIP" | "FESTA_JUNINA_2026" | "MANICERO" | "NITA"; + code: "BETA_TESTER" | "DEVCONNECT_BA_2025" | "PRODUCT_HUNT" | "OG_2025_10_12" | "SEEDLING_DEVCONNECT_BA_2025" | "ARBIVERSE_DEVCONNECT_BA_2025" | "CARD_PIONEER" | "FOUNDER_HOUSE" | "BUG_WHISPERER" | "SHHHHH" | "NOT_SO_SHHHH" | "CARD_FIRST_SWIPE" | "CARD_SPENT_1K" | "CARD_ALPHA" | "TOKEN_NATION_SP_2026" | "ETHFLORIPA_HUB" | "IRL_NOMADS" | "EVENT_ALUMNI" | "TOUCHED_GRASS" | "OFFRAMP_USER" | "PSYOPS_DIVISION" | "WAITLIST_SKIP" | "FESTA_JUNINA_2026" | "MANICERO" | "NITA" | "NAIJA" | "TERERE"; revoke?: boolean; }; }; @@ -10742,7 +10748,7 @@ export interface paths { path?: never; cookie?: never; }; - /** @description Public, indicative display-sell FX snapshot. unitsPerBase is quote-currency units per one base unit. */ + /** @description Public, indicative display-sell FX rates resolved relative to one base. unitsPerBase is quote-currency units per one base unit. */ get: { parameters: { query?: { @@ -10762,8 +10768,7 @@ export interface paths { }; content: { "application/json": { - /** @enum {string} */ - base: "USD"; + base: string; /** @enum {string} */ basis: "display_sell"; /** @enum {boolean} */ @@ -10773,7 +10778,9 @@ export interface paths { rates: { code: string; unitsPerBase: string; - source: "identity" | "bridge" | "manteca" | "reference"; + selection: "identity" | "provider_pair" | "reference_pair"; + baseSource: "identity" | "bridge" | "manteca" | "reference"; + quoteSource: "identity" | "bridge" | "manteca" | "reference"; effectiveAt: string | null; }[]; }; @@ -10792,6 +10799,29 @@ export interface paths { }; }; /** @description Default Response */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + message: string; + }; + }; + }; + /** @description Default Response */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Default Response */ 503: { headers: { [name: string]: unknown; @@ -10849,7 +10879,9 @@ export interface paths { basis: "display_sell"; /** @enum {boolean} */ indicative: true; - source: ("identity" | "bridge" | "manteca" | "reference") | "mixed"; + selection: "identity" | "provider_pair" | "reference_pair"; + fromSource: "identity" | "bridge" | "manteca" | "reference"; + toSource: "identity" | "bridge" | "manteca" | "reference"; effectiveAt: string | null; /** Format: date-time */ generatedAt: string; @@ -10881,6 +10913,17 @@ export interface paths { }; }; /** @description Default Response */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Default Response */ 503: { headers: { [name: string]: unknown; diff --git a/src/types/api.openapi.json b/src/types/api.openapi.json index f4f2cca192..7fdd863115 100644 --- a/src/types/api.openapi.json +++ b/src/types/api.openapi.json @@ -602,6 +602,10 @@ { "type": "string", "enum": ["provide-email"] + }, + { + "type": "string", + "enum": ["bridge-hosted"] } ] }, @@ -667,6 +671,10 @@ { "type": "string", "enum": ["provide-email"] + }, + { + "type": "string", + "enum": ["bridge-hosted"] } ] }, @@ -1636,6 +1644,10 @@ { "type": "string", "enum": ["provide-email"] + }, + { + "type": "string", + "enum": ["bridge-hosted"] } ] }, @@ -1701,6 +1713,10 @@ { "type": "string", "enum": ["provide-email"] + }, + { + "type": "string", + "enum": ["bridge-hosted"] } ] }, @@ -1810,9 +1826,11 @@ }, "externalActionId": { "type": "string" + }, + "verificationUrl": { + "type": "string" } - }, - "required": ["sumsubAccessToken", "levelName"] + } } } } @@ -1824,7 +1842,23 @@ "get": { "responses": { "200": { - "description": "Default Response" + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tosLink": { + "type": "string" + }, + "endorsement": { + "type": "string" + } + }, + "required": ["tosLink", "endorsement"] + } + } + } } } } @@ -12114,6 +12148,14 @@ { "type": "string", "enum": ["NITA"] + }, + { + "type": "string", + "enum": ["NAIJA"] + }, + { + "type": "string", + "enum": ["TERERE"] } ] }, @@ -14397,7 +14439,7 @@ }, "/fx/rates": { "get": { - "description": "Public, indicative display-sell FX snapshot. unitsPerBase is quote-currency units per one base unit.", + "description": "Public, indicative display-sell FX rates resolved relative to one base. unitsPerBase is quote-currency units per one base unit.", "parameters": [ { "schema": { @@ -14420,8 +14462,8 @@ "type": "object", "properties": { "base": { - "type": "string", - "enum": ["USD"] + "pattern": "^[A-Z]{3,4}$", + "type": "string" }, "basis": { "type": "string", @@ -14451,7 +14493,43 @@ "pattern": "^(?:0|[1-9]\\d*)(?:\\.\\d{1,18})?$", "type": "string" }, - "source": { + "selection": { + "anyOf": [ + { + "type": "string", + "enum": ["identity"] + }, + { + "type": "string", + "enum": ["provider_pair"] + }, + { + "type": "string", + "enum": ["reference_pair"] + } + ] + }, + "baseSource": { + "anyOf": [ + { + "type": "string", + "enum": ["identity"] + }, + { + "type": "string", + "enum": ["bridge"] + }, + { + "type": "string", + "enum": ["manteca"] + }, + { + "type": "string", + "enum": ["reference"] + } + ] + }, + "quoteSource": { "anyOf": [ { "type": "string", @@ -14483,7 +14561,14 @@ ] } }, - "required": ["code", "unitsPerBase", "source", "effectiveAt"] + "required": [ + "code", + "unitsPerBase", + "selection", + "baseSource", + "quoteSource", + "effectiveAt" + ] } } }, @@ -14512,6 +14597,43 @@ } } }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["error", "message"] + } + } + } + }, + "429": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + }, "503": { "description": "Default Response", "content": { @@ -14589,31 +14711,59 @@ "type": "boolean", "enum": [true] }, - "source": { + "selection": { "anyOf": [ { - "anyOf": [ - { - "type": "string", - "enum": ["identity"] - }, - { - "type": "string", - "enum": ["bridge"] - }, - { - "type": "string", - "enum": ["manteca"] - }, - { - "type": "string", - "enum": ["reference"] - } - ] + "type": "string", + "enum": ["identity"] + }, + { + "type": "string", + "enum": ["provider_pair"] + }, + { + "type": "string", + "enum": ["reference_pair"] + } + ] + }, + "fromSource": { + "anyOf": [ + { + "type": "string", + "enum": ["identity"] + }, + { + "type": "string", + "enum": ["bridge"] + }, + { + "type": "string", + "enum": ["manteca"] + }, + { + "type": "string", + "enum": ["reference"] + } + ] + }, + "toSource": { + "anyOf": [ + { + "type": "string", + "enum": ["identity"] + }, + { + "type": "string", + "enum": ["bridge"] + }, + { + "type": "string", + "enum": ["manteca"] }, { "type": "string", - "enum": ["mixed"] + "enum": ["reference"] } ] }, @@ -14639,7 +14789,9 @@ "rate", "basis", "indicative", - "source", + "selection", + "fromSource", + "toSource", "effectiveAt", "generatedAt" ] @@ -14687,6 +14839,23 @@ } } }, + "429": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": ["error"] + } + } + } + }, "503": { "description": "Default Response", "content": { diff --git a/src/utils/__tests__/fx.utils.test.ts b/src/utils/__tests__/fx.utils.test.ts index 94c248b63f..2efb192a78 100644 --- a/src/utils/__tests__/fx.utils.test.ts +++ b/src/utils/__tests__/fx.utils.test.ts @@ -11,7 +11,9 @@ const validResponse = { rate: '0.2322191619648635', basis: 'display_sell', indicative: true, - source: 'reference', + selection: 'reference_pair', + fromSource: 'reference', + toSource: 'reference', effectiveAt: '2026-08-04T00:00:00.000Z', generatedAt: '2026-08-05T08:00:00.000Z', } @@ -36,29 +38,67 @@ describe('fetchDisplayRate — shared backend contract', () => { }) }) - it('asks the backend to validate same-currency identity pairs', async () => { + it('returns same-currency identity without depending on the network', async () => { + await expect(fetchDisplayRate('eur', 'EUR')).resolves.toBe(1) + expect(mockApiFetch).not.toHaveBeenCalled() + }) + + it.each([ + ['the same provider', 'bridge', 'bridge'], + ['different providers', 'bridge', 'manteca'], + ['the other provider', 'manteca', 'manteca'], + ])('accepts an atomic provider pair using %s', async (_label, fromSource, toSource) => { mockApiFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ ...validResponse, - from: 'EUR', - to: 'EUR', - rate: '1', - source: 'identity', - effectiveAt: null, + selection: 'provider_pair', + fromSource, + toSource, + effectiveAt: '2026-08-05T07:00:00.000Z', }), }) - await expect(fetchDisplayRate('eur', 'EUR')).resolves.toBe(1) - expect(mockApiFetch).toHaveBeenCalledWith('/fx/rate?from=EUR&to=EUR', { - method: 'GET', - includeAuth: false, - credentials: 'omit', - timeoutMs: 10_000, + await expect(fetchDisplayRate('PLN', 'EUR')).resolves.toBeCloseTo(0.2322191619648635, 15) + }) + + it('uses per-leg provenance instead of a deprecated aggregate source', async () => { + mockApiFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ ...validResponse, source: 'mixed' }), }) + + await expect(fetchDisplayRate('PLN', 'EUR')).resolves.toBeCloseTo(0.2322191619648635, 15) }) + it.each([ + ['provider pair from USD', 'USD', 'EUR', 'provider_pair', 'identity', 'bridge'], + ['provider pair to USD', 'PLN', 'USD', 'provider_pair', 'manteca', 'identity'], + ['reference pair from USD', 'USD', 'PLN', 'reference_pair', 'identity', 'reference'], + ['reference pair to USD', 'PLN', 'USD', 'reference_pair', 'reference', 'identity'], + ])( + 'accepts the identity provenance only on the USD leg: %s', + async (_label, from, to, selection, fromSource, toSource) => { + mockApiFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + ...validResponse, + from, + to, + selection, + fromSource, + toSource, + effectiveAt: selection === 'provider_pair' ? '2026-08-05T07:00:00.000Z' : validResponse.effectiveAt, + }), + }) + + await expect(fetchDisplayRate(from, to)).resolves.toBeCloseTo(0.2322191619648635, 15) + } + ) + it.each([ ['numeric rate', { ...validResponse, rate: 0.23 }], ['zero rate', { ...validResponse, rate: '0' }], @@ -67,42 +107,77 @@ describe('fetchDisplayRate — shared backend contract', () => { ['over-precise rate', { ...validResponse, rate: '0.1234567890123456789' }], ['mismatched pair', { ...validResponse, from: 'USD' }], ['wrong basis', { ...validResponse, basis: 'midmarket' }], + ['non-indicative response', { ...validResponse, indicative: false }], + ['missing selection', { ...validResponse, selection: undefined }], + ['unknown selection', { ...validResponse, selection: 'mixed_pair' }], + ['missing from provenance', { ...validResponse, fromSource: undefined }], + ['missing to provenance', { ...validResponse, toSource: undefined }], + ['unknown provenance', { ...validResponse, toSource: 'other' }], ['non-canonical timestamp', { ...validResponse, generatedAt: '2026-08-05' }], ['missing generation time', { ...validResponse, generatedAt: undefined }], - ['stale generation time', { ...validResponse, generatedAt: '2026-08-04T05:59:59.999Z' }], + ['stale generation time', { ...validResponse, generatedAt: '2026-08-05T07:44:59.999Z' }], ['future generation time', { ...validResponse, generatedAt: '2026-08-05T08:05:00.001Z' }], ['future effective time', { ...validResponse, effectiveAt: '2026-08-05T08:05:00.001Z' }], ['stale effective time', { ...validResponse, effectiveAt: '2026-07-06T07:59:59.999Z' }], ['implausibly small rate', { ...validResponse, rate: '0.000000000000000000' }], ['implausibly large rate', { ...validResponse, rate: '10000000000000000000' }], - ['identity source on a cross pair', { ...validResponse, source: 'identity', effectiveAt: null }], + [ + 'identity selection on a cross pair', + { + ...validResponse, + rate: '1', + selection: 'identity', + fromSource: 'identity', + toSource: 'identity', + effectiveAt: null, + }, + ], ['missing effective time on a cross pair', { ...validResponse, effectiveAt: null }], + ['provider from leg under reference selection', { ...validResponse, fromSource: 'bridge' }], + ['provider to leg under reference selection', { ...validResponse, toSource: 'manteca' }], + [ + 'reference from leg under provider selection', + { ...validResponse, selection: 'provider_pair', fromSource: 'reference', toSource: 'bridge' }, + ], + [ + 'reference to leg under provider selection', + { ...validResponse, selection: 'provider_pair', fromSource: 'bridge', toSource: 'reference' }, + ], + [ + 'identity leg under provider selection', + { ...validResponse, selection: 'provider_pair', fromSource: 'identity', toSource: 'bridge' }, + ], + ['identity leg under reference selection', { ...validResponse, fromSource: 'identity' }], ])('rejects an unusable backend contract: %s', async (_label, body) => { mockApiFetch.mockResolvedValue({ ok: true, status: 200, json: async () => body }) await expect(fetchDisplayRate('PLN', 'EUR')).rejects.toThrow('invalid rate contract') }) - it.each([ - ['non-one rate', { rate: '2' }], - ['non-identity source', { source: 'reference' }], - ['non-null effective time', { effectiveAt: '2026-08-04T00:00:00.000Z' }], - ])('rejects invalid identity cross-fields: %s', async (_label, overrides) => { + it('applies the shorter provider-observation freshness ceiling', async () => { mockApiFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ ...validResponse, - from: 'EUR', - to: 'EUR', - rate: '1', - source: 'identity', - effectiveAt: null, - ...overrides, + selection: 'provider_pair', + fromSource: 'manteca', + toSource: 'bridge', + effectiveAt: '2026-08-04T07:59:59.999Z', }), }) - await expect(fetchDisplayRate('EUR', 'EUR')).rejects.toThrow('invalid rate contract') + await expect(fetchDisplayRate('PLN', 'EUR')).rejects.toThrow('invalid rate contract') + }) + + it('accepts a reference observation at the provider ceiling because its domain permits 30 days', async () => { + mockApiFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ ...validResponse, effectiveAt: '2026-08-04T07:59:59.999Z' }), + }) + + await expect(fetchDisplayRate('PLN', 'EUR')).resolves.toBeCloseTo(0.2322191619648635, 15) }) it('rejects malformed JSON from the backend', async () => { @@ -116,11 +191,21 @@ describe('fetchDisplayRate — shared backend contract', () => { }) it('rejects backend error responses', async () => { - mockApiFetch.mockResolvedValue({ ok: false, status: 503 }) + mockApiFetch.mockResolvedValue({ ok: false, status: 503, headers: new Headers() }) await expect(fetchDisplayRate('PLN', 'EUR')).rejects.toThrow('FX API returned 503') }) + it('retains Retry-After on a public rate-limit response', async () => { + mockApiFetch.mockResolvedValue({ + ok: false, + status: 429, + headers: new Headers({ 'Retry-After': '30' }), + }) + + await expect(fetchDisplayRate('PLN', 'EUR')).rejects.toMatchObject({ status: 429, retryAfter: '30' }) + }) + it('propagates backend transport errors', async () => { mockApiFetch.mockRejectedValue(new Error('backend unavailable')) diff --git a/src/utils/__tests__/sentry.utils.test.ts b/src/utils/__tests__/sentry.utils.test.ts index eb3d74a43b..94c5bfdd15 100644 --- a/src/utils/__tests__/sentry.utils.test.ts +++ b/src/utils/__tests__/sentry.utils.test.ts @@ -80,6 +80,16 @@ describe('fetchWithSentry — expected-response suppression', () => { expect(warnSpy).not.toHaveBeenCalled() }) + it('does not report an expected public FX rate limit response', async () => { + global.fetch = jest.fn().mockResolvedValue(mockResponse(429, { error: 'RATE_LIMITED' })) + + const res = await fetchWithSentry('https://api.peanut.me/fx/rate?from=PLN&to=EUR', { method: 'GET' }) + + expect(res.status).toBe(429) + expect(Sentry.captureMessage).not.toHaveBeenCalled() + expect(warnSpy).not.toHaveBeenCalled() + }) + it('still reports 400s from endpoints without a skip rule', async () => { global.fetch = jest.fn().mockResolvedValue(mockResponse(400, { error: 'bad request' })) diff --git a/src/utils/fx.utils.ts b/src/utils/fx.utils.ts index 8bf202475b..52791c4d3a 100644 --- a/src/utils/fx.utils.ts +++ b/src/utils/fx.utils.ts @@ -6,10 +6,15 @@ import type { paths } from '@/types/api.generated' // why it lives apart from utils/currency.ts, which pulls in useCurrency. type FxRateResponse = paths['/fx/rate']['get']['responses'][200]['content']['application/json'] -const FX_SOURCES = new Set(['identity', 'bridge', 'manteca', 'reference', 'mixed']) +type FxSelection = FxRateResponse['selection'] +type FxSource = FxRateResponse['fromSource'] const PLAIN_DECIMAL = /^(?:0|[1-9]\d*)(?:\.\d{1,18})?$/ -const MAX_GENERATED_AGE_MS = 26 * 60 * 60 * 1000 -const MAX_EFFECTIVE_AGE_MS = 30 * 24 * 60 * 60 * 1000 +// The API market, its shared HTTP cache, and the legacy compatibility route +// each hold a successful response for at most five minutes. Fifteen minutes +// bounds the full chain without accepting an old replay as current. +const MAX_GENERATED_AGE_MS = 15 * 60 * 1000 +const MAX_PROVIDER_EFFECTIVE_AGE_MS = 24 * 60 * 60 * 1000 +const MAX_REFERENCE_EFFECTIVE_AGE_MS = 30 * 24 * 60 * 60 * 1000 const MAX_FUTURE_CLOCK_SKEW_MS = 5 * 60 * 1000 // The backend constrains each USD leg to [1e-9, 1e9]. A cross-rate is a // quotient of two legs, so its corresponding safe envelope is [1e-18, 1e18]. @@ -20,7 +25,8 @@ export class FxApiError extends Error { constructor( readonly status: number, from: string, - to: string + to: string, + readonly retryAfter: string | null = null ) { super(`FX API returned ${status} for ${from}→${to}`) this.name = 'FxApiError' @@ -33,13 +39,31 @@ function timestamp(value: unknown): number | null { return Number.isFinite(parsed) && new Date(parsed).toISOString() === value ? parsed : null } +function isFxSelection(value: unknown): value is FxSelection { + return value === 'identity' || value === 'provider_pair' || value === 'reference_pair' +} + +function isFxSource(value: unknown): value is FxSource { + return value === 'identity' || value === 'bridge' || value === 'manteca' || value === 'reference' +} + +function isProviderSource(value: FxSource): value is 'bridge' | 'manteca' { + return value === 'bridge' || value === 'manteca' +} + +function isSelectionSource(currency: string, source: FxSource, selection: Exclude): boolean { + if (currency === 'USD') return source === 'identity' + if (selection === 'provider_pair') return isProviderSource(source) + return source === 'reference' +} + function parseFxRateResponse(value: unknown, from: string, to: string): number | null { if (!value || typeof value !== 'object') return null const data = value as Partial if (data.from !== from || data.to !== to) return null if (data.basis !== 'display_sell' || data.indicative !== true) return null - if (typeof data.source !== 'string' || !FX_SOURCES.has(data.source)) return null + if (!isFxSelection(data.selection) || !isFxSource(data.fromSource) || !isFxSource(data.toSource)) return null if (typeof data.rate !== 'string' || !PLAIN_DECIMAL.test(data.rate)) return null const generatedAt = timestamp(data.generatedAt) @@ -47,15 +71,29 @@ function parseFxRateResponse(value: unknown, from: string, to: string): number | const generatedAge = Date.now() - generatedAt if (generatedAge > MAX_GENERATED_AGE_MS || generatedAge < -MAX_FUTURE_CLOCK_SKEW_MS) return null - const isIdentity = from === to - if (isIdentity) { - if (data.rate !== '1' || data.source !== 'identity' || data.effectiveAt !== null) return null - } else { - if (data.source === 'identity' || data.effectiveAt === null) return null - const effectiveAt = timestamp(data.effectiveAt) - if (effectiveAt === null) return null - const effectiveAge = Date.now() - effectiveAt - if (effectiveAge > MAX_EFFECTIVE_AGE_MS || effectiveAge < -MAX_FUTURE_CLOCK_SKEW_MS) return null + // Identity is handled locally before the request. Every backend response + // consumed here must therefore be one complete non-identity domain. + if (data.selection === 'identity' || data.effectiveAt === null) return null + if ( + !isSelectionSource(from, data.fromSource, data.selection) || + !isSelectionSource(to, data.toSource, data.selection) + ) { + return null + } + + const effectiveAt = timestamp(data.effectiveAt) + if (effectiveAt === null) return null + const maxObservationAge = + data.selection === 'provider_pair' ? MAX_PROVIDER_EFFECTIVE_AGE_MS : MAX_REFERENCE_EFFECTIVE_AGE_MS + const observationAgeAtGeneration = generatedAt - effectiveAt + const effectiveAgeNow = Date.now() - effectiveAt + if ( + observationAgeAtGeneration > maxObservationAge || + observationAgeAtGeneration < -MAX_FUTURE_CLOCK_SKEW_MS || + effectiveAgeNow > maxObservationAge + MAX_GENERATED_AGE_MS || + effectiveAgeNow < -MAX_FUTURE_CLOCK_SKEW_MS + ) { + return null } const rate = Number(data.rate) @@ -71,6 +109,9 @@ function parseFxRateResponse(value: unknown, from: string, to: string): number | export async function fetchDisplayRate(fromCurrency: string, toCurrency: string): Promise { const from = fromCurrency.toUpperCase() const to = toCurrency.toUpperCase() + // Exact mathematical identity does not depend on network availability and + // was the established UI behavior before the shared API existed. + if (from === to) return 1 const query = new URLSearchParams({ from, to }) const response = await apiFetch(`/fx/rate?${query.toString()}`, { @@ -80,7 +121,7 @@ export async function fetchDisplayRate(fromCurrency: string, toCurrency: string) timeoutMs: 10_000, }) if (!response.ok) { - throw new FxApiError(response.status, from, to) + throw new FxApiError(response.status, from, to, response.headers?.get?.('Retry-After') ?? null) } let data: unknown diff --git a/src/utils/sentry.utils.ts b/src/utils/sentry.utils.ts index 9b3c12ecbf..40d3461f04 100644 --- a/src/utils/sentry.utils.ts +++ b/src/utils/sentry.utils.ts @@ -18,7 +18,7 @@ const SKIP_REPORTING: Array<{ pattern: string | RegExp; statuses: number[] }> = { pattern: /\/invites\/validate/, statuses: [400] }, // Public FX pair misses and validation failures are expected user/input // outcomes, not backend incidents. - { pattern: /\/fx\/rate(?:\?|$)/, statuses: [400, 404] }, + { pattern: /\/fx\/rate(?:\?|$)/, statuses: [400, 404, 429] }, // qr-payment/init: 400 = open QR awaiting merchant amount; 422 = a QR the // provider can't decode (bad/expired/unsupported) — both are user-input // outcomes shown to the user, not server bugs. (BE peanut-api-ts #1041.) From 2e141e06feace5c178424e05a989720721dc8e6a Mon Sep 17 00:00:00 2001 From: 0xkkonrad Date: Wed, 5 Aug 2026 15:22:40 +0000 Subject: [PATCH 05/14] fix(fx): fail closed after refresh errors --- src/hooks/__tests__/useExchangeRate.test.tsx | 33 +++++++++++++++++++- src/hooks/useExchangeRate.ts | 12 +++++-- src/utils/__tests__/fx.utils.test.ts | 1 + src/utils/fx.utils.ts | 1 + 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/hooks/__tests__/useExchangeRate.test.tsx b/src/hooks/__tests__/useExchangeRate.test.tsx index fc12ede08c..c07236b851 100644 --- a/src/hooks/__tests__/useExchangeRate.test.tsx +++ b/src/hooks/__tests__/useExchangeRate.test.tsx @@ -1,4 +1,4 @@ -import { renderHook, waitFor } from '@testing-library/react' +import { act, renderHook, waitFor } from '@testing-library/react' import React from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useExchangeRate } from '@/hooks/useExchangeRate' @@ -16,6 +16,8 @@ jest.mock('@/utils/fx.utils', () => { const mockFetchDisplayRate = fetchDisplayRate as jest.Mock describe('useExchangeRate retries', () => { + beforeEach(() => mockFetchDisplayRate.mockReset()) + it('does not amplify a public FX rate-limit response', async () => { mockFetchDisplayRate.mockRejectedValue(new FxApiError(429, 'PLN', 'EUR')) const client = new QueryClient({ @@ -32,4 +34,33 @@ describe('useExchangeRate retries', () => { expect(mockFetchDisplayRate).toHaveBeenCalledTimes(1) client.clear() }) + + it('clears a retained conversion when a background refresh reaches a terminal error', async () => { + mockFetchDisplayRate.mockResolvedValueOnce(0.25).mockRejectedValueOnce(new FxApiError(429, 'PLN', 'EUR')) + const client = new QueryClient({ + defaultOptions: { queries: { gcTime: 0, retryDelay: 0 } }, + }) + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client }, children) + + const { result } = renderHook( + () => useExchangeRate({ sourceCurrency: 'PLN', destinationCurrency: 'EUR', initialSourceAmount: 10 }), + { wrapper } + ) + + await waitFor(() => expect(result.current.destinationAmount).toBe(2.5)) + + await act(async () => { + await client.invalidateQueries({ queryKey: ['exchangeRate', 'PLN', 'EUR'] }) + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + expect(result.current.exchangeRate).toBe(0) + expect(result.current.destinationAmount).toBe('') + expect(result.current.destinationInputValue).toBe('') + }) + expect(mockFetchDisplayRate).toHaveBeenCalledTimes(2) + client.clear() + }) }) diff --git a/src/hooks/useExchangeRate.ts b/src/hooks/useExchangeRate.ts index 4f50319eb6..c8d10d4062 100644 --- a/src/hooks/useExchangeRate.ts +++ b/src/hooks/useExchangeRate.ts @@ -100,12 +100,20 @@ export function useExchangeRate({ enabled: enabled && !!sourceCurrency && !!destinationCurrency, }) - const exchangeRate = rateData?.rate ?? 0 + // TanStack intentionally retains the last successful data when a + // background refetch fails. FX must fail closed instead: otherwise a + // repeatedly failing refresh can leave an arbitrarily old conversion on + // screen even though the query is in its terminal error state. + const exchangeRate = isError ? 0 : (rateData?.rate ?? 0) const isLoading = isFetching // Recalculate amounts when debounced inputs or rate changes (no extra loading toggles) useEffect(() => { - if (exchangeRate <= 0) return + if (exchangeRate <= 0) { + if (lastEditedField === 'destination') setSourceAmount('') + else clearDestinationFields() + return + } const hasValidSource = isValidAmount(debouncedSourceAmount) const hasValidDestination = isValidAmount(debouncedDestinationAmount) diff --git a/src/utils/__tests__/fx.utils.test.ts b/src/utils/__tests__/fx.utils.test.ts index 2efb192a78..40bdbbd3ed 100644 --- a/src/utils/__tests__/fx.utils.test.ts +++ b/src/utils/__tests__/fx.utils.test.ts @@ -34,6 +34,7 @@ describe('fetchDisplayRate — shared backend contract', () => { method: 'GET', includeAuth: false, credentials: 'omit', + redirect: 'error', timeoutMs: 10_000, }) }) diff --git a/src/utils/fx.utils.ts b/src/utils/fx.utils.ts index 52791c4d3a..80cf0034ea 100644 --- a/src/utils/fx.utils.ts +++ b/src/utils/fx.utils.ts @@ -118,6 +118,7 @@ export async function fetchDisplayRate(fromCurrency: string, toCurrency: string) method: 'GET', includeAuth: false, credentials: 'omit', + redirect: 'error', timeoutMs: 10_000, }) if (!response.ok) { From b56922f0d18a5e5ecf8077fff748fe9fddca94fc Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:02:20 +0530 Subject: [PATCH 06/14] fix(ci): consolidate UI workflow reliability --- .github/workflows/capgo-deploy-ios.yml | 1 + .github/workflows/capgo-deploy.yml | 1 + .github/workflows/content-publish-automerge.yml | 5 ++++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/capgo-deploy-ios.yml b/.github/workflows/capgo-deploy-ios.yml index 684399f0c1..7960612f8d 100644 --- a/.github/workflows/capgo-deploy-ios.yml +++ b/.github/workflows/capgo-deploy-ios.yml @@ -82,6 +82,7 @@ jobs: --apikey "$CAPGO_API_KEY" \ --path ./out \ --auto-min-update-version \ + --version-exists-ok \ --comment "$COMMENT" - name: Deployment summary diff --git a/.github/workflows/capgo-deploy.yml b/.github/workflows/capgo-deploy.yml index ffafe6d01e..2fec142606 100644 --- a/.github/workflows/capgo-deploy.yml +++ b/.github/workflows/capgo-deploy.yml @@ -86,6 +86,7 @@ jobs: --key-data-v2 "$CAPGO_PRIVATE_KEY" \ --path ./out \ --auto-min-update-version \ + --version-exists-ok \ --comment "$COMMENT" - name: Deployment summary diff --git a/.github/workflows/content-publish-automerge.yml b/.github/workflows/content-publish-automerge.yml index 846e63754e..113eba6b89 100644 --- a/.github/workflows/content-publish-automerge.yml +++ b/.github/workflows/content-publish-automerge.yml @@ -61,7 +61,10 @@ jobs: REPO: ${{ github.repository }} run: | set -euo pipefail - FILES=$(gh pr diff "$PR" --repo "$REPO" --name-only) + # `gh pr diff` returns HTTP 406 after 300 files. The Files API + # is paginated, so large code PRs reach the same fail-closed + # exact-match decision instead of leaving a false-red check. + FILES=$(gh api --paginate "repos/$REPO/pulls/$PR/files" --jq '.[].filename') echo "Changed files:" echo "$FILES" if [ "$FILES" = "src/content" ]; then From f25cc2bb10171bcdc5d170f8dc5251bb896e65ad Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Fri, 7 Aug 2026 13:06:54 +0100 Subject: [PATCH 07/14] fix(websocket): stop shipping raw frames to Sentry on a parse error The parse-error catch logged the whole frame: console.error('Error parsing WebSocket message:', error, event.data) console.error is not local. instrumentation-client.ts and sentry.client.config.ts both register captureConsoleIntegration({ levels: ['error', 'warn'] }), so every console.error becomes a Sentry event. beforeSendHandler scrubs request.headers, request.data, extra, contexts and breadcrumb data by key name - it never touches event.message, and key-name redaction does nothing to a raw serialized blob anyway. The frames this handler receives are kyc_status_update, sumsub_kyc_status_update, manteca_kyc_status_update, history_entry, rain_card_balance_changed and user_rail_status_changed - user KYC state and financial data. A malformed one carried all of it to Sentry. Log the byte length instead. That still separates a truncated frame from a malformed one, which is the only thing this catch ever needed. The test pins it: it fails against the old line and passes against this one. CodeQL alert #145 (js/log-injection, medium). --- .../websocket-parse-error-pii.test.ts | 62 +++++++++++++++++++ src/services/websocket.ts | 12 +++- 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 src/services/__tests__/websocket-parse-error-pii.test.ts diff --git a/src/services/__tests__/websocket-parse-error-pii.test.ts b/src/services/__tests__/websocket-parse-error-pii.test.ts new file mode 100644 index 0000000000..613d305d27 --- /dev/null +++ b/src/services/__tests__/websocket-parse-error-pii.test.ts @@ -0,0 +1,62 @@ +import { PeanutWebSocket } from '@/services/websocket' + +/** + * console.error is wired to Sentry through + * captureConsoleIntegration({ levels: ['error', 'warn'] }), and + * beforeSendHandler scrubs headers/request.data/extra/contexts/breadcrumbs + * by key name — it never touches event.message. So anything handed to + * console.error leaves the browser verbatim. + * + * A malformed WebSocket frame carries the same shapes the good ones do + * (kyc_status_update, history_entry, rain_card_balance_changed), which is + * user KYC and financial data. This pins the parse-error path so nobody + * reintroduces the raw frame into that log line. + */ +describe('PeanutWebSocket — malformed frame never reaches Sentry via console', () => { + // A frame that fails JSON.parse but still carries recognisable PII. + const PII_FRAME = + '{"type":"kyc_status_update","data":{"status":"approved","fullName":"ALEKSEI SOKOLOV",' + + '"documentNumber":"AB1234567","email":"aleksei@example.com"}' // truncated → invalid JSON + + const SECRETS = ['ALEKSEI SOKOLOV', 'AB1234567', 'aleksei@example.com', 'kyc_status_update'] + + let socket: { onmessage: ((event: MessageEvent) => void) | null } + let errorSpy: jest.SpyInstance + + beforeEach(() => { + socket = { onmessage: null } + // Capture the handler `connect()` binds, without a real transport. + ;(global as unknown as { WebSocket: unknown }).WebSocket = jest.fn(() => socket) + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + errorSpy.mockRestore() + jest.resetAllMocks() + }) + + const deliver = (data: string) => { + const ws = new PeanutWebSocket('https://api.peanut.test', '/ws') + ws.connect() + socket.onmessage?.({ data } as MessageEvent) + } + + it('logs the parse failure without echoing the frame', () => { + deliver(PII_FRAME) + + expect(errorSpy).toHaveBeenCalled() + const logged = errorSpy.mock.calls.flat().map(String).join(' ') + + for (const secret of SECRETS) { + expect(logged).not.toContain(secret) + } + }) + + it('still reports the frame size so a truncated frame stays diagnosable', () => { + deliver(PII_FRAME) + + const logged = errorSpy.mock.calls.flat().map(String).join(' ') + expect(logged).toContain(String(PII_FRAME.length)) + expect(logged).toContain('Error parsing WebSocket message') + }) +}) diff --git a/src/services/websocket.ts b/src/services/websocket.ts index 11e16b6b38..e6767b4ac6 100644 --- a/src/services/websocket.ts +++ b/src/services/websocket.ts @@ -260,7 +260,17 @@ export class PeanutWebSocket { break } } catch (error) { - console.error('Error parsing WebSocket message:', error, event.data) + // Never log the raw frame. console.error is wired to Sentry via + // captureConsoleIntegration({ levels: ['error', 'warn'] }), and + // beforeSendHandler only scrubs headers/request.data/extra/ + // contexts/breadcrumbs by key name - it does not touch + // event.message. A raw frame here carries kyc_status_update, + // history_entry, rain_card_balance_changed and friends, so it + // would ship user KYC and financial data straight to Sentry. + // The byte length is enough to tell a truncated frame from a + // malformed one, which is all this catch ever needed. + const size = typeof event.data === 'string' ? event.data.length : 'non-string' + console.error('Error parsing WebSocket message:', error, `(frame bytes: ${size})`) } } From bc975e3074b3158dd05e540ed575a4f477100081 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Fri, 7 Aug 2026 14:45:20 +0200 Subject: [PATCH 08/14] feat(support): unread badge on the Support nav icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of TASK-21141. The backend now writes an in-app notification row for every support reply; this shows it. The Support icon in the mobile nav gets a pink dot while support has replied and the user has not opened the chat. Opening the drawer clears it. The count is server truth, read from /notifications/unread-count?category=support — the Crisp widget is a sandboxed iframe on web and an event-less plugin on native, so the client cannot work this out for itself. Clearing hangs off isSupportModalOpen, which is the one flag every entry sets before anything opens — the nav tap, openSupportWithMessage(), the push deep link and the Capacitor path. One effect covers all four. SupportDeepLink handles /home?support=open, the link a support push carries. The pink dot was copy-pasted in three places and the badge would have made a fourth, so it is now one IndicatorDot component. The three call sites render the same as before — twMerge resolves the size and animation overrides. The name is deliberately neutral: on a transaction card the dot means pending, on the perk carousel it means claimable. Do not merge before the backend PR is deployed. An old backend ignores the category param and would light the badge for any unread notification. --- src/app/(mobile-ui)/layout.tsx | 9 ++- src/components/Global/IndicatorDot/index.tsx | 14 ++++ .../Global/SupportDeepLink/index.tsx | 25 +++++++ .../__tests__/SupportDrawer.test.tsx | 30 ++++++++ src/components/Global/SupportDrawer/index.tsx | 16 +++++ .../Global/WalletNavigation/index.tsx | 10 ++- .../Home/HomeCarouselCTA/CarouselCTA.tsx | 3 +- .../Profile/components/ProfileMenuItem.tsx | 7 +- .../TransactionDetails/TransactionCard.tsx | 3 +- src/hooks/__tests__/useSupportUnread.test.ts | 72 +++++++++++++++++++ src/hooks/useSupportUnread.ts | 45 ++++++++++++ src/services/notifications.ts | 19 ++++- 12 files changed, 242 insertions(+), 11 deletions(-) create mode 100644 src/components/Global/IndicatorDot/index.tsx create mode 100644 src/components/Global/SupportDeepLink/index.tsx create mode 100644 src/hooks/__tests__/useSupportUnread.test.ts create mode 100644 src/hooks/useSupportUnread.ts diff --git a/src/app/(mobile-ui)/layout.tsx b/src/app/(mobile-ui)/layout.tsx index 1e39cd4ff6..d1e18d648b 100644 --- a/src/app/(mobile-ui)/layout.tsx +++ b/src/app/(mobile-ui)/layout.tsx @@ -10,11 +10,12 @@ import BackendErrorScreen from '@/components/Global/BackendErrorScreen' import { useAuth } from '@/context/authContext' import classNames from 'classnames' import { usePathname } from 'next/navigation' -import { useCallback, useEffect, useRef, useState } from 'react' +import { Suspense, useCallback, useEffect, useRef, useState } from 'react' import { twMerge } from 'tailwind-merge' import '../../styles/globals.css' import QRScannerOverlay from '@/components/Global/QRScannerOverlay' import SecurityVerificationOverlay from '@/components/Global/SecurityVerificationOverlay' +import SupportDeepLink from '@/components/Global/SupportDeepLink' import SupportDrawer from '@/components/Global/SupportDrawer' import JoinWaitlistPage from '@/components/Invites/JoinWaitlistPage' import { useRouter } from 'next/navigation' @@ -254,6 +255,12 @@ const Layout = ({ children }: { children: React.ReactNode }) => { + {/* Suspense is required: nuqs reads useSearchParams, which triggers + a client-side-rendering bailout without a boundary. */} + + + + diff --git a/src/components/Global/IndicatorDot/index.tsx b/src/components/Global/IndicatorDot/index.tsx new file mode 100644 index 0000000000..998073f645 --- /dev/null +++ b/src/components/Global/IndicatorDot/index.tsx @@ -0,0 +1,14 @@ +import { twMerge } from 'tailwind-merge' + +/** + * The small pink status dot. + * + * Neutral name on purpose: it marks "pending" on a transaction card, + * "claimable" on a perk carousel card, and "unread" on the support nav icon. + * Pass className for size, animation or position overrides. + */ +const IndicatorDot = ({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) => ( + +) + +export default IndicatorDot diff --git a/src/components/Global/SupportDeepLink/index.tsx b/src/components/Global/SupportDeepLink/index.tsx new file mode 100644 index 0000000000..15f8a2cbde --- /dev/null +++ b/src/components/Global/SupportDeepLink/index.tsx @@ -0,0 +1,25 @@ +'use client' + +import { useModalsContext } from '@/context/ModalsContext' +import { parseAsString, useQueryStates } from 'nuqs' +import { useEffect } from 'react' + +/** + * Opens the support drawer for `/home?support=open`, the deep link a support + * reply push carries. The param is cleared right after so a refresh or a back + * navigation does not reopen the drawer. Renders nothing. + */ +const SupportDeepLink = () => { + const { setIsSupportModalOpen } = useModalsContext() + const [{ support }, setQuery] = useQueryStates({ support: parseAsString }) + + useEffect(() => { + if (support !== 'open') return + setIsSupportModalOpen(true) + setQuery({ support: null }) + }, [support, setIsSupportModalOpen, setQuery]) + + return null +} + +export default SupportDeepLink diff --git a/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx b/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx index f6c0e4f712..7ba48c9140 100644 --- a/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx +++ b/src/components/Global/SupportDrawer/__tests__/SupportDrawer.test.tsx @@ -41,6 +41,15 @@ jest.mock('@/context/ModalsContext', () => ({ supportPrefilledMessage: undefined, }), })) +// Opening the drawer clears the support unread badge. That call is not what +// this file guards, and serverFetch reaches for Capacitor Preferences, which +// jsdom has no shim for. +const mockMarkAllRead = jest.fn(async () => ({ ok: true })) +jest.mock('@/services/notifications', () => ({ + notificationsApi: { + markAllRead: (category: string) => mockMarkAllRead(category), + }, +})) jest.mock('@/hooks/useCrispUserData', () => ({ useCrispUserData: () => mockUseCrispUserData(), })) @@ -106,6 +115,27 @@ describe('SupportDrawer Crisp session gate — web iframe', () => { }) }) +describe('SupportDrawer — support unread badge', () => { + beforeEach(() => { + mockUseCrispUserData.mockReset().mockReturnValue({ userId: 'user-abc', email: 'a@b.com' }) + mockUseCrispTokenId.mockReset().mockReturnValue('token-abc') + mockIsCapacitor.mockReset().mockReturnValue(false) + mockMarkAllRead.mockClear() + }) + + it('clears the support badge and tells the rest of the app when the drawer opens', async () => { + const onUpdated = jest.fn() + window.addEventListener('notifications:updated', onUpdated) + + render() + + await waitFor(() => expect(mockMarkAllRead).toHaveBeenCalledWith('support')) + await waitFor(() => expect(onUpdated).toHaveBeenCalled()) + + window.removeEventListener('notifications:updated', onUpdated) + }) +}) + describe('SupportDrawer — Crisp load-failure fallback', () => { beforeEach(() => { mockUseCrispUserData.mockReset().mockReturnValue({ userId: undefined, email: undefined }) diff --git a/src/components/Global/SupportDrawer/index.tsx b/src/components/Global/SupportDrawer/index.tsx index 0b2e506a5a..efead4c4fd 100644 --- a/src/components/Global/SupportDrawer/index.tsx +++ b/src/components/Global/SupportDrawer/index.tsx @@ -10,6 +10,7 @@ import { useVisualViewport } from '@/hooks/useVisualViewport' import PeanutLoading from '../PeanutLoading' import { Button } from '@/components/0_Bruddle/Button' import { SUPPORT_EMAIL } from '@/constants/crisp' +import { notificationsApi } from '@/services/notifications' import { isCapacitor } from '@/utils/capacitor' const DISMISS_THRESHOLD = 100 @@ -51,6 +52,21 @@ const SupportDrawer = () => { if (isSupportModalOpen) setHasBeenOpened(true) }, [isSupportModalOpen]) + /* + * Clear the support unread badge. This flag is the single choke point for + * "the user opened the chat" — the nav tap, openSupportWithMessage(), the + * push deep link and the Capacitor path all set it before anything opens, + * so one effect covers every entry. + */ + useEffect(() => { + if (!isSupportModalOpen) return + notificationsApi + .markAllRead('support') + .then(() => window.dispatchEvent(new CustomEvent('notifications:updated'))) + // A failed mark-read only means the badge stays on a bit longer. + .catch(() => {}) + }, [isSupportModalOpen]) + const handleRetry = useCallback(() => { setIsCrispFailed(false) setIsCrispReady(false) diff --git a/src/components/Global/WalletNavigation/index.tsx b/src/components/Global/WalletNavigation/index.tsx index ad9000ab11..48cbda7b7a 100644 --- a/src/components/Global/WalletNavigation/index.tsx +++ b/src/components/Global/WalletNavigation/index.tsx @@ -2,8 +2,10 @@ import PEANUT_LOGO from '@/assets/logos/peanut-logo.svg' import DirectSendQr from '@/components/Global/DirectSendQR' import { Icon, type IconName, Icon as NavIcon } from '@/components/Global/Icons/Icon' +import IndicatorDot from '@/components/Global/IndicatorDot' import underMaintenanceConfig from '@/config/underMaintenance.config' import { useModalsContext } from '@/context/ModalsContext' +import { useSupportUnread } from '@/hooks/useSupportUnread' import { useUserStore } from '@/redux/hooks' import classNames from 'classnames' import Image from 'next/image' @@ -76,6 +78,7 @@ const MobileNav: React.FC = ({ pathName }) => { const t = useTranslations('navigation') const { setIsSupportModalOpen } = useModalsContext() const { triggerHaptic } = useHaptic() + const hasUnreadSupport = useSupportUnread() return (
@@ -111,7 +114,12 @@ const MobileNav: React.FC = ({ pathName }) => { { 'text-primary-1': pathName === '/support' } )} > - + + + {hasUnreadSupport && ( + + )} + {t('support')}
diff --git a/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx b/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx index 660fc9c2bb..433d2f1b55 100644 --- a/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx +++ b/src/components/Home/HomeCarouselCTA/CarouselCTA.tsx @@ -1,6 +1,7 @@ 'use client' import { Icon, type IconName } from '@/components/Global/Icons/Icon' +import IndicatorDot from '@/components/Global/IndicatorDot' import type { StaticImageData } from 'next/image' import Image from 'next/image' import { useTranslations } from 'next-intl' @@ -80,7 +81,7 @@ const CarouselCTA = ({ {/* Close button or pink dot indicator for perk claims */} {isPerkClaim ? (
-
+
) : (