Assessment reports>WOOFi Swap>Medium findings>Halving spread in base-to-base may be unsafe
Category: Business Logic

Halving spread in base-to-base may be unsafe

Medium Severity
Medium Impact
Medium Likelihood

Description

In _swapBaseToBase, a base-to-base swap is executed by dividing the max spread of the two pairs, base-to-quote and quote-to-second-base, by two:

function _swapBaseToBase(
    address baseToken1,
    address baseToken2,
    uint256 base1Amount,
    uint256 minBase2Amount,
    address to,
    address rebateTo
) private nonReentrant whenNotPaused returns (uint256 base2Amount) {
    // [...]

    uint64 spread = _maxUInt64(state1.spread, state2.spread) / 2;
    uint16 feeRate = _maxUInt16(tokenInfos[baseToken1].feeRate, tokenInfos[baseToken2].feeRate);

    state1.spread = spread;
    state2.spread = spread;

    uint256 newBase1Price;
    (quoteAmount, newBase1Price) = _calcQuoteAmountSellBase(baseToken1, base1Amount, state1);

    // [...]

    uint256 newBase2Price;
    (base2Amount, newBase2Price) = _calcBaseAmountSellQuote(baseToken2, quoteAmount, state2);

    // [...]
}

Note that the swap-fee logic has been omitted from the above snippet to simplify the math below. Also, note that _sellBase and _sellQuote directly pass the state into the _calc* functions.

Assume that a user is swapping token A for token Q, which is the quote token, and then subsequently token Q for token B. Leaving out constant factors like decimals, let be the notional amount of A, defined as where is the amount of token A swapped and is the price.

With this, according to _calcQuoteAmountSellBase, for the sell base we have and then . Note that this is a quadratic polynomial in where the only occurrence of the spread is as a constant linear term in the coefficient for the first degree .

Similarly, according to _calcBaseAmountSellQuote, we have (note, this is a different ) and then . So, again substituting, we have .

Now, if we substitute in , the result becomes a fourth-degree polynomial in .

Now on the other hand, if they executed _swapBaseToBase instead of base-to-quote and then quote-to-base, then according to the above code snippet, the calculation will be the same except — let this just be . This means that the same expansion will follow in the calculation of in terms of , and so the difference will cancel out all the terms that do not depend on any .

So, with that simplification, the terms of that include some will sum to .

So, whether the base-to-base return is better than the base-to-quote-to-base case clearly depends on the exact quantities involved, and there are examples for both. As a quick example, if we assume the spread for token B were zero, then

  • The first term is always higher, because .

  • The second term cancels out because there is no dependence on .

  • The third term is always lower, because (note that the domain of is from zero to one, and the breakeven points are the roots at zero and 1.33).

This means that which is bigger depends entirely on whether is larger or smaller, because it will control whether the first or third term dominates and the liquidity coefficient is a completely free parameter.

Impact

Due to nonlinearity, the approximation spread = _maxUInt64(state1.spread, state2.spread) / 2 may produce a large error if there is a large difference in oracle parameters, especially the liquidity coefficient k, such that users may get better execution doing a two-stage swap over a direct swap — whether that be base-to-base-to-quote instead of base-to-quote, or a base-to-quote-to-base instead of a base-to-base depending on the direction of the discrepancy.

Recommendations

We recommend not improving the spread in the base-to-base case and using the original spreads such that the only savings of base-to-base compared to base-to-quote-to-base is that the swap fee is not charged twice.

Remediation

This issue has been acknowledged by WOOFI, and a fix was implemented in commit 879afe26.

Zellic © 2024Back to top ↑