Core Contracts

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

User's voting power never decays over time.

Summary

When a user locks tokens, the contract computes a “point” consisting of a bias (initial voting power) and a slope (the rate at which that power decays). In a correct implementation (like Curve’s veCRV), the voting power at any later time is given by:

  • voting power = bias − slope × (elapsed time)

However, in this implementation the following happens:

  1. The calculateAndUpdatePower(...) function correctly computes both the bias and the slope using the RAACVoting library.

  2. Immediately afterward, the contract writes a checkpoint that “freezes” the bias value (converted to a lower‑precision type, e.g. a uint128) and then mints that many ve‑tokens.

  3. Later, when the contract’s view functions (e.g. getCurrentPower) are used to calculate the “current” voting power, they recompute decay using the stored slope and the elapsed time. But because:

    • The decay amount (slope × elapsed time divided by RAY) is very small compared to the precision of the stored bias

The decay may be rounded to zero. Even if some decay is computed, the checkpoint itself is never updated after the lock is created, so the “stored” value never reflects any decay.

Vulnerability Details

In the lock function the contract calls:

(int128 bias, int128 slope) = _votingState.calculateAndUpdatePower(
msg.sender,
amount,
unlockTime
);
// Then it writes a checkpoint using the computed bias:
uint256 newPower = uint256(uint128(bias));
_checkpointState.writeCheckpoint(msg.sender, newPower);
// And mints that many ve‑tokens:
_mint(msg.sender, newPower);

At this point the bias (which represents the full voting power at lock time) is stored, but it never “ticks” down. (The slope is computed and stored inside the user's point, but it isn’t used to update the checkpointed balance)

Later, when someone calls getCurrentPower (via getVotingPower), the contract does:

function getCurrentPower(
VotingPowerState storage state,
address account,
uint256 timestamp
) internal view returns (uint256) {
RAACVoting.Point memory point = state.points[account];
if (point.timestamp == 0) return 0;
// If no time has passed, return the initial bias.
if (timestamp <= point.timestamp) {
return uint256(uint128(point.bias));
}
uint256 timeDelta = timestamp - point.timestamp;
// Calculate decay: (slope * elapsedTime) scaled by RAY.
int256 decay = (int256(point.slope) * int256(timeDelta)) / int256(RAY);
int256 currentBias = int256(point.bias) - decay;
return currentBias > 0 ? uint256(currentBias) : 0;
}

The bias is stored as an int128 even though the calculation uses “ray” precision (1e27). When converting the bias to a lower‑precision type (via uint256(uint128(point.bias))), any decay less than 1 unit of that 128‑bit value is lost. For example, if the decay per day is only a small fraction of one unit in that 128‑bit space, then after one day the computed decay may round down to zero.

The checkpoint is only written once (in the lock function) and never updated. Thus, the stored voting power does not “decay” as time passes, even though the view function computes a decayed value using the original timestamp and slope.

Impact

Voting power never decays, a user’s voting power remains artificially high throughout the lock duration. In protocols like Curve’s veCRV, voting power decays linearly so that a user’s influence (and boost on rewards) diminishes as the lock approaches its expiry. Here, if decay is not applied correctly, users can benefit from long-lasting, high voting power even when they have locked tokens for only part of the full period. This can be exploited to gain disproportionate influence in governance or reward distribution.

PoC

Add this test "BaseGaugeExploit.test.js" to the folder test/unit/core/governance/gauges/:

import { time } from "@nomicfoundation/hardhat-network-helpers";
import { expect } from "chai";
import hre from "hardhat";
const { ethers, network } = hre;
describe("VotingPowerDecayWithMaxLock", function () {
let raacVeToken;
let owner, user1;
const MAX_LOCK_DURATION = 1460 * 24 * 3600; // 4 years in seconds
const DAY = 24 * 3600;
beforeEach(async function () {
[owner, user1] = await ethers.getSigners();
// Deploy a mock ERC20 token to serve as RAAC
const MockToken = await ethers.getContractFactory("MockVeToken");
raacVeToken = await MockToken.deploy();
// Mint some RAAC tokens for user1 and approve the veRAACToken contract to spend them
await raacVeToken.mint(user1.address, ethers.parseEther("10000"));
await raacVeToken.connect(user1).approve(raacVeToken.getAddress(), ethers.parseEther("10000"));
});
it("should decay voting power over time for a maximum-duration lock", async function () {
// Lock 1000 RAAC tokens for the maximum duration (4 years)
const lockAmount = ethers.parseEther("1000");
await raacVeToken.connect(user1).lock(lockAmount, MAX_LOCK_DURATION);
// Immediately after locking, record the voting power
const initialVotingPower = await raacVeToken.getVotingPower(user1.address);
console.log("Initial Voting Power:", ethers.formatEther(initialVotingPower));
// Increase time by 500 days (to allow significant decay)
await ethers.provider.send("evm_increaseTime", [500 * 24 * 60 * 60]);
await network.provider.send("evm_mine");
// Read the voting power after 500 days
const votingPowerAfterTime = await raacVeToken.getVotingPower(user1.address);
console.log("Voting Power after 500 days:", ethers.formatEther(votingPowerAfterTime));
// In a secure implementation, the voting power should decay over time.
expect(votingPowerAfterTime).to.be.lt(initialVotingPower, "Voting power did not decay as expected");
});
});

Logs:

VotingPowerDecayWithMaxLock
Initial Voting Power: 10000.0
Voting Power after 500 days: 10000.0

Tools Used

Manual Review, Hardhat.

Recommendations

  1. Keep the bias and slope in “ray” precision (1e27) without immediately converting them to a low‑precision type when checkpointing.

  2. Implement an automatic “checkpointing” mechanism that updates the stored voting power (by subtracting the decay computed via the slope and elapsed time) as time passes. This can be done on each call to getVotingPower or via scheduled updates.

  3. Adjust getCurrentPower to ensure that the decay is computed in full precision before converting the result to the final unit. For example, avoid casting to uint128 too early or use a high‑precision fixed‑point math library throughout.

Updates

Lead Judging Commences

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

VotingPowerLib::calculateAndUpdatePower results in zero slope for small amounts (<MAX_LOCK_DURATION), creating non-decaying voting power that violates linear decay mechanism

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

VotingPowerLib::calculateAndUpdatePower results in zero slope for small amounts (<MAX_LOCK_DURATION), creating non-decaying voting power that violates linear decay mechanism

Support

FAQs

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