DEV Community

Cover image for Show Users the Real Cost: Building Honest Pricing Into a Crypto Swap UI
Swapcore.Exchange
Swapcore.Exchange

Posted on

Show Users the Real Cost: Building Honest Pricing Into a Crypto Swap UI

If your product quotes crypto conversions, your UI is making a claim about cost whether you intended to or not. Most make it badly — and the fix is a handful of fields you probably already have access to.

Here's what an honest quote object looks like and how to compute it.

Why fee: 0.005 is not a price

A swap has four cost components. Most quote objects surface one.

1. input network fee    — paid by the user to their own chain (never yours)
2. output network fee   — deducted from the payout
3. service fee          — your explicit charge
4. spread               — quoted rate vs. market rate
Enter fullscreen mode Exit fullscreen mode

Display only component 3 and your interface is technically accurate and practically misleading. Users discover the difference by comparing the balance change in their wallet against what your UI promised, and they discover it after the transaction is irreversible.

The number a user cares about is a single figure: what fraction of market value did this operation consume. Everything else is internal accounting.

Computing the effective rate

def effective_cost_bps(amount_in, amount_out_received, mid_market_rate):
    """
    Total cost in basis points, relative to mid-market.
    Captures spread + service fee + output network fee together.
    """
    expected = amount_in * mid_market_rate
    if expected <= 0:
        raise ValueError("mid_market_rate and amount_in must be positive")
    return (expected - amount_out_received) / expected * 10_000
Enter fullscreen mode Exit fullscreen mode

Two implementation notes that matter more than the formula.

Your reference rate needs a timestamp and a source. A cost figure computed against a rate from thirty seconds ago is noise on a volatile pair. Pin the rate you quoted against, store it with the quote, and surface both. cost_bps: 180 with no reference rate is an unfalsifiable claim.

Decide gross or net and label it. If your amount_out is before the output network fee is deducted, say so in the field name — amount_out_gross — because a user comparing your quote against their wallet balance is comparing net. This single ambiguity generates more "you charged me more than you said" tickets than actual overcharging does.

A quote object that doesn't lie

{
  "pair": "ETH_USDT",
  "amount_in": "1.0",
  "rate_type": "floating",

  "reference_rate": "3000.00",
  "reference_source": "coingecko",
  "reference_at": "2026-09-28T01:40:12Z",

  "amount_out_gross": "2955.00",
  "output_network_fee": "15.00",
  "amount_out_net": "2940.00",

  "service_fee_bps": 50,
  "spread_bps": 100,
  "total_cost_bps": 200,

  "quote_expires_at": "2026-09-28T01:50:12Z",
  "minimum_in": "0.012"
}
Enter fullscreen mode Exit fullscreen mode

Points worth arguing about:

spread_bps should be derived, not asserted. Compute it as total_cost_bps − service_fee_bps − (output_network_fee / expected × 10000). If you assert it independently, the three numbers will drift out of agreement and someone will notice.

total_cost_bps is the headline field. It's the only one that maps to what the user experiences. If your UI shows a percentage anywhere, it should be this one.

quote_expires_at, not valid_for_seconds. A duration requires the client to know when the quote was issued, and clock skew makes that unreliable. An absolute timestamp doesn't.

Minimums are dynamic and most codebases hardcode them

minimum_in exists because of component 2: if the output network fee exceeds the output value, the operation is uneconomic. Network fees move with congestion, so the minimum moves with them.

def minimum_input(output_network_fee, rate, max_fee_fraction=0.05):
    """
    Smallest input where the output fee stays under max_fee_fraction
    of the payout. Recompute per quote — never cache across sessions.
    """
    min_output = output_network_fee / max_fee_fraction
    return min_output / rate
Enter fullscreen mode Exit fullscreen mode

A hardcoded minimum fails in both directions — rejecting valid amounts when fees fall, accepting uneconomic ones when they spike — and it fails silently. Take it from the live quote every time.

Fixed vs floating: same schema, different honesty requirement

A fixed rate legitimately costs more, because the platform absorbs price movement for the quote window. Your UI should say that in one line rather than presenting the two as if one is simply worse value.

Floating — 1.2% est. cost. Final amount set when your deposit confirms.
Fixed    — 2.1% cost. Exact amount locked for 9:47.
Enter fullscreen mode Exit fullscreen mode

Two things this framing gets right that a bare toggle doesn't: the cost difference is visible, and the reason for it is attributed to something the user chose.

One rule worth encoding: do not offer a fixed rate as the default when the source chain is slow. A locked window funded by a Bitcoin transaction will expire. Either shorten the choice to floating, or extend the window and price it.

Comparing against other providers

If you aggregate or display competing quotes, three requirements to be honest about it:

  • Same timestamp. Quotes fetched more than a few seconds apart on a volatile pair aren't comparable. Fetch in parallel, record the fetch time, and show it.
  • Same net basis. If one provider quotes gross and another net, normalise before comparing or the comparison is meaningless.
  • Same rate type. Ranking a fixed quote against floating quotes makes the fixed provider look expensive for offering more.

Skip any of these and you have a marketing table, not a price comparison.

The test worth writing

def test_cost_fields_reconcile(quote):
    expected = quote.amount_in * quote.reference_rate
    fee_bps = quote.output_network_fee / expected * 10_000
    derived = quote.service_fee_bps + quote.spread_bps + fee_bps

    assert abs(derived - quote.total_cost_bps) < 1  # bps tolerance
    assert quote.amount_out_net == (
        quote.amount_out_gross - quote.output_network_fee
    )
Enter fullscreen mode Exit fullscreen mode

If that test can't pass, your pricing fields don't describe the same transaction, and the discrepancy will surface as a support ticket rather than a failing build.


Disclosure: I work on SwapCore (swapcore.exchange), a non-custodial instant exchange. The schema above is what we'd defend; the reconciliation test is the part I'd argue every provider in this category should be able to pass.

Top comments (1)