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);
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);
}
_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(); }
>> 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;
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 {
EpochInfo storage info = epochs[epochId];
if (!info.hasEnded()) { revert CannotClaim(epochId); }
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:
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.