Core Contracts

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

Proposal cancellation in `Governance` does not cancel underlying operation in `TimelockController`, allowing to be executed anyway

Description

Governance allows users to cancel proposals through the cancel function. However, if the proposal's underlying operation has already been queued, it will not be canceled. As a result, any address with the EXECUTOR_ROLE can still execute the operation.

Context

Impact

High. Any canceled proposal with an already queued operation can still be executed, compromising the integrity of the governance mechanism.

Likelihood

Medium. The lack of cancellation for underlying operations will occur for every canceled proposal with an already queued operation. However, only addresses with the EXECUTOR_ROLE will be able to execute them.

Proof of Concept

// SPDX-License-Identifier: MIT
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));
vm.stopPrank();
}
function test_proposalOperationNotCanceled_cancel() 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);
// Queue proposal operation in timelock
governance.execute(proposalId);
// Cancel proposal
governance.cancel(proposalId);
// Assert that proposal was canceled
assertEq(uint256(governance.state(proposalId)), uint256(IGovernance.ProposalState.Canceled));
// Assert that operation is still queued in timelock
bytes32 operationId = timelock.hashOperationBatch(
targets, values, calldatas, bytes32(0), governance.getProposal(proposalId).descriptionHash
);
assertTrue(timelock.isOperationPending(operationId));
}
}

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_proposalOperationNotCanceled_cancel -vvv

Recommendation

Consider canceling the proposal's underlying operation in the cancel function if it has been already queued.

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");
}
+ if (currentState == ProposalState.Queued) {
+ bytes32 opId = _timelock.hashOperationBatch(
+ proposal.targets, proposal.values, proposal.calldatas, bytes32(0), proposal.descriptionHash
+ );
+ _timelock.cancel(opId);
+ }
// 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");
}
Updates

Lead Judging Commences

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

Governance::cancel and state lack synchronization with TimelockController

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

Governance::cancel and state lack synchronization with TimelockController

Support

FAQs

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