Core Contracts

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

Proposals Defeated Incorrectly Due to Incorrect totalSupply() Calculation in Quorum()

Summary

The Governance contract quorum() relies on the wrong total supply. It calculates the static total veRAACToken supply when it should be calculating the dynamic currentVotingPower supply.
The total supply calculation should account for slope and bias. The contract does not ensure that at all times the sum of all user balance (which decay over time) equals exactly the total supply (which is static as implemented).
The total supply should represent the sum of all users’ current voting power. Currently, quorum() relies on totalVotingPower()::veRAACToken.sol (which relies on totalSupply()).
The total supply doesn’t correctly reflect decayed voting power. In order to calculate the totalSupply, we need a special function which takes into account slope changes.
As a result, the protocol believes there is more voting power available than actually exists, leading to incorrect governance outcomes.

The global total voting power (or totalSupply) must reflect the real-time voting power of all locks.

Vulnerability Details

VotingPowerLib.sol:

function getTotalVotingPower() external view override returns (uint256) {//@audit - not correct or accurate. BUG\
return totalSupply(); //@audit - global slope and bias is not updated. (confirm).
}

PoC:

Note we are deploying the real VeRAACToken and RAACToken for this PoC.

Step1: Paste the following into the beforeEach block of Governance.test.js:

let realVeToken, RAACToken; //@audit added for POC. DECLARE these let Variables
//Deploy RAAC token, in order to be able to deploy VeRAACToken //@audit added to beforeEach for POC
const RAACTokenFactory = await ethers.getContractFactory(
"RAACToken",
owner
);
RAACToken = await RAACTokenFactory.deploy(owner.address, 100, 50);
await RAACToken.waitForDeployment();
// Deploy the real veRAACToken //@audit added to beforeEach for POC
const VeRAACToken = await ethers.getContractFactory("veRAACToken");
realVeToken = await VeRAACToken.deploy(RAACToken.target); // Adjust constructor if needed
await realVeToken.waitForDeployment();

Step2: Modify the deployment of governance (include address of real veRAACToken):

// Deploy governance
const Governance = await ethers.getContractFactory("Governance");
governance = await Governance.deploy(
await realVeToken.getAddress(), //<===== *****MODIFY THIS LINE to include realVeToken *****
await timelock.getAddress()
);
await governance.waitForDeployment();

Step3: Paste in this Describe block of Governance.test.js:

describe.only("Governance Quorum Calculation Bug PoC", function () {
const ONE_DAY = 24 * 3600;
const THIRTY_DAYS = 30 * ONE_DAY;
const FIVE_DAYS = 5 * ONE_DAY;
const ONE_YEAR = 365 * ONE_DAY;
// Helper function to advance time
async function advanceTime(seconds) {
await ethers.provider.send("evm_increaseTime", [seconds]);
await ethers.provider.send("evm_mine", []);
}
// Helper function to lock tokens for a user
async function lockTokens(user, amount, duration) {
await RAACToken.connect(user).approve(realVeToken.target, amount);
await realVeToken.connect(user).lock(amount, duration);
}
it("should show static totalSupply remains unchanged while voting power decays and the proposal fails due to inflated quorum", async function () {
// Step 1: Mint & Lock Tokens
const lockAmount = ethers.parseEther("6000000"); // 6M veRAAC
const smallAmount = ethers.parseEther("275000"); // 275k veRAAC
await RAACToken.connect(owner).setMinter(owner.address);
await RAACToken.connect(owner).mint(user1.address, lockAmount);
await RAACToken.connect(owner).mint(user2.address, smallAmount);
// Lock tokens for user1 and user2
await lockTokens(user1, lockAmount, ONE_YEAR);
await lockTokens(user2, smallAmount, ONE_YEAR);
// Step 2: Get Initial Values
const initialTotalSupply = await realVeToken.totalSupply();
const initialVotingPowerUser1 = await realVeToken.getVotingPower(
user1.address
);
// console.log("Initial Total Supply:", initialTotalSupply.toString());
// console.log(
// "User1 Initial Voting Power:",
// initialVotingPowerUser1.toString()
// );
// Step 3: Advance Time by 30 Days to Simulate Decay
await advanceTime(THIRTY_DAYS);
// Step 4: Fetch Updated Values
const decayedVotingPowerUser1 = await realVeToken.getVotingPower(
user1.address
);
const totalSupplyAfter = await realVeToken.totalSupply();
// console.log(
// "User1 Voting Power After 30 Days:",
// decayedVotingPowerUser1.toString()
// );
// console.log("Total Supply After 30 Days:", totalSupplyAfter.toString());
// Assertions: Voting power should decay; total supply remains static
expect(decayedVotingPowerUser1).to.be.lt(initialVotingPowerUser1);
expect(totalSupplyAfter).to.equal(initialTotalSupply);
// Step 5: Check Governance Quorum Calculation
const totalDecayedVotingPower =
(await realVeToken.getVotingPower(user1.address)) +
(await realVeToken.getVotingPower(user2.address)); //In this PoC user1 and user2 are the only users with any veTokens thus we can add their getVotingPower() to compute total decayed supply
const decayedQuorumCalc =
(totalDecayedVotingPower * (await governance.quorumNumerator())) /
(await governance.QUORUM_DENOMINATOR());
const staticQuorum = await governance.quorum();
console.log(
"Governance Quorum (Static Total Supply):",
staticQuorum.toString()
);
console.log(
"Correct Quorum Calc (Decayed Total Voting Power):",
decayedQuorumCalc.toString()
);
// Confirm the bug: static quorum is higher than correct decayed quorum
expect(staticQuorum).to.be.gt(decayedQuorumCalc);
// Step 6: Create and Submit a Proposal
const targets = [await testTarget.getAddress()];
const values = [0];
// Encode a call to setValue(42) as the proposal action
const calldatas = [
testTarget.interface.encodeFunctionData("setValue", [42]),
];
const description = "Test Proposal";
const proposeTx = await governance.connect(user1).propose(
targets,
values,
calldatas,
description,
0 // Proposal type 0
);
const receipt = await proposeTx.wait();
// Optionally, parse the ProposalCreated event for logging
//uncomment below to show logs and verify proposalId == 0
// for (const log of receipt.logs) {
// try {
// const parsed = governance.interface.parseLog(log);
// if (parsed.name === "ProposalCreated") {
// console.log("ProposalCreated:", parsed.args);
// }
// } catch (error) {
// // Ignore logs that don't match the event
// }
// }
// Step 7: Vote on the Proposal
// Advance time so voting can start
await advanceTime(FIVE_DAYS);
// User2 casts a vote (vote value 1 for "in favor")
await governance.connect(user2).castVote(0, 1); //note that proposalID is 0 as per logs
console.log("----User2 cast a vote for the proposal !----");
// Advance time to pass the voting period
const votingPeriod = await governance.votingPeriod();
await advanceTime(Number(votingPeriod));
// Step 8: Check the Final Proposal State
const latestStaticQuorum = await governance.quorum();
console.log("Latest RAAC quorum:", latestStaticQuorum.toString());
const decayedQuorumCalc2 =
(((await realVeToken.getVotingPower(user1.address)) +
(await realVeToken.getVotingPower(user2.address))) *
(await governance.quorumNumerator())) /
(await governance.QUORUM_DENOMINATOR());
console.log("Latest Correct Quorum:", decayedQuorumCalc2.toString());
const user1Power = await realVeToken.getVotingPower(user1.address);
const user2Power = await realVeToken.getVotingPower(user2.address);
console.log("User1 Latest Voting Power:", user1Power.toString());
console.log("User2 Latest Voting Power:", user2Power.toString());
const proposalState = await governance.state(0); // Proposal ID 0
console.log("Proposal state after voting period:", proposalState);
// Assert the intended outcome:
// - User2's voting power is greater than the correct (decayed) quorum calculation,
// meaning his vote should have been sufficient to pass.
// - However, because the contract uses the static quorum, his vote is insufficient to pass the buggy static quorum calculation.
expect(user2Power).to.be.gt(decayedQuorumCalc2);
expect(user2Power).to.be.lt(latestStaticQuorum);
// State 3 represents 'Defeated', show that the vote failed due wrongly to inflated quorum
expect(proposalState).to.equal(3);
});
});

Step4: Run with following command:

npx hardhat test test/unit/core/Governance/proposals/Governance.test.js --show-stack-traces

Impact

  • 1) The bug affects the following other functions which rely on quorum(): state(), getDebugInfo() and _isProposalSuccessful(). The result is that the quorum will be overestimated and be higher than it should be

  • 2) Proposals will fail incorrectly as _isProposalSuccessful() relies on quorum() to check if a vote succeeded or not. Because the quorum will always be higher than it should be, many proposals will fail that should pass.

Tools Used

Manual review, Hardhat

Recommendations

  • Modify the total voting power calculation to reflect the real-time decayed voting power rather than using a static total supply. Update or add a dedicated function that sums the current voting power of all locked tokens—taking into account both slope and bias—for use in the quorum() calculation.

  • Ensure that the quorum() function leverages a dynamic total voting power value that decreases over time as individual voting power decays.
    This adjustment will prevent governance outcomes from being skewed by an overestimation of available voting power.

Updates

Lead Judging Commences

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