TempleGold

TempleDAO
Foundry
25,000 USDC
View results
Submission Details
Severity: low
Valid

If no bids are placed to the DaiGoldAuction or SpiceAuction contracts before an auction ends, the auctioned tokens will get stuck in the contract

Summary

If no bids are placed on DaiGoldAuction in an entire epoch, the auctioned tokens will get stuck in the contact, with no way to recover them.

Vulnerability Details

The startAuction() function is in charge of starting a new auction, and allocating the auctioned tokens. When called, a new auction starts, and the field totalAuctionTokenAmount of TempleGOLD tokens is set for the new auction. Once the auction finishes, the totalAuctionTokenAmount can be claimed by the bidders, proportionally to their bids.

However, if there are no bidders, nobody can claim the allocated auction tokens because any wallet calling claim() will get a CommonEventsAndErrors.ExpectedNonZero() revert message.

/**
* @notice Claim share of Temple Gold for epoch
* Can only claim for past epochs, not current auction epoch.
* @param epochId Id of epoch
*/
function claim(uint256 epochId) external virtual override {
/// @notice cannot claim for current live epoch
EpochInfo storage info = epochs[epochId];
if (!info.hasEnded()) { revert CannotClaim(epochId); }
/// @dev epochId could be invalid. eg epochId > _currentEpochId
if (info.startTime == 0) { revert InvalidEpoch(); }
uint256 bidTokenAmount = depositors[msg.sender][epochId];
if (bidTokenAmount == 0) { revert CommonEventsAndErrors.ExpectedNonZero(); }
delete depositors[msg.sender][epochId];
uint256 claimAmount = bidTokenAmount.mulDivRound(info.totalAuctionTokenAmount, info.totalBidTokenAmount, false);
templeGold.safeTransfer(msg.sender, claimAmount);
emit Claim(msg.sender, epochId, bidTokenAmount, claimAmount);
}

Moreover, the startAuction() function does not carry over the unallocated totalAuctionTokenAmount to the next round if no bids are placed, which will make these auction tokens stuck in the DaiGoldAuction contract.

function startAuction() external override {
if (auctionStarter != address(0) && msg.sender != auctionStarter) { revert CommonEventsAndErrors.InvalidAccess(); }
EpochInfo storage prevAuctionInfo = epochs[_currentEpochId];
if (!prevAuctionInfo.hasEnded()) { revert CannotStartAuction(); }
AuctionConfig storage config = auctionConfig;
/// @notice last auction end time plus wait period
if (_currentEpochId > 0 && (prevAuctionInfo.endTime + config.auctionsTimeDiff > block.timestamp)) {
revert CannotStartAuction();
}
_distributeGold();
@> uint256 totalGoldAmount = nextAuctionGoldAmount;
nextAuctionGoldAmount = 0;
uint256 epochId = _currentEpochId = _currentEpochId + 1;
if (totalGoldAmount < config.auctionMinimumDistributedGold) { revert LowGoldDistributed(totalGoldAmount); }
EpochInfo storage info = epochs[epochId];
@> info.totalAuctionTokenAmount = totalGoldAmount;
uint128 startTime = info.startTime = uint128(block.timestamp) + config.auctionStartCooldown;
uint128 endTime = info.endTime = startTime + AUCTION_DURATION;
emit AuctionStarted(epochId, msg.sender, startTime, endTime, totalGoldAmount);
}

Note that there is a function called recoverToken(), that allows recovering templeGold tokens.
However, this function only allows recovering tokens from the last non-started auction. Therefore, it is not possible to recover tokens from the last finished auction.

/**
* @notice Recover auction tokens for last but not started auction.
* Any other token which is not Temple Gold can be recovered too at any time
* @dev For recovering Temple Gold, Epoch data is deleted and leftover amount is addedd to nextAuctionGoldAmount.
* so admin should recover total auction amount for epoch if that's the requirement
* @param token Token to recover
* @param to Recipient
* @param amount Amount to auction tokens
*/
function recoverToken(
address token,
address to,
uint256 amount
) external override onlyElevatedAccess {
if (to == address(0)) { revert CommonEventsAndErrors.InvalidAddress(); }
if (amount == 0) { revert CommonEventsAndErrors.ExpectedNonZero(); }
if (token != address(templeGold)) {
emit CommonEventsAndErrors.TokenRecovered(to, token, amount);
IERC20(token).safeTransfer(to, amount);
return;
}
// auction started but cooldown pending
uint256 epochId = _currentEpochId;
EpochInfo storage info = epochs[epochId];
if (info.startTime == 0) { revert InvalidOperation(); }
if (info.isActive()) { revert AuctionActive(); }
if (info.hasEnded()) { revert AuctionEnded(); }
uint256 _totalAuctionTokenAmount = info.totalAuctionTokenAmount; // templeGold token
if (amount > _totalAuctionTokenAmount) { revert CommonEventsAndErrors.InvalidAmount(token, amount); }
/// @dev Epoch data is deleted and leftover amount is addedd to nextAuctionGoldAmount.
delete epochs[epochId];
/// @dev `nextAuctionGoldAmount` is set to 0 in `startAuction`.
unchecked {
nextAuctionGoldAmount += _totalAuctionTokenAmount - amount;
}
emit CommonEventsAndErrors.TokenRecovered(to, token, amount);
templeGold.safeTransfer(to, amount);
}

Other instances of the issue

The SpiceAuction contract has the same issue.

Impact

The auction tokens allocated to an auction will get stuck in the DaiGoldAuction contract if no bids are placed.

Proof of code

The following test proofs that if an auction has no bids, the allocated auction tokens are not passed to the next auction, and they stay however in the contract balance, with no means of recovering them.

This test can be added in the DaiGoldAuctionTest inside the existing test file protocol/test/forge/templegold/DaiGoldAuction.t.sol.

function test_auctionWithoutBidsLeadsToStuckFunds() public {
// setting up the auction for the test
_setVestingFactor(templeGold);
skip(1 days);
vm.startPrank(executor);
_startAuction();
// read some parameters of the current auction
uint256 currentEpoch = daiGoldAuction.currentEpoch();
IAuctionBase.EpochInfo memory info = daiGoldAuction.getEpochInfo(currentEpoch);
// the auction finishes with no bids
vm.warp(info.endTime);
// Some more TempleGold tokens have been released, and nextAuctionGoldAmount is large enough to start a new auction
vm.startPrank(executor);
_startAuction();
// read parameters from the new auction
IAuctionBase.EpochInfo memory newInfo = daiGoldAuction.getEpochInfo(daiGoldAuction.currentEpoch());
// after starting the new auction nextAuctionGoldAmount should be 0 again, as it has been distributed
uint256 nextAuctionGoldAmount = daiGoldAuction.nextAuctionGoldAmount();
assertEq(nextAuctionGoldAmount, 0, "right after starting a new auction, this should be 0");
// since there were no bids in the previous round, and the auction ended, the "claimable" tokens from last round is 0
uint256 pendingToBeClaimedFromPreviousAuctions = 0;
// at any given time, the TempleGold balance of the DaiGoldAuction contract should be:
// (currentAuction.totalAuctionTokenAmount + nextAuctionGoldAmount + pendingToBeClaimedFromPreviousAuctions)
// note: This assertion reverts with the current implementation
assertEq(
templeGold.balanceOf(address(daiGoldAuction)),
newInfo.totalAuctionTokenAmount + nextAuctionGoldAmount + pendingToBeClaimedFromPreviousAuctions,
"the balance of the contract should be (pendingToBeClaimedFromPreviousAuctions + currentAuction.totalAuctionTokenAmount + nextAuctionGoldAmount)"
);
// instead what happens, is that the balance of the contract is the auctionTokenAmount from both rounds, plus the nexAuctionGoldAmount
// note: This assertion reverts with the current implementation
assertFalse(
templeGold.balanceOf(address(daiGoldAuction)) ==
info.totalAuctionTokenAmount + newInfo.totalAuctionTokenAmount + nextAuctionGoldAmount + pendingToBeClaimedFromPreviousAuctions
);
// note that the tokens cannot be recovered because they are from a previous round
}

Tools Used

Manual review, foundry for PoC.

Recommendations

If no bids are placed, carry on the allocated auction tokens to the next epoch by adding it to nextAuctionGoldAmount.

function startAuction() external override {
if (auctionStarter != address(0) && msg.sender != auctionStarter) { revert CommonEventsAndErrors.InvalidAccess(); }
EpochInfo storage prevAuctionInfo = epochs[_currentEpochId];
if (!prevAuctionInfo.hasEnded()) { revert CannotStartAuction(); }
AuctionConfig storage config = auctionConfig;
/// @notice last auction end time plus wait period
if (_currentEpochId > 0 && (prevAuctionInfo.endTime + config.auctionsTimeDiff > block.timestamp)) {
revert CannotStartAuction();
}
_distributeGold();
+ if (prevAuctionInfo.totalBidTokenAmount == 0) {
+ nextAuctionGoldAmount += prevAuctionInfo.totalAuctionTokenAmount;
+ }
uint256 totalGoldAmount = nextAuctionGoldAmount;
nextAuctionGoldAmount = 0;
uint256 epochId = _currentEpochId = _currentEpochId + 1;
if (totalGoldAmount < config.auctionMinimumDistributedGold) { revert LowGoldDistributed(totalGoldAmount); }
EpochInfo storage info = epochs[epochId];
info.totalAuctionTokenAmount = totalGoldAmount;
uint128 startTime = info.startTime = uint128(block.timestamp) + config.auctionStartCooldown;
uint128 endTime = info.endTime = startTime + AUCTION_DURATION;
emit AuctionStarted(epochId, msg.sender, startTime, endTime, totalGoldAmount);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 11 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Auctioned tokens cannot be recovered for epochs with empty bids in DaiGoldAuction

Support

FAQs

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