From c18514d63693236c819b68040ff3aee539fbb579 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 15 Apr 2026 12:04:38 +0100 Subject: [PATCH 1/4] fix: bake 0.5% Bridge fee into displayed "you'll receive" amount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users were promised more than Bridge actually delivered because the UI computed amount * rawExchangeRate without deducting the fee. Example: €500 deposit showed $583.50 but user received $580.58. Fixes onramp (AddMoneyBankDetails) and offramp (ExchangeRate, WithdrawBankPage, ConfirmBankClaimView) quote displays. USD↔USDC unaffected (0% fee). Manteca flow unaffected (already correct). --- .../components/AddMoneyBankDetails.tsx | 36 +++++++++++++++++-- src/components/ExchangeRate/index.tsx | 12 +++++-- src/constants/payment.consts.ts | 4 +++ src/utils/bridge.utils.ts | 22 ++++++++++++ 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/components/AddMoney/components/AddMoneyBankDetails.tsx b/src/components/AddMoney/components/AddMoneyBankDetails.tsx index d3c28cc0f..3a23b08e6 100644 --- a/src/components/AddMoney/components/AddMoneyBankDetails.tsx +++ b/src/components/AddMoney/components/AddMoneyBankDetails.tsx @@ -10,7 +10,7 @@ import { useCallback, useEffect, useMemo } from 'react' import { countryData } from '@/components/AddMoney/consts' import { formatCurrencyAmount } from '@/utils/currency' import { formatBankAccountDisplay } from '@/utils/format.utils' -import { getCurrencyConfig, getCurrencySymbol } from '@/utils/bridge.utils' +import { applyBridgeCrossCurrencyFee, getCurrencyConfig, getCurrencySymbol } from '@/utils/bridge.utils' import { RequestFulfillmentBankFlowStep, useRequestFulfillmentFlow } from '@/context/RequestFulfillmentFlowContext' import { formatAmount } from '@/utils/general.utils' import InfoCard from '@/components/Global/InfoCard' @@ -20,6 +20,25 @@ import { Button } from '@/components/0_Bruddle/Button' import { useExchangeRate } from '@/hooks/useExchangeRate' import { useQueryState, parseAsString } from 'nuqs' +/** + * TODO(architecture): Quote math is computed client-side instead of trusting backend. + * + * This file (and ExchangeRate component, MantecaDepositShareDetails, etc.) each + * re-derive "amount user will receive" by multiplying raw exchange rate by amount. + * This caused a production bug where UI promised more than Bridge actually delivered + * because the 0.5% developer fee was not baked into the displayed rate. + * + * PROPER FIX: Add backend /bridge/onramp/quote and /bridge/offramp/quote endpoints + * that return { gross, fee, net, exchangeRate }. UI displays `net`. This makes fee + * changes propagate automatically and eliminates this whole class of bugs. + * + * Related: backend BRIDGE_DEVELOPER_FEE_PERCENT constant in peanut-api-ts must be + * kept in sync manually with BRIDGE_DEVELOPER_FEE_RATE in payment.consts.ts. + * A shared types package / OpenAPI spec would enforce this at compile time. + * + * See PR description of fix/bridge-fee-display-quote for full writeup. + */ + interface IAddMoneyBankDetails { flow?: 'add-money' | 'request-fulfillment' } @@ -113,11 +132,22 @@ export default function AddMoneyBankDetails({ flow = 'add-money' }: IAddMoneyBan if (baseAmount === null) return amount if (isNonUsdCurrency) { // for non-usd deposits, show the approximate amount in usd - return '≈ ' + usdCurrencySymbol + ' ' + formatAmount(baseAmount * exchangeRate) + // bake in the 0.5% Bridge developer fee so displayed amount matches + // what Bridge actually delivers (applyBridgeCrossCurrencyFee is a no-op for USD) + const grossUsd = baseAmount * exchangeRate + const netUsd = applyBridgeCrossCurrencyFee(grossUsd, onrampCurrency, 'USD') + return '≈ ' + usdCurrencySymbol + ' ' + formatAmount(netUsd) } return '≈ ' + currencySymbolBasedOnCountry + ' ' + formatAmount(baseAmount * exchangeRate) }, - [exchangeRate, isNonUsdCurrency, usdCurrencySymbol, currencySymbolBasedOnCountry, parseAmountToNumber] + [ + exchangeRate, + isNonUsdCurrency, + usdCurrencySymbol, + currencySymbolBasedOnCountry, + parseAmountToNumber, + onrampCurrency, + ] ) useEffect(() => { diff --git a/src/components/ExchangeRate/index.tsx b/src/components/ExchangeRate/index.tsx index c403495af..89ceb7cc2 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,21 @@ 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 + // (applyBridgeCrossCurrencyFee is a no-op when either side is USD) 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, sourceCurrency, currency) + localCurrencyAmount = net.toFixed(2) } } - - const currency = nonEuroCurrency || toCurrency const currencySymbol = SYMBOLS_BY_CURRENCY_CODE[currency] || currency return ( 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/bridge.utils.ts b/src/utils/bridge.utils.ts index ba9cb7eba..b1f56aebf 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,27 @@ 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) +} + /** * Get minimum amount for onramp operations by country */ From 2e9a140cc27b57adefc626e37f81a9eb0a64ebb7 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 15 Apr 2026 12:33:17 +0100 Subject: [PATCH 2/4] fix: apply Peanut dev fee to ExchangeRateWidget destination amount Landing-page calculator showed raw-rate amount, promising more than Bridge would actually deliver. Same class of bug as PR #1889. --- .../Global/ExchangeRateWidget/index.tsx | 56 +++++++++++++++++-- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/src/components/Global/ExchangeRateWidget/index.tsx b/src/components/Global/ExchangeRateWidget/index.tsx index 98be84868..87a3dbaef 100644 --- a/src/components/Global/ExchangeRateWidget/index.tsx +++ b/src/components/Global/ExchangeRateWidget/index.tsx @@ -1,13 +1,30 @@ import CurrencySelect from '@/components/LandingPage/CurrencySelect' import countryCurrencyMappings from '@/constants/countryCurrencyMapping' +import { BRIDGE_DEVELOPER_FEE_RATE } from '@/constants/payment.consts' import { useDebounce } from '@/hooks/useDebounce' import { useExchangeRate } from '@/hooks/useExchangeRate' +import { applyBridgeCrossCurrencyFee } 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' import { Icon, type IconName } from '../Icons/Icon' import { Button } from '@/components/0_Bruddle/Button' +/** + * Gross up a net destination amount by the Bridge cross-currency fee. + * Inverse of applyBridgeCrossCurrencyFee — used when the user types a + * "Recipient Gets" (net) value and we need the gross equivalent to + * feed back into rate math. USD pairs pass through unchanged. + */ +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) +} + interface IExchangeRateWidgetProps { ctaLabel: string ctaIcon: IconName @@ -43,6 +60,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 +130,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 +200,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 +251,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" From 9a413de466693f22a62894945d44ba7511f0a84d Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 15 Apr 2026 12:54:39 +0100 Subject: [PATCH 3/4] fix: pass USDC (not USD) to fee helper so fee actually applies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit caught that the fee was short-circuiting because callers passed 'USD' as a display stand-in. Helper mirrors backend which uses 'usd' for fiat and 'usdc' for the stablecoin — pass the real Bridge currency code. Adds test coverage for the EUR/MXN/GBP <-> USDC paths that exercises the real call pattern. --- .../components/AddMoneyBankDetails.tsx | 6 +- src/components/ExchangeRate/index.tsx | 11 +++- src/utils/__tests__/bridge.utils.test.ts | 57 +++++++++++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/components/AddMoney/components/AddMoneyBankDetails.tsx b/src/components/AddMoney/components/AddMoneyBankDetails.tsx index 3a23b08e6..d61d32ae2 100644 --- a/src/components/AddMoney/components/AddMoneyBankDetails.tsx +++ b/src/components/AddMoney/components/AddMoneyBankDetails.tsx @@ -135,7 +135,11 @@ export default function AddMoneyBankDetails({ flow = 'add-money' }: IAddMoneyBan // bake in the 0.5% Bridge developer fee so displayed amount matches // what Bridge actually delivers (applyBridgeCrossCurrencyFee is a no-op for USD) const grossUsd = baseAmount * exchangeRate - const netUsd = applyBridgeCrossCurrencyFee(grossUsd, onrampCurrency, 'USD') + // NOTE: pass 'USDC' (the real Bridge destination) not 'USD' — the helper + // mirrors backend `getBridgeDeveloperFeeParams` which treats 'usd' as the + // fiat rail (fee-free USD↔USDC) and 'usdc' as the stablecoin. The "$" shown + // to the user is just display; the on-chain transfer is EUR/GBP/MXN → USDC. + const netUsd = applyBridgeCrossCurrencyFee(grossUsd, onrampCurrency, 'USDC') return '≈ ' + usdCurrencySymbol + ' ' + formatAmount(netUsd) } return '≈ ' + currencySymbolBasedOnCountry + ' ' + formatAmount(baseAmount * exchangeRate) diff --git a/src/components/ExchangeRate/index.tsx b/src/components/ExchangeRate/index.tsx index 89ceb7cc2..27c344819 100644 --- a/src/components/ExchangeRate/index.tsx +++ b/src/components/ExchangeRate/index.tsx @@ -59,14 +59,19 @@ const ExchangeRate = ({ // 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 - // (applyBridgeCrossCurrencyFee is a no-op when either side is USD) + // 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) { const gross = amount * rate - const net = applyBridgeCrossCurrencyFee(gross, sourceCurrency, currency) + const net = applyBridgeCrossCurrencyFee(gross, 'USDC', currency) localCurrencyAmount = net.toFixed(2) } } diff --git a/src/utils/__tests__/bridge.utils.test.ts b/src/utils/__tests__/bridge.utils.test.ts index 6ca21339e..513001a84 100644 --- a/src/utils/__tests__/bridge.utils.test.ts +++ b/src/utils/__tests__/bridge.utils.test.ts @@ -1,4 +1,5 @@ import { + applyBridgeCrossCurrencyFee, getCurrencyConfig, getOfframpCurrencyConfig, getPaymentRailDisplayName, @@ -201,6 +202,62 @@ 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('payment rail differences between onramp and offramp', () => { it('should use different ACH payment rails for US onramp vs offramp', () => { const onrampConfig = getCurrencyConfig('US', 'onramp') From a8befc9ff4747f50752565b86cd100719f7ba23e Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Wed, 15 Apr 2026 13:07:47 +0100 Subject: [PATCH 4/4] docs+test: clarify Frankfurter spread vs dev fee, add reverse invariant test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frankfurter fallback simulates Bridge's FX spread; Peanut dev fee is applied separately by callers. Two fees that legitimately stack — the comment prevents future audit confusion (this caused two false "double counting" alarms during PR #1889 review). Adds invariant test for reverse fee helper to lock in the algebra. --- src/app/api/exchange-rate/route.ts | 5 ++- .../Global/ExchangeRateWidget/index.tsx | 18 +-------- src/utils/__tests__/bridge.utils.test.ts | 40 +++++++++++++++++++ src/utils/bridge.utils.ts | 26 ++++++++++++ 4 files changed, 71 insertions(+), 18 deletions(-) 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 { - const src = (srcCurrency ?? '').toLowerCase() - const dst = (dstCurrency ?? '').toLowerCase() - if (src === 'usd' || dst === 'usd') { - return netAmount - } - return netAmount / (1 - BRIDGE_DEVELOPER_FEE_RATE) -} - interface IExchangeRateWidgetProps { ctaLabel: string ctaIcon: IconName diff --git a/src/utils/__tests__/bridge.utils.test.ts b/src/utils/__tests__/bridge.utils.test.ts index 513001a84..7fd727cf5 100644 --- a/src/utils/__tests__/bridge.utils.test.ts +++ b/src/utils/__tests__/bridge.utils.test.ts @@ -4,6 +4,7 @@ import { getOfframpCurrencyConfig, getPaymentRailDisplayName, getMinimumAmount, + reverseBridgeCrossCurrencyFee, } from '../bridge.utils' describe('bridge.utils', () => { @@ -258,6 +259,45 @@ describe('bridge.utils', () => { }) }) + 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 b1f56aebf..a4ee6ce13 100644 --- a/src/utils/bridge.utils.ts +++ b/src/utils/bridge.utils.ts @@ -84,6 +84,32 @@ export const applyBridgeCrossCurrencyFee = (amount: number, srcCurrency: string, 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 */