A month ago I wrote about what a crypto app should do before you press "Send". There's one more thing that happens before that button, and most swap interfaces still get it wrong: the number they put in the biggest font.
Usually it's the exchange rate. 1 XMR = 0.12 ETH. Clean, comparable, easy to sort.
It's also the least useful figure on the screen.
What lands in the user's wallet is an amount. Between the rate and that amount sit several costs the rate doesn't show.
One quote, four costs
Cross-chain swaps make this easy to see, because each leg has its own costs. Take Monero to Ethereum. CoinMarketCal recently published a breakdown of XMR to ETH exchange fees and counted four separate costs behind a single quote:
XMR network fee → paid by the user, from their own wallet
Provider fees → trading fees charged by liquidity sources
Spread → gap between the market price and the offered rate
ETH payout fee → gas to send ETH to the user's address
Only the first one is under the user's control. The Monero fee depends on network load and transaction size, not on the amount sent, and wallets let you choose from four priority levels. Everything else is decided by the provider.
The Ethereum side has its own formula. According to ethereum.org, a transaction costs gas units multiplied by the base fee plus a priority fee. A plain ETH transfer uses 21,000 gas. The base fee can move by up to 12.5% per block with demand, so the same payout can cost noticeably more at a busy moment.
None of this shows up in "1 XMR = 0.12 ETH".
Better rate, worse result
Here's a simple case. Two quotes for a 2 XMR swap (the numbers are illustrative):
- Quote A shows a rate of 0.12000 ETH, charges a 0.1% trading fee, and takes a 0.002 ETH withdrawal fee afterwards.
- Quote B shows 0.11925 ETH, with every cost after the deposit already included.
Sort by rate and A wins. Now compute what actually arrives:
function rankByReceived(quotes) {
return quotes
.map((q) => ({
name: q.name,
received: Number(
(q.amountIn * q.rate * (1 - q.tradingFee) - q.fixedFees).toFixed(6)
),
}))
.sort((a, b) => b.received - a.received);
}
rankByReceived([
{ name: "A", amountIn: 2, rate: 0.12, tradingFee: 0.001, fixedFees: 0.002 },
{ name: "B", amountIn: 2, rate: 0.11925, tradingFee: 0, fixedFees: 0 },
]);
// [ { name: "B", received: 0.2385 }, { name: "A", received: 0.23776 } ]
B pays out more. The gap is small here, but a UI that ranks by rate picks the wrong option every time this pattern appears. And it appears a lot, because "no fees" usually means the fees moved into the spread or into a step that comes later.
The rate wasn't lying. It answered a question the user didn't ask.
Small swaps hurt the most
Network fees are roughly fixed per transaction. They don't scale with the amount, so their share grows as the swap gets smaller.
const feeShare = (receivedEth, fixedFeesEth) => fixedFeesEth / receivedEth;
feeShare(0.24, 0.002); // ≈ 0.0083 → 0.8%
feeShare(0.03, 0.002); // ≈ 0.0667 → 6.7%
Same fees, eight times the impact. This is also why providers set minimum amounts: below a certain point, fees eat most of the order. A screen that shows only the rate hides this completely. A screen that shows the received amount makes it visible the moment someone types a small number.
What the quote object should carry
If I were designing the API behind a swap screen, the response would put the received amount first and say what's inside it:
{
"amountIn": { "value": "2.0", "asset": "XMR" },
"amountOut": { "value": "0.2385", "asset": "ETH" },
"rateMode": "floating",
"includedInQuote": ["spread", "provider fees", "ETH payout fee"],
"paidSeparately": ["XMR network fee"],
"expiresAt": "2026-10-15T12:00:00Z"
}
amountOut is the headline. Anyone who wants the rate can derive it.
includedInQuote and paidSeparately matter more than they look. Two quotes are only comparable if they cover the same costs. Without these fields, the frontend has no way to tell whether it's comparing like with like.
rateMode tells the user what kind of promise they're getting. A floating quote follows the market until execution and can land slightly above or below the estimate. A fixed quote locks the amount for a limited window, usually with a small buffer priced in for volatility. Both are fine. Hiding which one it is isn't.
expiresAt is there for fixed quotes. A locked amount with no visible deadline is a support ticket waiting to happen.
Thin liquidity widens the gap
For pairs with deep markets, the difference between the rate and the received amount stays small. For thinner pairs it grows, and Monero is a good example. Kraken stopped XMR trading and deposits for clients in the European Economic Area from October 31, 2024. Fewer venues usually means wider spreads, and the spread is exactly the cost a rate-first screen never shows.
So the less liquid the asset, the more misleading the headline rate gets. That's the opposite of what users need.
A short checklist for swap screens
- Lead with the amount the user will receive.
- Say whether that number is an estimate or a guaranteed amount, and for how long.
- List what's already included and what the user still pays.
- Flag amounts close to the minimum, where fixed fees dominate.
- Compare quotes by received amount, never by rate.
None of this needs new infrastructure. The provider already calculates every one of these numbers to build the quote. The only question is whether the interface passes them on.
People spend what arrives in their wallet. The screen should start there.
Top comments (1)
Re-ran your two quotes and the numbers hold: A gives 2 × 0.12 × 0.999 − 0.002 = 0.23776, B gives 0.2385, and the fee shares at 0.24 and 0.03 ETH come out at 0.83% and 6.67%.
One thing the ranking function should say out loud: because A has a fixed fee and B does not, the winner depends on the amount. Setting the two equal, A beats B only above 0.002 / (0.12 × 0.999 − 0.11925) ≈ 3.17 XMR, so at 2 XMR B wins and at 5 XMR A wins. A UI that ranks once and caches the order will be wrong for some amounts, so the ranking has to be recomputed on every amount change, not just every rate refresh.
Also, since the payout fee is gas, a quote should carry an expiry for that line; otherwise "received" is only an estimate again a minute later.