Lucky Malachite Blackbird
High
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.
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.
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);
}
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.
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);
}