Core Contracts

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

Block re-orgs can force users to buy `ZENO` at an higher price than expected

Description

The Auction contract implements a dutch auction mechanism where the price of ZENO tokens decays linearly over time. However this mechanism lacks protection against block re-orgs which can cause transactions to be executed at earlier timestamps than intended, resulting in users paying more USDC than expected.

Context

Impact

Medium. Users can be forced to pay more USDC than intended to buy ZENO tokens due to block re-orgs, with the excess amount paid in USDC scaling based on re-org depth and purchase size.

Likelihood

Medium. Block re-orgs occur frequently on Ethereum Mainnet (about ~8 times a day, making this a common scenario that will affect users purchasing ZENO tokens through the auction mechanism.

Proof of Concept

The following proof of concept demonstrates how a block re-org can cause users to pay more USDC than expected in the auction. The test describes a scenario where:

  1. A 10-minute auction is created with a starting price of 1 USDC and ending price of 0.5 USDC.

  2. Alice expects to buy ZENO tokens at the 2-minute mark (120 seconds into the auction).

  3. Due to a block re-org, her transaction gets executed 12 seconds earlier than intended (at the 108-second mark).

  4. This time difference results in Alice paying a higher amount of USDC than expected, as the linear price decay function means earlier timestamps correspond to higher prices.

To execute this proof of concept integrate foundry by running the following commands in your terminal, in the project's root directory:

# Create required directories
mkdir out lib
# Add `forge-std` module to `lib`
git submodule add https://github.com/foundry-rs/forge-std lib/forge-std
# Create foundry.toml
touch foundry.toml

Next, configure foundry by adding the following settings to foundry.toml:

[profile.default]
src = "contracts"
out = "out"
lib = "lib"

After that, create a foundry/ directory inside the test/ directory. Inside foundry/, create the following files:

  • ZenoModule.t.sol

  • mocks/ERC20Mock.sol

Then, paste the following code to mocks/ERC20Mock.sol:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract ERC20Mock is ERC20 {
uint8 private _decimals;
constructor(string memory _tokenName, string memory _tokenSymbol, uint8 _tokenDecimals)
ERC20(_tokenName, _tokenSymbol)
{
_decimals = _tokenDecimals;
}
function mint(address account, uint256 amount) external {
_mint(account, amount);
}
function burn(address account, uint256 amount) external {
_burn(account, amount);
}
function decimals() public view override returns (uint8) {
return _decimals;
}
}

And the following code to ZenoModule.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test, console} from "../../lib/forge-std/src/Test.sol";
import {Auction, IAuction} from "../../contracts/zeno/Auction.sol";
import {AuctionFactory} from "../../contracts/zeno/AuctionFactory.sol";
import {ZENO} from "../../contracts/zeno/ZENO.sol";
import {ZENOFactory} from "../../contracts/zeno/ZENOFactory.sol";
import {ERC20Mock} from "./mocks/ERC20Mock.sol";
contract ZenoModuleTest is Test {
address public immutable OWNER = makeAddr("OWNER");
address public immutable BUSINESS = makeAddr("BUSINESS");
address public immutable ALICE = makeAddr("ALICE");
address public immutable BOB = makeAddr("BOB");
AuctionFactory public auctionFactory;
ZENOFactory public zenoFactory;
Auction public auction;
ZENO public zeno;
ERC20Mock public usdc;
function setUp() public virtual {
// Deploy the contracts.
vm.startPrank(OWNER);
auctionFactory = new AuctionFactory(OWNER);
zenoFactory = new ZENOFactory(OWNER);
usdc = new ERC20Mock("USDC", "USDC", 6);
// Owner create a new zeno contract.
zenoFactory.createZENOContract(address(usdc), block.timestamp + 365 days);
// Get the instance of the new zeno contract.
zeno = zenoFactory.getZENO(0);
// Owner creates an auction for the zeno contract.
auctionFactory.createAuction(
address(zeno), address(usdc), BUSINESS, block.timestamp, block.timestamp + 10 minutes, 1e6, 0.5e6, 100_000e6
);
// Get the instance of the auction contract.
auction = auctionFactory.getAuction(0);
// Owner transfers the ownership of the zeno contract to the auction contract.
zenoFactory.transferZenoOwnership(0, address(auction));
vm.stopPrank();
}
function test_poc_block_re_org() public {
// Store the timestamp in which the auction was created.
uint256 auctionStart = block.timestamp;
// Get the expected price after 2 minutes of the auction being active.
vm.warp(auctionStart + 120);
uint256 expectedPrice = auction.getPrice();
// Give 50k USDC to Alice for her to buy 50k ZENO.
deal(address(usdc), ALICE, 50_000e6);
// Alice approves the auction contract to spend her USDC.
vm.startPrank(ALICE);
usdc.approve(address(auction), 50_000e6);
// A block re-org happens and Alice's transaction is executed sooner than expected.
// Assuming a re-org depth of 1 block for an average block time of 12 seconds.
vm.warp(auctionStart + 108);
// Alice buys 50k ZENO from the auction contract.
auction.buy(50_000);
// Get the actual price paid by Alice.
uint256 actualPrice = auction.getPrice();
// Assert that the actual price paid by Alice is bigger than the expected price.
assertTrue(actualPrice > expectedPrice);
// Log the expected and actual price paid by Alice.
console.log("Expected price : ", expectedPrice);
console.log("Actual price : ", actualPrice);
// Compute the expected and actual amount of USDC paid by Alice.
uint256 expectedAmount = 50_000 * expectedPrice;
uint256 actualAmount = 50_000 * actualPrice;
// Log the expected and actual amount paid by Alice.
console.log("Expected paid : ", expectedAmount);
console.log("Actual paid : ", actualAmount);
// Log the amount paid in excess by Alice.
console.log("Excess paid : ", actualAmount - expectedAmount);
}
}

And finally, run the proof of concept with the following command:

forge test --mt test_poc_block_re_org -vvv

The above proof of concept demonstrates a scenario based on Ethereum Mainnet's characteristics (average block time of ~12.08 seconds, with approximately 8 re-orgs occurring daily at an average depth of 1 block). Even with such a minimal re-org of 12 seconds, Alice ends up paying 500 USDC more than expected.

Logs:
Expected price : 900000
Actual price : 910000
Expected paid : 45000000000
Actual paid : 45500000000
Excess paid : 500000000

Recommendations

Modify the Auction::buy() function to allow users to specify a maximum acceptable price per ZENO and automatically revert if the current price exceeds the maximum price specified by the user.

Updates

Lead Judging Commences

inallhonesty Lead Judge
5 months ago
inallhonesty Lead Judge 3 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.