Core Contracts

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

Accidental Execution of an Old Governance Proposal

Summary

Accidental Execution of an Old Governance Proposal

Vulnerability Details

The execute() function in the governance contract calls state() to determine the proposal's status before execution.

// Governance::execute()
function execute(uint256 proposalId) external override nonReentrant {
ProposalCore storage proposal = _proposals[proposalId];
if (proposal.executed) revert ProposalAlreadyExecuted(proposalId, block.timestamp);
@> ProposalState currentState = state(proposalId);
// Check if the proposal is in the correct state for execution
if (currentState == ProposalState.Succeeded) {
// Queue the proposal
_queueProposal(proposalId);
} else if (currentState == ProposalState.Queued) {
// Execute the queued proposal
_executeProposal(proposalId);
} else {
// If not in Succeeded or Queued state, revert
revert InvalidProposalState(
proposalId,
currentState,
currentState == ProposalState.Active ? ProposalState.Succeeded : ProposalState.Queued,
"Invalid state for execution"
);
}
}

In the state() function, proposals do not expire automatically but rely on the quorum() function to determine success. If the quorum() value drops after voting has ended, a previously unsuccessful proposal may become executable.

// Governance::state()
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 not pending and voting passed, it's Succeeded
return ProposalState.Succeeded;
}

The quorum() function is affected by _veToken.getTotalVotingPower() and quorumNumerator. If these values decrease, the quorum requirement also decreases, potentially allowing old proposals to be executed unexpectedly.

// Governance::quorum()
function quorum() public view override returns (uint256) {
@> return (_veToken.getTotalVotingPower() * quorumNumerator) / QUORUM_DENOMINATOR;
}
// Governance::setParameter()
function setParameter(GovernanceParameter param, uint256 newValue) external override onlyOwner {
// SNIP...
if (param == GovernanceParameter.VotingDelay) {
votingDelay = newValue;
emit VotingDelaySet(oldValue, newValue, msg.sender);
} else if (param == GovernanceParameter.VotingPeriod) {
votingPeriod = newValue;
emit VotingPeriodSet(oldValue, newValue, msg.sender);
} else if (param == GovernanceParameter.ProposalThreshold) {
proposalThreshold = newValue;
emit ProposalThresholdSet(oldValue, newValue, msg.sender);
} else if (param == GovernanceParameter.QuorumNumerator) {
@> quorumNumerator = newValue;
emit QuorumNumeratorSet(oldValue, newValue, msg.sender);
}
}

A proposal that initially failed due to insufficient quorum may remain dormant. If _veToken.getTotalVotingPower() or quorumNumerator decreases over time, the required quorum threshold also decreases, allowing the proposal to pass and be executed, even if it is outdated.

Poc

Add the following test to test/unit/core/governance/proposals/Governance.test.js and execute it:

describe("Old proposal accidentally executed", () => {
let proposalId;
beforeEach(async () => {
// Set up voting power for the proposer
await veToken.mock_setInitialVotingPower(
await owner.getAddress(),
ethers.parseEther("150000")
);
// Create a proposal
const targets = [await testTarget.getAddress()];
const values = [0];
const calldatas = [
testTarget.interface.encodeFunctionData("setValue", [42])
];
const tx = await governance.connect(owner).propose(
targets,
values,
calldatas,
"Test Proposal",
0 // ParameterChange
);
const receipt = await tx.wait();
const event = receipt.logs.find(
log => governance.interface.parseLog(log)?.name === 'ProposalCreated'
);
proposalId = event.args.proposalId;
// Advance time to voting period
await time.increase(VOTING_DELAY);
// Cache requiredQuorum
expect(await governance.quorum()).to.equal(ethers.parseEther("6000"));
// Setup voting power
// Use a number of votes that does not satisfy requiredQuorum
await veToken.mock_setVotingPower(await user1.getAddress(), ethers.parseEther("5900"));
const startTime = await moveToNextTimeframe();
expect(await governance.state(proposalId)).to.equal(ProposalState.Active);
// Cast vote
await governance.connect(user1).castVote(proposalId, true);
expect(await governance.state(proposalId)).to.equal(ProposalState.Active);
// Wait for voting period to end
await time.increaseTo(startTime + VOTING_PERIOD);
await network.provider.send("evm_mine");
// We got a defeated proposal
expect(await governance.state(proposalId)).to.equal(ProposalState.Defeated);
});
it("if _veToken.getTotalVotingPower() decrease", async () => {
expect(await governance.state(proposalId)).to.equal(ProposalState.Defeated);
await time.increase(365 * 24 * 60 * 60);
await network.provider.send("evm_mine");
// if `_veToken.getTotalVotingPower()` decreases, `requiredQuorum` will decrease ❌
await veToken.mock_setInitialVotingPower(
await owner.getAddress(),
ethers.parseEther("140000")
);
expect(await governance.quorum()).to.equal(ethers.parseEther("5600"));
// Verify state is Succeeded
expect(await governance.state(proposalId)).to.equal(ProposalState.Succeeded);
// Queue the proposal
await governance.execute(proposalId);
expect(await governance.state(proposalId)).to.equal(ProposalState.Queued);
// Wait for timelock delay
const timelockDelay = await timelock.getMinDelay();
await time.increase(timelockDelay);
await network.provider.send("evm_mine");
// Execute the proposal
await governance.execute(proposalId);
await logProposalState(governance, proposalId);
expect(await governance.state(proposalId)).to.equal(ProposalState.Executed);
// Verify the change
expect(await testTarget.value()).to.equal(42n);
});
it("if quorumNumerator decrease", async () => {
expect(await governance.state(proposalId)).to.equal(ProposalState.Defeated);
await time.increase(365 * 24 * 60 * 60);
await network.provider.send("evm_mine");
// if `quorumNumerator` decreases, `requiredQuorum` will decrease ❌
const newQuorum = 3;
await governance.setParameter(GovernanceParameter.Quorum, newQuorum);
// Verify state is Succeeded
expect(await governance.state(proposalId)).to.equal(ProposalState.Succeeded);
// Queue the proposal
await governance.execute(proposalId);
expect(await governance.state(proposalId)).to.equal(ProposalState.Queued);
// Wait for timelock delay
const timelockDelay = await timelock.getMinDelay();
await time.increase(timelockDelay);
await network.provider.send("evm_mine");
// Execute the proposal
await governance.execute(proposalId);
await logProposalState(governance, proposalId);
expect(await governance.state(proposalId)).to.equal(ProposalState.Executed);
// Verify the change
expect(await testTarget.value()).to.equal(42n);
});
});

output:

Governance
Old proposal accidentally executed
Proposal Info:
Timeline for Proposal: 0n
= State : EXECUTED | isExecuted: true | isCanceled: false
Current 2026-03-01T00:00:01.000Z
Start 2025-02-14T13:30:38.000Z
End 2025-02-21T13:30:38.000Z
Voting Results: For: 5900.0 | Against: 0.0
Quorum Status:
Current: 5900.0 / 5600.0
if _veToken.getTotalVotingPower() decrease (895ms)
Proposal Info:
Timeline for Proposal: 0n
= State : EXECUTED | isExecuted: true | isCanceled: false
Current 2027-03-14T00:00:00.000Z
Start 2026-03-02T00:00:09.000Z
End 2026-03-09T00:00:09.000Z
Voting Results: For: 5900.0 | Against: 0.0
Quorum Status:
Current: 5900.0 / 4500.0
if quorumNumerator decrease (1428ms)
2 passing (57s)

Impact

Since the governance contract does not have a proposalExpiration mechanism, historical proposals may suddenly meet the quorum() requirements and enter the execution state at some point in the future, which may lead to unpredictable consequences.
For example, months or years later, due to the decrease in voting token holdings, historical proposals are unexpectedly executed, affecting protocol security or fund allocation.
If the proposal content involves contract upgrades, fund transfers, or permission changes, they are reasonable in the historical context, but may no longer be applicable in the current environment, and execution may lead to asset loss or governance chaos.

Tools Used

Manual Review

Recommendations

To prevent this issue, consider implementing one of the following solutions:

  1. Proposal Expiry: Introduce an expiration mechanism where proposals become invalid after a certain period.

  2. Snapshot Quorum: Store the requiredQuorum at the time of vote completion to ensure the threshold does not change retroactively.

  3. State Validation on Execution: Check if the proposal’s original quorum conditions were met at the time of voting completion before allowing execution.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

Governance allows execution of previously-defeated proposals if quorum requirements are later lowered, enabling unexpected resurrection of old proposals

inallhonesty Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

Governance allows execution of previously-defeated proposals if quorum requirements are later lowered, enabling unexpected resurrection of old proposals

Support

FAQs

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