Summary
The Governance
contract implements two functions: state(
which determines a proposal's current state, and castVote
which allows users to vote on active proposals. These functions use different boundary conditions when checking the proposal's end time. The inconsistent use of boundary creates a time window where votes cast at exactly proposal.endTime
will be accepted and counted. However, these votes are cast on a proposal that state
considers inactive.
Vulnerability Details
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;
}
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;
}
Impact
Add Foundry to the project following this procedure
Create a file named Governance.t.sol and copy/paste this:
pragma solidity ^0.8.0;
import {Test, console2} from "forge-std/Test.sol";
import {Governance} from "../contracts/core/governance/proposals/Governance.sol";
import {MockVeToken} from "../contracts/mocks/core/tokens/MockVeToken.sol";
import {TimelockController} from "../contracts/core/governance/proposals/TimelockController.sol";
import {TimelockTestTarget} from "../contracts/mocks/core/governance/proposals/TimelockTestTarget.sol";
import "../contracts/interfaces/core/governance/proposals/IGovernance.sol";
contract GovernanceTest is Test {
Governance public governance;
MockVeToken public veToken;
TimelockController public timelock;
TimelockTestTarget public testTarget;
address owner = makeAddr("owner");
address user1 = makeAddr("user1");
address user2 = makeAddr("user2");
address[] public proposers = new address[](1);
address[] public executors = new address[](1);
address[] public targets = new address[](1);
uint256[] public values = new uint256[](1);
bytes[] public calldatas = new bytes[](1);
string public description;
function setUp() public {
proposers[0] = owner;
executors[0] = owner;
veToken = new MockVeToken();
timelock = new TimelockController(2 * 24 * 3600, proposers, executors, owner);
governance = new Governance(address(veToken), address(timelock));
veToken.mock_setTotalSupply(10000000 ether);
testTarget = new TimelockTestTarget();
vm.startPrank(owner);
timelock.grantRole(timelock.PROPOSER_ROLE(), address(governance));
timelock.grantRole(timelock.EXECUTOR_ROLE(), address(governance));
timelock.grantRole(timelock.CANCELLER_ROLE(), address(governance));
vm.stopPrank();
}
function test_canVoteOnEndedProposal() public {
veToken.mock_setInitialVotingPower(owner, 4000000 ether);
vm.startPrank(owner);
targets[0] = address(testTarget);
values[0] = 0;
calldatas[0] = abi.encodeWithSignature("setValue", 42);
description = "Test Proposal";
governance.propose(targets, values, calldatas, description, IGovernance.ProposalType.ParameterChange);
vm.stopPrank();
vm.warp(block.timestamp + governance.votingDelay() + governance.votingPeriod());
IGovernance.ProposalState checkSucceedProposalState = governance.state(0);
assertEq(uint256(checkSucceedProposalState), 3);
(uint256 forBefore, ) = governance.getVotes(0);
vm.startPrank(owner);
governance.castVote(0, true);
vm.stopPrank();
(uint256 forAfter, ) = governance.getVotes(0);
(, uint256 startTime, uint256 endTime, uint256 currentTime, , , , , , ) = governance.getDebugInfo(0);
assertEq(currentTime, endTime);
assertEq(forAfter, 4000000 ether);
assertEq(forAfter, forBefore + 4000000 ether);
console2.log("endTime: ", endTime);
console2.log("currentTime: ", currentTime);
}
}
Run forge test --match-test test_canVoteOnEndedProposal -vv
[PASS] test_canVoteOnEndedProposal() (gas: 775808)
Logs:
endTime: 691201
currentTime: 691201
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 10.06ms (1.53ms CPU time)
The test shows that at block.timestamp == proposal.endTime
, the state
returns an inactive proposal (status = defeated
) but the owner can cast a vote.
At exactly block.timestamp == proposal.endTime
:
This creates a logical contradiction in the contract's state. Users relying on state
might incorrectly assume voting has ended. Malicious users could intentionally target the endTime
block to cast votes when other users believe voting has ended leading to last-second vote manipulation that's difficult for other participants to counter.
Tools Used
Manual review
Recommendations
Align the time boundary checks in both functions.
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;
+ 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();
// Check if quorum is met and votes are in favor
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 operation is pending in timelock, it's Queued
if (_timelock.isOperationPending(id)) {
return ProposalState.Queued;
}
// If not pending and voting passed, it's Succeeded
return ProposalState.Succeeded;
}