Skip to content
LogoLogo

TIP-1005: Fix ask swap rounding loss

Abstract

This TIP fixes a rounding bug in the swapExactAmountIn function when filling ask orders. Due to double-rounding, the maker can receive slightly less quote tokens than the taker paid, causing tokens to be lost.

Motivation

When a taker swaps quote tokens for base tokens against an ask order, the following calculation occurs:

  1. Convert taker's amountIn (quote) to base: base_out = floor(amountIn / price)
  2. Credit maker with quote: makerReceives = ceil(base_out * price)

Due to the floor in step 1, makerReceives can be less than amountIn. For example:

  • Taker pays amountIn = 102001 quote at price 1.02 (tick 2000)
  • base_out = floor(102001 / 1.02) = 100000
  • makerReceives = ceil(100000 * 1.02) = 102000
  • 1 token is lost

This violates the zero-sum invariant: the taker pays more than the maker receives. It also means there is no canonical amount swapped—the trade for the maker is different from the trade for the taker.


Specification

Bug location

The bug is in _fillOrdersExactIn when processing ask orders (the baseForQuote = false path). Specifically, when a partial fill occurs:

  1. fillAmount (base) is calculated by rounding down: baseOut = (remainingIn * PRICE_SCALE) / price
  2. _fillOrder is called with fillAmount
  3. Inside _fillOrder, the maker's quote credit is re-derived: quoteAmount = ceil(fillAmount * price)

The re-derivation in step 3 loses the original remainingIn information.

Fix

For partial fills in the ask path, pass the actual remainingIn (quote) to _fillOrder and use it directly for the maker's credit, rather than re-deriving it from fillAmount.

The fix requires:

  1. Modify _fillOrder to accept an optional quoteOverride parameter for ask orders
  2. In _fillOrdersExactIn, when partially filling an ask, pass remainingIn as the quote override
  3. When quoteOverride is provided, use it directly for the maker's balance increment instead of computing ceil(fillAmount * price)

Reference implementation changes

The fix requires changes to two functions in docs/specs/src/StablecoinDEX.sol:

1. _fillOrder (line 551-556)

Add an optional quoteOverride parameter. When non-zero and the order is an ask, use quoteOverride directly for the maker's balance increment instead of computing ceil(fillAmount * price).

// Before:
uint128 quoteAmount =
    uint128((uint256(fillAmount) * uint256(price) + PRICE_SCALE - 1) / PRICE_SCALE);
balances[order.maker][book.quote] += quoteAmount;
 
// After:
uint128 quoteAmount = quoteOverride > 0
    ? quoteOverride
    : uint128((uint256(fillAmount) * uint256(price) + PRICE_SCALE - 1) / PRICE_SCALE);
balances[order.maker][book.quote] += quoteAmount;

2. _fillOrdersExactIn (line 923-926)

In the partial fill branch for asks, pass remainingIn as the quote override:

// Before:
orderId = _fillOrder(orderId, fillAmount);
 
// After (for partial fills where fillAmount == baseOut):
orderId = _fillOrder(orderId, fillAmount, remainingIn);

Affected code paths

  • _fillOrdersExactIn with baseForQuote = false (ask path), partial fill case only
  • Full fills are not affected because the quote amount is derived from order.remaining, not remainingIn
  • Bid swaps are not affected because the taker pays base tokens directly

Example: Before and after

Before (buggy):

amountIn = 102001 quote
base_out = floor(102001 / 1.02) = 100000
makerReceives = ceil(100000 * 1.02) = 102000
Lost: 1 token

After (fixed):

amountIn = 102001 quote
base_out = floor(102001 / 1.02) = 100000
makerReceives = 102001 (passed directly)
Lost: 0 tokens

Invariants

  • Zero-sum: for any swap, takerPaid == makerReceived (within the same token)
  • Taker receives floor(amountIn / price) base tokens (rounds in favor of protocol)
  • Maker receives exactly what taker paid in quote tokens