Skip to content

Latest commit

 

History

History
151 lines (112 loc) · 5 KB

File metadata and controls

151 lines (112 loc) · 5 KB

Lucky Malachite Blackbird

High

Reserves auction could be drained through fee claims, breaking auction settlement

Summary

The fee calculation using total pool balance instead of available balance will cause auction failures as governance/beneficiary can claim fees from auction-reserved funds, impacting auction winners and bondETH holders.

Root Cause

The fee can be withdrawn at any time by calling claimFees, with the amount based on time elapsed since lastFeeClaimTime. There is no fixed withdrawal period ( **including a running Auction **) - it's calculated continuously and can be claimed whenever the feeBeneficiary or governance decides to call the function.

Attack Path

We could show this as follow : when the Pool initiates the auction :

function startAuction() external whenNotPaused() {
    uint256 couponAmountToDistribute = (normalizedTotalSupply * normalizedShares)
        .toBaseUnit(maxDecimals * 2 - IERC20(couponToken).safeDecimals());

    auctions[currentPeriod] = Utils.deploy(
        address(new Auction()),
        abi.encodeWithSelector(
            Auction.initialize.selector,
            address(couponToken),
            address(reserveToken),
            couponAmountToDistribute,
            block.timestamp + auctionPeriod,
            1000,
            address(this),
            poolSaleLimit
        )
    );
}

Pool provides ETH to Auction:

function transferReserveToAuction(uint256 amount) external {
    (uint256 currentPeriod, ) = bondToken.globalPool();
    address auctionAddress = auctions[currentPeriod];
    require(msg.sender == auctionAddress, CallerIsNotAuction());
    
    IERC20(reserveToken).safeTransfer(msg.sender, amount);
}

Auction requests ETH from Pool:

function endAuction() external {
    if (state == State.SUCCEEDED) {
        Pool(pool).transferReserveToAuction(totalSellReserveAmount);
        IERC20(buyCouponToken).safeTransfer(beneficiary, IERC20(buyCouponToken).balanceOf(address(this)));
    }
}

The Vulnerability exist in getFeeAmount function calculates fees on the total pool balance including auction reserves:

function getFeeAmount() internal view returns (uint256) {
    return (IERC20(reserveToken).balanceOf(address(this)) * fee * (block.timestamp - lastFeeClaimTime)) / (PRECISION * SECONDS_PER_YEAR);
}

Impact

The auction participants cannot receive their promised ETH as fee claims reduce available reserves below required amounts, causing auction failure through FAILED_POOL_SALE_LIMIT state.

PoC

Add this to Pool.t.sol :

function testComprehensiveFeeAuctionFailure() public {
    // Setup initial state
    vm.startPrank(governance);
    Token rToken = Token(params.reserveToken);
    
    // Create pool with 2% fee
    params.fee = 20000; // 2% fee (1000000 precision)
    params.feeBeneficiary = address(0x942);
    
    // Initial pool setup with 1000 ETH
    rToken.mint(governance, 1000 ether);
    rToken.approve(address(poolFactory), 1000 ether);
    
    Pool pool = Pool(poolFactory.createPool(params, 1000 ether, 500 ether, 250 ether, "", "", "", "", false));
    
    // Start auction which reserves 95% (950 ETH)
    pool.startAuction();
    
    // Get auction contract
    (uint256 currentPeriod,) = pool.bondToken().globalPool();
    address auctionAddress = pool.auctions(currentPeriod);
    Auction auction = Auction(auctionAddress);
    
    // Setup bidders
    address bidder1 = address(0x1);
    address bidder2 = address(0x2);
    
    // Place bids
    vm.startPrank(bidder1);
    rToken.mint(bidder1, 500 ether);
    rToken.approve(auctionAddress, 500 ether);
    auction.bid(100 ether, 500 ether);
    
    vm.startPrank(bidder2);
    rToken.mint(bidder2, 450 ether);
    rToken.approve(auctionAddress, 450 ether);
    auction.bid(90 ether, 450 ether);
    
    // Fast forward 5 days into auction
    vm.warp(block.timestamp + 5 days);
    
    // Calculate and claim fees
    uint256 expectedFee = (1000 ether * 20000 * 5 days) / (1000000 * 365 days);
    
    vm.startPrank(params.feeBeneficiary);
    uint256 initialBalance = rToken.balanceOf(params.feeBeneficiary);
    pool.claimFees();
    
    // Verify fee claim
    uint256 finalBalance = rToken.balanceOf(params.feeBeneficiary);
    uint256 feeAmount = finalBalance - initialBalance;
    assertEq(feeAmount, expectedFee);
    
    // Verify pool balance reduced below auction needs
    uint256 poolBalance = rToken.balanceOf(address(pool));
    assertLt(poolBalance, 950 ether);
    
    // Fast forward to auction end
    vm.warp(block.timestamp + 5 days);
    
    // End auction
    auction.endAuction();
    
    // Verify auction failed due to insufficient pool balance
    assertEq(uint256(auction.state()), uint256(Auction.State.FAILED_POOL_SALE_LIMIT));
    
    // Verify bidders can't claim ETH
    vm.startPrank(bidder1);
    vm.expectRevert(Auction.AuctionFailed.selector);
    auction.claimBid(1);
}