Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: low
Valid

Incorrect `proposal.endTime` check in `castVote` function of `Governance` allows voting on `Succeeded` proposals

Description

In Governance, a proposal progresses through multiple stages. According to the documentation, the Succeeded stage occurs when a proposal has passed voting but has not yet been queued. Voting should only be allowed during the Active stage, which is determined by the following condition in the state function:

function state(uint256 proposalId) public view override returns (ProposalState) {
...
if (block.timestamp < proposal.endTime) return ProposalState.Active;
...
}

Additionally, the castVote function prevents users from voting only if block.timestamp > proposal.endTime. Since the state function treats block.timestamp == proposal.endTime as not Active, but castVote still allows voting at this timestamp, users can vote on an already Succeeded proposal, potentially altering the final outcome.

Context

Impact

Medium. Users can cast votes on a Succeeded proposal, which could change the final vote results in a way that was not intended.

Likelihood

Low. The only moment this can happen is precisely when block.timestamp == proposal.endTime.

Proof of Concept

pragma solidity ^0.8.19;
import {Test, console} from "../../lib/forge-std/src/Test.sol";
import {Governance, IGovernance} from "../../contracts/core/governance/proposals/Governance.sol";
import {TimelockController} from "../../contracts/core/governance/proposals/TimelockController.sol";
import {RAACToken} from "../../contracts/core/tokens/RAACToken.sol";
import {veRAACToken, IveRAACToken} from "../../contracts/core/tokens/veRAACToken.sol";
contract GovernanceTest is Test {
// Contracts
Governance governance;
TimelockController timelock;
RAACToken raac;
veRAACToken veRaac;
// Actors
address OWNER = makeAddr("owner");
address PROPOSER = makeAddr("proposer");
address EXECUTOR = makeAddr("executor");
address USER = makeAddr("user");
function setUp() public {
vm.startPrank(OWNER);
// Set proposers and executors
address[] memory proposers = new address[](1);
proposers[0] = PROPOSER;
address[] memory executors = new address[](1);
executors[0] = EXECUTOR;
// Deploy governance contracts
timelock = new TimelockController(2 days, proposers, executors, OWNER);
raac = new RAACToken(OWNER, 0, 0);
veRaac = new veRAACToken(address(raac));
governance = new Governance(address(veRaac), address(timelock));
// Grant proposer role to governance
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_wrongEndTimeCheck_castVote() public {
// Mock voting power for all users
vm.mockCall(
address(veRaac),
abi.encodeWithSelector(bytes4(keccak256("getVotingPower(address)"))),
abi.encode(100_000e18)
);
// Set proposal content
address[] memory targets = new address[](1);
uint256[] memory values = new uint256[](1);
bytes[] memory calldatas = new bytes[](1);
string memory description = "Some Proposal";
IGovernance.ProposalType proposalType = IGovernance.ProposalType.ParameterChange;
// Create a new proposal
vm.startPrank(PROPOSER);
uint256 proposalId = governance.propose(targets, values, calldatas, description, proposalType);
// Skip 1 days to the start time of voting period
skip(1 days);
// Vote in favor for proposal
governance.castVote(proposalId, true);
// Warp to end time of proposal
vm.warp(governance.getProposal(proposalId).endTime);
// Assert that proposal is already in Succeeded state
assertEq(uint256(governance.state(proposalId)), uint256(IGovernance.ProposalState.Succeeded));
console.log(
"PROPOSAL STATE :",
governance.state(proposalId) == IGovernance.ProposalState.Succeeded ? "SUCCEEDED" : "NOT SUCCEEDED"
);
// Assert that voting period is still active
vm.startPrank(USER);
governance.castVote(proposalId, true);
(uint256 forVotes, uint256 againstVotes) = governance.getVotes(proposalId);
assertEq(forVotes + againstVotes, veRaac.getVotingPower(USER) + veRaac.getVotingPower(PROPOSER));
console.log("TOTAL VOTES :", forVotes + againstVotes);
}
}

Logs

Ran 1 test for test/foundry/GovernanceTest.t.sol:GovernanceTest
[PASS] test_wrongEndTimeCheck_castVote() (gas: 446242)
Logs:
PROPOSAL STATE : SUCCEEDED
TOTAL VOTES : 200000000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 17.85ms (3.29ms CPU time)

Instructions

First, integrate Foundry by running the following commands in your terminal, in the project's root directory:

# Create required directories
mkdir out lib
# Add `forge-std` module to `lib`
git submodule add https://github.com/foundry-rs/forge-std lib/forge-std
# Create foundry.toml
touch foundry.toml

Next, configure Foundry by adding the following settings to foundry.toml:

[profile.default]
src = "contracts"
out = "out"
lib = "lib"

After that, create a foundry/ directory inside the test/ directory. Inside foundry/, create the following files:

  • GovernanceTest.t.sol

Finally, paste the provided (PoC) into GovernanceTest.t.sol and run:

forge test --mt test_wrongEndTimeCheck_castVote -vvv

Recommendation

Consider changing the proposal.endTime check in the castVote function:

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) {
+ 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;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Governance::castVote lacks canceled/executed proposal check, allowing users to waste gas voting on proposals that can never be executed

inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Governance::castVote lacks canceled/executed proposal check, allowing users to waste gas voting on proposals that can never be executed

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.