code-423n4 / 2024-07-reserve-findings

5 stars 4 forks source link

Critical Bug in prepareTradeToCoverDeficit Function Causing Zero Sell Amount in TradeLib Contract #114

Closed howlbot-integration[bot] closed 2 months ago

howlbot-integration[bot] commented 3 months ago

Lines of code

https://github.com/reserve-protocol/protocol/blob/4.0.0/contracts/p1/mixins/TradeLib.sol#L148

Vulnerability details

Impact

Based on the provided echidna logs there is an issue within the TradeLib contract the prepareTradeToCoverDeficit function. Specifically, the Exact Sell Amount and Request Sell Amount are coming out as 0, which implies that the calculations for determining the sell amount aren't producing valid results.

Potential Causes:

  1. Trade Amount Calculations: The exactSellAmount and slippedSellAmount calculations are producing non-zero values (2000288176520567114 and 2020493107596532439 respectively), but these aren't being reflected in the final sellAmount in the trade request, which is suspiciously 0.
  2. Trade Size Constraints: There might be an issue where the calculated slippedSellAmount is being incorrectly limited or discarded due to internal checks, such as maximum trade size limits or dust trade conditions.

Proof of Concept

echidna test: testPrepareTradeToCoverDeficit(uint192): failed!💥
Call sequence: TradeLibEchidnaTest3.testPrepareTradeToCoverDeficit(1000101419437632800)

Traces: emit LogUint(name=«Exact Sell Amount Calculation», value=2000202838875265600) (/share/echidna/mixins/TradeLibEchidnaTest3.sol:78) emit LogUint(name=«Slipped Sell Amount Calculation», value=2020406907954813738) (/share/echidna/mixins/TradeLibEchidnaTest3.sol:79) call MockAsset2::maxTradeVolume()() (/share/p1/mixins/TradeLib.sol:185) └╴← (1000000000000000000000000) call MockAsset2::maxTradeVolume()() (/share/p1/mixins/TradeLib.sol:185) └╴← (1000000000000000000000000) call MockAsset2::erc20Decimals()() (/share/p1/mixins/TradeLib.sol:80) └╴← (18) call MockAsset2::erc20Decimals()() (/share/p1/mixins/TradeLib.sol:81) └╴← (18) emit LogUint(name=«Buy Amount», value=1000101419437632800) (/share/echidna/mixins/TradeLibEchidnaTest3.sol:87) emit LogUint(name=«Exact Sell Amount», value=0) (/share/echidna/mixins/TradeLibEchidnaTest3.sol:88) emit LogUint(name=«Request Sell Amount», value=0) (/share/echidna/mixins/TradeLibEchidnaTest3.sol:89) emit LogUint(name=«Request Min Buy Amount», value=0) (/share/echidna/mixins/TradeLibEchidnaTest3.sol:90) emit LogUint(name=«Sell Low Price», value=1000000000000000000) (/share/echidna/mixins/TradeLibEchidnaTest3.sol:91) emit LogUint(name=«Max Trade Slippage», value=10000000000000000) (/share/echidna/mixins/TradeLibEchidnaTest3.sol:93) emit LogUint(name=«Is Not Dust Trade», value=0) (/share/echidna/mixins/TradeLibEchidnaTest3.sol:94)

this is the echidna test that was used, // SPDX-License-Identifier: BlueOak-1.0.0 pragma solidity 0.8.19;

import "./MockAsset2.sol"; import "../../libraries/Fixed.sol"; import "../../interfaces/IAsset.sol"; import "../../echidna/IERC20.sol"; import "../../p1/mixins/TradeLib.sol"; import "../../echidna/open/IERC20Metadata.sol";

contract TradeLibEchidnaTest3 { using FixLib for uint192;

// Mock Assets
MockAsset2 public sellAsset;
MockAsset2 public buyAsset;

uint192 public constant minVolume = 1e18; // 1 token
uint192 public constant maxSlippage = 1e16; // 1%

event LogUint(string name, uint256 value);

constructor() {
    sellAsset = new MockAsset2(1e18, 2e18, 18, 1e24); // Mocked with 18 decimals and high maxTradeVolume
    buyAsset = new MockAsset2(1e18, 2e18, 18, 1e24);  // Same for the buy asset
}

function testPrepareTradeSell(uint192 sellAmount) public {
    require(sellAmount >= minVolume, "Sell amount too small to trade");

    TradeInfo memory tradeInfo = TradeInfo({
        sell: IAsset(address(sellAsset)),
        buy: IAsset(address(buyAsset)),
        sellAmount: sellAmount,
        buyAmount: 0, // unused in prepareTradeSell
        prices: TradePrices({
            sellLow: 1e18,
            sellHigh: 2e18,
            buyLow: 1e18,
            buyHigh: 2e18
        })
    });

    (bool notDust, TradeRequest memory req) = TradeLib.prepareTradeSell(
        tradeInfo,
        minVolume,
        maxSlippage
    );

    emit LogUint("Sell Amount", sellAmount);
    emit LogUint("Request Sell Amount", req.sellAmount);
    emit LogUint("Request Min Buy Amount", req.minBuyAmount);
    emit LogUint("Is Not Dust Trade", notDust ? 1 : 0);

    assert(notDust && req.sellAmount > 0);
}

function testPrepareTradeToCoverDeficit(uint192 buyAmount) public {
    require(buyAmount >= minVolume, "Buy amount too small to trade");

    TradeInfo memory tradeInfo = TradeInfo({
        sell: IAsset(address(sellAsset)),
        buy: IAsset(address(buyAsset)),
        sellAmount: 0, // will be calculated
        buyAmount: buyAmount,
        prices: TradePrices({
            sellLow: 1e18,
            sellHigh: 2e18,
            buyLow: 1e18,
            buyHigh: 2e18
        })
    });

    // Prepare trade and log intermediate values
    uint192 exactSellAmount = buyAmount.mulDiv(tradeInfo.prices.buyHigh, tradeInfo.prices.sellLow, CEIL);
    uint192 slippedSellAmount = exactSellAmount.div(FIX_ONE.minus(maxSlippage), CEIL);

    emit LogUint("Exact Sell Amount Calculation", exactSellAmount);
    emit LogUint("Slipped Sell Amount Calculation", slippedSellAmount);

    (bool notDust, TradeRequest memory req) = TradeLib.prepareTradeToCoverDeficit(
        tradeInfo,
        minVolume,
        maxSlippage
    );

    emit LogUint("Buy Amount", buyAmount);
    emit LogUint("Exact Sell Amount", uint256(tradeInfo.sellAmount));
    emit LogUint("Request Sell Amount", req.sellAmount);
    emit LogUint("Request Min Buy Amount", req.minBuyAmount);
    emit LogUint("Sell Low Price", tradeInfo.prices.sellLow);
    emit LogUint("Buy High Price", tradeInfo.prices.buyHigh);
    emit LogUint("Max Trade Slippage", maxSlippage);
    emit LogUint("Is Not Dust Trade", notDust ? 1 : 0);

    assert(notDust && req.minBuyAmount > 0);
}

}

The bug identified in the prepareTradeToCoverDeficit function can have several significant consequences:

  1. Failed Trades: The bug causes the calculated sellAmount to be incorrectly set to zero, leading to a situation where no trade can be executed. In a scenario where a user or the protocol itself is trying to cover a deficit, this failure to trade could prevent the system from acquiring necessary assets, leading to imbalances or even insolvency.
  2. Unintended Economic Behavior: If the sellAmount is incorrectly calculated, it could result in trades that are not economically viable or that do not meet the protocol's requirements for minimizing slippage or avoiding dust trades. This could lead to suboptimal trading outcomes, such as acquiring less value than expected or failing to execute a trade when it is needed to maintain protocol stability.

Tools Used

echidna and VS code

Recommended Mitigation Steps

  1. We need to update the prepareTradeToCoverDeficit function in the TradeLib contract at line 148: Original function: function prepareTradeToCoverDeficit( TradeInfo memory trade, uint192 minTradeVolume, uint192 maxTradeSlippage ) internal view returns (bool notDust, TradeRequest memory req) { assert( trade.prices.sellLow != 0 && trade.prices.sellLow != FIX_MAX && trade.prices.buyHigh != 0 && trade.prices.buyHigh != FIX_MAX );

    // Don't buy dust.
    trade.buyAmount = fixMax(
        trade.buyAmount,
        minTradeSize(minTradeVolume, trade.prices.buyHigh)
    );
    
    // {sellTok} = {buyTok} * {UoA/buyTok} / {UoA/sellTok}
    uint192 exactSellAmount = trade.buyAmount.mulDiv(
        trade.prices.buyHigh,
        trade.prices.sellLow,
        CEIL
    );
    // exactSellAmount: Amount to sell to buy `deficitAmount` if there's no slippage
    
    // slippedSellAmount: Amount needed to sell to buy `deficitAmount`, counting slippage
    uint192 slippedSellAmount = exactSellAmount.div(FIX_ONE.minus(maxTradeSlippage), CEIL);
    
    trade.sellAmount = fixMin(slippedSellAmount, trade.sellAmount); // {sellTok}
    return prepareTradeSell(trade, minTradeVolume, maxTradeSlippage);

    // line 148 insert change here }

  2. Here is the Updated prepareTradeToCoverDeficit function, changes at line 148: function prepareTradeToCoverDeficit( TradeInfo memory trade, uint192 minTradeVolume, uint192 maxTradeSlippage ) internal view returns (bool notDust, TradeRequest memory req) { assert( trade.prices.sellLow != 0 && trade.prices.sellLow != FIX_MAX && trade.prices.buyHigh != 0 && trade.prices.buyHigh != FIX_MAX );

    // Don't buy dust. trade.buyAmount = fixMax( trade.buyAmount, minTradeSize(minTradeVolume, trade.prices.buyHigh) );

    // Calculate the exact and slipped sell amounts uint192 exactSellAmount = trade.buyAmount.mulDiv( trade.prices.buyHigh, trade.prices.sellLow, CEIL ); uint192 slippedSellAmount = exactSellAmount.div(FIX_ONE.minus(maxTradeSlippage), CEIL);

    // Assign the calculated sell amount to trade.sellAmount trade.sellAmount = fixMin(slippedSellAmount, trade.sellAmount == 0 ? slippedSellAmount : trade.sellAmount); // {sellTok}

    // Check if the trade is still considered dust notDust = trade.sellAmount > 0 && isEnoughToSell(trade.sell, trade.sellAmount, trade.prices.sellLow, minTradeVolume); // line 148 // Prepare the trade request if (notDust) { req = TradeRequest({ sell: trade.sell, buy: trade.buy, sellAmount: trade.sellAmount.shiftl_toUint(int8(trade.sell.erc20Decimals()), FLOOR), minBuyAmount: trade.sellAmount.mulDiv( trade.prices.sellLow, trade.prices.buyHigh, CEIL ).shiftl_toUint(int8(trade.buy.erc20Decimals()), CEIL) }); }

    return (notDust, req); }

Explanation of the Updated Function:

The updated prepareTradeToCoverDeficit function includes essential changes to address the bug:

Proper Assignment to trade.sellAmount:

  1. Problem: The original function assumed that trade.sellAmount would be correctly set before invoking the prepareTradeSell function. If not properly initialized, this could lead to a zero value, causing the trade to fail.
  2. Solution: The update introduces a conditional assignment, ensuring that trade.sellAmount is correctly set. Specifically, it assigns slippedSellAmount to trade.sellAmount if the latter is zero.
  3. Why This Works: This change prevents the sellAmount from being unintentionally left as zero. By conditionally assigning slippedSellAmount, the function guarantees a valid and economically meaningful sellAmount, allowing the trade request to proceed as intended.

Refinement of the notDust Condition:

  1. Problem: The notDust condition determines if a trade is economically significant. The original function did not sufficiently ensure that trade.sellAmount met this condition.
  2. Solution: The update refines this condition by ensuring trade.sellAmount is both greater than zero and meets the isEnoughToSell threshold.
  3. Why This Works: By reinforcing the notDust condition, the function prevents the creation of economically insignificant trades (dust trades). This ensures that only meaningful trades are executed, aligning with the protocol's economic requirements.

Summary of Changes:

  1. Assignment Logic: The conditional assignment prevents zero sellAmount by ensuring it is set to slippedSellAmount when appropriate.
  2. Economic Viability: The refined notDust condition ensures that only trades meeting the protocol’s economic thresholds are executed, enhancing trade reliability and preventing unintended economic outcomes.

After updating the tradeLib.sol contract, ALL echidna tests PASS:

testPrepareTradeToCoverDeficit(uint192): passing sellAsset(): passing maxSlippage(): passing buyAsset(): passing testPrepareTradeSell(uint192): passing minVolume(): passing AssertionFailed(..): passing

Conclusion: These updates ensure that the trade preparation logic within TradeLib operates as intended, preventing failed trades and maintaining the protocol's economic integrity. The successful passing of all Echidna tests after implementing these changes confirms the robustness of the fix.

Assessed type

Math

tbrent commented 3 months ago

The analysis that has been performed has been performed on the library in isolation without regard to the surrounding calling environment.

In the actual codebase the usage of the library does not involve calls with 0 sellAmount. This property is achieved by the early return here and the setting of the trade data here.

If the warden can present evidence that the broader codebase has the issue claimed that would change our minds.

thereksfour commented 2 months ago
function testPrepareTradeToCoverDeficit(uint192 buyAmount) public {
    require(buyAmount >= minVolume, "Buy amount too small to trade");

    TradeInfo memory tradeInfo = TradeInfo({
        sell: IAsset(address(sellAsset)),
        buy: IAsset(address(buyAsset)),
        sellAmount: 0, // will be calculated
        buyAmount: buyAmount,
        prices: TradePrices({
            sellLow: 1e18,
            sellHigh: 2e18,
            buyLow: 1e18,
            buyHigh: 2e18
        })
    });
        trade.sellAmount = fixMin(slippedSellAmount, trade.sellAmount); // {sellTok}

The sellAmount in the test is 0, which I think is the reason for the conclusion below.

Trade Amount Calculations: The exactSellAmount and slippedSellAmount calculations are producing non-zero values (2000288176520567114 and 2020493107596532439 respectively), but these aren't being reflected in the final sellAmount in the trade request, which is suspiciously 0.

Also, as the sponsor said, if warden can provide a test with full context, I will consider it again, but currently think it is invalid.

c4-judge commented 2 months ago

thereksfour marked the issue as unsatisfactory: Insufficient proof