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 over 1 year ago
Submission Judgement Published
Invalidated
Reason: Design choice
inallhonesty Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Design choice

Appeal created

osuolale Submitter
over 1 year ago
inallhonesty Lead Judge
over 1 year ago
inallhonesty Lead Judge over 1 year 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!