diff --git a/src/app/api/exchange-rate/route.ts b/src/app/api/exchange-rate/route.ts index 3c061ba43..961b4eb62 100644 --- a/src/app/api/exchange-rate/route.ts +++ b/src/app/api/exchange-rate/route.ts @@ -204,7 +204,10 @@ async function fetchDirectFromFrankfurter(from: string, to: string): Promise { diff --git a/src/components/ExchangeRate/index.tsx b/src/components/ExchangeRate/index.tsx index c403495af..27c344819 100644 --- a/src/components/ExchangeRate/index.tsx +++ b/src/components/ExchangeRate/index.tsx @@ -3,6 +3,7 @@ import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import useGetExchangeRate, { type IExchangeRate } from '@/hooks/useGetExchangeRate' import { useExchangeRate } from '@/hooks/useExchangeRate' import { SYMBOLS_BY_CURRENCY_CODE } from '@/hooks/useCurrency' +import { applyBridgeCrossCurrencyFee } from '@/utils/bridge.utils' // constants for exchange rate messages, specific to ExchangeRate component const APPROXIMATE_VALUE_MESSAGE = @@ -54,16 +55,26 @@ const ExchangeRate = ({ moreInfoText = `Exchange rates apply when converting to ${toCurrency}` } + const currency = nonEuroCurrency || toCurrency + // calculate local currency amount if provided + // bake in the 0.5% Bridge developer fee for cross-currency pairs so the + // displayed "amount you will receive" matches what Bridge actually delivers. + // NOTE: this component is used for Bridge offramp / bank-claim flows where the + // on-chain source is always USDC (even though the UI sourceCurrency prop defaults + // to 'USD' for display/rate-fetch purposes). Pass 'USDC' explicitly to the fee + // helper — it mirrors backend `getBridgeDeveloperFeeParams` where 'usd' is the + // fee-free fiat rail and 'usdc' is the stablecoin that incurs the 0.5% fee when + // crossing currencies. let localCurrencyAmount: string | null = null if (amountToConvert && rate && rate > 0) { const amount = parseFloat(amountToConvert) if (!isNaN(amount) && amount > 0) { - localCurrencyAmount = (amount * rate).toFixed(2) + const gross = amount * rate + const net = applyBridgeCrossCurrencyFee(gross, 'USDC', currency) + localCurrencyAmount = net.toFixed(2) } } - - const currency = nonEuroCurrency || toCurrency const currencySymbol = SYMBOLS_BY_CURRENCY_CODE[currency] || currency return ( diff --git a/src/components/Global/ExchangeRateWidget/index.tsx b/src/components/Global/ExchangeRateWidget/index.tsx index 98be84868..6ba07ef6d 100644 --- a/src/components/Global/ExchangeRateWidget/index.tsx +++ b/src/components/Global/ExchangeRateWidget/index.tsx @@ -2,6 +2,7 @@ import CurrencySelect from '@/components/LandingPage/CurrencySelect' import countryCurrencyMappings from '@/constants/countryCurrencyMapping' import { useDebounce } from '@/hooks/useDebounce' import { useExchangeRate } from '@/hooks/useExchangeRate' +import { applyBridgeCrossCurrencyFee, reverseBridgeCrossCurrencyFee } from '@/utils/bridge.utils' import Image from 'next/image' import { useRouter, useSearchParams } from 'next/navigation' import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -43,6 +44,24 @@ const ExchangeRateWidget: FC = ({ ctaLabel, ctaIcon, c const debouncedSourceAmount = useDebounce(sourceAmount, 500) + // Bridge charges a 0.5% developer fee on cross-currency transfers (non-USD ↔ non-USD). + // The hook returns gross `source × rate`; we display net so "Recipient Gets" matches + // what Bridge actually delivers. USD pairs pass through unchanged. + const netDestinationAmount = useMemo(() => { + if (typeof destinationAmount !== 'number') return destinationAmount + return applyBridgeCrossCurrencyFee(destinationAmount, sourceCurrency, destinationCurrency) + }, [destinationAmount, sourceCurrency, destinationCurrency]) + + // Track whether the user is actively typing in the destination field so we can + // echo their input verbatim instead of formatting a net value over it. + const [isEditingDestination, setIsEditingDestination] = useState(false) + + const netDestinationDisplayValue = useMemo(() => { + if (isEditingDestination) return getDestinationDisplayValue() + if (netDestinationAmount === '' || typeof netDestinationAmount !== 'number') return '' + return netDestinationAmount.toFixed(2) + }, [isEditingDestination, getDestinationDisplayValue, netDestinationAmount]) + // Function to update URL parameters const updateUrlParams = useCallback( (params: { from?: string; to?: string; amount?: number }) => { @@ -95,13 +114,16 @@ const ExchangeRateWidget: FC = ({ ctaLabel, ctaIcon, c const swapCurrencies = useCallback(() => { setIsSwapping(true) + setIsEditingDestination(false) skipNextDebounceSyncRef.current = true + // Use the displayed net amount as the new source so post-swap values match + // what the user saw in "Recipient Gets" before swapping. const newAmount = - typeof destinationAmount === 'number' && destinationAmount > 0 - ? Math.round(destinationAmount * 100) / 100 + typeof netDestinationAmount === 'number' && netDestinationAmount > 0 + ? Math.round(netDestinationAmount * 100) / 100 : undefined updateUrlParams({ from: destinationCurrency, to: sourceCurrency, amount: newAmount }) - }, [sourceCurrency, destinationCurrency, destinationAmount, updateUrlParams]) + }, [sourceCurrency, destinationCurrency, netDestinationAmount, updateUrlParams]) // clear swapping state once exchange rate hook finishes recalculating useEffect(() => { @@ -162,6 +184,7 @@ const ExchangeRateWidget: FC = ({ ctaLabel, ctaIcon, c value={sourceAmount === '' ? '' : sourceAmount} onChange={(e) => { const inputValue = e.target.value + setIsEditingDestination(false) if (inputValue === '') { handleSourceAmountChange('') } else { @@ -212,14 +235,21 @@ const ExchangeRateWidget: FC = ({ ctaLabel, ctaIcon, c { const inputValue = e.target.value + setIsEditingDestination(true) if (inputValue === '') { handleDestinationAmountChange('', '') } else { const value = parseFloat(inputValue) - handleDestinationAmountChange(inputValue, isNaN(value) ? '' : value) + // User typed a net "Recipient Gets" value — gross it up + // before handing to the hook so the source amount is + // computed from the gross equivalent (net / (1 - fee) / rate). + const grossValue = isNaN(value) + ? '' + : reverseBridgeCrossCurrencyFee(value, sourceCurrency, destinationCurrency) + handleDestinationAmountChange(inputValue, grossValue) } }} type="number" diff --git a/src/constants/payment.consts.ts b/src/constants/payment.consts.ts index 699b3791e..44c44856c 100644 --- a/src/constants/payment.consts.ts +++ b/src/constants/payment.consts.ts @@ -19,6 +19,10 @@ export const MIN_MANTECA_WITHDRAW_AMOUNT = 1 export const MIN_MANTECA_QR_PAYMENT_AMOUNT = 0.1 // Manteca provider minimum export const MAX_QR_PAYMENT_AMOUNT_FOREIGN = 2000 // max per transaction for foreign users +// Bridge developer fee applied to cross-currency (non-USD) transfers. +// Must match backend BRIDGE_DEVELOPER_FEE_PERCENT in peanut-api-ts/src/bridge/consts.ts +export const BRIDGE_DEVELOPER_FEE_RATE = 0.005 + /** * validate if amount meets minimum requirement for a payment method * @param amount - amount in USD diff --git a/src/utils/__tests__/bridge.utils.test.ts b/src/utils/__tests__/bridge.utils.test.ts index 6ca21339e..7fd727cf5 100644 --- a/src/utils/__tests__/bridge.utils.test.ts +++ b/src/utils/__tests__/bridge.utils.test.ts @@ -1,8 +1,10 @@ import { + applyBridgeCrossCurrencyFee, getCurrencyConfig, getOfframpCurrencyConfig, getPaymentRailDisplayName, getMinimumAmount, + reverseBridgeCrossCurrencyFee, } from '../bridge.utils' describe('bridge.utils', () => { @@ -201,6 +203,101 @@ describe('bridge.utils', () => { }) }) + describe('applyBridgeCrossCurrencyFee', () => { + // These tests mirror REAL caller usage: the Bridge side of the transfer + // is the USDC stablecoin (not the 'USD' fiat display code). Callers must + // pass 'USDC' so the fee helper matches backend `getBridgeDeveloperFeeParams`. + + it('applies 0.5% fee for EUR → USDC (onramp EUR deposit)', () => { + expect(applyBridgeCrossCurrencyFee(100, 'EUR', 'USDC')).toBeCloseTo(99.5, 10) + }) + + it('applies 0.5% fee for USDC → EUR (offramp to EUR bank)', () => { + expect(applyBridgeCrossCurrencyFee(100, 'USDC', 'EUR')).toBeCloseTo(99.5, 10) + }) + + it('applies 0.5% fee for GBP → USDC', () => { + expect(applyBridgeCrossCurrencyFee(100, 'GBP', 'USDC')).toBeCloseTo(99.5, 10) + }) + + it('applies 0.5% fee for MXN → USDC', () => { + expect(applyBridgeCrossCurrencyFee(100, 'MXN', 'USDC')).toBeCloseTo(99.5, 10) + }) + + it('applies 0.5% fee for USDC → MXN (offramp to Mexican bank)', () => { + expect(applyBridgeCrossCurrencyFee(100, 'USDC', 'MXN')).toBeCloseTo(99.5, 10) + }) + + it('does not apply fee for USD → USDC (fiat rail ↔ stablecoin is fee-free)', () => { + expect(applyBridgeCrossCurrencyFee(100, 'USD', 'USDC')).toBe(100) + }) + + it('does not apply fee for USDC → USD', () => { + expect(applyBridgeCrossCurrencyFee(100, 'USDC', 'USD')).toBe(100) + }) + + it('does not apply fee when either side is USD (EUR → USD)', () => { + expect(applyBridgeCrossCurrencyFee(100, 'EUR', 'USD')).toBe(100) + }) + + it('is case-insensitive', () => { + expect(applyBridgeCrossCurrencyFee(100, 'eur', 'usdc')).toBeCloseTo(99.5, 10) + expect(applyBridgeCrossCurrencyFee(100, 'Usd', 'Usdc')).toBe(100) + }) + + it('matches the real onramp display-quote math (EUR 500 @ 1.167)', () => { + // 500 EUR × 1.167 rate = 583.50 gross USDC + // after 0.5% Bridge fee = 580.5825 USDC delivered + const gross = 500 * 1.167 + const net = applyBridgeCrossCurrencyFee(gross, 'EUR', 'USDC') + expect(net).toBeCloseTo(580.5825, 4) + }) + + it('handles zero and negative amounts without surprises', () => { + expect(applyBridgeCrossCurrencyFee(0, 'EUR', 'USDC')).toBe(0) + expect(applyBridgeCrossCurrencyFee(-100, 'EUR', 'USDC')).toBeCloseTo(-99.5, 10) + }) + }) + + describe('reverseBridgeCrossCurrencyFee', () => { + // Invariant: apply(reverse(net)) ≈ net for any amount & currency pair. + // Guards against the classic algebra bug of using `net * (1 + rate)` + // instead of `net / (1 - rate)` — those differ by rate² (~0.0025%). + + it('reverse(99.5) for EUR → USDC yields exactly 100 (not 99.9975)', () => { + // The canonical sanity check: the naive `net * (1 + rate)` = 99.9975 + // would under-shoot. Correct inverse `net / (1 - rate)` lands on 100. + expect(reverseBridgeCrossCurrencyFee(99.5, 'EUR', 'USDC')).toBeCloseTo(100, 10) + }) + + it.each([0.01, 1, 100, 999.99, 1_000_000])('apply(reverse(%f)) round-trips for EUR → USDC', (amount) => { + const gross = reverseBridgeCrossCurrencyFee(amount, 'EUR', 'USDC') + expect(applyBridgeCrossCurrencyFee(gross, 'EUR', 'USDC')).toBeCloseTo(amount, 4) + }) + + it.each([ + ['EUR', 'USDC'], + ['USDC', 'EUR'], + ['GBP', 'USDC'], + ['MXN', 'USDC'], + ['USDC', 'MXN'], + ])('apply(reverse(100)) round-trips for %s → %s', (src, dst) => { + const gross = reverseBridgeCrossCurrencyFee(100, src, dst) + expect(applyBridgeCrossCurrencyFee(gross, src, dst)).toBeCloseTo(100, 10) + }) + + it('passes USD pairs through unchanged (no fee to reverse)', () => { + expect(reverseBridgeCrossCurrencyFee(100, 'USD', 'USDC')).toBe(100) + expect(reverseBridgeCrossCurrencyFee(100, 'USDC', 'USD')).toBe(100) + expect(reverseBridgeCrossCurrencyFee(100, 'EUR', 'USD')).toBe(100) + }) + + it('is case-insensitive', () => { + expect(reverseBridgeCrossCurrencyFee(99.5, 'eur', 'usdc')).toBeCloseTo(100, 10) + expect(reverseBridgeCrossCurrencyFee(100, 'Usd', 'Usdc')).toBe(100) + }) + }) + describe('payment rail differences between onramp and offramp', () => { it('should use different ACH payment rails for US onramp vs offramp', () => { const onrampConfig = getCurrencyConfig('US', 'onramp') diff --git a/src/utils/bridge.utils.ts b/src/utils/bridge.utils.ts index ba9cb7eba..a4ee6ce13 100644 --- a/src/utils/bridge.utils.ts +++ b/src/utils/bridge.utils.ts @@ -1,4 +1,5 @@ import { countryData as ALL_METHODS_DATA, type CountryData } from '@/components/AddMoney/consts' +import { BRIDGE_DEVELOPER_FEE_RATE } from '@/constants/payment.consts' import { type Account, AccountType } from '@/interfaces' export interface CurrencyConfig { @@ -62,6 +63,53 @@ export const getCurrencySymbol = (currency: string): string => { return symbols[currency.toLowerCase()] || currency.toUpperCase() } +/** + * Apply the Bridge developer fee to a cross-currency quote. + * + * Bridge charges a 0.5% developer fee on any transfer that crosses a + * currency boundary (i.e. neither side is USD). USD↔USDC is fee-free. + * Mirrors backend `getBridgeDeveloperFeeParams` in peanut-api-ts. + * + * @param amount - Gross amount computed from the raw exchange rate + * @param srcCurrency - Source currency code (case-insensitive) + * @param dstCurrency - Destination currency code (case-insensitive) + * @returns Net amount after fee deduction, or unchanged amount if either side is USD + */ +export const applyBridgeCrossCurrencyFee = (amount: number, srcCurrency: string, dstCurrency: string): number => { + const src = (srcCurrency ?? '').toLowerCase() + const dst = (dstCurrency ?? '').toLowerCase() + if (src === 'usd' || dst === 'usd') { + return amount + } + return amount * (1 - BRIDGE_DEVELOPER_FEE_RATE) +} + +/** + * Inverse of {@link applyBridgeCrossCurrencyFee}. + * + * Given a net (post-fee) destination amount, return the gross amount that + * would produce it. Used when the user types a "Recipient Gets" value and + * we need the pre-fee gross to feed back into rate math. USD pairs pass + * through unchanged (no fee, so gross === net). + * + * Math note: since `apply(gross) = gross * (1 - rate)`, the reverse is + * `gross = net / (1 - rate)` — NOT `net * (1 + rate)`, which would + * under-shoot by `rate²` (e.g. reversing 99.5 must yield exactly 100). + * + * @param netAmount - Net amount after Bridge dev fee + * @param srcCurrency - Source currency code (case-insensitive) + * @param dstCurrency - Destination currency code (case-insensitive) + * @returns Gross amount before fee, or unchanged amount if either side is USD + */ +export const reverseBridgeCrossCurrencyFee = (netAmount: number, srcCurrency: string, dstCurrency: string): number => { + const src = (srcCurrency ?? '').toLowerCase() + const dst = (dstCurrency ?? '').toLowerCase() + if (src === 'usd' || dst === 'usd') { + return netAmount + } + return netAmount / (1 - BRIDGE_DEVELOPER_FEE_RATE) +} + /** * Get minimum amount for onramp operations by country */