Summary
The function state() determines the current state of a governance proposal. However, it does not check if a proposal has been canceled in the timelock, leading to a scenario where a canceled proposal may be incorrectly returned as Succeeded.
This can cause governance manipulation, where proposals that should have failed can appear valid, creating inconsistencies in the governance process.
Vulnerability Details
This is the state function where we check
if (_timelock.isOperationPending(id)) {
return ProposalState.Queued;
}
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;
}
However, this does not account for cases where the timelock operation was canceled.
If _timelock.isOperationPending(id) returns false (because the proposal was canceled), the function falls through to return ProposalState.Succeeded, even though the proposal should not succeed.
This creates an inconsistency between the governance contract and the timelock contract.
Impact
Governance Manipulation: A canceled proposal may still appear as Succeeded, allowing an attacker or malicious actor to rerun a proposal that should have failed.
State Inconsistency: The governance contract and the timelock contract will store different statuses for the same proposal, which can cause unexpected behaviors in governance execution.
Security Risk: If automated execution relies on a proposal being Succeeded, a canceled proposal might still get executed in some cases.
Tools Used
Manual Review
Recommendations
1. Explicitly Check if the Proposal was Canceled in the Timelock
Modify the function to check _timelock.isOperationCanceled(id) before marking a proposal as Succeeded
2. Implement a Mapping to Track Proposal Status
Instead of only relying on _timelock.isOperationPending(), maintain a mapping to track proposal cancellations
contract TimeLockController{
mapping(uint256 => bool) public canceledInTimelock;
function cancelProposalInTimelock(uint256 proposalId) external onlyTimelock {
canceledInTimelock[proposalId] = true;
}
}
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;
}
++ if (_timelock.isOperationCanceled(id) || || canceledInTimelock[proposalId]) {
++ return ProposalState.Defeated;
++ }
return ProposalState.Succeeded;
}