Description
The current implementation of governance quorum calculation in the RAAC protocol uses the present total voting supply instead of taking a snapshot at proposal creation time. This creates potential attack vectors that could manipulate voting outcomes.
The vulnerable code is in the quorum()
function:
function quorum() public view override returns (uint256) {
return (_veToken.getTotalVotingPower() * quorumNumerator) / QUORUM_DENOMINATOR;
}
This implementation allows the quorum requirement to fluctuate based on changes in total voting power during a proposal's lifecycle.
Impact
Consider these 3 problematic scenarios which arise due to the current implementation style:
Scenario 1: Decreasing Total Supply to Pass Proposals
Initial State:
- Total Supply: 1,000,000 veRAAC
- Quorum needed: 40,000 veRAAC (4%)
- YES votes: 35,000 veRAAC
- Proposal status: FAILING (votes < quorum)
Sequence of events:
- Some of the locked tokens which were used as voting power to vote YES expire DURING the course of the proposal i.e. before proposal end time
- Total supply decreases to 800,000 veRAAC
- New quorum requirement reduces to 32,000 veRAAC
- Existing 35,000 YES votes now exceed quorum
- Proposal passes despite potentially not having broad enough support
Note that another variation to this is:
- The expired tokens can be transferred to another wallet, then locked once again and used for voting YES on the same proposal, leading to double-voting.
Scenario 2: Increasing Total Supply to Invalidate Passing Proposals
Initial State:
- Total Supply: 1,000,000 veRAAC
- Quorum needed: 40,000 veRAAC (4%)
- YES votes: 42,000 veRAAC
- Proposal status: PASSING (votes > quorum)
Sequence of events:
- Attacker observes a proposal they oppose is about to pass with 42,000 YES votes
- Attacker (or an honest user using the system normally) locks significant RAAC (e.g., 100,000) to increase total veRAAC supply to 1,100,000
- New quorum becomes 44,000 veRAAC (4% of 1,100,000)
- Existing 42,000 YES votes no longer meet quorum requirement
- Proposal fails despite having significant support relative to initial conditions
Scenario 3: Proposal can be DEFEATED after**** _queueProposal
and before**** _executeProposal
This one can happen inadvertently, in the normal course of operations.
Initial State:
- Total Supply: 1,000,000 veRAAC
- Quorum needed: 40,000 veRAAC (4%)
- YES votes: 42,000 veRAAC
- Proposal status: PASSING (votes > quorum)
Sequence of events:
- The protcol mandates that execute() be called twice. The first call results in '_queueProposal()' call and after the queueing time is up, the second call results in '_executeProposal()'
- Before the internal call to either of these 2 aforementioned functions, a call to 'state()' happens to check the currentState
- state() makes this check each time:
ProposalVote storage proposalVote = _proposalVotes[proposalId];
uint256 currentQuorum = proposalVote.forVotes + proposalVote.againstVotes;
uint256 requiredQuorum = quorum();
if (currentQuorum < requiredQuorum || proposalVote.forVotes <= proposalVote.againstVotes) {
return ProposalState.Defeated;
}
- So if quorum() has increased due to more users locking in their tokens during the queuing period, the proposal which has succeeded previously will now be marked as 'ProposalState.Defeated'
- The inverse may happen too.
- In fact generalizing this, if a DEFEATED proposal is not cancelled then execute() can be called any time in the future over and over again until a condition is met when quorum() is above the required one. At this point of time, it can succeed and execute to completion.
Mitigation
Implement snapshot-based quorum calculation:
function propose(...) external override returns (uint256) {
uint256 snapshotBlock = block.number;
_checkpointState.setProposalSnapshot(proposalId, snapshotBlock);
}
function quorum(uint256 proposalId) public view override returns (uint256) {
uint256 snapshotBlock = proposalPowerSnapshots[proposalId];
if (snapshotBlock == 0) revert InvalidProposal();
uint256 totalSupplyAtSnapshot = _veToken.getPastTotalSupply(snapshotBlock);
return (totalSupplyAtSnapshot * quorumNumerator) / QUORUM_DENOMINATOR;
}
Note that getPastTotalSupply()
is available inside PowerCheckpoint.sol
library which is already imported in veRAACToken.sol
.
These changes will ensure that: