Core Contracts

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

Incorrect Voting Power Calculation in GaugeController Leading to Inflated Voting Weight

Summary

The GaugeController uses balanceOf() instead of getVotingPower() when processing votes, which leads to using inflated voting power that doesn't account for the time decay of the vote-escrowed token (veRAACToken). This allow users to have more voting influence than they should have based on the intended vote-escrow mechanics.

Vulnerability Details

  1. veRAACToken implements a vote-escrow system similar to veCRV where voting power decays linearly over time

  2. The issue is in the GaugeController's vote function where it uses the raw balance instead of the time-weighted voting power:

function vote(address gauge, uint256 weight) external override whenNotPaused {
// This should be getVotingPower() to get the correct voting power
uint256 votingPower = veRAACToken.balanceOf(msg.sender);
}

Linear decay means a value decreases at a constant rate over time.

Using the actual token balance ignores this mechanism completly, because the balance doesn't decrease, instead the calculated voting power decreases and should be used.

For example, if you lock 1000 RAAC for 4 years:

  • Initial voting power (bias) = 1000

  • Slope = 1000 / (4 years in seconds)

  • As time passes, voting power decreases linearly until it reaches 0 at unlock time

Read more about this behavior here [https://resources.curve.fi/vecrv/overview]

PoC

In order to run the test you need to:

  1. Run foundryup to get the latest version of Foundry

  2. Install hardhat-foundry: npm install --save-dev @nomicfoundation/hardhat-foundry

  3. Import it in your Hardhat config: require("@nomicfoundation/hardhat-foundry");

  4. Make sure you've set the BASE_RPC_URL in the .env file or comment out the forking option in the hardhat config.

  5. Run npx hardhat init-foundry

  6. There is one file in the test folder that will throw an error during compilation so rename the file in test/unit/libraries/ReserveLibraryMock.sol to => ReserveLibraryMock.sol_broken so it doesn't get compiled anymore (we don't need it anyways).

  7. Create a new folder test/foundry

  8. Paste the below code into a new test file i.e.: FoundryTest.t.sol

  9. Run the test: forge test --mc FoundryTest -vvvv

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import {Test} from "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20Mock} from "../../contracts/mocks/core/tokens/ERC20Mock.sol";
import {GaugeController} from "../../contracts/core/governance/gauges/GaugeController.sol";
import {RAACGauge} from "../../contracts/core/governance/gauges/RAACGauge.sol";
import {RWAGauge} from "../../contracts/core/governance/gauges/RWAGauge.sol";
import {veRAACToken} from "../../contracts/core/tokens/veRAACToken.sol";
import {IGaugeController} from "../../contracts/interfaces/core/governance/gauges/IGaugeController.sol";
contract FoundryTest is Test {
// Contracts
ERC20Mock public raacToken;
ERC20Mock public stakingToken;
ERC20Mock public rewardToken;
veRAACToken public veToken;
GaugeController public controller;
RAACGauge public raacGauge;
RWAGauge public rwaGauge;
// Test addresses
address public admin = address(this);
address public alice = address(0x1);
address public bob = address(0x2);
address public carol = address(0x3);
// Constants
uint256 public constant INITIAL_SUPPLY = 1_000_000e18;
uint256 public constant LOCK_AMOUNT = 100_000e18;
uint256 public constant YEAR = 365 days;
function setUp() public {
// Deploy mock tokens
raacToken = new ERC20Mock("RAAC Token", "RAAC");
stakingToken = new ERC20Mock("Staking Token", "STK");
rewardToken = new ERC20Mock("Reward Token", "RWD");
// Deploy veToken
veToken = new veRAACToken(address(raacToken));
// Deploy controller
controller = new GaugeController(address(veToken));
// Deploy gauges
raacGauge = new RAACGauge(address(rewardToken), address(stakingToken), address(controller));
rwaGauge = new RWAGauge(address(rewardToken), address(stakingToken), address(controller));
// Setup initial token balances
raacToken.mint(alice, INITIAL_SUPPLY);
raacToken.mint(bob, INITIAL_SUPPLY);
stakingToken.mint(alice, INITIAL_SUPPLY);
stakingToken.mint(bob, INITIAL_SUPPLY);
rewardToken.mint(address(controller), INITIAL_SUPPLY * 10);
// Add gauges to controller
vm.startPrank(admin);
controller.addGauge(address(raacGauge), IGaugeController.GaugeType.RAAC, 5000);
controller.addGauge(address(rwaGauge), IGaugeController.GaugeType.RWA, 5000);
vm.stopPrank();
// Setup approvals
vm.startPrank(alice);
raacToken.approve(address(veToken), type(uint256).max);
stakingToken.approve(address(raacGauge), type(uint256).max);
stakingToken.approve(address(rwaGauge), type(uint256).max);
vm.stopPrank();
vm.startPrank(bob);
raacToken.approve(address(veToken), type(uint256).max);
stakingToken.approve(address(raacGauge), type(uint256).max);
stakingToken.approve(address(rwaGauge), type(uint256).max);
vm.stopPrank();
}
function test_VoteWithWrongVotingPower() public {
vm.startPrank(alice);
veToken.lock(LOCK_AMOUNT, YEAR);
vm.stopPrank();
// wait some time to make sure the voting power declined
vm.warp(block.timestamp + 200 days);
vm.startPrank(alice);
uint256 actualVotingPower = veToken.getVotingPower(alice);
uint256 balance = veToken.balanceOf(alice);
console2.log("Actual Voting Power:", actualVotingPower);
console2.log("Balance:", balance);
uint256 oldWeight = controller.userGaugeVotes(alice, address(raacGauge));
uint256 expectedGaugeWeight = _calculateGaugeWeight(address(raacGauge), oldWeight, 10000, actualVotingPower);
console2.log("Expected Gauge Weight:", expectedGaugeWeight);
controller.vote(address(raacGauge), 10000);
vm.stopPrank();
// get actual gauge weight
uint256 gaugeWeight = controller.getGaugeWeight(address(raacGauge));
console2.log("Gauge Weight:", gaugeWeight);
uint256 difference = gaugeWeight - expectedGaugeWeight;
console2.log("Difference:", difference);
}
function _calculateGaugeWeight(
address gauge,
uint256 oldWeight,
uint256 newWeight,
uint256 votingPower
) internal view returns (uint256) {
(uint256 weight, , , , , ) = controller.gauges(gauge);
uint256 oldGaugeWeight = weight;
uint256 newGaugeWeight = oldGaugeWeight -
((oldWeight * votingPower) / controller.WEIGHT_PRECISION()) +
((newWeight * votingPower) / controller.WEIGHT_PRECISION());
return newGaugeWeight;
}
}

Impact

  • Users can vote with more power than they should have based on the time-decay mechanism

  • This undermines the entire vote-escrow tokenomics where longer lock times should provide more voting power

  • Lead to manipulation of gauge weights and consequently reward distributions

  • The impact increases as time passes and the actual voting power decays while the used balance remains constant

Tools Used

  • Foundry

  • Manual Review

Recommendations

Update the interface for the veRAACToken :

constructor(address _veRAACToken) {
- veRAACToken = IERC20(_veRAACToken);
+ veRAACToken = IveRAACToken(_veRAACToken);
}

Then replace the voting power calculation:

function vote(address gauge, uint256 weight) external override whenNotPaused {
//...
uint256 votingPower = veRAACToken.getVotingPower(msg.sender);
}
Updates

Lead Judging Commences

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