TempleGold

TempleDAO
Foundry
25,000 USDC
View results
Submission Details
Severity: medium
Invalid

Value extraction from regular users by MEV bots

Summary

If someone deposits a significant amount into the staking pool in the last second of the epoch, other participants in the DaiGoldAuction will receive fewer TGLD tokens than expected.

Vulnerability details

DaiGoldAuction is used to distribute the TGLD tokens minted from TempleGold. When TempleGold.mint() is called, the mintAmount is calculated based on the elapsed time since the previous mint and then distributed across DaiGoldAuction (escrow), TempleGoldStaking, and teamGnosis.

function mint() external override onlyArbitrum {
VestingFactor memory vestingFactorCache = vestingFactor;
DistributionParams storage distributionParamsCache = distributionParams;
if (vestingFactorCache.numerator == 0) { revert ITempleGold.MissingParameter(); }
>> uint256 mintAmount = _getMintAmount(vestingFactorCache);
/// @dev no op silently
if (!_canDistribute(mintAmount)) { return; }
lastMintTimestamp = uint32(block.timestamp);
>> _distribute(distributionParamsCache, mintAmount);
}
function _distribute(DistributionParams storage params, uint256 mintAmount) private {
uint256 stakingAmount = TempleMath.mulDivRound(params.staking, mintAmount, DISTRIBUTION_DIVISOR, false);
if (stakingAmount > 0) {
_mint(address(staking), stakingAmount);
staking.notifyDistribution(stakingAmount);
}
uint256 escrowAmount = TempleMath.mulDivRound(params.escrow, mintAmount, DISTRIBUTION_DIVISOR, false);
if (escrowAmount > 0) {
_mint(address(escrow), escrowAmount);
>> escrow.notifyDistribution(escrowAmount);
}
uint256 gnosisAmount = mintAmount - stakingAmount - escrowAmount;
if (gnosisAmount > 0) {
_mint(teamGnosis, gnosisAmount);
/// @notice no requirement to notify gnosis because no action has to be taken
}
_totalDistributed += mintAmount;
emit Distributed(stakingAmount, escrowAmount, gnosisAmount, block.timestamp);
}

When DaiGoldAuction.notifyDistribution() is called, the amount is added to the next epoch amount - nextAuctionGoldAmount.

function notifyDistribution(uint256 amount) external override {
if (msg.sender != address(templeGold)) { revert CommonEventsAndErrors.InvalidAccess(); }
/// @notice Temple Gold contract mints TGLD amount to contract before calling `notifyDistribution`
>> nextAuctionGoldAmount += amount;
emit GoldDistributionNotified(amount, block.timestamp);
}

When the next epoch starts - DaiGoldAuction.startAuction(), nextEpochInfo.totalAuctionTokenAmount is set 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();
>> 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);
}

When someone bid() in the DaiGoldAuction, the totalBidTokenAmount and the account’s deposited amount for the current epoch increase.

function bid(uint256 amount) external virtual override onlyWhenLive {
if (amount == 0) { revert CommonEventsAndErrors.ExpectedNonZero(); }
bidToken.safeTransferFrom(msg.sender, treasury, amount);
uint256 epochIdCache = _currentEpochId;
>> depositors[msg.sender][epochIdCache] += amount;
EpochInfo storage info = epochs[epochIdCache];
>> info.totalBidTokenAmount += amount;
emit Deposit(msg.sender, epochIdCache, amount);
}

When the epoch ends, all participants who have bid can claim a portion of the totalAuctionTokenAmount.

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);
}

Consider the following scenario:

  • Alice, Bob, Charlie, and Dave have each participated in the current DaiGoldAuction epoch with 1000 DAI.

  • The current pool of bids is 4000 DAI (totalBidTokenAmount).

  • 10_000e18 TGLD tokens will be distributed (totalAuctionTokenAmount).

  • The current epoch is ending in the next few blocks.

Here the issue occurs: An MEV bot that places a transaction just before the end of the epoch can withdraw all tokens from other participants (e.g., Alice, Bob, Charlie, and Dave). The collected reward (TGLD) can then be exchanged for another transferable, external asset (e.g., DAI, USDC) in some of the SpiceAuctions.

With the current design of the DaiGoldAuction, the end of an epoch creates intense competition between different MEV bots. This leaves regular users (EOAs) unable to increase their bids because they either won't notice the new bids or won't have the opportunity to bid additionally. The last block can be filled to prevent other users or MEV bots from bidding. The winner of the pot will be the MEV bot that successfully inserts the last bid transaction.

Impact

Regular users will receive little to no TGLD tokens and will lose all of their bid tokens.

Proof of Concept

The formula for claimAmount is:

If we consider the scenario where the MEV bot does not intervene: TGLD will be received each.

However, if the MEV bot bid() with 50000e18 in the last second of the epoch:

  • TGLD will be received by regular users

  • TGLD will be received by the MEV bot

Paste these tests in TempleGold.t.sol and run them with forge test --mt test_claimWith -vvv.

function test_claimWithoutMEV() public {
skip(10);
templeGold.mint();
daiGoldAuction.startAuction();
uint256 bidAmount = 2000e18;
deal(daiToken, alice, bidAmount);
deal(daiToken, bob, bidAmount);
vm.startPrank(alice);
FakeERC20(daiToken).approve(address(daiGoldAuction), bidAmount);
daiGoldAuction.bid(bidAmount);
vm.stopPrank();
vm.startPrank(bob);
FakeERC20(daiToken).approve(address(daiGoldAuction), bidAmount);
daiGoldAuction.bid(bidAmount);
vm.stopPrank();
uint epochId = daiGoldAuction.currentEpoch();
vm.warp(daiGoldAuction.getEpochInfo(epochId).endTime + 1);
assert(daiGoldAuction.isCurrentEpochEnded() == true);
vm.prank(alice);
daiGoldAuction.claim(epochId);
emit log_uint(templeGold.balanceOf(alice));
vm.prank(bob);
daiGoldAuction.claim(epochId);
emit log_uint(templeGold.balanceOf(bob));
}
function test_claimWithMEV() public {
address mev = makeAddr("MEV");
skip(10);
templeGold.mint();
daiGoldAuction.startAuction();
uint256 bidAmount = 2000e18;
deal(daiToken, alice, bidAmount);
deal(daiToken, bob, bidAmount);
deal(daiToken, mev, 50000e18);
vm.startPrank(alice);
FakeERC20(daiToken).approve(address(daiGoldAuction), bidAmount);
daiGoldAuction.bid(bidAmount);
vm.stopPrank();
vm.startPrank(bob);
FakeERC20(daiToken).approve(address(daiGoldAuction), bidAmount);
daiGoldAuction.bid(bidAmount);
vm.stopPrank();
vm.startPrank(mev);
FakeERC20(daiToken).approve(address(daiGoldAuction), 50000e18);
daiGoldAuction.bid(50000e18);
vm.stopPrank();
uint epochId = daiGoldAuction.currentEpoch();
vm.warp(daiGoldAuction.getEpochInfo(epochId).endTime + 1);
assert(daiGoldAuction.isCurrentEpochEnded() == true);
vm.prank(alice);
daiGoldAuction.claim(epochId);
emit log_uint(templeGold.balanceOf(alice));
vm.prank(bob);
daiGoldAuction.claim(epochId);
emit log_uint(templeGold.balanceOf(bob));
vm.prank(mev);
daiGoldAuction.claim(epochId);
emit log_uint(templeGold.balanceOf(mev));
}

Result - regular users receive 5,555,555e18 TGLD less:

[PASS] test_claimWithMEV() (gas: 1000848)
Logs:
444444444444444444444444
444444444444444444444444
11111111111111111111111111
[PASS] test_claimWithoutMEV() (gas: 759960)
Logs:
6000000000000000000000000
6000000000000000000000000

Tools Used

Manual Review

Recommended Mitigation Steps

Due to the complexity of the current DaiGoldAuction implementation, providing a complete solution to the issue is challenging. However, here are a few ideas:

  • Add a slippage check to the claim function.

  • Set a limit on the maximum bid amount.

  • Decrease the maximum bid amount proportionally to the time left in the auction.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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