Summary
The economic bug in the auction.solcontract is that the first bidder with sufficient liquidity can buy up all the ZENO tokens available in the contract.
At that point, totalRemainingwill be 0, and no one else can bid for ZENO, which defeats the whole point of an auction.
This way, the first bidder will have all the ZENO supply and can conviniently decide to resell the tokens even at outrageously increased prices post-auction!
In addition, the first bidder will be the only one to enjoy all the present and future utilities of ZENO.
Vulnerability Details
This vulnerability is in the buyfunction:
function buy(uint256 amount) external whenActive {
require(amount <= state.totalRemaining, "Not enough ZENO remaining");
uint256 price = getPrice();
uint256 cost = price * amount;
require(usdc.transferFrom(msg.sender, businessAddress, cost), "Transfer failed");
bidAmounts[msg.sender] += amount;
state.totalRemaining -= amount;
state.lastBidTime = block.timestamp;
state.lastBidder = msg.sender;
zeno.mint(msg.sender, amount);
emit ZENOPurchased(msg.sender, amount, price);
}
With this function, it is clearly possible for a "rich" first bidder to buy the entire ZENO that is up for auction.
Impact
The impact is that a single entity can dominate the entire ownership of the ZENO token. Further implications are that:
Tools Used
Manual Review, Foundry
PoC
The instructions here were followed to migrate the project from Hardhat to Foundry.
Here is a coded PoC in Foundry named auction.t.sol:
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../../contracts/zeno/Auction.sol";
import "../../contracts/zeno/ZENO.sol";
import "../../contracts/interfaces/IUSDC.sol";
contract AuctionTest is Test {
Auction public auction;
ZENO public zeno;
MockUSDC public usdc;
address public owner;
address public bidder;
address public businessAddress;
uint256 public auctionStartTime;
uint256 public auctionEndTime;
uint256 public startingPrice;
uint256 public reservePrice;
uint256 public totalZENOAllocated;
function setUp() public {
owner = address(1);
bidder = address(2);
businessAddress = address(3);
usdc = new MockUSDC();
zeno = new ZENO(
address(usdc),
block.timestamp + 365 days,
"ZENO Bond",
"ZENO",
owner
);
auctionStartTime = block.timestamp + 1 hours;
auctionEndTime = auctionStartTime + 1 days;
startingPrice = 100 * 10**6;
reservePrice = 10 * 10**6;
totalZENOAllocated = 1000;
auction = new Auction(
address(zeno),
address(usdc),
businessAddress,
auctionStartTime,
auctionEndTime,
startingPrice,
reservePrice,
totalZENOAllocated,
owner
);
vm.startPrank(owner);
zeno.transferOwnership(address(auction));
vm.stopPrank();
usdc.mint(bidder, 1000000 * 10**6);
vm.startPrank(bidder);
usdc.approve(address(auction), 1000000 * 10**6);
vm.stopPrank();
}
function testFirstBidderBuysAllZENO() public {
vm.warp(auctionStartTime + 1);
vm.startPrank(bidder);
auction.buy(totalZENOAllocated);
vm.stopPrank();
assertEq(zeno.balanceOf(bidder), totalZENOAllocated);
}
}
contract MockUSDC is IERC20, ERC20 {
constructor() ERC20("Mock USDC", "USDC") {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
function decimals() public view virtual override returns (uint8) {
return 6;
}
function approve(address spender, uint256 amount) public override(IERC20, ERC20) returns (bool) {
return super.approve(spender, amount);
}
function balanceOf(address account) public view override(IERC20, ERC20) returns (uint256) {
return super.balanceOf(account);
}
function totalSupply() public view override(IERC20, ERC20) returns (uint256) {
return super.totalSupply();
}
function transfer(address to, uint256 amount) public override(IERC20, ERC20) returns (bool) {
return super.transfer(to, amount);
}
function transferFrom(address from, address to, uint256 amount) public override(IERC20, ERC20) returns (bool) {
return super.transferFrom(from, to, amount);
}
function allowance(address owner, address spender) public view override(IERC20, ERC20) returns (uint256) {
return super.allowance(owner, spender);
}
}
Here is the result:
Ran 1 test for test/auction.t.sol:AuctionTest
[PASS] testFirstBidderBuysAllZENO() (gas: 210383)
Recommendations
Add a check to prevent a single bidder from being able to call totalAllocated. Here is an implementation:
function buy(uint256 amount) external whenActive {
require(amount <= state.totalRemaining, "Not enough ZENO remaining");
++ require(amount != state.totalAllocated, "total not available for a single bidder");
uint256 price = getPrice();
uint256 cost = price * amount;
require(usdc.transferFrom(msg.sender, businessAddress, cost), "Transfer failed");
bidAmounts[msg.sender] += amount;
state.totalRemaining -= amount;
state.lastBidTime = block.timestamp;
state.lastBidder = msg.sender;
zeno.mint(msg.sender, amount);
emit ZENOPurchased(msg.sender, amount, price);
}