Summary
In the Governance
contract, voting power is used to determine the outcome of proposals rather than a simple "one vote per voter" mechanism. However, this approach introduces a vulnerability where a proposer with a disproportionately high voting power can manipulate the governance process in their favor.
Currently, a proposal's outcome is determined by comparing the total voting power of "forVotes" and "againstVotes" after quorum is met. However, because voting power is distributed unevenly across users, a single proposer with a significant amount of voting power can pass their proposal even when a larger number of individual voters oppose it.
This flaw undermines the fairness of governance and can lead to centralized decision-making, where a few powerful voters dominate the voting process, ultimately harming the protocol's decentralization and legitimacy.
Vulnerability Details
The issue arises due to how the governance contract processes votes and determines quorum. Below are the relevant functions involved in this vulnerability:
Proposal Creation (propose
function)
function propose(
address[] memory targets,
uint256[] memory values,
bytes[] memory calldatas,
string memory description,
ProposalType proposalType
) external override returns (uint256) {
uint256 proposerVotes = _veToken.getVotingPower(msg.sender);
if (proposerVotes < proposalThreshold) {
revert InsufficientProposerVotes(msg.sender, proposerVotes, proposalThreshold, "Below threshold");
}
uint256 proposalId = _proposalCount++;
uint256 startTime = block.timestamp + votingDelay;
uint256 endTime = startTime + votingPeriod;
_proposals[proposalId] = ProposalCore({
id: proposalId,
proposer: msg.sender,
proposalType: proposalType,
startTime: startTime,
endTime: endTime,
executed: false,
canceled: false,
descriptionHash: keccak256(bytes(description)),
targets: targets,
values: values,
calldatas: calldatas
});
emit ProposalCreated(proposalId, msg.sender, targets, values, calldatas, description, proposalType, startTime, endTime, proposerVotes);
return proposalId;
}
Vote Casting (castVote
function)
function castVote(uint256 proposalId, bool support) external override returns (uint256) {
ProposalCore storage proposal = _proposals[proposalId];
if (proposal.startTime == 0) revert ProposalDoesNotExist(proposalId);
if (block.timestamp < proposal.startTime) {
revert VotingNotStarted(proposalId, proposal.startTime, block.timestamp);
}
if (block.timestamp > proposal.endTime) {
revert VotingEnded(proposalId, proposal.endTime, block.timestamp);
}
ProposalVote storage proposalVote = _proposalVotes[proposalId];
if (proposalVote.hasVoted[msg.sender]) {
revert AlreadyVoted(proposalId, msg.sender, block.timestamp);
}
uint256 weight = _veToken.getVotingPower(msg.sender);
if (weight == 0) {
revert NoVotingPower(msg.sender, block.number);
}
proposalVote.hasVoted[msg.sender] = true;
if (support) {
proposalVote.forVotes += weight;
} else {
proposalVote.againstVotes += weight;
}
emit VoteCast(msg.sender, proposalId, support, weight, "");
return weight;
}
Proposal State Calculation (state
function)
function state(uint256 proposalId) public view override returns (ProposalState) {
ProposalCore storage proposal = _proposals[proposalId];
if (proposal.startTime == 0) revert ProposalDoesNotExist(proposalId);
if (proposal.canceled) return ProposalState.Canceled;
if (proposal.executed) return ProposalState.Executed;
if (block.timestamp < proposal.startTime) return ProposalState.Pending;
if (block.timestamp < proposal.endTime) return ProposalState.Active;
ProposalVote storage proposalVote = _proposalVotes[proposalId];
uint256 currentQuorum = proposalVote.forVotes + proposalVote.againstVotes;
uint256 requiredQuorum = quorum();
@> if (currentQuorum < requiredQuorum || proposalVote.forVotes <= proposalVote.againstVotes) {
return ProposalState.Defeated;
}
return ProposalState.Succeeded;
}
Proof of Concept (PoC)
Consider the following scenario:
Alice (Proposer): Holds 8% of the total voting power.
BigE, XavierWoods, and KofiKingston: Each holds 2% voting power, totaling 6%.
Exploit Execution:
Alice creates a proposal and immediately votes in favor with her 8% voting power.
The three other voters (BigE, XavierWoods, and KofiKingston) vote against the proposal, with a combined voting power of 6%.
Since Alice has a larger share of voting power than all three combined, the proposal is counted as approved despite having fewer individual voters in favor.
The quorum requirement is met, and forVotes > againstVotes
, allowing Alice to manipulate governance and push her proposal for execution.
PoC Test Suite
To demonstrate this vulnerability, the following Proof of Concept (PoC) is provided. The PoC is written using the Foundry tool.
-
Step 1: Create a Foundry project and place all the contracts in the src
directory.
-
Step 2: Create a test
directory and a mocks
folder within the src
directory (or use an existing mocks folder).
-
Step 3: Create all necessary mock contracts, if required.
-
Step 4: Create a test file (with any name) in the test
directory.
pragma solidity ^0.8.19;
import {Test, console} from "forge-std/Test.sol";
import {veRAACToken} from "../src/core/tokens/veRAACToken.sol";
import {RAACToken} from "../src/core/tokens/RAACToken.sol";
import {TimelockController} from "../src/core/governance/proposals/TimelockController.sol";
import {Governance} from "../src/core/governance/proposals/Governance.sol";
import {TimeWeightedAverage} from "../src/libraries/math/TimeWeightedAverage.sol";
import {LockManager} from "../src/libraries/governance/LockManager.sol";
import {IveRAACToken} from "../src/interfaces/core/tokens/IveRAACToken.sol";
import {IGovernance} from "../src/interfaces/core/governance/proposals/IGovernance.sol";
contract GovernanceTest is Test {
veRAACToken veRaacToken;
RAACToken raacToken;
TimelockController timelockController;
Governance governance;
address RAAC_OWNER = makeAddr("RAAC_OWNER");
address RAAC_MINTER = makeAddr("RAAC_MINTER");
uint256 initialRaacSwapTaxRateInBps = 200;
uint256 initialRaacBurnTaxRateInBps = 150;
address VE_RAAC_OWNER = makeAddr("VE_RAAC_OWNER");
address ALICE = makeAddr("ALICE");
address BOB = makeAddr("BOB");
address CHARLIE = makeAddr("CHARLIE");
address DEVIL = makeAddr("DEVIL");
address BIG_E = makeAddr("BIG_E");
address XAVIER_WOODS = makeAddr("XAVIER_WOODS");
address KOFI_KINGSTON = makeAddr("KOFI_KINGSTON");
address PROPOSER_1 = makeAddr("PROPOSER_1");
address PROPOSER_2 = makeAddr("PROPOSER_2");
address PROPOSER_3 = makeAddr("PROPOSER_3");
address PROPOSER_4 = makeAddr("PROPOSER_4");
address PROPOSER_5 = makeAddr("PROPOSER_5");
address EXECUTOR_1 = makeAddr("EXECUTOR_1");
address EXECUTOR_2 = makeAddr("EXECUTOR_2");
address EXECUTOR_3 = makeAddr("EXECUTOR_3");
address EXECUTOR_4 = makeAddr("EXECUTOR_4");
address EXECUTOR_5 = makeAddr("EXECUTOR_5");
address TIMELOCK_OWNER = makeAddr("TIMELOCK_OWNER");
address GOVERNANCE_OWNER = makeAddr("GOVERNANCE_OWNER");
uint256 timelockControllerMinDelay = 2 days;
address[] private proposers;
address[] private executors;
address admin;
address[] private proposalTargets;
uint256[] private proposalValues;
bytes[] private proposalCalldatas;
string private proposalDescription;
IGovernance.ProposalType proposalProposalType;
function setUp() public {
raacToken = new RAACToken(RAAC_OWNER, initialRaacSwapTaxRateInBps, initialRaacBurnTaxRateInBps);
vm.startPrank(VE_RAAC_OWNER);
veRaacToken = new veRAACToken(address(raacToken));
vm.stopPrank();
proposers.push(PROPOSER_1);
proposers.push(PROPOSER_2);
proposers.push(PROPOSER_3);
proposers.push(PROPOSER_4);
proposers.push(PROPOSER_5);
executors.push(EXECUTOR_1);
executors.push(EXECUTOR_2);
executors.push(EXECUTOR_3);
executors.push(EXECUTOR_4);
executors.push(EXECUTOR_5);
vm.startPrank(TIMELOCK_OWNER);
TimelockController tempTimelockController =
new TimelockController(timelockControllerMinDelay, proposers, executors, TIMELOCK_OWNER);
timelockController = new TimelockController(timelockControllerMinDelay, proposers, executors, TIMELOCK_OWNER);
vm.stopPrank();
vm.startPrank(GOVERNANCE_OWNER);
governance = new Governance(address(veRaacToken), address(tempTimelockController));
governance.setTimelock(address(timelockController));
vm.stopPrank();
proposalTargets.push(address(tempTimelockController));
proposalValues.push(0);
proposalCalldatas.push(abi.encodeWithSignature("setValue(uint256)", 42));
proposalDescription = "Proposal String";
proposalProposalType = IGovernance.ProposalType.ParameterChange;
vm.startPrank(TIMELOCK_OWNER);
timelockController.grantRole(timelockController.PROPOSER_ROLE(), address(governance));
timelockController.grantRole(timelockController.EXECUTOR_ROLE(), address(governance));
timelockController.grantRole(timelockController.CANCELLER_ROLE(), address(governance));
vm.stopPrank();
getveRaacTokenForProposer();
}
function getveRaacTokenForProposer() private {
uint256 LOCK_AMOUNT = 10_000_000e18;
uint256 LOCK_DURATION = 365 days;
vm.startPrank(RAAC_OWNER);
raacToken.setMinter(RAAC_MINTER);
vm.stopPrank();
vm.startPrank(RAAC_MINTER);
raacToken.mint(PROPOSER_1, LOCK_AMOUNT);
raacToken.mint(PROPOSER_2, LOCK_AMOUNT);
raacToken.mint(PROPOSER_3, LOCK_AMOUNT);
raacToken.mint(PROPOSER_4, LOCK_AMOUNT);
raacToken.mint(PROPOSER_5, LOCK_AMOUNT);
raacToken.mint(ALICE, LOCK_AMOUNT);
raacToken.mint(BOB, LOCK_AMOUNT);
raacToken.mint(CHARLIE, LOCK_AMOUNT);
raacToken.mint(DEVIL, LOCK_AMOUNT);
raacToken.mint(BIG_E, 1_000_000e18);
raacToken.mint(XAVIER_WOODS, 1_000_000e18);
raacToken.mint(KOFI_KINGSTON, 1_000_000e18);
vm.stopPrank();
vm.startPrank(PROPOSER_1);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(PROPOSER_2);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(PROPOSER_3);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(PROPOSER_4);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(PROPOSER_5);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(ALICE);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(BOB);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(CHARLIE);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(DEVIL);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(BIG_E);
raacToken.approve(address(veRaacToken), 1_000_000e18);
veRaacToken.lock(1_000_000e18, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(XAVIER_WOODS);
raacToken.approve(address(veRaacToken), 1_000_000e18);
veRaacToken.lock(1_000_000e18, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(KOFI_KINGSTON);
raacToken.approve(address(veRaacToken), 1_000_000e18);
veRaacToken.lock(1_000_000e18, LOCK_DURATION);
vm.stopPrank();
}
}
Step 5: Add the following test PoC in the test file, after the setUp
function.
function testVotersWithHighVotingPowerCanRigTheVoting() public {
vm.startPrank(ALICE);
uint256 proposalId = governance.propose(
proposalTargets, proposalValues, proposalCalldatas, proposalDescription, proposalProposalType
);
vm.stopPrank();
IGovernance.ProposalCore memory proposalCore = governance.getProposal(proposalId);
vm.warp(block.timestamp + proposalCore.startTime + 1);
vm.roll(block.number + 1);
uint256 totalVotingPower = veRaacToken.getTotalVotingPower();
uint256 aliceVotingPower = veRaacToken.getVotingPower(ALICE);
uint256 bigEVotingPower = veRaacToken.getVotingPower(BIG_E);
uint256 xavierWoodsVotingPower = veRaacToken.getVotingPower(XAVIER_WOODS);
uint256 kofiKingstonVotingPower = veRaacToken.getVotingPower(KOFI_KINGSTON);
uint256 currentQuorum = aliceVotingPower + bigEVotingPower + xavierWoodsVotingPower + kofiKingstonVotingPower;
uint256 expectedRequiredQuorum =
(totalVotingPower * governance.quorumNumerator()) / governance.QUORUM_DENOMINATOR();
uint256 requiredQuorum = governance.quorum();
assertEq(requiredQuorum, expectedRequiredQuorum);
assert(currentQuorum >= requiredQuorum);
console.log("currentQuorum : ", currentQuorum);
console.log("requiredQuorum: ", requiredQuorum);
vm.startPrank(ALICE);
governance.castVote(proposalId, true);
vm.stopPrank();
vm.startPrank(BIG_E);
governance.castVote(proposalId, false);
vm.stopPrank();
vm.startPrank(XAVIER_WOODS);
governance.castVote(proposalId, false);
vm.stopPrank();
vm.startPrank(KOFI_KINGSTON);
governance.castVote(proposalId, false);
vm.stopPrank();
vm.warp(block.timestamp + proposalCore.endTime + 1);
vm.roll(block.number + 1);
vm.startPrank(ALICE);
governance.execute(proposalId);
vm.stopPrank();
vm.warp(block.timestamp + timelockController.getMinDelay() + 1);
vm.roll(block.number + 1);
bytes32 id = timelockController.hashOperationBatch(
proposalTargets, proposalValues, proposalCalldatas, bytes32(0), proposalCore.descriptionHash
);
vm.startPrank(ALICE);
vm.expectRevert(abi.encodeWithSelector(bytes4(keccak256("CallReverted(bytes32,uint256)")), id, 0));
governance.execute(proposalId);
vm.stopPrank();
}
Step 6: To run the test, execute the following commands in your terminal:
forge test --mt testVotersWithHighVotingPowerCanRigTheVoting -vv
Step 7: Review the output. The expected output should indicate that malicious actor can rig the Governance voting if they have unproportionately High voting power.
[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/GovernanceTest.t.sol:GovernanceTest
[PASS] testVotersWithHighVotingPowerCanRigTheVoting() (gas: 847281)
Logs:
currentQuorum : 3241095684297311009824592
requiredQuorum: 930000000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 27.02ms (3.19ms CPU time)
Ran 1 test suite in 43.77ms (27.02ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
As demonstrated, the test confirms that the malicious actors can rig the Governance Proposal Voting.
Impact
Governance Manipulation: A single large voting power holder can unilaterally pass proposals even when more voters oppose them.
Centralization Risk: The governance process favors wealthier participants with more tokens, undermining decentralization.
Protocol Integrity Compromise: Unfair governance decisions can lead to malicious proposals being executed, damaging the protocol's reputation.
Voter Discouragement: Smaller token holders may feel powerless and disengage from governance, reducing overall participation.
Tools Used
Recommendations
1. Implement Weighted Majority Approval
Modify the proposal state logic to ensure that the majority of individual voters must be in favor, rather than only comparing weighted voting power.
if (proposalVote.forVotes > proposalVote.againstVotes && proposalVote.forVoters > proposalVote.againstVoters) {
return ProposalState.Succeeded;
}
2. Implement Voting Power Caps
Limit the maximum percentage of voting power any single address can contribute to a single proposal.
3. Introduce Quadratic Voting
Adopt a quadratic voting system where voting power is proportional to the square root of the tokens held, reducing the influence of large holders.
4. Require a Minimum Number of Distinct Voters
Ensure that a proposal needs votes from a minimum number of unique voters before it is considered valid.