Axis-Fi / axis-core

Axis Protocol
https://axis.finance
Other
3 stars 0 forks source link

In some cases, the auction cannot be successfully settled due to precision errors in FPB. #201

Closed etherSky111 closed 4 weeks ago

etherSky111 commented 1 month ago

Let me give an example. The base and quote decimals are 18, the price is 2e18, and the minimum fill percent is 100%. The creator sets up an auction with a capacity of 10e18.

_fpbParams =
    IFixedPriceBatch.AuctionDataParams({price: 2e18, minFillPercent: 10e4});
_auctionParams = IAuction.AuctionParams({
    start: 0,
    duration: 1 days,
    capacityInQuote: false,
    capacity: 10e18,
    implParams: abi.encode(_fpbParams)
});

vm.prank(address(_auctionHouse));
_module.auction(_lotId, _auctionParams, _quoteTokenDecimals, _baseTokenDecimals);

One bidder places a bid with 2e19 - 1.

vm.prank(address(_auctionHouse));
_module.bid(_lotId, _BIDDER, _REFERRER, 2e19 - 1, abi.encode(""));

Then the auction remains active because the capacity is 10e18 and the newFilledCapacity is 10e18 - 1.

function _bid(
    uint96 lotId_,
    address bidder_,
    address referrer_,
    uint256 amount_,
    bytes calldata
) internal override returns (uint64) {
    uint256 baseScale = 10 ** lotData[lotId_].baseTokenDecimals;  // 1e18
    uint256 newFilledCapacity = Math.fullMulDiv(data.totalBidAmount, baseScale, data.price); // (2e19 - 1) * 1e18 / (2e18) = 10e18 - 1

    // If the new filled capacity is less than the lot capacity, the auction continues
    if (newFilledCapacity < lotCapacity) { // 10e18 - 1 < 10e18
        return bidId;
    }
}

Another bidder places a bid with 1e18 + 1. Then in the _calculatePartialFill function, the specific values are as follows:

function _calculatePartialFill(
    uint64 bidId_,
    uint256 capacity_,
    uint256 capacityExpended_,
    uint96 bidAmount_,
    uint256 baseScale_,
    uint256 price_
) internal pure returns (PartialFill memory) {
    uint256 fullFill = Math.fullMulDiv(bidAmount_, baseScale_, price_); 
    // fullFill = (1e18 + 1) * 1e18 / (2 * 1e18) = 5 * 1e17
    uint256 excess = capacityExpended_ - capacity_;
    // capacityExpended = (2e19 - 1 + 1e18 + 1) * 1e18 / (2 * 1e18) = 1e19 + 5 * 1e17
    // capacity = 1e19
    // excess = 5 * 1e17

    // Refund will be within the bounds of uint96
    // bidAmount is uint96, excess < fullFill, so bidAmount * excess / fullFill < bidAmount < uint96 max
    uint96 refund = uint96(Math.fullMulDiv(bidAmount_, excess, fullFill));
    // refund = bidAmount
    uint256 payout = fullFill - excess;

    return (PartialFill({bidId: bidId_, refund: refund, payout: payout}));
}

Due to the fullFill equaling the excess, the entire bid amount for the second bidder will be refunded, and the auction is marked as concluded. Therefore, no further bids are allowed. However, we cannot settle this auction because the total bid amount remains 2e19 - 1 and cannot fill the whole capacity.

function _settle(
    uint96 lotId_,
    uint256
)
    internal
    override
    returns (uint256 totalIn_, uint256 totalOut_, bool finished_, bytes memory auctionOutput_)
{
    _auctionData[lotId_].status = LotStatus.Settled;

    uint256 filledCapacity = Math.fullMulDiv(
        _auctionData[lotId_].totalBidAmount,
        10 ** lotData[lotId_].baseTokenDecimals,
        _auctionData[lotId_].price
    );  // 1e19 - 1

    if (filledCapacity < _auctionData[lotId_].minFilled) {  // minFilled = 1e19
        return (totalIn_, totalOut_, true, auctionOutput_);
    }
    _auctionData[lotId_].settlementCleared = true;

    // Set the output values
    totalIn_ = _auctionData[lotId_].totalBidAmount;
    totalOut_ = filledCapacity;
    finished_ = true;
}

However, this auction should be settled successfully.

Please check below comments:

totalBidAmount before    ==>   19999999999999999999
conclusion before        ==>   1086400
totalBidAmount after     ==>   19999999999999999999
conclusion after         ==>   1000000
settlementCleared final  ==>   false

Please add below test to the FPB/auction.t.sol:

function test_settleDoNotComplete() public {
    _fpbParams =
        IFixedPriceBatch.AuctionDataParams({price: 2e18, minFillPercent: 10e4});
    _auctionParams = IAuction.AuctionParams({
        start: 0,
        duration: 1 days,
        capacityInQuote: false,
        capacity: 10e18,
        implParams: abi.encode(_fpbParams)
    });

    vm.prank(address(_auctionHouse));
    _module.auction(_lotId, _auctionParams, _quoteTokenDecimals, _baseTokenDecimals);

    vm.prank(address(_auctionHouse));
    _module.bid(_lotId, _BIDDER, _REFERRER, 2e19 - 1, abi.encode(""));

    FixedPriceBatch.AuctionData memory auctionData_before = _module.getAuctionData(_lotId);
    IAuction.Lot memory lot_before = _module.getLot(_lotId);
    console2.log('totalBidAmount before    ==>  ', auctionData_before.totalBidAmount);
    console2.log('conclusion before        ==>  ', lot_before.conclusion);

    vm.prank(address(_auctionHouse));
    _module.bid(_lotId, _BIDDER, _REFERRER, 1e18 + 1, abi.encode(""));

    FixedPriceBatch.AuctionData memory auctionData_after = _module.getAuctionData(_lotId);
    IAuction.Lot memory lot_after = _module.getLot(_lotId);

    console2.log('totalBidAmount after     ==>  ', auctionData_after.totalBidAmount);
    console2.log('conclusion after         ==>  ', lot_after.conclusion);

    vm.prank(address(_auctionHouse));
    _module.settle(_lotId, 100_000);

    FixedPriceBatch.AuctionData memory auctionData_final = _module.getAuctionData(_lotId);

    console2.log('settlementCleared final  ==>  ', auctionData_final.settlementCleared);
}

This is a complicated and rare case, but I believe we should avoid it.

Recommended

function _bid(
    uint96 lotId_,
    address bidder_,
    address referrer_,
    uint256 amount_,
    bytes calldata
) internal override returns (uint64) {
    uint256 baseScale = 10 ** lotData[lotId_].baseTokenDecimals;
    uint256 newFilledCapacity = Math.fullMulDiv(data.totalBidAmount, baseScale, data.price);

    // If the new filled capacity is less than the lot capacity, the auction continues
    if (newFilledCapacity < lotCapacity) {
        return bidId;
    }

    // If partial fill, then calculate new payout and refund
    if (newFilledCapacity > lotCapacity) {
        // Store the partial fill
        _lotPartialFill[lotId_] = _calculatePartialFill(
            bidId, lotCapacity, newFilledCapacity, amount96, baseScale, data.price
        );

        // Decrement the total bid amount by the refund
        data.totalBidAmount -= _lotPartialFill[lotId_].refund;

+        uint256 updatedFilledCapacity = Math.fullMulDiv(data.totalBidAmount, baseScale, data.price);
+        if (updatedFilledCapacity < data.minFilled) {
+            data.minFilled = updatedFilledCapacity;
+        }
    }
}

This will prevent 1 wei precision error.