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:
A 10-minute auction is created with a starting price of 1 USDC
and ending price of 0.5 USDC
.
Alice expects to buy ZENO
tokens at the 2-minute mark (120 seconds into the auction).
Due to a block re-org, her transaction gets executed 12 seconds earlier than intended (at the 108-second mark).
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:
mkdir out lib
git submodule add https://github.com/foundry-rs/forge-std lib/forge-std
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
:
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
:
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 {
vm.startPrank(OWNER);
auctionFactory = new AuctionFactory(OWNER);
zenoFactory = new ZENOFactory(OWNER);
usdc = new ERC20Mock("USDC", "USDC", 6);
zenoFactory.createZENOContract(address(usdc), block.timestamp + 365 days);
zeno = zenoFactory.getZENO(0);
auctionFactory.createAuction(
address(zeno), address(usdc), BUSINESS, block.timestamp, block.timestamp + 10 minutes, 1e6, 0.5e6, 100_000e6
);
auction = auctionFactory.getAuction(0);
zenoFactory.transferZenoOwnership(0, address(auction));
vm.stopPrank();
}
function test_poc_block_re_org() public {
uint256 auctionStart = block.timestamp;
vm.warp(auctionStart + 120);
uint256 expectedPrice = auction.getPrice();
deal(address(usdc), ALICE, 50_000e6);
vm.startPrank(ALICE);
usdc.approve(address(auction), 50_000e6);
vm.warp(auctionStart + 108);
auction.buy(50_000);
uint256 actualPrice = auction.getPrice();
assertTrue(actualPrice > expectedPrice);
console.log("Expected price : ", expectedPrice);
console.log("Actual price : ", actualPrice);
uint256 expectedAmount = 50_000 * expectedPrice;
uint256 actualAmount = 50_000 * actualPrice;
console.log("Expected paid : ", expectedAmount);
console.log("Actual paid : ", actualAmount);
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.