Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: high
Invalid

The First Bidder can buy `totalAllocated` ZENO, Gain Monopoly, and Resell at Higher Prices Post-auction

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:

  • such person will be the sole determinant of how much to sell ZENO post-auction

  • such person will only be the one to enjoy all the present and future utilities of ZENO

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:

// SPDX-License-Identifier: UNLICENSED
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);
// Deploy USDC token (mock)
usdc = new MockUSDC();
// Deploy ZENO token
zeno = new ZENO(
address(usdc), // USDC address
block.timestamp + 365 days, // Maturity date
"ZENO Bond", // Name
"ZENO", // Symbol
owner // Initial owner
);
// Set auction parameters
auctionStartTime = block.timestamp + 1 hours; // 1 hour later
auctionEndTime = auctionStartTime + 1 days; // 1 day later
startingPrice = 100 * 10**6; // 100 USDC
reservePrice = 10 * 10**6; // 10 USDC
totalZENOAllocated = 1000; // 1000 ZENO tokens
// Deploy Auction contract
auction = new Auction(
address(zeno),
address(usdc),
businessAddress,
auctionStartTime,
auctionEndTime,
startingPrice,
reservePrice,
totalZENOAllocated,
owner
);
// Transfer ownership of ZENO to Auction contract
vm.startPrank(owner);
zeno.transferOwnership(address(auction));
vm.stopPrank();
// Mint USDC to bidder
usdc.mint(bidder, 1000000 * 10**6); // 1,000,000 USDC
// Approve Auction contract to spend bidder's USDC
vm.startPrank(bidder);
usdc.approve(address(auction), 1000000 * 10**6);
vm.stopPrank();
}
function testFirstBidderBuysAllZENO() public {
// Fast forward to auction start time
vm.warp(auctionStartTime + 1);
// Bidder buys all ZENO tokens
vm.startPrank(bidder);
auction.buy(totalZENOAllocated);
vm.stopPrank();
// Check that bidder received all ZENO tokens
assertEq(zeno.balanceOf(bidder), totalZENOAllocated);
}
}
// Mock USDC token contract
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;
}
// Override conflicting functions
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);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!