Core Contracts

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

Buyers never get minted any ZENO tokens after paying for them in an auction

Summary

The current logic in the Auction::buy() function is meant to ensure that the buyer gets minted some ZENO tokens after paying for them. Well, apparently they get nothing. And yeah, they still lose their usdc. Yikes!

P.S. This is not reported in the LightyChaser report. And if the protocol wants to argue that this will be fixed later on, the README doesn't say so. And the README should be the source of truth for this contest.

Vulnerability Details

The flow of the Auction::buy() function is as follows:

  • Buyer specifies amount of ZENO tokens they wish to buy

  • Function calculates the cost to be paid based on current slippage price

  • Cost is transferred from buyer to businessAddress

  • Buyer is minted some ZENO tokens equivalent to the amount they specified

But, a closer look at the ZENO::mint() funtion shows that it can only be called the owner of the Auction contract.

/**
* CAN BE CALLED ONLY BY ASSOCIATED AUCTION CONTRACT (THE OWNER)
*/
function mint(address to, uint256 amount) external onlyOwner {
if (amount == 0) {
revert ZeroAmount();
}
_mint(to, amount);
totalZENOMinted += amount;
}

Thus this function will revert with an OwnableUnauthorizedAccount() error

Impact

The buyer never gets their ZENO tokens, and they lose their usdc also.

Tools Used

  • Foundry

PoC

P.S. For this test, I have already fixed the miscalculation error of cost to be transferred from the buyer in the Auction::buy() function. That way, we focus on only the traces of this finding.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test, console2} from "forge-std/Test.sol";
import {Auction} from "../contracts/zeno/Auction.sol";
import {ZENO} from "../contracts/zeno/ZENO.sol";
import {MockUSDC} from "../contracts/mocks/core/tokens/MockUSDC.sol";
contract AuctionTest is Test {
Auction auction;
ZENO zeno;
MockUSDC usdc;
address owner;
address businessAddress;
address buyer;
uint256 startTime = 3600 seconds;
uint256 endTime = startTime + 3600 seconds; // i.e. auction lasts for 2 hours
uint256 startingPrice = 1e18; // 1 USDC per ZENO
uint256 reservePrice = 5e17; // 0.5 USDC per ZENO
uint256 totalAllocated = 1000e18; // 1000 ZENO tokens allocated for the auction
uint256 initialSupply = 10_000e18;
uint256 zenoMaturityDate = 1 hours;
uint256 amount = 1e18;
function setUp() public {
owner = makeAddr("owner");
businessAddress = makeAddr("businessAddress");
buyer = makeAddr("buyer");
owner = makeAddr("owner");
usdc = new MockUSDC(initialSupply);
zeno = new ZENO(address(usdc), zenoMaturityDate, "ZTOKEN", "ZTK", owner);
vm.prank(owner);
auction = new Auction(
address(zeno),
address(usdc),
businessAddress,
startTime,
endTime,
startingPrice,
reservePrice,
totalAllocated,
owner
);
}
function testBuyerGetsNoZeno() public {
usdc.mint(buyer, 10e18); // Minted the buyer some usdc
vm.warp(startTime + 1 seconds);
vm.startPrank(buyer);
usdc.approve(address(auction), 1e18); // Buyer approves
auction.buy(1e18); // Buyer wants to buy 1 ZENO token
vm.stopPrank();
}
}

From the traces, we can see that we always get an OwnableUnauthorizedAccount() error, but the buyer's usdc has already been sent to the businessAddress.

[193113] AuctionTest::testBuyerGetsNoZeno()
├─ [30191] MockUSDC::mint(buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], 10000000000000000000 [1e19])
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], value: 10000000000000000000 [1e19])
│ └─ ← [Stop]
├─ [0] VM::warp(3601)
│ └─ ← [Return]
├─ [0] VM::startPrank(buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02])
│ └─ ← [Return]
├─ [25296] MockUSDC::approve(Auction: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], 1000000000000000000 [1e18])
│ ├─ emit Approval(owner: buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], spender: Auction: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], value: 1000000000000000000 [1e18])
│ └─ ← [Return] true
├─ [117408] Auction::buy(1000000000000000000 [1e18])
│ ├─ [26814] MockUSDC::transferFrom(buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], businessAddress: [0x39dC574AA044Aafda5FB92E2e66dBE3D7DD34aE5], 999861111111111112 [9.998e17])
│ │ ├─ emit Transfer(from: buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], to: businessAddress: [0x39dC574AA044Aafda5FB92E2e66dBE3D7DD34aE5], value: 999861111111111112 [9.998e17])
│ │ └─ ← [Return] true
│ ├─ [3202] ZENO::mint(buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], 1000000000000000000 [1e18])
│ │ └─ ← [Revert] OwnableUnauthorizedAccount(0x88F59F8826af5e695B13cA934d6c7999875A9EeA)
│ └─ ← [Revert] OwnableUnauthorizedAccount(0x88F59F8826af5e695B13cA934d6c7999875A9EeA)
└─ ← [Revert] OwnableUnauthorizedAccount(0x88F59F8826af5e695B13cA934d6c7999875A9EeA)

Recommendations

Refactor the ::buy() function to transfer ZENO tokens to the buyer, instead of minting directly.
Before any auction starts, the Auction contract is minted some ZENO tokens proportionate to the AuctionState.totalAllocated value by the owner.

function buy(uint256 amount) external whenActive {
require(amount <= state.totalRemaining, "Not enough ZENO remaining");
uint256 price = getPrice();
uint256 cost = (price * amount) / 1e18;
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);
+ zeno.transfer(msg.sender, amount); // transfer from contract's zeno tokens balance
emit ZENOPurchased(msg.sender, amount, price);
}

Here is a test to proof this mitigation:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test, console2} from "forge-std/Test.sol";
import {Auction} from "../contracts/zeno/Auction.sol";
import {ZENO} from "../contracts/zeno/ZENO.sol";
import {MockUSDC} from "../contracts/mocks/core/tokens/MockUSDC.sol";
contract AuctionTest is Test {
Auction auction;
ZENO zeno;
MockUSDC usdc;
address owner;
address businessAddress;
address buyer;
uint256 startTime = 3600 seconds;
uint256 endTime = startTime + 3600 seconds; // i.e. auction lasts for 2 hours
uint256 startingPrice = 1e18; // 1 USDC per ZENO
uint256 reservePrice = 5e17; // 0.5 USDC per ZENO
uint256 totalAllocated = 1000e18; // 1000 ZENO tokens allocated for the auction
uint256 initialSupply = 10_000e18;
uint256 zenoMaturityDate = 1 hours;
function setUp() public {
owner = makeAddr("owner");
businessAddress = makeAddr("businessAddress");
buyer = makeAddr("buyer");
owner = makeAddr("owner");
usdc = new MockUSDC(initialSupply);
zeno = new ZENO(address(usdc), zenoMaturityDate, "ZTOKEN", "ZTK", owner);
vm.prank(owner);
auction = new Auction(
address(zeno),
address(usdc),
businessAddress,
startTime,
endTime,
startingPrice,
reservePrice,
totalAllocated,
owner
);
// mint some zeno to auction contract
vm.prank(owner);
zeno.mint(address(auction), totalAllocated);
}
function testBuyerGetsZeno() public {
usdc.mint(buyer, 10e18); // Minted the buyer some usdc
vm.warp(startTime + 1 seconds);
vm.startPrank(buyer);
usdc.approve(address(auction), 1e18); // Buyer approves for type(uint256).max
auction.buy(1e18); // Buyer wants to buy 1 ZENO token
vm.stopPrank();
assert(zeno.balanceOf(buyer) == 1e18); // buyer gets zeno tokens worth 1e18
assert(zeno.balanceOf(address(auction)) == (totalAllocated - 1e18)); // auction contract's zeno tokens decrease by 1e18
}
}

And the test passed with the following traces:

[PASS] testBuyerGetsZeno() (gas: 231080)
Traces:
[231080] AuctionTest::testBuyerGetsZeno()
├─ [30191] MockUSDC::mint(buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], 10000000000000000000 [1e19])
│ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], value: 10000000000000000000 [1e19])
│ └─ ← [Stop]
├─ [0] VM::warp(3601)
│ └─ ← [Return]
├─ [0] VM::startPrank(buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02])
│ └─ ← [Return]
├─ [25296] MockUSDC::approve(Auction: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], 1000000000000000000 [1e18])
│ ├─ emit Approval(owner: buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], spender: Auction: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], value: 1000000000000000000 [1e18])
│ └─ ← [Return] true
├─ [146890] Auction::buy(1000000000000000000 [1e18])
│ ├─ [26814] MockUSDC::transferFrom(buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], businessAddress: [0x39dC574AA044Aafda5FB92E2e66dBE3D7DD34aE5], 999861111111111112 [9.998e17])
│ │ ├─ emit Transfer(from: buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], to: businessAddress: [0x39dC574AA044Aafda5FB92E2e66dBE3D7DD34aE5], value: 999861111111111112 [9.998e17])
│ │ └─ ← [Return] true
│ ├─ [30590] ZENO::transfer(buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], 1000000000000000000 [1e18])
│ │ ├─ emit Transfer(from: Auction: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA], to: buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], value: 1000000000000000000 [1e18])
│ │ └─ ← [Return] true
│ ├─ emit ZENOPurchased(buyer: buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02], amount: 1000000000000000000 [1e18], price: 999861111111111112 [9.998e17])
│ └─ ← [Stop]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [939] ZENO::balanceOf(buyer: [0x0fF93eDfa7FB7Ad5E962E4C0EdB9207C03a0fe02]) [staticcall]
│ └─ ← [Return] 1000000000000000000 [1e18]
├─ [939] ZENO::balanceOf(Auction: [0x88F59F8826af5e695B13cA934d6c7999875A9EeA]) [staticcall]
│ └─ ← [Return] 999000000000000000000 [9.99e20]
└─ ← [Stop]
Updates

Lead Judging Commences

inallhonesty Lead Judge
9 months ago
inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Appeal created

yeahchibyke Submitter
7 months ago
inallhonesty Lead Judge
7 months ago
yeahchibyke Submitter
7 months ago
yeahchibyke Submitter
7 months ago
inallhonesty Lead Judge
7 months ago
yeahchibyke Submitter
7 months ago
inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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

Give us feedback!