Core Contracts

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

Faulty Gauge Weight Update Formula: Voting Power Delta Not Considered Leading to Arithmetic Underflow and Vote Weight Inconsistency

Summary

The GaugeController contract facilitates voting on gauges by users with sufficient voting power—derived from their veRAAC token holdings. When a user votes, the contract calls the internal function _updateGaugeWeight to adjust the gauge's weight using the formula:

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

This formula is designed to subtract the user's previous vote contribution and add the new contribution based on their current voting power. Under the assumption that the user's voting power remains unchanged, the math works correctly. However, if the user’s voting power increases—due either to additional tokens locked (which increases veRAAC) or due to decaying weight not being recalculated properly, the formula does not account for the delta (i.e., the change in voting power).

For example, consider the following scenario:

  • Initially, a user votes with weight = 100 and a voting power of 100000e18, resulting in a gauge weight of:

    newGaugeWeight = 0 - (0) + (100 * 100000e18 / 10000) = 1000e18.

  • Later, the user increases their voting power to 10_000_000e18 and changes their vote weight to 5000. The intended calculation is:

    newGaugeWeight = 1000e18 - (100 * 10_000_000e18 / 10000) + (5000 * 10_000_000e18 / 10000).

    However, due to left-to-right evaluation in Solidity, the subtraction 1000e18 - (100 * 10_000_000e18 / 10000) underflows before the addition is applied. Moreover, the formula does not incorporate the change in voting power (delta), leading to a gauge weight that does not correctly represent the user's effective voting contribution.

In addition, the contract retrieves voting power via veRAACToken.balanceOf(msg.sender), which may return a stale value that does not account for decay in voting power. This further compounds the problem by using an inaccurate measure in the weight update.

Vulnerability Details

Function Flow and Faulty Formula

GaugeController::vote

This function allows a user to cast a vote for a gauge:

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

GaugeController::_updateGaugeWeight

This internal function updates the gauge’s weight using the following formula:

function _updateGaugeWeight(address gauge, uint256 oldWeight, uint256 newWeight, uint256 votingPower) internal {
Gauge storage g = gauges[gauge];
uint256 oldGaugeWeight = g.weight;
uint256 newGaugeWeight =
oldGaugeWeight - (oldWeight * votingPower / WEIGHT_PRECISION) + (newWeight * votingPower / WEIGHT_PRECISION);
g.weight = newGaugeWeight;
g.lastUpdateTime = block.timestamp;
}

Highlighted Issue:

  • Formula Limitation:
    The formula subtracts the prior vote contribution (oldWeight * votingPower / WEIGHT_PRECISION) and adds the new vote contribution (newWeight * votingPower / WEIGHT_PRECISION). If the user’s voting power has increased between votes, the subtraction is performed using the current voting power, causing an arithmetic underflow due to left-to-right evaluation in Solidity.

  • Stale Voting Power:
    The function uses veRAACToken.balanceOf(msg.sender) to determine voting power. This method may not factor in decay (since decay is handled via a separate mechanism) and thus can yield a stale value.

  • Lack of Delta Handling:
    The formula does not calculate the delta in voting power—that is, the difference between the previous and current voting power is not accounted for, leading to an inaccurate gauge weight update.

How the Issue Can Rekt the Protocol

  • Arithmetic Underflow:
    With an increase in voting power, the subtraction part of the expression may underflow, causing the transaction to revert and leading to a denial of service for users attempting to update their votes.

  • Inaccurate Gauge Weight:
    Even if underflow did not occur, the gauge weight would be calculated incorrectly. This misrepresentation affects reward distribution and governance decisions since gauge weights determine the allocation of rewards.

  • Stale Data Impact:
    Relying on balanceOf rather than a dynamic measure like getVotingPower can further distort the gauge weight, leading to long-term inconsistencies in the system's state.

Proof of Concept

Scenario Walkthrough

  1. Initial Vote:

    • A user (e.g., PATRICK) locks tokens and obtains an initial voting power of 100000e18.

    • PATRICK votes with a weight of 100.

    • The gauge weight is updated as:

      newGaugeWeight = 0 - (0 * 100000e18 / 10000) + (100 * 100000e18 / 10000) = 1000e18.

  2. Voting Power Increase and Vote Change:

    • PATRICK increases his locked tokens, boosting his voting power to 10_000_000e18.

    • He now changes his vote weight to 5000.

    • The gauge weight update is computed as:

      newGaugeWeight = 1000e18 - (100 * 10000000e18 / 10000) + (5000 * 10000000e18 / 10000).

    • Due to left-to-right evaluation, the subtraction 1000e18 - (100 * 10_000_000e18 / 10000) underflows before the addition occurs.

  3. Resulting Denial of Service:

    • PATRICK’s transaction reverts due to an arithmetic underflow error.

    • Consequently, every subsequent vote update that involves a change in voting power will fail, potentially leading to a denial of service for users.

Foundry Test Suite

Below is a complete Foundry test suite that 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%, 10000 - 100%
uint256 initialRaacBurnTaxRateInBps = 150; // 1.5%, 10000 - 100%
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 testGaugeControllerChangeInVotingPowerCausesArithmeticUnderflow() public {
address PATRICK = makeAddr("PATRICK");
vm.startPrank(RAAC_OWNER);
raacToken.setMinter(RAAC_MINTER);
vm.stopPrank();
vm.startPrank(RAAC_MINTER);
raacToken.mint(PATRICK, MAX_LOCK_AMOUNT);
vm.stopPrank();
vm.startPrank(PATRICK);
raacToken.approve(address(veToken), MAX_LOCK_AMOUNT);
veToken.lock(100_000e18, MAX_LOCK_DURATION);
vm.stopPrank();
uint256 initialWeight = 0;
uint256 weight = 100;
vm.startPrank(GAUGE_CONTROLLER_OWNER);
gaugeController.addGauge(address(rwaGauge), IGaugeController.GaugeType.RWA, initialWeight);
vm.stopPrank();
vm.startPrank(PATRICK);
gaugeController.vote(address(rwaGauge), weight);
vm.stopPrank();
(
uint256 weightStored,
uint256 typeWeight,
uint256 lastUpdateTime,
IGaugeController.GaugeType gaugeType,
bool isActive,
uint256 lastRewardTime
) = gaugeController.gauges(address(rwaGauge));
uint256 patrickVotingPower = veToken.balanceOf(PATRICK);
console.log("Gauge information after vote...");
console.log("expectedWeight : ", (patrickVotingPower * weight) / 10000);
console.log("patrickVotingPower : ", patrickVotingPower);
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);
// Increase Patrick's voting power
vm.startPrank(PATRICK);
veToken.increase(MAX_LOCK_AMOUNT - 100_000e18);
vm.stopPrank();
weight = 5000;
patrickVotingPower = veToken.balanceOf(PATRICK);
console.log("-------------------------------------------");
console.log("expectedWeight : ", (patrickVotingPower * weight) / 10000);
console.log("patrickVotingPower : ", patrickVotingPower);
vm.startPrank(PATRICK);
vm.expectRevert();
gaugeController.vote(address(rwaGauge), weight);
vm.stopPrank();
}
}

How to Run the Test

  1. Step 1: Create a Foundry project:

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

  3. Step 3: Convert your Hardhat project 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: In the test directory, create a test file (e.g., GaugeTest.t.sol) and paste the above test suite.

  6. Step 6: Run the test:

    forge test --mt testGaugeControllerChangeInVotingPowerCausesArithmeticUnderflow -vv
  7. Expected Output:

    [⠒] Compiling...
    No files changed, compilation skipped
    Ran 1 test for test/GaugeTest.t.sol:GaugeTest
    [PASS] testGaugeControllerChangeInVotingPowerCausesArithmeticUnderflow() (gas: 1064823)
    Logs:
    Gauge information after vote...
    expectedWeight : 1000000000000000000000
    patrickVotingPower : 100000000000000000000000
    weight : 1000000000000000000000
    typeWeight : 0
    lastUpdateTime : 1
    gaugeType : 0
    isActive : true
    lastRewardTime : 1
    -------------------------------------------
    expectedWeight : 9550000000000000000000000
    patrickVotingPower : 19100000000000000000000000
    Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.30ms (620.20µs CPU time)
    Ran 1 test suite in 10.20ms (4.30ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

You might notice that patrickVotingPower is way much high than expected, why it happened? It happened due to a bug inside increase function...

veRAACToken::increase

(int128 newBias, int128 newSlope) =
_votingState.calculateAndUpdatePower(msg.sender, userLock.amount + amount, userLock.end);
-----------------------------------------------------------------------------^

The amount added twice to calculate new voting power. However, whatever a DoS still happens.

Impact

  • Short-Term Impact:

    • Arithmetic Underflow: Vote updates involving increased voting power trigger an underflow error, causing the vote transaction to revert.

    • Vote Failure: Users cannot update their vote if their voting power increases, leading to a denial of service on vote changes.

  • Long-Term Impact:

    • Reward Distribution Distortion: Gauge weights directly affect reward allocations; incorrect weights can lead to unfair distribution.

    • Governance Instability: The gauge system is used for governance; persistent inaccuracies can undermine trust and fairness.

    • User Frustration and Abuse: Users may be prevented from properly casting or updating votes, opening the door for potential manipulative or abusive behaviors.

Recommendations

To address these issues, the gauge weight update mechanism must account for changes (delta) in voting power. One solution is to store the user's last recorded voting power and compute the change, then update the gauge weight accordingly. For example:

Diff-1 for _updateGaugeWeight

- uint256 newGaugeWeight =
- oldGaugeWeight - (oldWeight * votingPower / WEIGHT_PRECISION) + (newWeight * votingPower / WEIGHT_PRECISION);
+ // Proposed approach: use the delta in voting power to update gauge weight.
+ // Assume we add a mapping: mapping(address => uint256) public lastUserVotingPower;
+ uint256 previousVotingPower = lastUserVotingPower[msg.sender];
+ uint256 currentVotingPower = votingPower; // Ideally, use veRAACToken.getVotingPower(msg.sender, block.timestamp)
+
+ // Calculate delta from vote weight change based on previous voting power
+ uint256 oldContribution = (oldWeight * previousVotingPower) / WEIGHT_PRECISION;
+ uint256 newContribution = (newWeight * currentVotingPower) / WEIGHT_PRECISION;
+
+ // Adjust gauge weight by the difference plus the effect of any voting power change
+ uint256 newGaugeWeight = oldGaugeWeight + newContribution - oldContribution;
+
+ // Update stored voting power for user
+ lastUserVotingPower[msg.sender] = currentVotingPower;

or if we want to have voting power impact in calculation then...

Diff-2 for _updateGaugeWeight

- uint256 newGaugeWeight =
- oldGaugeWeight - (oldWeight * votingPower / WEIGHT_PRECISION) + (newWeight * votingPower / WEIGHT_PRECISION);
+ // Calculate delta from vote weight change based on voting power
+ uint256 oldContribution = (oldWeight * votingPower) / WEIGHT_PRECISION;
+ uint256 newContribution = (newWeight * votingPower) / WEIGHT_PRECISION;
+ // if below expression reverts then something different is messy in the contract.
+ uint256 newGaugeWeight = oldGaugeWeight + newContribution - oldContribution;

Additional Recommendations

  • Use Dynamic Voting Power:
    Replace calls to veRAACToken.balanceOf(msg.sender) with a function such as getVotingPower(msg.sender, block.timestamp) that factors in decay.

  • Implement SafeMath:
    Ensure that all arithmetic operations use safe math (or Solidity 0.8+ built-in overflow checks) to prevent underflow/overflow errors.

By incorporating these changes, the gauge weight update will correctly reflect both the change in vote weight and the delta in voting power, ensuring accurate reward distribution and robust governance functionality.

Updates

Lead Judging Commences

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

GaugeController::_updateGaugeWeight uses current voting power for both old and new vote calculations, causing underflows when voting power increases and incorrect gauge weights

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

GaugeController::_updateGaugeWeight uses current voting power for both old and new vote calculations, causing underflows when voting power increases and incorrect gauge weights

Support

FAQs

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