Core Contracts

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

Canceled vote still get voted on and accumulate voting power in Goverance.sol

Summary

In governance.sol vote handling after proposal cancellation. When a proposal is cancelled, the system still allows voting to occur and counts these votes, which should not be possible. This creates an inconsistent state where:

  • A proposal is be officially cancelled

  • Yet still accumulate votes

  • These votes are recorded and counted

  • The voting power is still tracked

  • The proposal remains in a state where it's simultaneously cancelled but actively collecting votes

This breaks the fundamental governance flow and security assumptions about proposal lifecycle management. Caused from missing state validation in the castVote() function, which doesn't properly check if a proposal is in a cancelled state before allowing votes.

Vulnerability Details

The root cause is in the castVote() function's insufficient state validation:

function castVote(uint256 proposalId, bool support) external override returns (uint256) {
ProposalCore storage proposal = _proposals[proposalId];
// Only checks timing, not cancelled state
if (block.timestamp < proposal.startTime) {
revert VotingNotStarted(proposalId, proposal.startTime, block.timestamp);
}
if (block.timestamp > proposal.endTime) {
revert VotingEnded(proposalId, proposal.endTime, block.timestamp);
}
// Missing check for cancelled state
// Should have: if (proposal.canceled) revert ProposalCancelled();
...
}

Proof of code:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "forge-std/console.sol";
import "../../../../../contracts/core/governance/proposals/Governance.sol";
import "../../../../../contracts/core/governance/proposals/TimelockController.sol";
import "../../../../../contracts/interfaces/core/governance/proposals/IGovernance.sol";
import "../../../../../contracts/interfaces/core/tokens/IveRAACToken.sol";
import "../../../../../contracts/core/tokens/veRAACToken.sol";
contract MockRAACToken is ERC20 {
constructor() ERC20("RAAC", "RAAC") {
_mint(msg.sender, 10_000_000e18);
}
}
contract GovernanceTest is Test {
Governance public governance;
TimelockController public timelock;
veRAACToken public veToken;
MockRAACToken public raacToken;
address public admin = address(this);
address public attacker = makeAddr("attacker");
address[] public attackerAccounts;
uint256 constant NUM_ACCOUNTS = 5;
function setUp() public {
raacToken = new MockRAACToken();
veToken = new veRAACToken(address(raacToken));
address[] memory proposers = new address[](1);
address[] memory executors = new address[](1);
proposers[0] = admin;
executors[0] = admin;
timelock = new TimelockController(2 days, proposers, executors, admin);
governance = new Governance(address(veToken), address(timelock));
timelock.grantRole(timelock.PROPOSER_ROLE(), address(governance));
timelock.grantRole(timelock.EXECUTOR_ROLE(), address(governance));
// Create multiple attacker accounts
for(uint i = 0; i < NUM_ACCOUNTS; i++) {
attackerAccounts.push(makeAddr(string.concat("attacker", vm.toString(i))));
// Give each account enough tokens for their larger lock amount
uint256 accountTokens = 200_000e18 * (i + 1);
raacToken.transfer(attackerAccounts[i], accountTokens);
}
// Give extra tokens to attacker0 for the proposal
raacToken.transfer(attackerAccounts[0], 500_000e18);
// Label accounts for better trace readability
vm.label(attackerAccounts[1], "legitimateUser");
vm.label(attackerAccounts[0], "attacker");
}
function testVoteOnCanceledProposal() public {
// Setup initial voting power for proposer
vm.startPrank(attackerAccounts[0]);
raacToken.approve(address(veToken), 500_000e18);
veToken.lock(500_000e18, 1460 days);
// Create proposal
address[] memory targets = new address[](1);
uint256[] memory values = new uint256[](1);
bytes[] memory calldatas = new bytes[](1);
uint256 proposalId = governance.propose(
targets,
values,
calldatas,
"Test Proposal",
IGovernance.ProposalType.TreasuryAction
);
// Move to voting period
vm.warp(block.timestamp + governance.votingDelay() + 1);
// Cancel the proposal
governance.cancel(proposalId);
// Verify proposal is canceled
assertEq(uint(governance.state(proposalId)), uint(IGovernance.ProposalState.Canceled));
// But we can still vote on it!
vm.stopPrank();
// New voter tries to vote on canceled proposal
vm.startPrank(attackerAccounts[1]);
raacToken.approve(address(veToken), 200_000e18);
veToken.lock(200_000e18, 1460 days);
// This should revert but doesn't!
governance.castVote(proposalId, true);
// Check that vote was actually counted
(uint256 forVotes, uint256 againstVotes) = governance.getVotes(proposalId);
assertTrue(forVotes > 0, "Vote was counted on canceled proposal!");
// Verify voter is marked as having voted
assertTrue(governance.hasVoted(proposalId, attackerAccounts[1]),
"Vote was recorded for canceled proposal");
console.log("\n=== Vote On Canceled Proposal Impact ===");
console.log("Proposal State:", uint(governance.state(proposalId)));
console.log("For Votes after cancel:", forVotes);
console.log("Voter marked as voted:", governance.hasVoted(proposalId, attackerAccounts[1]));
vm.stopPrank();
}
}

Flow:

Initial Setup and Proposal Creation:

vm.startPrank(attackerAccounts[0]);
raacToken.approve(address(veToken), 500_000e18);
veToken.lock(500_000e18, 1460 days);
uint256 proposalId = governance.propose(
targets,
values,
calldatas,
"Test Proposal",
IGovernance.ProposalType.TreasuryAction
);

Proposal Cancellation:

governance.cancel(proposalId);
assertEq(uint(governance.state(proposalId)), uint(IGovernance.ProposalState.Canceled));

Post-Cancellation Voting (Should Not Be Possible):

vm.startPrank(attackerAccounts[1]);
raacToken.approve(address(veToken), 200_000e18);
veToken.lock(200_000e18, 1460 days);
governance.castVote(proposalId, true); // This should revert but doesn't!

Vote Counting Still Active:

(uint256 forVotes, uint256 againstVotes) = governance.getVotes(proposalId);
assertTrue(forVotes > 0, "Vote was counted on canceled proposal!");

Vote Recording Still Active:

assertTrue(governance.hasVoted(proposalId, attackerAccounts[1]),
"Vote was recorded for canceled proposal");

The test output shows:

=== Vote On Canceled Proposal Impact ===
Proposal State: 2
For Votes after cancel: 200000000000000000000000
Voter marked as voted: true

Normal Scenario Should Be:

  • Proposal Created -> Active

  • Proposal Cancelled

  • All subsequent vote attempts should revert

  • No vote counting should occur

  • No vote recording should happen

  • Proposal should be permanently locked in cancelled state

Current Broken Scenario:

1. Proposal Created -> Active

  • Proposal Cancelled

  • Votes still accepted

  • Votes still counted

5. Votes still recorded

  • Proposal in inconsistent state (cancelled but accepting votes)

Impact

Direct Governance Manipulation

  • Cancelled proposals remain votable

  • Votes are counted and recorded after cancellation

  • Creates parallel governance state where cancelled proposals still accumulate power

// Proof from test
governance.cancel(proposalId); // Proposal cancelled
governance.castVote(proposalId, true); // Still works
(uint256 forVotes, uint256 againstVotes) = governance.getVotes(proposalId); // Votes counted
assertTrue(forVotes > 0); // Votes accumulated

Cancelled proposals still affect voting metrics

  • skew governance statistics and historical data

  • Vote power is still consumed on cancelled proposals

Tools Used

Recommendations

Add a check to revert if a vote want to be casted on a canceled vote

Updates

Lead Judging Commences

inallhonesty Lead Judge 4 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 4 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.