Summary
Proposal can still be queued even if they doesn't reach the required quorum. Plus a successfull proposal can also be defeated.
Vulnerability Details
Following is the execute function which handles queuing of proposals
function execute(uint256 proposalId) external override nonReentrant {
ProposalCore storage proposal = _proposals[proposalId];
if (proposal.executed) revert ProposalAlreadyExecuted(proposalId, block.timestamp);
ProposalState currentState = state(proposalId);
if (currentState == ProposalState.Succeeded) {
_queueProposal(proposalId);
} else if (currentState == ProposalState.Queued) {
_executeProposal(proposalId);
} else {
revert InvalidProposalState(
proposalId,
currentState,
currentState == ProposalState.Active ? ProposalState.Succeeded : ProposalState.Queued,
"Invalid state for execution"
);
}
}
State function is as follows
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;
}
bytes32 id = _timelock.hashOperationBatch(
proposal.targets,
proposal.values,
proposal.calldatas,
bytes32(0),
proposal.descriptionHash
);
if (_timelock.isOperationPending(id)) {
return ProposalState.Queued;
}
return ProposalState.Succeeded;
}
Quorum function is as follows
function quorum() public view override returns (uint256) {
return (_veToken.getTotalVotingPower() * quorumNumerator) / QUORUM_DENOMINATOR;
}
Now suppose proposal end time has been passed or it is just about to end. Lets say quorum required = 4%
Suppose end time has passed and still this proposal has not been executed. Note this can also happen just before the end time.
Suppose total voting power is 4000 tokens, quorum numerator 4, quorum denominator = 100 so quorum required = 160 tokens.
Suppose till now For votes = 155 and against votes are 0
uint256 currentQuorum = proposalVote.forVotes + proposalVote.againstVotes;
uint256 requiredQuorum = quorum();
if (currentQuorum < requiredQuorum || proposalVote.forVotes <= proposalVote.againstVotes) {
return ProposalState.Defeated;
}
Due to the above if condition if the current quorum remains same even after end time , this proposal will be defeated. Seeing this suppose the ve token holders which voted in favour of this proposal decide to unlock their raac token thus burning the ve racc token equal to 155 tokens. This will decrease the total supply of ve raac token. Due to this _veToken.getTotalVotingPower() will get reduced to 3845 tokens and now the quorum required will be 153 tokens and as for votes are still 155 tokens this proposal will be succeded. So by just burning a lot ve tokens after voting for the proposal users can successfully execute their proposals
Now suppose a proposal has been successfully queued. Now again execute is called in order to execute the proposal in timelock contract.
Now supose that the total voting power increased i.e more ve tokens we minted and now current qourum becomes less than the required quorum then the following check will return defeated proposal.
And as end time has passed current quorum can never increase .
uint256 currentQuorum = proposalVote.forVotes + proposalVote.againstVotes;
uint256 requiredQuorum = quorum();
if (currentQuorum < requiredQuorum || proposalVote.forVotes <= proposalVote.againstVotes) {
return ProposalState.Defeated;
}
Instead of returning queued proposal it now returns defeated proposal this will cause failure in executing the proposal.
This defeated proposal can later be cancelled when the proposer voting power reduces to less than the threshold required
function cancel(uint256 proposalId) external override {
ProposalCore storage proposal = _proposals[proposalId];
if (proposal.startTime == 0) revert ProposalDoesNotExist(proposalId);
ProposalState currentState = state(proposalId);
if (currentState == ProposalState.Executed) {
revert InvalidProposalState(proposalId, currentState, ProposalState.Active, "Cannot cancel executed proposal");
}
if (msg.sender != proposal.proposer &&
_veToken.getVotingPower(proposal.proposer) >= proposalThreshold) {
revert InsufficientProposerVotes(proposal.proposer,
_veToken.getVotingPower(proposal.proposer), proposalThreshold, "Proposer lost required voting power");
}
proposal.canceled = true;
emit ProposalCanceled(proposalId, msg.sender, "Proposal canceled by proposer");
}
Thus a succesfull proposal can still be defeated.
Impact
Proposal which will eventually be defeated can be made successful even by not having voting power and vice versa too.
Tools Used
Manual review
Recommendations
Use a different mechanismto calculate the quorum required.