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:
- Convert taker's
amountIn(quote) to base:base_out = floor(amountIn / price) - 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 = 102001quote at price 1.02 (tick 2000) base_out = floor(102001 / 1.02) = 100000makerReceives = 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:
fillAmount(base) is calculated by rounding down:baseOut = (remainingIn * PRICE_SCALE) / price_fillOrderis called withfillAmount- 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:
- Modify
_fillOrderto accept an optionalquoteOverrideparameter for ask orders - In
_fillOrdersExactIn, when partially filling an ask, passremainingInas the quote override - When
quoteOverrideis provided, use it directly for the maker's balance increment instead of computingceil(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
_fillOrdersExactInwithbaseForQuote = false(ask path), partial fill case only- Full fills are not affected because the quote amount is derived from
order.remaining, notremainingIn - 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