Core Contracts

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

Dynamic calculation of quorum means a proposal can pass even though it doesn't cover the original quorum requirement

Summary

The Governance contract's state(proposalId) function determines proposal status using the current total voting power instead of the voting power at proposal creation. Due to the decay mechanism in veToken, this allows proposals to pass with fewer votes than initially required, circumventing the intended quorum threshold.

Vulnerability Details

The vulnerability exists in how the quorum is calculated at proposal execution time:

  1. The quorum() function calculates the required threshold based on current values:

solidity
return (_veToken.getTotalVotingPower() * quorumNumerator) / QUORUM_DENOMINATOR;

Due to veToken's decay mechanism, the total voting power decreases over time. It is possible that users who already voted in the beginning of the voting process, will have less voting power at the end. The total voting power of the veToken will also be lower at the end. When state(proposalId) is called during execution, it uses this reduced quorum value to check whether the proposal can pass.

This creates a scenario where:

  • Users vote when their voting power is high

  • Voting power decays over the proposal period

  • Final quorum check uses the reduced total voting power but the inflated user voting power

  • Proposal passes with fewer effective votes than should be required

For example:

  • Day 1: Total voting power = 1000, Quorum = 500 (50%)

  • Day 30: Total voting power decays to 600, Quorum = 300 (50% of 600)

  • A proposal with 400 votes would fail on Day 1 but pass on Day 30

Impact

This vulnerability has serious implications for governance:

  • Proposals can pass with fewer votes than intended

  • Malicious actors could time proposals around known decay periods

  • The governance system's security assumptions are undermined

  • Critical protocol changes could be approved without proper consensus

Proof of Concept

Add this test case under "Proposal Execution" in Governance.test.js

javascript
it("should execute successful proposal with low votes", async () => {
// Setup voting power
await veToken.mock_setVotingPower(await user1.getAddress(), ethers.parseEther("6500"));
await veToken.mock_setInitialVotingPower(
await owner.getAddress(),
ethers.parseEther("150000")
);
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");
await veToken.mock_setVotingPower(await user1.getAddress(), ethers.parseEther("0")); // Below threshold
await veToken.mock_setTotalSupply(ethers.parseEther("10000")); // Total supply goes down now that user1's votes have decayed, so required quorum will also go down.
// Verify state is Succeeded
console.log(await governance.quorum());
expect(await governance.state(proposalId)).to.equal(ProposalState.Succeeded); //proposal succeeded, even though user1's votes have decayed

Recommendation

Implement a snapshot of the required quorum at proposal creation. First, add a quorum snapshot variable to the ProposalCore struct.

solidity
struct ProposalCore {
uint256 id; // Unique proposal identifier
address proposer; // Address that created the proposal
ProposalType proposalType; // Type of proposal
uint256 startTime; // Start of voting period
uint256 endTime; // End of voting period
bool executed; // Whether proposal has been executed
bool canceled; // Whether proposal has been canceled
bytes32 descriptionHash; // Hash of proposal description
address[] targets; // Target addresses for calls
uint256[] values; // ETH values for calls
bytes[] calldatas; // Calldata for each call
+ uint256 quorumAtCreation; // Quorum required at proposal creation
}
//Then, in the propose() function:
_proposals[proposalId] = ProposalCore({
id: proposalId,
proposer: msg.sender,
proposalType: proposalType,
startTime: startTime,
endTime: endTime,
executed: false,
canceled: false,
descriptionHash: keccak256(bytes(description)),
targets: targets,
values: values,
calldatas: calldatas,
+ quorumAtCreation: quorum()
});

Finally, modify the state() function as follows:

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();
+ uint256 requiredQuorum = proposal.quorumAtCreation;

In addition, only addresses This ensures that proposals must meet the quorum threshold that was in place when they were created, maintaining the integrity of the governance system.

Updates

Lead Judging Commences

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

Governance::quorum uses current total voting power instead of proposal creation snapshot, allowing manipulation of threshold requirements to force proposals to pass or fail

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

Governance::quorum uses current total voting power instead of proposal creation snapshot, allowing manipulation of threshold requirements to force proposals to pass or fail

Support

FAQs

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