Summary
The governance contract allows a proposer to delay proposal execution indefinitely by continuously updating the proposal threshold. Additionally, if the proposer loses voting power after submitting a proposal, the contract does not cancel the proposal, allowing inactive or malicious proposers to block governance.
Vulnerability Details
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");
}
Problems:
Proposer can submit a proposal and immediately transfer out their voting power. The contract does not check proposer’s current voting power before allowing the proposal to stay active.
No enforcement of proposer eligibility after proposal submission. Even if the proposer drops below the proposalThreshold
, their proposal remains valid until someone manually cancels it.
PoC
Attacker submits a proposal with sufficient voting power.
Attacker immediately transfers or unstakes their tokens, dropping below the threshold.
Proposal remains active and cannot be canceled by anyone except the attacker.
Governance system gets blocked because the proposal is stuck indefinitely.
Impact
Governance can be blocked indefinitely by inactive or malicious proposers.
Tools Used
Manual Review
Recommendations
Modify cancel()
to check whether the proposer still meets the proposal threshold before allowing the proposal to continue.
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");
}
uint256 proposerVotes = _veToken.getVotingPower(proposal.proposer);
if (proposerVotes < proposalThreshold) {
proposal.canceled = true;
emit ProposalCanceled(proposalId, msg.sender, "Proposer no longer meets threshold");
return;
}
if (msg.sender != proposal.proposer) {
revert UnauthorizedCancel(msg.sender);
}
proposal.canceled = true;
emit ProposalCanceled(proposalId, msg.sender, "Proposal canceled by proposer");
}