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.
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:
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;
}
}
* @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 {
TestGauge public gauge;
MockERC20 public rewardToken;
MockERC20 public stakingToken;
MockVeToken public veToken;
MockGaugeController public controller;
address public admin = address(this);
address public user1 = address(0x1);
address public user2 = address(0x2);
uint256 constant PERIOD_DURATION = 7 days;
uint256 constant MAX_EMISSION = 1000e18;
uint256 constant WEEK = 7 days;
uint256 constant BASIS_POINTS = 10000;
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...");
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));
gauge = new TestGauge(
address(rewardToken),
address(stakingToken),
address(controller),
MAX_EMISSION,
PERIOD_DURATION
);
console.log("Deployed TestGauge:", address(gauge));
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));
vm.startPrank(user1);
stakingToken.approve(address(gauge), type(uint256).max);
vm.stopPrank();
vm.startPrank(user2);
stakingToken.approve(address(gauge), type(uint256).max);
vm.stopPrank();
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");
}
* @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");
uint256 stakeAmount = 1e18;
uint256 lockedAmount = 1e18;
uint256 direction = 5000;
console.log("Initial setup:");
console.log("- Stake amount:", stakeAmount);
console.log("- Locked amount:", lockedAmount);
console.log("- Direction:", direction);
veToken.setLockedAmount(user1, 0);
veToken.setBalance(user1, 0);
veToken.setTotalSupply(0);
vm.startPrank(user1);
stakingToken.mint(user1, stakeAmount);
stakingToken.approve(address(gauge), type(uint256).max);
gauge.stake(stakeAmount);
vm.stopPrank();
console.log("Initial staking completed");
veToken.setLockedAmount(user1, lockedAmount);
veToken.setTotalSupply(lockedAmount);
veToken.setBalance(user1, lockedAmount);
vm.startPrank(address(controller));
gauge.setBoostParameters(20000, 5000, 7 days);
vm.stopPrank();
console.log("Voting power setup:");
console.log("- User1 locked amount:", veToken.getLockedAmount(user1));
console.log("- Total supply:", veToken.totalSupply());
vm.startPrank(user1);
gauge.voteDirection(direction);
vm.stopPrank();
console.log("Vote executed successfully");
(, 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"
);
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");
}
}
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;
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;
}
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;
}
function setMinter(address minter) external override {
minterAddress = minter;
}
function calculateVeAmount(uint256 amount, uint256) external pure returns (uint256) {
return amount;
}
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;
}
function getLockedAmount(address user) external view returns (uint256) {
return lockPositions[user].amount;
}
function mint(address to, uint256 amount) external {
require(msg.sender == minterAddress || minterAddress == address(0), "Not minter");
balances[to] += amount;
totalSupplyVal += amount;
LockPosition storage position = lockPositions[to];
position.amount += amount;
position.power += amount;
if (position.end == 0) {
position.end = block.timestamp + 365 days;
}
}
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;
}
}
function setVotingPower(address account, uint256 amount) external {
_votingPower[account] = amount;
}
}
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: