Core Contracts

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

Incorrect BoostCalculator::endTime expectations cause DOS which attackers can use to manipulate key votes

Summary

This vulnerability introduces a denial-of-service (DoS) condition that prevents users from locking their RAACTokens for veRAAC during a specific time frame due to an inconsistent period validation mechanism. This issue becomes particularly critical in governance scenarios, where users need to lock tokens to participate in voting. If this bug occurs right before an important governance vote, affected users will be unable to obtain voting power, leading to unrepresentative governance decisions. Additionally, malicious actors could exploit this bug by strategically extending totalDuration to prevent new users from locking tokens before a vote, consolidating voting power among a smaller group of existing veRAAC holders. This could compromise protocol integrity, allowing governance control to be manipulated.

Vulnerability Details

A user can lock RAACTokens to gain veRAAC tokens via veRAACToken::lock. See below:

/**
* @notice Creates a new lock position for RAAC tokens
* @dev Locks RAAC tokens for a specified duration and mints veRAAC tokens representing voting power
* @param amount The amount of RAAC tokens to lock
* @param duration The duration to lock tokens for, in seconds
*/
function lock(uint256 amount, uint256 duration) external nonReentrant whenNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount > MAX_LOCK_AMOUNT) revert AmountExceedsLimit();
if (totalSupply() + amount > MAX_TOTAL_SUPPLY) revert TotalSupplyLimitExceeded();
if (duration < MIN_LOCK_DURATION || duration > MAX_LOCK_DURATION)
revert InvalidLockDuration();
// Do the transfer first - this will revert with ERC20InsufficientBalance if user doesn't have enough tokens
raacToken.safeTransferFrom(msg.sender, address(this), amount);
// Calculate unlock time
uint256 unlockTime = block.timestamp + duration;
// Create lock position
_lockState.createLock(msg.sender, amount, duration);
_updateBoostState(msg.sender, amount);
// Calculate initial voting power
(int128 bias, int128 slope) = _votingState.calculateAndUpdatePower(
msg.sender,
amount,
unlockTime
);
// Update checkpoints
uint256 newPower = uint256(uint128(bias));
_checkpointState.writeCheckpoint(msg.sender, newPower);
// Mint veTokens
_mint(msg.sender, newPower);
emit LockCreated(msg.sender, amount, unlockTime);
}

This function calls veRAACToken::_updateBoostState which in turn calls BoostCalculator::updateBoostPeriod which is our main focus.

/**
* @notice Updates the global boost period
* @dev Initializes or updates the time-weighted average for global boost
* @param state The boost state to update
*/
function updateBoostPeriod(
BoostState storage state
) internal {
if (state.boostWindow == 0) revert InvalidBoostWindow();
if (state.maxBoost < state.minBoost) revert InvalidBoostBounds();
uint256 currentTime = block.timestamp;
uint256 periodStart = state.boostPeriod.startTime;
// If no period exists, create initial period starting from current block
if(periodStart > 0) {
// If current period has ended, create new period
if (currentTime >= periodStart + state.boostWindow) {
TimeWeightedAverage.createPeriod(
state.boostPeriod,
currentTime,
state.boostWindow,
state.votingPower,
state.maxBoost
);
return;
}
// Update existing period
state.boostPeriod.updateValue(state.votingPower, currentTime);
return;
}
// If no period exists, create initial period starting from current block
TimeWeightedAverage.createPeriod(
state.boostPeriod,
currentTime,
state.boostWindow,
state.votingPower,
state.maxBoost
);
}

The idea of this function is that it creates a period struct that uses time weighted averages to keep track of all user's locked voting weight by measuring their voting power weighted against how long they have been locked for. This period is a global period that measures the boost multiplier of all users based on their weighted values. If a period has already been started but the period has ended, the function calls TimeWeightedAverage::createPeriod to create a new period. The criteria for a period ending is determined when the current timestamp is >= period start time + the boost window of the period which is initialised in veRAACToken as 7 days.

The bug occurs via the following check in TimeWeightedAverage::createPeriod:

if (
self.startTime != 0 &&
startTime < self.startTime + self.totalDuration
) {
revert PeriodNotElapsed();
}

self.totalduration is a variable in the point struct that is initially set to the boost window but whenever the period is updated via TimeWeightedAverage::updateValue, self.totalDuration is incremented. See below:

/**
* @notice Updates current value and accumulates time-weighted sums
* @dev Calculates weighted sum based on elapsed time since last update
* @param self Storage reference to Period struct
* @param newValue New value to set
* @param timestamp Time of update
*/
function updateValue(
Period storage self,
uint256 newValue,
uint256 timestamp
) internal {
if (timestamp < self.startTime || timestamp > self.endTime) {
revert InvalidTime();
}
unchecked {
uint256 duration = timestamp - self.lastUpdateTime;
if (duration > 0) {
uint256 timeWeightedValue = self.value * duration;
if (timeWeightedValue / duration != self.value) revert ValueOverflow();
self.weightedSum += timeWeightedValue;
self.totalDuration += duration;
}
}

As a result, if the period is updated, the endTime is not consistent with the expected endTime in BoostCalculator::updateBoostPeriod. Since the endTime in TimeWeightedAverage::createPeriod is no longer periodStart + state.boostWindow due to the increment of the totalDuration variable, if a user attempts to lock tokens between the expected endTime in BoostCalculator::updateBoostPeriod and the expected endTime in TimeWeightedAverage::createPeriod , the transaction will revert which prevents the user from being able to lock their RAACTokens for a period of time which causes an unnecessary DOS.

Proof Of Code (POC)

This test was run in the veRAACToken.test.js file in the "Lock Mechamism" describe block

This is the process flow of the DOS with an example :

A user creates a period at T=20 with a boostWindow = 10.

This sets endTime = 20 + 10 = 30.
totalDuration is now initialized to 10.
Another user locks tokens at T=25 with a boostWindow = 10.

Since the current time (T=25) is less than the period's endTime (30), the contract calls TimeWeightedAverage::updateValue().

Within this function:
duration is calculated as currentTime - lastUpdateTime = 25 - 20 = 5.
totalDuration increases by 5, making it 15.
A new user attempts to lock tokens at T=32.

The contract checks:
periodStart > 0 → True (period started at T=20).
currentTime (32) > periodStart + boostWindow (30) → True.

Since the period is assumed to have ended, the contract attempts to create a new period by calling TimeWeightedAverage::createPeriod().

Inside createPeriod(), the function checks:

if (self.startTime != 0 && currentTime < self.startTime + self.totalDuration)

Substituting values:
self.startTime = 20
currentTime = 32
self.totalDuration = 15

The condition evaluates to:

32 < 20 + 15 // 32 < 35 → True

Since the condition is true, the function reverts—even though it should not.
As a result, the user attempting to lock tokens at T=32 encounters an unexpected reversion.

it("user cannot create lock via when previous period end time has elapsed", async () => {
const amount = ethers.parseEther("1000");
const duration = 365 * 24 * 3600; // 1 year
// Create lock first
const tx = await veRAACToken.connect(users[0]).lock(amount, duration);
// Wait for the transaction
await tx.wait();
// Create another lock within same time frame but within same time frame as previous period
await time.increase(24 * 3600); // 1 day
const tx2 = await veRAACToken.connect(users[1]).lock(amount, duration);
// Wait for the transaction
await tx2.wait();
// get boost state
const boost = await veRAACToken.getBoostState();
const boostwindow = boost.boostWindow;
const startTime = boost.boostPeriod.startTime;
const endTime = boost.boostPeriod.endTime;
const lastUpdateTime = boost.boostPeriod.lastUpdateTime;
const totalDuration = boost.boostPeriod.totalDuration;
console.log("Boost Window: ", boostwindow);
console.log("Start Time: ", startTime);
console.log("End Time: ", endTime);
console.log("Last Update Time: ", lastUpdateTime);
console.log("Total Duration: ", totalDuration);
// Create another lock within same time frame but within same time frame as previous period
await time.increase(6 * 24 * 3600); //c allow 6 more days to pass so that the period has ended and a new one should be started
//c confirm that the current timestamp is greater than or equal to the endtime of the previous period which means that when a user locks tokkens, a new global period should be started and no revert should occur
const currentTime = await time.latest();
console.log("Current Time: ", currentTime);
assert(endTime == startTime + boostwindow);
assert(
currentTime >= endTime,
"Current Time is less than end time of previous period"
);
await expect(
veRAACToken.connect(users[2]).lock(amount, duration)
).to.be.revertedWithCustomError(veRAACToken, "PeriodNotElapsed");
});

Note: For test to run successfully, I added the following to veRAACToken::BoostStateView :

/**
* @notice View struct for boost state information
*/
struct BoostStateView {
uint256 minBoost; // Minimum boost multiplier in basis points
uint256 maxBoost; // Maximum boost multiplier in basis points
uint256 boostWindow; // Time window for boost calculations
uint256 totalVotingPower; // Total voting power in the system
uint256 totalWeight; // Total weight of all locks
TimeWeightedAverage.Period boostPeriod; // Global boost period //c for testing purposes
}
function getBoostState() external view returns (BoostStateView memory) {
return
BoostStateView({
minBoost: _boostState.minBoost,
maxBoost: _boostState.maxBoost,
boostWindow: _boostState.boostWindow,
totalVotingPower: _boostState.totalVotingPower,
totalWeight: _boostState.totalWeight,
boostPeriod: _boostState.boostPeriod //c for testing purposes
});
}

This was a view function and I adapted it to allow for viewing the period's data.

Impact

This vulnerability poses a critical governance risk, as it can prevent users from locking RAACTokens and obtaining veRAAC voting power during key decision-making periods. If a governance vote is scheduled while this bug is active, affected users will be unable to participate, leading to skewed governance outcomes. Malicious actors could strategically trigger this issue to block new veRAAC holders from voting, ensuring only pre-existing stakeholders influence decisions. This creates centralization risks, as governance control shifts to a small group of participants. Additionally, users unable to lock tokens may miss out on governance incentives.

Tools Used

Manual Review, Hardhat

Recommendations

Update Period Validation in createPeriod
Modify the if condition in TimeWeightedAverage::createPeriod to use boostWindow rather than totalDuration:

if (
self.startTime != 0 &&
startTime < self.startTime + boostWindow // Use boostWindow instead of totalDuration
) {
revert PeriodNotElapsed();
}

This ensures that period validation remains consistent and does not revert incorrectly.

Updates

Lead Judging Commences

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

veRAACToken can be DOSed when totalDuration exceeds boostWindow in TimeWeightedAverage, preventing new users from locking tokens until extended duration elapses

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

veRAACToken can be DOSed when totalDuration exceeds boostWindow in TimeWeightedAverage, preventing new users from locking tokens until extended duration elapses

Appeal created

anonymousjoe Auditor
4 months ago
io10 Submitter
4 months ago
inallhonesty Lead Judge
4 months ago
inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Validated
Assigned finding tags:

veRAACToken can be DOSed when totalDuration exceeds boostWindow in TimeWeightedAverage, preventing new users from locking tokens until extended duration elapses

Support

FAQs

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