Summary
The Governance contract's state(proposalId) function determines proposal status using the current total voting power instead of the voting power at proposal creation. Due to the decay mechanism in veToken, this allows proposals to pass with fewer votes than initially required, circumventing the intended quorum threshold.
Vulnerability Details
The vulnerability exists in how the quorum is calculated at proposal execution time:
The quorum() function calculates the required threshold based on current values:
solidity
return (_veToken.getTotalVotingPower() * quorumNumerator) / QUORUM_DENOMINATOR;
Due to veToken's decay mechanism, the total voting power decreases over time. It is possible that users who already voted in the beginning of the voting process, will have less voting power at the end. The total voting power of the veToken will also be lower at the end. When state(proposalId) is called during execution, it uses this reduced quorum value to check whether the proposal can pass.
This creates a scenario where:
Users vote when their voting power is high
Voting power decays over the proposal period
Final quorum check uses the reduced total voting power but the inflated user voting power
Proposal passes with fewer effective votes than should be required
For example:
Day 1: Total voting power = 1000, Quorum = 500 (50%)
Day 30: Total voting power decays to 600, Quorum = 300 (50% of 600)
A proposal with 400 votes would fail on Day 1 but pass on Day 30
Impact
This vulnerability has serious implications for governance:
Proposals can pass with fewer votes than intended
Malicious actors could time proposals around known decay periods
The governance system's security assumptions are undermined
Critical protocol changes could be approved without proper consensus
Proof of Concept
Add this test case under "Proposal Execution" in Governance.test.js
javascript
it("should execute successful proposal with low votes", async () => {
await veToken.mock_setVotingPower(await user1.getAddress(), ethers.parseEther("6500"));
await veToken.mock_setInitialVotingPower(
await owner.getAddress(),
ethers.parseEther("150000")
);
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");
await veToken.mock_setVotingPower(await user1.getAddress(), ethers.parseEther("0"));
await veToken.mock_setTotalSupply(ethers.parseEther("10000"));
console.log(await governance.quorum());
expect(await governance.state(proposalId)).to.equal(ProposalState.Succeeded);
Recommendation
Implement a snapshot of the required quorum at proposal creation. First, add a quorum snapshot variable to the ProposalCore struct.
solidity
struct ProposalCore {
uint256 id; // Unique proposal identifier
address proposer; // Address that created the proposal
ProposalType proposalType; // Type of proposal
uint256 startTime; // Start of voting period
uint256 endTime; // End of voting period
bool executed; // Whether proposal has been executed
bool canceled; // Whether proposal has been canceled
bytes32 descriptionHash; // Hash of proposal description
address[] targets; // Target addresses for calls
uint256[] values; // ETH values for calls
bytes[] calldatas; // Calldata for each call
+ uint256 quorumAtCreation; // Quorum required at proposal creation
}
//Then, in the propose() function:
_proposals[proposalId] = ProposalCore({
id: proposalId,
proposer: msg.sender,
proposalType: proposalType,
startTime: startTime,
endTime: endTime,
executed: false,
canceled: false,
descriptionHash: keccak256(bytes(description)),
targets: targets,
values: values,
calldatas: calldatas,
+ quorumAtCreation: quorum()
});
Finally, modify the state() function 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;
// After voting period ends, check quorum and votes
ProposalVote storage proposalVote = _proposalVotes[proposalId];
uint256 currentQuorum = proposalVote.forVotes + proposalVote.againstVotes;
- uint256 requiredQuorum = quorum();
+ uint256 requiredQuorum = proposal.quorumAtCreation;
In addition, only addresses This ensures that proposals must meet the quorum threshold that was in place when they were created, maintaining the integrity of the governance system.