Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/app/api/exchange-rate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,10 @@ async function fetchDirectFromFrankfurter(from: string, to: string): Promise<num
}

const data = await response.json()
return data.rates[to] * 0.995 // Subtract 50bps
// Simulate Bridge's ~50bps FX spread when falling back to Frankfurter mid-market rates.
// This represents Bridge's own take, NOT Peanut's developer fee — that's applied
// separately by callers via applyBridgeCrossCurrencyFee. The two fees stack.
return data.rates[to] * 0.995
} catch (error) {
console.error(`Frankfurter direct API exception for ${from}-${to}:`, error)
return null
Expand Down
40 changes: 37 additions & 3 deletions src/components/AddMoney/components/AddMoneyBankDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
}
Expand Down Expand Up @@ -113,11 +132,26 @@ 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
// 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)
},
[exchangeRate, isNonUsdCurrency, usdCurrencySymbol, currencySymbolBasedOnCountry, parseAmountToNumber]
[
exchangeRate,
isNonUsdCurrency,
usdCurrencySymbol,
currencySymbolBasedOnCountry,
parseAmountToNumber,
onrampCurrency,
]
)

useEffect(() => {
Expand Down
17 changes: 14 additions & 3 deletions src/components/ExchangeRate/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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 (
Expand Down
40 changes: 35 additions & 5 deletions src/components/Global/ExchangeRateWidget/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -43,6 +44,24 @@ const ExchangeRateWidget: FC<IExchangeRateWidgetProps> = ({ 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<number | ''>(() => {
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<string>(() => {
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 }) => {
Expand Down Expand Up @@ -95,13 +114,16 @@ const ExchangeRateWidget: FC<IExchangeRateWidgetProps> = ({ 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(() => {
Expand Down Expand Up @@ -162,6 +184,7 @@ const ExchangeRateWidget: FC<IExchangeRateWidgetProps> = ({ ctaLabel, ctaIcon, c
value={sourceAmount === '' ? '' : sourceAmount}
onChange={(e) => {
const inputValue = e.target.value
setIsEditingDestination(false)
if (inputValue === '') {
handleSourceAmountChange('')
} else {
Expand Down Expand Up @@ -212,14 +235,21 @@ const ExchangeRateWidget: FC<IExchangeRateWidgetProps> = ({ ctaLabel, ctaIcon, c
<input
min={0}
placeholder="0"
value={getDestinationDisplayValue()}
value={netDestinationDisplayValue}
onChange={(e) => {
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"
Expand Down
4 changes: 4 additions & 0 deletions src/constants/payment.consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
97 changes: 97 additions & 0 deletions src/utils/__tests__/bridge.utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {
applyBridgeCrossCurrencyFee,
getCurrencyConfig,
getOfframpCurrencyConfig,
getPaymentRailDisplayName,
getMinimumAmount,
reverseBridgeCrossCurrencyFee,
} from '../bridge.utils'

describe('bridge.utils', () => {
Expand Down Expand Up @@ -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')
Expand Down
48 changes: 48 additions & 0 deletions src/utils/bridge.utils.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
*/
Expand Down
Loading