Vulnerability Details
removeAuctionConfig is used to remove auction config set for last epoch. If the auction was started but the cooldown has not passed yet, both auction config and epoch info are deleted and the _currentEpochId is decremented:
function removeAuctionConfig() external override onlyDAOExecutor {
    
    uint256 id = _currentEpochId;
        
    EpochInfo storage info = epochs[id];
    
    bool configSetButAuctionStartNotCalled = auctionConfigs[id+1].duration > 0;
    if (!configSetButAuctionStartNotCalled) {
        
        if (info.hasEnded()) { revert AuctionEnded(); }
        
        delete auctionConfigs[id];
        delete epochs[id];
        _currentEpochId = id - 1;
        
        emit AuctionConfigRemoved(id, id);
    } 
    
}
The issue is that _totalAuctionTokenAllocation is not updated in this situation and this will lead to the following consequences for the next epochs:
_totalAuctionTokenAllocation will always contain extra auction tokens for all subsequent epochs.
 
Those extra auction tokens can not be recovered.
 
Consider the following example (that is demonstrated by PoC below) to better explain the consequences:
For simplicity, we set the auction config for the first epoch with 100 ether auction tokens sent to the Spice contract
 
The auction is started, and based on the startAuction logic, the following states are updated as follows:
    uint256 totalAuctionTokenAllocation = _totalAuctionTokenAllocation[auctionToken]; 
    uint256 balance = IERC20(auctionToken).balanceOf(address(this)); 
    uint256 epochAuctionTokenAmount = balance - (totalAuctionTokenAllocation - _claimedAuctionTokens[auctionToken]); 
    
    info.totalAuctionTokenAmount = epochAuctionTokenAmount; 
 
    _totalAuctionTokenAllocation[auctionToken] = totalAuctionTokenAllocation + epochAuctionTokenAmount;
 
Now, for some reason, the DaoExecutor removes the auction config for the last epoch (the first epoch in our example). Because the epoch is still at cooldown, the following block of removeAuctionConfig will be executed:
    delete auctionConfigs[id];
    delete epochs[id];
    _currentEpochId = id - 1;
Notice that _totalAuctionTokenAllocation is NOT updated.
 
Now, the DaoExecutor wants to set up a new auction (with 100 ether auction token amount) and starts it, so he calls setAuctionConfig and calls startAuction. Normally, we should not feed the Spice contract with 100 ether because the contract already holds it from the previous deleted auction. However, because the _totalAuctionTokenAllocation still holds 100 ether, the epochAuctionTokenAmount will be evaluated to zero, and thus the transaction will revert:
    uint256 totalAuctionTokenAllocation = _totalAuctionTokenAllocation[auctionToken]; 
    uint256 balance = IERC20(auctionToken).balanceOf(address(this)); 
    uint256 epochAuctionTokenAmount = balance - (totalAuctionTokenAllocation - _claimedAuctionTokens[auctionToken]); 
    if (epochAuctionTokenAmount < config.minimumDistributedAuctionToken) { revert NotEnoughAuctionTokens(); } 
So, the contract should be fed with an extra 100 ether if we want to start a new auction with 100 ether tokens.
 
Now, we fed the Spice contract with the extra 100 ether that we were obliged to, the _totalAuctionTokenAllocation will be updated wrongly:
    uint256 totalAuctionTokenAllocation = _totalAuctionTokenAllocation[auctionToken]; 
    uint256 balance = IERC20(auctionToken).balanceOf(address(this)); 
    uint256 epochAuctionTokenAmount = balance - (totalAuctionTokenAllocation - _claimedAuctionTokens[auctionToken]); 
    
    info.totalAuctionTokenAmount = epochAuctionTokenAmount; 
    
    _totalAuctionTokenAllocation[auctionToken] = totalAuctionTokenAllocation + epochAuctionTokenAmount; 
We can see that the _totalAuctionTokenAllocation[auctionToken] is updated to 200 ether while it should have been updated to 100 ether because there is only ONE auction running with 100 ether tokens.
 
Even if the DaoExecutor attempts to recover those extra 100 ether tokens stuck in the contract after the auction ends, he will not be able to do so. For simplicity, let's consider no one has claimed his tokens yet:
function recoverToken(
    address token,
    address to,
    uint256 amount
) external override onlyDAOExecutor {
    
    uint256 epochId = _currentEpochId;
    EpochInfo storage info = epochs[epochId];
    
    uint256 totalAuctionTokenAllocation = _totalAuctionTokenAllocation[token]; 
    uint256 balance = IERC20(token).balanceOf(address(this)); 
    uint256 maxRecoverAmount = balance - (totalAuctionTokenAllocation - _claimedAuctionTokens[token]); 
    
    if (amount > maxRecoverAmount) { revert CommonEventsAndErrors.InvalidParam(); } 
}
 
PoC
The following PoC demonstrates the example illustrated above. Copy/Paste the following code into test/forge/templegold/SpiceAuction.t.sol:
contract SpiceAuctionAccessTest is SpiceAuctionTestBase {
    error NotEnoughAuctionTokens();
    error InvalidParam();
    
function testPoC() public {
    
    _startAuction(true, true);
    address auctionToken = spice.getAuctionTokenForCurrentEpoch();
    vm.startPrank(daoExecutor);
    spice.removeAuctionConfig(); 
        
    uint256 auctionTokenBalance = IERC20(auctionToken).balanceOf(address(spice));
    assertEq(100 ether, auctionTokenBalance);
    
    
    
    ISpiceAuction.SpiceAuctionConfig memory config = _getAuctionConfig();
    spice.setAuctionConfig(config);
    if (config.starter != address(0)) { vm.startPrank(config.starter); }
    vm.warp(block.timestamp + config.waitPeriod);
    vm.expectRevert(abi.encodeWithSelector(NotEnoughAuctionTokens.selector)); 
    spice.startAuction();
    
    auctionToken = config.isTempleGoldAuctionToken ? address(templeGold) : spice.spiceToken();
    dealAdditional(IERC20(auctionToken), address(spice), 100 ether);
    
    auctionTokenBalance = IERC20(auctionToken).balanceOf(address(spice));
    assertEq(200 ether, auctionTokenBalance);
    uint256 epoch = spice.currentEpoch();
    IAuctionBase.EpochInfo memory epochInfo = spice.getEpochInfo(epoch);
    uint64 startTime = uint64(block.timestamp + config.startCooldown);
    uint64 endTime = uint64(startTime+config.duration);
    vm.expectEmit(address(spice));
    emit AuctionStarted(epoch+1, alice, startTime, endTime, 100 ether); 
    spice.startAuction(); 
    vm.startPrank(daoExecutor);
    
    vm.warp(endTime);
    vm.expectRevert(abi.encodeWithSelector(InvalidParam.selector));
    spice.recoverToken(auctionToken, alice, 100 ether);
    
}
}
forge test --mt testPoC
[PASS] testPoC() (gas: 734490)
Impact
If the last auction config at cooldown is removed, two serious impacts will occur:
_totalAuctionTokenAllocation will always contain extra auction tokens for all subsequent epochs.
 
Those extra auction tokens can not be recovered.
 
Tools Used
Manual Review
Recommendations
Consider updating _totalAuctionTokenAllocation when removing the config of the last started auction at the cooldown period. Below is a suggestion for an updated code of SpiceAuction::removeAuctionConfig:
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(); }
    /// @dev could be that `auctionStart` is triggered but there's cooldown, which is not reached (so can delete epochInfo for _currentEpochId)
    // or `auctionStart` is not triggered but `auctionConfig` is set (where _currentEpochId is not updated yet)
    bool configSetButAuctionStartNotCalled = auctionConfigs[id+1].duration > 0;
    if (!configSetButAuctionStartNotCalled) {
        /// @dev unlikely because this is a DAO execution, but avoid deleting old ended auctions
        if (info.hasEnded()) { revert AuctionEnded(); }
        /// auction was started but cooldown has not passed yet
+        SpiceAuctionConfig storage config = auctionConfigs[id];
+        (,address auctionToken) = _getBidAndAuctionTokens(config);
+        _totalAuctionTokenAllocation[auctionToken] -= info.totalAuctionTokenAmount;
        delete auctionConfigs[id];
        delete epochs[id];
        _currentEpochId = id - 1;
        emit AuctionConfigRemoved(id, id);
    } else {
        // `auctionStart` is not triggered but `auctionConfig` is set
        id += 1;
        delete auctionConfigs[id];
        emit AuctionConfigRemoved(id, 0);
    }
}