Summary
An issue exists in the Governance.sol
contract where users can still cast votes on proposals that have already been canceled. Although these votes do not impact the governance process, they introduce unnecessary gas costs, create confusion, and can mislead users regarding the status of proposals.
Vulnerability Details
This is the propose
function used to create a proposal: https://github.com/Cyfrin/2025-02-raac/blob/main/contracts/core/governance/proposals/Governance.sol#L127-L168
function propose(
address[] memory targets,
uint256[] memory values,
bytes[] memory calldatas,
string memory description,
ProposalType proposalType
) external override returns (uint256) {
}
This is the cancel
function which cancels a proposal. It can be called by the proposer anytime and by anyone when proposer's voting power drops below threshold.: https://github.com/Cyfrin/2025-02-raac/blob/main/contracts/core/governance/proposals/Governance.sol#L252-L270
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");
}
It is important to note that it sets: proposal.canceled = true;
And this is the 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;
}
The castVote()
function does not check whether a proposal has been canceled before allowing users to vote. As a result, even after a proposal is successfully canceled through the cancel()
function, users can still submit votes on it. This issue arises because:
-
The cancel()
function marks a proposal as canceled but does not prevent further voting.
-
The castVote()
function lacks a check to verify if the proposal has been canceled.
-
Votes cast on canceled proposals have no actual effect but still consume gas
Proof of Concept (PoC)
Steps to Reproduce:
1, A user creates a proposal using the propose() function.
2, The proposer or another eligible user cancels the proposal via the cancel() function.
3, Another user attempts to vote on the canceled proposal using castVote(proposalId, true/false).
4, The vote is successfully registered, even though the proposal has been canceled.
Expected Behavior:
Once a proposal is canceled, users should not be able to vote on it.
Actual Behavior:
Users can still call castVote()
function and register votes even after the proposal is canceled, leading to gas wastage and potential confusion.
Impact
Users spend gas on votes that do not impact governance decisions.
Participants may believe their votes still count.
Potential misleading of users about proposal status.
Tools Used
Manual Review
Recommendations
Modify the castVote()
function to check if the proposal has been canceled before allowing a vote to be cast.
Add this check:
if (proposal.canceled) {
revert InvalidProposalState(proposalId, ProposalState.Canceled, ProposalState.Active, "Cannot vote on canceled proposal");
}
Updated 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);
}
if (proposal.canceled) {
revert InvalidProposalState(proposalId, ProposalState.Canceled, ProposalState.Active, "Cannot vote on canceled proposal");
}
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;
}