Core Contracts

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

Missing Vote Delay and Minimum Vote Weight Checks in GaugeController::vote: Insufficient Constraints Enable Arbitrary Frequent Voting and Weight Manipulation

Summary

The GaugeController contract’s vote function allows any user with sufficient voting power (derived from their veRAAC token balance) to cast votes on gauges. Its purpose is to update the gauge’s weight based on the user's chosen vote weight, which in turn affects reward distribution and governance outcomes. The function performs several checks: it verifies that the gauge exists, that the vote weight does not exceed WEIGHT_PRECISION, and that the caller has nonzero voting power. It then updates the gauge’s weight via an internal function and emits a WeightUpdated event.

However, critical constraints are missing. Although the contract defines constants such as:

  • VOTE_DELAY (required delay between votes),

  • MIN_VOTE_DELAY, MAX_VOTE_DELAY (allowed bounds for vote delay),

  • MIN_VOTE_WEIGHT (minimum allowed vote weight),

these are not enforced in the vote function. Consequently, there is no mechanism to prevent a user from voting repeatedly in a short period or casting votes with weights below the minimum threshold. This oversight may allow malicious actors to manipulate gauge weights arbitrarily, thereby skewing reward distributions and governance outcomes.

The following excerpt from GaugeController::vote illustrates the issue:

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);
}

Missing Checks:

  • Vote Delay: No verification that the caller waited at least VOTE_DELAY seconds since their last vote.

  • Minimum Vote Weight: No enforcement of a minimum vote weight (MIN_VOTE_WEIGHT).

Vulnerability Details

How the Issue Arises

  1. Function Flow:

    • The vote function first checks that the provided gauge is registered via isGauge(gauge).

    • It verifies that the vote weight does not exceed WEIGHT_PRECISION.

    • The function then retrieves the caller’s voting power via veRAACToken.balanceOf(msg.sender) and ensures it is nonzero.

    • It records the vote weight and calls _updateGaugeWeight to adjust the gauge’s weight using the formula:

      newGaugeWeight =
      oldGaugeWeight - (oldWeight _ votingPower / WEIGHT_PRECISION) + (newWeight _ votingPower / WEIGHT_PRECISION);

    • Finally, it emits a WeightUpdated event.

  2. Missing Constraints:

    • Voting Delay: The contract defines VOTE_DELAY (e.g., 10 days) and maintains a lastVoteTime mapping for each user, but these are not checked before a vote is accepted. Without a delay, users can vote repeatedly, possibly altering gauge weights in rapid succession.

    • Minimum Vote Weight: Although MIN_VOTE_WEIGHT is declared (e.g., 100, representing a 1% minimum vote), the vote function does not enforce that the submitted weight is at least this minimum. Votes with extremely low weight might be accepted, which could enable spamming or dilution of meaningful votes.

  3. Protocol Implications:

    • Reward Distribution: Gauge weights determine the distribution of rewards. If voters can manipulate their votes arbitrarily, the reward allocation can be skewed, potentially resulting in an unfair distribution.

    • Governance and System Stability: Frequent and arbitrary vote changes may undermine the intended stability of the gauge system, possibly affecting long-term governance decisions and protocol integrity.

Proof of Concept

Scenario Walkthrough

  1. Setup:

    • A gauge is added using GaugeController::addGauge by a gauge admin.

    • The gauge is now active, and any user with sufficient veRAAC voting power can cast a vote.

  2. Voting Without Constraints:

    • Initial Vote:
      User ALICE has locked RAAC tokens to obtain veRAAC tokens. ALICE calls vote(address(rwaGauge), 100) and successfully casts a vote.

    • Rapid Re-Voting:
      Immediately after, ALICE calls vote(address(rwaGauge), 99) to change her vote. Since there is no check on voting frequency, the contract processes this vote without any delay requirement.

    • Minimum Weight Violation:
      A user could even vote with a weight lower than MIN_VOTE_WEIGHT (if not explicitly reverted), potentially polluting the gauge weight calculations.

  3. Foundry Test Suite:

    The following Foundry test demonstrates the issue:

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.19;
    import {Test, console} from "forge-std/Test.sol";
    import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
    import {BaseGauge} from "../src/core/governance/gauges/BaseGauge.sol";
    import {GaugeController} from "../src/core/governance/gauges/GaugeController.sol";
    import {RAACGauge} from "../src/core/governance/gauges/RAACGauge.sol";
    import {RWAGauge} from "../src/core/governance/gauges/RWAGauge.sol";
    import {veRAACToken} from "../src/core/tokens/veRAACToken.sol";
    import {RAACToken} from "../src/core/tokens/RAACToken.sol";
    import {RewardTokenMock} from "./mocks/RewardTokenMock.m.sol";
    import {StakingTokenMock} from "./mocks/StakingTokenMock.m.sol";
    import {IGaugeController} from "../src/interfaces/core/governance/gauges/IGaugeController.sol";
    import {IGauge} from "../src/interfaces/core/governance/gauges/IGauge.sol";
    contract GaugeTest is Test {
    GaugeController gaugeController;
    BaseGauge baseGauge;
    RAACGauge raacGauge;
    RWAGauge rwaGauge;
    veRAACToken veToken;
    RAACToken raacToken;
    RewardTokenMock rewardToken;
    StakingTokenMock stakingToken;
    address GAUGE_CONTROLLER_OWNER = makeAddr("GAUGE_CONTROLLER_OWNER");
    address RAAC_GAUGE_OWNER = makeAddr("RAAC_GAUGE_OWNER");
    address RWA_GAUGE_OWNER = makeAddr("RWA_GAUGE_OWNER");
    address RAAC_OWNER = makeAddr("RAAC_OWNER");
    address RAAC_MINTER = makeAddr("RAAC_MINTER");
    uint256 initialRaacSwapTaxRateInBps = 200; // 2%
    uint256 initialRaacBurnTaxRateInBps = 150; // 1.5%
    uint256 MAX_LOCK_AMOUNT = 10_000_000e18;
    uint256 MAX_LOCK_DURATION = 1460 days;
    address VE_RAAC_OWNER = makeAddr("VE_RAAC_OWNER");
    address ALICE = makeAddr("ALICE");
    address BOB = makeAddr("BOB");
    address CHARLIE = makeAddr("CHARLIE");
    address DEVIL = makeAddr("DEVIL");
    function setUp() public {
    vm.startPrank(RAAC_OWNER);
    raacToken = new RAACToken(RAAC_OWNER, initialRaacSwapTaxRateInBps, initialRaacBurnTaxRateInBps);
    vm.stopPrank();
    vm.startPrank(VE_RAAC_OWNER);
    veToken = new veRAACToken(address(raacToken));
    vm.stopPrank();
    rewardToken = new RewardTokenMock();
    stakingToken = new StakingTokenMock();
    vm.startPrank(GAUGE_CONTROLLER_OWNER);
    gaugeController = new GaugeController(address(veToken));
    vm.stopPrank();
    vm.startPrank(RAAC_GAUGE_OWNER);
    raacGauge = new RAACGauge(address(rewardToken), address(stakingToken), address(gaugeController));
    vm.stopPrank();
    vm.startPrank(RWA_GAUGE_OWNER);
    rwaGauge = new RWAGauge(address(rewardToken), address(stakingToken), address(gaugeController));
    vm.stopPrank();
    }
    function raacTokenAllotmentAndAcquireVeRaac() private {
    vm.startPrank(RAAC_OWNER);
    raacToken.setMinter(RAAC_MINTER);
    vm.stopPrank();
    vm.startPrank(RAAC_MINTER);
    raacToken.mint(ALICE, MAX_LOCK_AMOUNT);
    raacToken.mint(BOB, MAX_LOCK_AMOUNT);
    raacToken.mint(CHARLIE, MAX_LOCK_AMOUNT);
    raacToken.mint(DEVIL, MAX_LOCK_AMOUNT);
    raacToken.mint(ALICE, MAX_LOCK_AMOUNT);
    vm.stopPrank();
    }
    function raacTokenLock() private {
    vm.startPrank(ALICE);
    raacToken.approve(address(veToken), MAX_LOCK_AMOUNT);
    veToken.lock(MAX_LOCK_AMOUNT, MAX_LOCK_DURATION);
    vm.stopPrank();
    vm.startPrank(BOB);
    raacToken.approve(address(veToken), MAX_LOCK_AMOUNT);
    veToken.lock(MAX_LOCK_AMOUNT, MAX_LOCK_DURATION);
    vm.stopPrank();
    vm.startPrank(CHARLIE);
    raacToken.approve(address(veToken), MAX_LOCK_AMOUNT);
    veToken.lock(MAX_LOCK_AMOUNT, MAX_LOCK_DURATION);
    vm.stopPrank();
    vm.startPrank(DEVIL);
    raacToken.approve(address(veToken), MAX_LOCK_AMOUNT);
    veToken.lock(MAX_LOCK_AMOUNT, MAX_LOCK_DURATION);
    vm.stopPrank();
    }
    function testGuageControllerVotingHasNotImpConstraints() public {
    raacTokenAllotmentAndAcquireVeRaac();
    raacTokenLock();
    uint256 initialWeight = 0;
    uint256 weight = 100;
    vm.startPrank(GAUGE_CONTROLLER_OWNER);
    gaugeController.addGauge(address(rwaGauge), IGaugeController.GaugeType.RWA, initialWeight);
    vm.stopPrank();
    (
    uint256 weightStored,
    uint256 typeWeight,
    uint256 lastUpdateTime,
    IGaugeController.GaugeType gaugeType,
    bool isActive,
    uint256 lastRewardTime
    ) = gaugeController.gauges(address(rwaGauge));
    console.log("Gauge information before vote...");
    console.log("weight : ", weightStored);
    console.log("typeWeight : ", typeWeight);
    console.log("lastUpdateTime : ", lastUpdateTime);
    console.log("gaugeType : ", uint256(gaugeType));
    console.log("isActive : ", isActive);
    console.log("lastRewardTime : ", lastRewardTime);
    vm.startPrank(ALICE);
    gaugeController.vote(address(rwaGauge), weight);
    vm.stopPrank();
    (weightStored, typeWeight, lastUpdateTime, gaugeType, isActive, lastRewardTime) =
    gaugeController.gauges(address(rwaGauge));
    uint256 aliceVotingPower = veToken.balanceOf(ALICE);
    console.log("Gauge information after vote...");
    console.log("expectedWeight : ", (aliceVotingPower * weight) / 10000);
    console.log("weight : ", weightStored);
    console.log("typeWeight : ", typeWeight);
    console.log("lastUpdateTime : ", lastUpdateTime);
    console.log("gaugeType : ", uint256(gaugeType));
    console.log("isActive : ", isActive);
    console.log("lastRewardTime : ", lastRewardTime);
    vm.startPrank(ALICE);
    gaugeController.vote(address(rwaGauge), weight - 1);
    vm.stopPrank();
    (weightStored, typeWeight, lastUpdateTime, gaugeType, isActive, lastRewardTime) =
    gaugeController.gauges(address(rwaGauge));
    aliceVotingPower = veToken.balanceOf(ALICE);
    console.log("Gauge information after 2nd vote...");
    console.log("expectedWeight : ", (aliceVotingPower * (weight - 1)) / 10000);
    console.log("weight : ", weightStored);
    console.log("typeWeight : ", typeWeight);
    console.log("lastUpdateTime : ", lastUpdateTime);
    console.log("gaugeType : ", uint256(gaugeType));
    console.log("isActive : ", isActive);
    console.log("lastRewardTime : ", lastRewardTime);
    }
    }

How to Run the Test

  1. Step 1: Create a new Foundry project (if not already done):

    forge init my-foundry-project
  2. Step 2: Remove any unnecessary files from the project.

  3. Step 3: Convert your Hardhat project (if applicable) to a Foundry project by placing your contracts in the src directory.

  4. Step 4: Create a test directory adjacent to your src folder and add all necessary contract files and mocks.

  5. Step 5: Paste the above test suite into a new test file (e.g., GaugeTest.t.sol) in the test directory.

  6. Step 6: Run the test with:

    forge test --mt testGuageControllerVotingHasNotImpConstraints -vv
  7. Expected Log Output:

    [⠒] Compiling...
    No files changed, compilation skipped
    Ran 1 test for test/GaugeTest.t.sol:GaugeTest
    [PASS] testGuageControllerVotingHasNotImpConstraints() (gas: 1638843)
    Logs:
    Gauge information before vote...
    weight : 0
    typeWeight : 0
    lastUpdateTime : 1
    gaugeType : 0
    isActive : true
    lastRewardTime : 1
    Gauge information after vote...
    expectedWeight : 100000000000000000000000
    weight : 100000000000000000000000
    typeWeight : 0
    lastUpdateTime : 1
    gaugeType : 0
    isActive : true
    lastRewardTime : 1
    Gauge information after 2nd vote...
    expectedWeight : 99000000000000000000000
    weight : 99000000000000000000000
    typeWeight : 0
    lastUpdateTime : 1
    gaugeType : 0
    isActive : true
    lastRewardTime : 1
    Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.38ms (839.80µs CPU time)
    Ran 1 test suite in 8.55ms (3.38ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Impact

  • Reward Distribution Distortion:
    Unrestricted voting may allow malicious users to repeatedly cast votes, thereby artificially inflating or deflating gauge weights. This can lead to skewed reward distributions, where some gauges receive disproportionate allocations.

  • Governance Manipulation:
    Frequent vote changes could influence governance outcomes by modifying the relative weight of gauges. This undermines the intended fairness and stability of the governance mechanism.

  • System Abuse and Spam:
    Without a voting delay, attackers may spam the vote function with minimal cost, burdening the system and potentially increasing gas usage unnecessarily.

  • Long-Term Trust Erosion:
    Persistent manipulation of gauge weights and rewards can lead to a loss of trust among protocol participants and undermine confidence in the system’s fairness and security.

Tools Used

  • Manual Review

  • Foundry

Recommendations

The following diffs provide recommendations to incorporate the missing constraints (voting delay and minimum vote weight) in the vote function of the GaugeController contract.

Diff for GaugeController::vote

function vote(address gauge, uint256 weight) external override whenNotPaused {
if (!isGauge(gauge)) revert GaugeNotFound();
if (weight > WEIGHT_PRECISION) revert InvalidWeight();
+ if (weight < MIN_VOTE_WEIGHT) revert InvalidWeight(); // Enforce minimum vote weight
+
+ // Enforce voting delay: Ensure that the user waits at least VOTE_DELAY seconds between votes
+ if (block.timestamp < lastVoteTime[msg.sender] + VOTE_DELAY) {
+ revert("VoteTooFrequent");
+ }
+ lastVoteTime[msg.sender] = block.timestamp;
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);
}

Implementing these changes ensures that voters cannot manipulate gauge weights by frequently re-voting or by submitting votes with negligible weight. This strengthens the integrity of reward distribution, stabilizes governance processes, and upholds the trust in the protocol's gauge system.

Updates

Lead Judging Commences

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

GaugeController::vote never enforces VOTE_DELAY or updates lastVoteTime, allowing users to spam votes and manipulate gauge weights without waiting

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

GaugeController::vote never enforces VOTE_DELAY or updates lastVoteTime, allowing users to spam votes and manipulate gauge weights without waiting

Support

FAQs

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