| id | TIP-1005 |
|---|---|
| title | Fix ask swap rounding loss |
| description | A fix for a rounding bug in the Stablecoin DEX where partial fills on ask orders can cause small amounts of quote tokens to be lost. |
| authors | Dan Robinson |
| status | Draft |
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.
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.
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.
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)
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);_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
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
- 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