Summary
Accidental Execution of an Old Governance Proposal
Vulnerability Details
The execute()
function in the governance contract calls state()
to determine the proposal's status before execution.
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"
);
}
}
In the state()
function, proposals do not expire automatically but rely on the quorum()
function to determine success. If the quorum()
value drops after voting has ended, a previously unsuccessful proposal may become executable.
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;
}
The quorum()
function is affected by _veToken.getTotalVotingPower()
and quorumNumerator
. If these values decrease, the quorum requirement also decreases, potentially allowing old proposals to be executed unexpectedly.
function quorum() public view override returns (uint256) {
@> return (_veToken.getTotalVotingPower() * quorumNumerator) / QUORUM_DENOMINATOR;
}
function setParameter(GovernanceParameter param, uint256 newValue) external override onlyOwner {
if (param == GovernanceParameter.VotingDelay) {
votingDelay = newValue;
emit VotingDelaySet(oldValue, newValue, msg.sender);
} else if (param == GovernanceParameter.VotingPeriod) {
votingPeriod = newValue;
emit VotingPeriodSet(oldValue, newValue, msg.sender);
} else if (param == GovernanceParameter.ProposalThreshold) {
proposalThreshold = newValue;
emit ProposalThresholdSet(oldValue, newValue, msg.sender);
} else if (param == GovernanceParameter.QuorumNumerator) {
@> quorumNumerator = newValue;
emit QuorumNumeratorSet(oldValue, newValue, msg.sender);
}
}
A proposal that initially failed due to insufficient quorum may remain dormant. If _veToken.getTotalVotingPower()
or quorumNumerator
decreases over time, the required quorum threshold also decreases, allowing the proposal to pass and be executed, even if it is outdated.
Poc
Add the following test to test/unit/core/governance/proposals/Governance.test.js
and execute it:
describe("Old proposal accidentally executed", () => {
let proposalId;
beforeEach(async () => {
await veToken.mock_setInitialVotingPower(
await owner.getAddress(),
ethers.parseEther("150000")
);
const targets = [await testTarget.getAddress()];
const values = [0];
const calldatas = [
testTarget.interface.encodeFunctionData("setValue", [42])
];
const tx = await governance.connect(owner).propose(
targets,
values,
calldatas,
"Test Proposal",
0
);
const receipt = await tx.wait();
const event = receipt.logs.find(
log => governance.interface.parseLog(log)?.name === 'ProposalCreated'
);
proposalId = event.args.proposalId;
await time.increase(VOTING_DELAY);
expect(await governance.quorum()).to.equal(ethers.parseEther("6000"));
await veToken.mock_setVotingPower(await user1.getAddress(), ethers.parseEther("5900"));
const startTime = await moveToNextTimeframe();
expect(await governance.state(proposalId)).to.equal(ProposalState.Active);
await governance.connect(user1).castVote(proposalId, true);
expect(await governance.state(proposalId)).to.equal(ProposalState.Active);
await time.increaseTo(startTime + VOTING_PERIOD);
await network.provider.send("evm_mine");
expect(await governance.state(proposalId)).to.equal(ProposalState.Defeated);
});
it("if _veToken.getTotalVotingPower() decrease", async () => {
expect(await governance.state(proposalId)).to.equal(ProposalState.Defeated);
await time.increase(365 * 24 * 60 * 60);
await network.provider.send("evm_mine");
await veToken.mock_setInitialVotingPower(
await owner.getAddress(),
ethers.parseEther("140000")
);
expect(await governance.quorum()).to.equal(ethers.parseEther("5600"));
expect(await governance.state(proposalId)).to.equal(ProposalState.Succeeded);
await governance.execute(proposalId);
expect(await governance.state(proposalId)).to.equal(ProposalState.Queued);
const timelockDelay = await timelock.getMinDelay();
await time.increase(timelockDelay);
await network.provider.send("evm_mine");
await governance.execute(proposalId);
await logProposalState(governance, proposalId);
expect(await governance.state(proposalId)).to.equal(ProposalState.Executed);
expect(await testTarget.value()).to.equal(42n);
});
it("if quorumNumerator decrease", async () => {
expect(await governance.state(proposalId)).to.equal(ProposalState.Defeated);
await time.increase(365 * 24 * 60 * 60);
await network.provider.send("evm_mine");
const newQuorum = 3;
await governance.setParameter(GovernanceParameter.Quorum, newQuorum);
expect(await governance.state(proposalId)).to.equal(ProposalState.Succeeded);
await governance.execute(proposalId);
expect(await governance.state(proposalId)).to.equal(ProposalState.Queued);
const timelockDelay = await timelock.getMinDelay();
await time.increase(timelockDelay);
await network.provider.send("evm_mine");
await governance.execute(proposalId);
await logProposalState(governance, proposalId);
expect(await governance.state(proposalId)).to.equal(ProposalState.Executed);
expect(await testTarget.value()).to.equal(42n);
});
});
output:
Governance
Old proposal accidentally executed
Proposal Info:
Timeline for Proposal: 0n
= State : EXECUTED | isExecuted: true | isCanceled: false
Current 2026-03-01T00:00:01.000Z
Start 2025-02-14T13:30:38.000Z
End 2025-02-21T13:30:38.000Z
Voting Results: For: 5900.0 | Against: 0.0
Quorum Status:
Current: 5900.0 / 5600.0
✔ if _veToken.getTotalVotingPower() decrease (895ms)
Proposal Info:
Timeline for Proposal: 0n
= State : EXECUTED | isExecuted: true | isCanceled: false
Current 2027-03-14T00:00:00.000Z
Start 2026-03-02T00:00:09.000Z
End 2026-03-09T00:00:09.000Z
Voting Results: For: 5900.0 | Against: 0.0
Quorum Status:
Current: 5900.0 / 4500.0
✔ if quorumNumerator decrease (1428ms)
2 passing (57s)
Impact
Since the governance contract does not have a proposalExpiration
mechanism, historical proposals may suddenly meet the quorum()
requirements and enter the execution state at some point in the future, which may lead to unpredictable consequences.
For example, months or years later, due to the decrease in voting token holdings, historical proposals are unexpectedly executed, affecting protocol security or fund allocation.
If the proposal content involves contract upgrades, fund transfers, or permission changes, they are reasonable in the historical context, but may no longer be applicable in the current environment, and execution may lead to asset loss or governance chaos.
Tools Used
Manual Review
Recommendations
To prevent this issue, consider implementing one of the following solutions:
-
Proposal Expiry: Introduce an expiration mechanism where proposals become invalid after a certain period.
-
Snapshot Quorum: Store the requiredQuorum
at the time of vote completion to ensure the threshold does not change retroactively.
-
State Validation on Execution: Check if the proposal’s original quorum conditions were met at the time of voting completion before allowing execution.