Core Contracts

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

Flawed Voting Power Calculation in veToken System

Summary

The current implementation of voting power in the veToken system incorrectly calculates voting power based solely on raw token balances. This flaw allows users with short lock durations to gain disproportionate influence over governance decisions, undermining the intended design of the system. The report outlines the implications of this flaw, provides a concrete example, and presents a recommended fix.

Current Flawed Implementation

The flawed code snippet for calculating voting power is as follows:

// Gets simple token balance (WRONG)
uint256 votingPower = IERC20(veToken).balanceOf(msg.sender);

How Voting Power Should Work

In a properly designed veToken system, voting power should be calculated using both the amount of tokens held and the duration for which they are locked:

Voting Power = Token Amount × Lock Duration

Where:

  • Max duration (e.g., 4 years) = 100% multiplier

  • Shorter locks receive proportionally less power.

Concrete Example Scenario

Two Users With Same Balance, Different Lock Durations:

User Token Balance Lock Duration True Voting Power Code Reports
A 1,000 4 years 1,000 × 1.0 = 1,000 1,000
B 1,000 1 week 1,000 × 0.005 = 5 1,000

Result:

  • Both users show 1,000 voting power in the gauge.

  • User B has 200x more influence than deserved.

Protocol Impact

  1. Incentive Distortion:

    • Users can exploit the system with short-term locks, leading to a lack of incentive for long-term locking.

    • This undermines the purpose of encouraging users to lock tokens for extended periods to gain higher rewards.

  2. Governance Attacks:

    • Attackers could lock large amounts of tokens for a short duration (e.g., 1 week) to gain control over gauge direction.

    • This could result in draining rewards from legitimate users and skewing governance outcomes.

  3. Reward Distribution Skew:

    • The flawed voting power calculation affects reward distribution:

    // Rewards distributed based on flawed voting power:
    function earned(address account) {
    return getUserWeight(account) * ...; // ← Corrupted weights
    }

Visual Representation

[Intended System] [Current Implementation]
User A 🟢 1000 VP (4y lock) → 1000 VP
User B 🔴 5 VP (1w lock) → 1000 VP

PoC

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../contracts/core/governance/gauges/BaseGauge.sol";
import "../contracts/interfaces/core/governance/gauges/IGauge.sol";
import "./mocks/MockERC20.sol";
import "./mocks/MockVeToken.sol";
/**
* @title TestGauge
* @notice Mock implementation of BaseGauge for testing purposes
* @dev Implements required abstract functions with fixed values for testing
*/
contract TestGauge is BaseGauge {
constructor(
address _rewardToken,
address _stakingToken,
address _controller,
uint256 _maxEmission,
uint256 _periodDuration
)
BaseGauge(
_rewardToken,
_stakingToken,
_controller,
_maxEmission,
_periodDuration
)
{}
function _getBaseWeight(address) internal pure override returns (uint256) {
return 1000; // Fixed weight for testing
}
}
/**
* @title MockGaugeController
* @notice Mock controller implementation for testing gauge interactions
* @dev Simulates basic controller functionality required for tests
*/
contract MockGaugeController {
MockVeToken public veRAACToken;
constructor(address _veToken) {
veRAACToken = MockVeToken(_veToken);
}
function getGaugeWeight(address) external pure returns (uint256) {
return 1000;
}
function getVotingPower(address user) external view returns (uint256) {
return veRAACToken.getLockedAmount(user);
}
}
/**
* @title BaseGaugeTest
* @notice Comprehensive test suite for BaseGauge contract
* @dev Tests are organized into phases focusing on different aspects of functionality
*/
contract BaseGaugeTest is Test {
// Test contracts
TestGauge public gauge;
MockERC20 public rewardToken;
MockERC20 public stakingToken;
MockVeToken public veToken;
MockGaugeController public controller;
// Test addresses
address public admin = address(this);
address public user1 = address(0x1);
address public user2 = address(0x2);
// Constants
uint256 constant PERIOD_DURATION = 7 days;
uint256 constant MAX_EMISSION = 1000e18;
uint256 constant WEEK = 7 days;
uint256 constant BASIS_POINTS = 10000;
// Events
event DirectionVoted(
address indexed user,
uint256 direction,
uint256 votingPower
);
event EmissionUpdated(uint256 emission);
event PeriodUpdated(uint256 timestamp, uint256 avgWeight);
function setUp() public {
console.log("Setting up test environment...");
// Deploy mock tokens
rewardToken = new MockERC20();
stakingToken = new MockERC20();
veToken = new MockVeToken();
controller = new MockGaugeController(address(veToken));
console.log("Deployed mock tokens:");
console.log("- Reward Token:", address(rewardToken));
console.log("- Staking Token:", address(stakingToken));
console.log("- veToken:", address(veToken));
console.log("- Controller:", address(controller));
// Deploy gauge with initial configuration
gauge = new TestGauge(
address(rewardToken),
address(stakingToken),
address(controller),
MAX_EMISSION,
PERIOD_DURATION
);
console.log("Deployed TestGauge:", address(gauge));
// Setup initial token balances
rewardToken.mint(address(gauge), 10000e18);
stakingToken.mint(user1, 1000e18);
stakingToken.mint(user2, 1000e18);
veToken.mint(user1, 100e18);
veToken.mint(user2, 100e18);
console.log("Initial token balances set up");
console.log(
"- Gauge reward balance:",
rewardToken.balanceOf(address(gauge))
);
console.log("- User1 staking balance:", stakingToken.balanceOf(user1));
console.log("- User2 staking balance:", stakingToken.balanceOf(user2));
// Approve tokens for testing
vm.startPrank(user1);
stakingToken.approve(address(gauge), type(uint256).max);
vm.stopPrank();
vm.startPrank(user2);
stakingToken.approve(address(gauge), type(uint256).max);
vm.stopPrank();
// Grant roles
vm.startPrank(address(this));
bytes32 adminRole = gauge.DEFAULT_ADMIN_ROLE();
gauge.grantRole(adminRole, address(this));
gauge.grantRole(gauge.CONTROLLER_ROLE(), address(controller));
gauge.grantRole(gauge.CONTROLLER_ROLE(), address(this));
gauge.grantRole(gauge.FEE_ADMIN(), address(this));
vm.stopPrank();
console.log("Setup complete - roles granted and approvals set");
}
// ======== Phase 3: Voting Power Tests ========
/**
* @notice Test suite for voting power calculations
* @dev Verifies correct handling of voting power and direction setting
* Scenarios:
* 1. Setup initial staking and voting power
* 2. Execute vote with valid voting power
* 3. Attempt vote with zero voting power - should revert
*/
function test_Phase3_VotingPowerCalculation() public {
console.log("\\nTesting Voting Power Calculation");
// ARRANGE: Setup voting power scenario
uint256 stakeAmount = 1e18; // Reduced amount to prevent overflow
uint256 lockedAmount = 1e18;
uint256 direction = 5000; // 50% direction
console.log("Initial setup:");
console.log("- Stake amount:", stakeAmount);
console.log("- Locked amount:", lockedAmount);
console.log("- Direction:", direction);
// Reset initial balances to prevent interference
veToken.setLockedAmount(user1, 0);
veToken.setBalance(user1, 0);
veToken.setTotalSupply(0);
// Setup initial staking
vm.startPrank(user1);
stakingToken.mint(user1, stakeAmount);
stakingToken.approve(address(gauge), type(uint256).max);
gauge.stake(stakeAmount);
vm.stopPrank();
console.log("Initial staking completed");
// Setup initial voting power
veToken.setLockedAmount(user1, lockedAmount);
veToken.setTotalSupply(lockedAmount); // Set total supply equal to locked amount
veToken.setBalance(user1, lockedAmount);
// Set boost parameters before voting
vm.startPrank(address(controller));
gauge.setBoostParameters(20000, 5000, 7 days); // 2x max boost, 0.5x min boost
vm.stopPrank();
console.log("Voting power setup:");
console.log("- User1 locked amount:", veToken.getLockedAmount(user1));
console.log("- Total supply:", veToken.totalSupply());
// ACT: Execute vote
vm.startPrank(user1);
gauge.voteDirection(direction);
vm.stopPrank();
console.log("Vote executed successfully");
// ASSERT: Verify voting power
(, uint256 weight, ) = gauge.userVotes(user1);
console.log("Vote verification:");
console.log("- Expected weight:", lockedAmount);
console.log("- Actual weight:", weight);
assertEq(
weight,
lockedAmount,
"Voting power should equal locked amount"
);
// Test voting with zero locked tokens
console.log("\\nTesting zero voting power scenario");
vm.startPrank(user2);
veToken.setLockedAmount(user2, 0);
veToken.setBalance(user2, 0);
vm.expectRevert(abi.encodeWithSignature("NoVotingPower()"));
gauge.voteDirection(direction);
vm.stopPrank();
console.log("Zero voting power test passed - reverted as expected");
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "../../contracts/interfaces/core/tokens/IveRAACToken.sol";
/**
* @notice A mock implementation of IveRAACToken for testing
*/
contract MockVeToken is IveRAACToken {
uint256 public totalSupplyVal = 10000e18; // Just an arbitrary total supply
mapping(address => uint256) public balances;
mapping(address => LockPosition) public lockPositions;
mapping(address => mapping(address => uint256)) public allowances;
mapping(address => uint256) private _votingPower;
address public minterAddress;
function setBalance(address user, uint256 amount) external {
balances[user] = amount;
lockPositions[user] = LockPosition({
amount: amount,
end: block.timestamp + 365 days,
power: amount
});
}
function balanceOf(address user) external view returns (uint256) {
return balances[user];
}
function totalSupply() external view returns (uint256) {
return totalSupplyVal;
}
function getVotingPower(address user) external view returns (uint256) {
return lockPositions[user].power;
}
function getVotingPower(address user, uint256) external view returns (uint256) {
return lockPositions[user].power;
}
function getTotalVotingPower() external view returns (uint256) {
return totalSupplyVal;
}
function getTotalVotingPower(uint256) external view returns (uint256) {
return totalSupplyVal;
}
function getLockPosition(address user) external view returns (LockPosition memory) {
return lockPositions[user];
}
function setTotalSupply(uint256 amount) external {
totalSupplyVal = amount;
}
// Required interface functions
function lock(uint256 amount, uint256 duration) external override {
balances[msg.sender] = amount;
lockPositions[msg.sender] = LockPosition({
amount: amount,
end: block.timestamp + duration,
power: amount
});
}
function extend(uint256 newDuration) external override {
LockPosition storage position = lockPositions[msg.sender];
position.end = block.timestamp + newDuration;
}
function increase(uint256 amount) external override {
LockPosition storage position = lockPositions[msg.sender];
position.amount += amount;
position.power += amount;
balances[msg.sender] += amount;
}
function withdraw() external override {
LockPosition storage position = lockPositions[msg.sender];
require(block.timestamp >= position.end, "Lock not expired");
uint256 amount = position.amount;
position.amount = 0;
position.power = 0;
balances[msg.sender] = 0;
// In a real implementation, we would transfer tokens here
}
function setMinter(address minter) external override {
minterAddress = minter;
}
function calculateVeAmount(uint256 amount, uint256) external pure returns (uint256) {
return amount; // Simplified calculation for testing
}
function transfer(address to, uint256 amount) external override returns (bool) {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
balances[to] += amount;
return true;
}
function transferFrom(address from, address to, uint256 amount) external override returns (bool) {
require(allowances[from][msg.sender] >= amount, "Insufficient allowance");
require(balances[from] >= amount, "Insufficient balance");
allowances[from][msg.sender] -= amount;
balances[from] -= amount;
balances[to] += amount;
return true;
}
function approve(address spender, uint256 amount) external returns (bool) {
allowances[msg.sender][spender] = amount;
return true;
}
// Additional helper functions for testing
function getLockedAmount(address user) external view returns (uint256) {
return lockPositions[user].amount;
}
// Helper function to mint tokens for testing
function mint(address to, uint256 amount) external {
require(msg.sender == minterAddress || minterAddress == address(0), "Not minter");
balances[to] += amount;
totalSupplyVal += amount;
// Also update lock position for voting power
LockPosition storage position = lockPositions[to];
position.amount += amount;
position.power += amount;
if (position.end == 0) {
position.end = block.timestamp + 365 days;
}
}
// Helper function to set locked amount for testing
function setLockedAmount(address user, uint256 amount) external {
LockPosition storage position = lockPositions[user];
position.amount = amount;
position.power = amount;
if (position.end == 0) {
position.end = block.timestamp + 365 days;
}
}
// Additional helper for tests
function setVotingPower(address account, uint256 amount) external {
_votingPower[account] = amount;
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockERC20 is ERC20 {
uint8 private _decimals = 18;
constructor() ERC20("Mock Token", "MOCK") {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
function setDecimals(uint8 decimals_) external {
_decimals = decimals_;
}
function decimals() public view override returns (uint8) {
return _decimals;
}
function approve(address spender, uint256 amount) public virtual override returns (bool) {
_approve(_msgSender(), spender, amount);
return true;
}
}

The Fix

To correct the voting power calculation, the implementation should use duration-weighted voting power instead of raw balance. The corrected code should look like this:

// CORRECTED CODE
uint256 votingPower = IVeToken(veToken).getVotingPower(msg.sender);

After Fix:

[Fixed System]
User A 🟢 1000 VP
User B 🔴 5 VP

Recommendations

  1. Implement Duration-Weighted Voting Power: Update the voting power calculation to use the correct formula that incorporates both token amount and lock duration.

  2. Testing: Conduct thorough testing to ensure that the new voting power calculation works as intended and that it accurately reflects user influence based on their lock durations.

  3. User Education: Inform users about the changes made to the voting power calculation and the importance of long-term locking for governance participation.

  4. Monitoring: Implement monitoring tools to track voting power distributions and detect any anomalies that may arise from governance actions.

Updates

Lead Judging Commences

inallhonesty Lead Judge 3 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.