Summary
The SpiceAuction::removeAuctionConfig() function doesn't allow the config for the first ever spice auction (auction id 1) to be removed unless the auction is in cooldown period. If the cooldown period is 0, then the auction config can never be removed.
NOTE
Auction id isn't a state variable. It is used to better represent the latest auction number. Auction config is set for the _currentEpochId + 1 index, and the _currentEpochId variable lags behind the auction id by 1 until the auction starts. Then the _currentEpochId is incremented, and epoch info is set.
Vulnerability Details
The SpiceAuction::removeAuctionConfig() function can be used to remove the config set for an auction before it starts or when it's in cooldown period. This works correctly for all auctions with id greater than 1, however, for auction id 1, which has not started yet (epoch info hasn't been set, with epoch id at 0 and auction id at 1), the function will always revert.
Consider the following code segment from the SpiceAuction contract,
function removeAuctionConfig() external override onlyDAOExecutor {
uint256 id = _currentEpochId;
@> EpochInfo storage info = epochs[id];
@> if (info.startTime == 0) revert InvalidConfigOperation();
if (info.isActive()) revert InvalidConfigOperation();
}
Suppose, auction config has been set using the SpiceAuction::setAuctionConfig() function with id 1. However, epoch id isn't incremented and epoch info isn't set until SpiceAuction::startAucion() function is called. So the SpiceAuction::removeAuctionConfig() function reads a non-existent epoch info (with id 0). All fields in the EpochInfo struct hold value 0. This will cause a revert at L#113.
It is to be noted that auction config can be removed once the auction starts and is in cooldown period. However, what makes this issue severe is that spice auctions can have no cooldown period. This can be seen in the following code segment from SpiceAuction::setAuctionConfig(),
function setAuctionConfig(SpiceAuctionConfig calldata _config) external onlyDAOExecutor {
if (
_config.duration < MINIMUM_AUCTION_PERIOD || _config.duration > MAXIMUM_AUCTION_DURATION
|| _config.waitPeriod > MAXIMUM_AUCTION_WAIT_PERIOD
) revert CommonEventsAndErrors.InvalidParam();
@>
if (_config.waitPeriod == 0 || _config.minimumDistributedAuctionToken == 0) {
revert CommonEventsAndErrors.ExpectedNonZero();
}
}
There is no check to see if the starting cooldown period is greater than 0.
Impact
In case where the first spice auction needs to be taken down, the dao executer won't be able to do so unless the auction has started and is in cooldown period. However, if the auction config for the first spice auction has no cooldown period, then the auction can never be removed.
Proof of Concept
Add the following test function to the SpiceAuctionTest contract in ./protocol/test/forge/templegold/SpiceAuction.t.sol,
function testRemovingAuctionConfigBeforeAuctionStartForFirstAuctionFails() public {
ISpiceAuction.SpiceAuctionConfig memory config;
config = _getAuctionConfig();
vm.startPrank(daoExecutor);
spice.setAuctionConfig(config);
vm.stopPrank();
address auctionToken = config.isTempleGoldAuctionToken ? address(templeGold) : spice.spiceToken();
dealAdditional(IERC20(auctionToken), address(spice), 100 ether);
vm.expectRevert(ISpiceAuction.InvalidConfigOperation.selector);
vm.prank(daoExecutor);
spice.removeAuctionConfig();
}
The test runs successfully, with the following logs,
Ran 1 test for test/SpiceAuction.t.sol:SpiceAuctionTest
[PASS] testRemovingAuctionConfigBeforeAuctionStartForFirstAuctionFails() (gas: 395916)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 914.28ms (2.64ms CPU time)
Ran 1 test suite in 936.04ms (914.28ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Tools Used
Manual review, and Foundry for writing POC and tests.
Recommended Mitigation
Make the following changes to SpiceAuction::removeAuctionConfig() function,
function removeAuctionConfig() external override onlyDAOExecutor {
/// only delete latest epoch if auction is not started
uint256 id = _currentEpochId;
EpochInfo storage info = epochs[id];
- // _currentEpochId = 0
- if (info.startTime == 0) revert InvalidConfigOperation();
- // Cannot reset an ongoing auction
- if (info.isActive()) revert InvalidConfigOperation();
+ if (id != 0) {
+ // _currentEpochId = 0
+ if (info.startTime == 0) revert InvalidConfigOperation();
+ // Cannot reset an ongoing auction
+ if (info.isActive()) revert InvalidConfigOperation();
+ }
// ...
}
Now let's unit test the changes. Add the following test to the SpiceAuctionTest contract in ./protocol/test/forge/templegold/SpiceAuction.t.sol,
function testRemoveAuctionConfigBeforeAuctionStartForFirstAuction() public {
ISpiceAuction.SpiceAuctionConfig memory config;
config = _getAuctionConfig();
vm.startPrank(daoExecutor);
spice.setAuctionConfig(config);
vm.stopPrank();
address auctionToken = config.isTempleGoldAuctionToken ? address(templeGold) : spice.spiceToken();
dealAdditional(IERC20(auctionToken), address(spice), 100 ether);
vm.prank(daoExecutor);
spice.removeAuctionConfig();
}
The test passes with the following logs,
Ran 1 test for test/SpiceAuction.t.sol:SpiceAuctionTest
[PASS] testRemoveAuctionConfigBeforeAuctionStartForFirstAuction() (gas: 395766)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.05s (2.97ms CPU time)
Ran 1 test suite in 1.07s (1.05s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)