Core Contracts

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

Doesn't support automatic cancellation as intended

Summary

The current `cancel()` function in the `governance` contract is a manual cancellation mechanism that does not automatically cancel a proposal during execution if the proposer's voting power falls below the required threshold. To achieve truly automatic cancellation, this check must be enforced within the `execute()` function.

Vulnerability Details

In the provided implementation, the `cancel()` function allows a proposal to be canceled manually only if either the caller is the proposer or the proposer’s voting power has dropped below the threshold:
```solidity
function cancel(uint256 proposalId) external override {
ProposalCore storage proposal = _proposals[proposalId];
if (proposal.startTime == 0) revert ProposalDoesNotExist(proposalId);
ProposalState currentState = state(proposalId);
if (currentState == ProposalState.Executed) {
revert InvalidProposalState(
proposalId, currentState, ProposalState.Active, "Cannot cancel executed proposal"
);
}
// Only proposer or if proposer's voting power dropped below threshold
if (msg.sender != proposal.proposer && _veToken.getVotingPower(proposal.proposer) >= proposalThreshold) {
revert InsufficientProposerVotes(
proposal.proposer,
_veToken.getVotingPower(proposal.proposer),
proposalThreshold,
"Proposer lost required voting power"
);
}
proposal.canceled = true;
emit ProposalCanceled(proposalId, msg.sender, "Proposal canceled by proposer");
}
```
However, because this check is only performed when `cancel()` is explicitly called, proposals that lose the necessary support (i.e., proposer's voting power falls below the threshold) may still proceed to execution if no one calls `cancel()`.
### Proof of Concept
The following unit test demonstrates the issue:
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "../../../../contracts/core/tokens/veRAACToken.sol";
import "../../../../contracts/core/governance/proposals/Governance.sol";
import "../../../../contracts/core/governance/proposals/TimelockController.sol";
import "../../../../contracts/interfaces/core/governance/proposals/IGovernance.sol";
// Mock RAAC token for voting power (not necessarily minted in proposals)
contract MockRAAC is ERC20 {
constructor() ERC20("Mock RAAC", "RAAC") {
_mint(msg.sender, 2_200_000 * 10 ** 18); // Enough for proposers and voters
}
}
contract GovernanceTest is Test {
Governance governance;
veRAACToken veToken;
TimelockController timelock;
MockRAAC raac;
address admin = address(this);
address[] proposers;
address[] executors;
address[] voters;
uint256 constant ONE_DAY = 1 days;
uint256 constant SEVEN_DAYS = 7 days;
function setUp() public {
// Deploy MockRAAC and veRAACToken
raac = new MockRAAC();
veToken = new veRAACToken(address(raac));
// Initialize 5 proposers and 5 executors
for (uint256 i = 0; i < 5; i++) {
proposers.push(address(uint160(i + 1)));
executors.push(address(uint160(i + 6)));
}
// Initialize 20 voters
for (uint256 i = 0; i < 20; i++) {
voters.push(address(uint160(i + 11)));
}
// Deploy TimelockController with 2-day delay
timelock = new TimelockController(2 days, proposers, executors, admin);
// Deploy Governance contract
governance = new Governance(address(veToken), address(timelock));
// Grant roles to Governance contract in Timelock
timelock.grantRole(timelock.PROPOSER_ROLE(), address(governance));
timelock.grantRole(timelock.EXECUTOR_ROLE(), address(governance));
// Fund TimelockController with ETH for transfers
vm.deal(address(timelock), 10 ether);
// Distribute RAAC tokens and lock them for voting power
for (uint256 i = 0; i < proposers.length; i++) {
raac.transfer(proposers[i], 400_000 * 10 ** 18); // 400K RAAC
vm.startPrank(proposers[i]);
raac.approve(address(veToken), 400_000 * 10 ** 18);
veToken.lock(400_000 * 10 ** 18, 365 days); // Assume 1-year lock for voting power
vm.stopPrank();
}
for (uint256 i = 0; i < voters.length; i++) {
raac.transfer(voters[i], 10_000 * 10 ** 18); // 10K RAAC
vm.startPrank(voters[i]);
raac.approve(address(veToken), 10_000 * 10 ** 18);
veToken.lock(10_000 * 10 ** 18, 365 days);
vm.stopPrank();
}
}
// Test that execution does not automatically cancel a proposal with low voting power
function testExecuteAutoCancelsOnLowVotingPower() public {
// Step 1: Create a proposal
vm.startPrank(proposers[0]);
address[] memory targets = new address[](1);
targets[0] = voters[0]; // Target: voter receives 1 ETH
uint256[] memory values = new uint256[](1);
values[0] = 1 ether;
bytes[] memory calldatas = new bytes[](1);
calldatas[0] = "";
uint256 proposalId = governance.propose(
targets, values, calldatas, "Send 1 ETH to voter", IGovernance.ProposalType.ParameterChange
);
vm.stopPrank();
// Step 2: Move to voting period and cast votes to make it Succeeded
vm.warp(block.timestamp + ONE_DAY + 1);
for (uint256 i = 0; i < 10; i++) {
vm.startPrank(voters[i]);
governance.castVote(proposalId, true);
vm.stopPrank();
}
// Step 3: Warp past voting period to reach Succeeded state
vm.warp(block.timestamp + SEVEN_DAYS);
assertEq(
uint256(governance.state(proposalId)),
uint256(IGovernance.ProposalState.Succeeded),
"State should be Succeeded after voting"
);
// Step 4: Reduce proposer’s voting power below threshold
vm.store(
address(veToken),
keccak256(abi.encode(proposers[0], keccak256("votingPower"))), // Adjust slot if known
bytes32(uint256(50_000 * 10 ** 18))
);
uint256 newVotingPower = veToken.getVotingPower(proposers[0]);
assertLt(newVotingPower, governance.proposalThreshold(), "Proposer voting power should be below threshold");
// Step 5: Execute should queue (not cancel) since no auto-cancel logic exists
vm.prank(proposers[0]);
governance.execute(proposalId);
assertEq(
uint256(governance.state(proposalId)),
uint256(IGovernance.ProposalState.Queued),
"State should be Queued, not canceled"
);
// Step 6: Warp past timelock delay and execute
vm.warp(block.timestamp + 2 days + 1);
uint256 initialBalance = voters[0].balance;
vm.prank(proposers[0]);
governance.execute(proposalId);
// Step 7: Verify no automatic cancellation occurred and ETH was transferred
assertNotEq(
uint256(governance.state(proposalId)),
uint256(IGovernance.ProposalState.Canceled),
"Proposal should not be automatically canceled due to low voting power"
);
assertEq(
uint256(governance.state(proposalId)),
uint256(IGovernance.ProposalState.Executed),
"State should be Executed"
);
assertEq(
voters[0].balance,
initialBalance + 1 ether,
"Target should have received 1 ETH, confirming no automatic cancellation"
);
}
}
```
Test Output:
```yaml
Ran 1 test for test/foundry/Governance/proposals/GovernanceTest.t.sol:GovernanceTest
[PASS] testExecuteAutoCancelsOnLowVotingPower() (gas: 1083871)
```

Impact

Inconsistent Proposal States: Proposals with insufficient proposer voting power can still be executed because the cancellation logic is not automatically triggered during execution.
Governance Exploitation: Malicious actors might exploit this gap by allowing proposals to be executed even after the proposer’s voting power falls below the threshold, undermining the governance process.
Violation of Intended Safeguards: The system’s intended safeguard—automatically canceling proposals that no longer meet the required voting power—is not fully implemented, potentially leading to the execution of proposals that should be invalidated.

Tools Used

Manual review and foundry

Recommendations

To implement truly automatic cancellation, incorporate a voting power check into the `execute()` function. For instance, before proceeding with the execution, verify that the proposer’s voting power still meets the threshold; if not, automatically cancel the proposal:
```solidity
function execute(uint256 proposalId) external override {
ProposalCore storage proposal = _proposals[proposalId];
// Automatic cancellation check
if (_veToken.getVotingPower(proposal.proposer) < proposalThreshold) {
proposal.canceled = true;
emit ProposalCanceled(proposalId, msg.sender, "Automatic cancellation due to insufficient voting power");
revert("Proposal automatically cancelled due to insufficient voting power");
}
// Proceed with the normal execution flow...
}
```
This adjustment ensures that proposals with insufficient support are automatically canceled during the execution process, aligning the system behavior with the intended governance safeguards.
Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Invalidated
Reason: Design choice
inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Appeal created

osuolale Submitter
6 months ago
inallhonesty Lead Judge
6 months ago
inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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

Give us feedback!