Core Contracts

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

Incorrect Proposal Status Handling Due to Timelock Cancellation

Summary

The function state() determines the current state of a governance proposal. However, it does not check if a proposal has been canceled in the timelock, leading to a scenario where a canceled proposal may be incorrectly returned as Succeeded.

This can cause governance manipulation, where proposals that should have failed can appear valid, creating inconsistencies in the governance process.

Vulnerability Details

This is the state function where we check

if (_timelock.isOperationPending(id)) {
return ProposalState.Queued;
}

function state(uint256 proposalId) public view override returns (ProposalState) {
ProposalCore storage proposal = _proposals[proposalId];
if (proposal.startTime == 0) revert ProposalDoesNotExist(proposalId);
if (proposal.canceled) return ProposalState.Canceled;
if (proposal.executed) return ProposalState.Executed;
if (block.timestamp < proposal.startTime) return ProposalState.Pending;
if (block.timestamp < proposal.endTime) return ProposalState.Active;
// After voting period ends, check quorum and votes
ProposalVote storage proposalVote = _proposalVotes[proposalId];
uint256 currentQuorum = proposalVote.forVotes + proposalVote.againstVotes;
uint256 requiredQuorum = quorum();
// Check if quorum is met and votes are in favor
if (currentQuorum < requiredQuorum || proposalVote.forVotes <= proposalVote.againstVotes) {
return ProposalState.Defeated;
}
bytes32 id = _timelock.hashOperationBatch(
proposal.targets,
proposal.values,
proposal.calldatas,
bytes32(0),
proposal.descriptionHash
);
// If operation is pending in timelock, it's Queued
>> if (_timelock.isOperationPending(id)) { //@audit-issue : Cancelled proposals in timelock will be returned as succeeded.
return ProposalState.Queued;
}
// If not pending and voting passed, it's Succeeded
return ProposalState.Succeeded;
}
  • However, this does not account for cases where the timelock operation was canceled.

  • If _timelock.isOperationPending(id) returns false (because the proposal was canceled), the function falls through to return ProposalState.Succeeded, even though the proposal should not succeed.

  • This creates an inconsistency between the governance contract and the timelock contract.

Impact

Governance Manipulation: A canceled proposal may still appear as Succeeded, allowing an attacker or malicious actor to rerun a proposal that should have failed.

State Inconsistency: The governance contract and the timelock contract will store different statuses for the same proposal, which can cause unexpected behaviors in governance execution.

Security Risk: If automated execution relies on a proposal being Succeeded, a canceled proposal might still get executed in some cases.

Tools Used

Manual Review

Recommendations

1. Explicitly Check if the Proposal was Canceled in the Timelock

Modify the function to check _timelock.isOperationCanceled(id) before marking a proposal as Succeeded

2. Implement a Mapping to Track Proposal Status

Instead of only relying on _timelock.isOperationPending(), maintain a mapping to track proposal cancellations

contract TimeLockController{
mapping(uint256 => bool) public canceledInTimelock;
function cancelProposalInTimelock(uint256 proposalId) external onlyTimelock {
canceledInTimelock[proposalId] = true;
}
}
function state(uint256 proposalId) public view override returns (ProposalState) {
ProposalCore storage proposal = _proposals[proposalId];
if (proposal.startTime == 0) revert ProposalDoesNotExist(proposalId);
if (proposal.canceled) return ProposalState.Canceled;
if (proposal.executed) return ProposalState.Executed;
if (block.timestamp < proposal.startTime) return ProposalState.Pending;
if (block.timestamp < proposal.endTime) return ProposalState.Active;
// After voting period ends, check quorum and votes
ProposalVote storage proposalVote = _proposalVotes[proposalId];
uint256 currentQuorum = proposalVote.forVotes + proposalVote.againstVotes;
uint256 requiredQuorum = quorum();
// Check if quorum is met and votes are in favor
if (currentQuorum < requiredQuorum || proposalVote.forVotes <= proposalVote.againstVotes) {
return ProposalState.Defeated;
}
bytes32 id = _timelock.hashOperationBatch(
proposal.targets,
proposal.values,
proposal.calldatas,
bytes32(0),
proposal.descriptionHash
);
// If operation is pending in timelock, it's Queued
if (_timelock.isOperationPending(id)) {
return ProposalState.Queued;
}
++ if (_timelock.isOperationCanceled(id) || || canceledInTimelock[proposalId]) { // Fix: Prevents canceled proposals from being marked as succeeded
++ return ProposalState.Defeated;
++ }
// If not pending and voting passed, it's Succeeded
return ProposalState.Succeeded;
}
Updates

Lead Judging Commences

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

Governance::cancel and state lack synchronization with TimelockController

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

Give us feedback!