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)
```