Core Contracts

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

Incorrect Lock Extension Logic Enables Repeated Voting Power Reset. Vulnerability causes Governance Voting Rekt & manipulation.

Summary

The extend function in the veRAACToken contract is designed to allow users to extend the duration of an existing lock, thereby increasing their voting power (ve tokens) by delaying the decay. The extension mechanism, however, contains a flaw in its duration constraint. The contract calculates the "total new duration" as the sum of the remaining lock time and the extension duration, and then rejects the extension if this total exceeds the predefined MAX_LOCK_DURATION (1460 days).

The bug arises when a user's lock is near expiry (i.e., the remaining duration is very low). In such cases, a user can request an extension with an extension duration nearly equal to the maximum allowed duration. This effectively resets the lock to a nearly full duration, resulting in a significant recovery of voting power—even though the user’s original commitment was minimal at that moment. Malicious actors can exploit this behavior repeatedly to maintain or artificially boost their governance influence, undermining the intended time-decay mechanism of voting power.

Vulnerability Details

  1. Function Behavior:

    • The extend function first calls _lockState.extendLock, which calculates:

      uint256 remainingDuration = lock.end - block.timestamp;
      uint256 totalNewDuration = remainingDuration + extensionDuration;
      if (totalNewDuration > state.maxLockDuration) revert InvalidLockDuration();
      newEnd = block.timestamp + totalNewDuration;
    • It then recalculates the user’s voting power using the new unlock time and adjusts the ve token balance accordingly.

  2. Flawed Constraint:

    • The check if (totalNewDuration > state.maxLockDuration) compares the sum of the remaining duration and the extension to MAX_LOCK_DURATION.

    • Issue: When the lock is near expiry, remainingDuration is very small. Therefore, a user can supply an extensionDuration almost as large as MAX_LOCK_DURATION without violating the check.

    • This loophole allows a user to extend their lock almost to the full allowed duration, thereby "resetting" the decay process and regaining nearly full voting power—even if most of the originally locked duration has already elapsed.

  3. Exploitation Potential:

    • An attacker can repeatedly extend their lock right before expiry, each time extending it to near the maximum duration.

    • This repeated resetting effectively prevents the intended decay of voting power, enabling the attacker to maintain an undeservedly high influence in governance decisions indefinitely.

Proof of Concept

Scenario Walkthrough:

  1. Initial Lock:

    • A user creates a lock with the minimum allowed duration (MIN_LOCK_DURATION = 365 days).

    • The voting power (ve tokens) is initially calculated based on this duration.

  2. Approaching Expiry:

    • After nearly 364 days, the lock has only a short period remaining before expiration.

    • At this point, the remaining duration (lock.end - block.timestamp) is minimal.

  3. Extension Exploit:

    • The user calls the extend function with an extensionDuration nearly equal to MAX_LOCK_DURATION (1460 days).

    • Calculation in _lockState.extendLock:

      • Remaining Duration: Very small (e.g., a few hours or minutes).

      • Total New Duration: ≈ (small remaining duration) + (nearly 1460 days) ≤ 1460 days.

    • The check passes, and the new unlock time is set to almost block.timestamp + 1460 days.

  4. Resetting Voting Power:

    • Voting power is recalculated using the new, nearly maximal duration.

    • The user receives a significant boost in ve tokens, effectively “resetting” the decay process.

    • This process can be repeated, allowing the attacker to perpetually maintain a high voting power.

Code PoC (to above scenario)

To demonstrate this vulnerability, the following Proof of Concept (PoC) is provided. The PoC is written using the Foundry tool.

  1. Step 1: Create a Foundry project and place all the contracts in the src directory.

  2. Step 2: Create a test directory and a mocks folder within the src directory (or use an existing mocks folder).

  3. Step 3: Create all necessary mock contracts, if required.

  4. Step 4: Create a test file (with any name) in the test directory.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test, console} from "forge-std/Test.sol";
import {veRAACToken} from "../src/core/tokens/veRAACToken.sol";
import {RAACToken} from "../src/core/tokens/RAACToken.sol";
import {TimeWeightedAverage} from "../src/libraries/math/TimeWeightedAverage.sol";
import {LockManager} from "../src/libraries/governance/LockManager.sol";
import {IveRAACToken} from "../src/interfaces/core/tokens/IveRAACToken.sol";
contract VeRAACTokenTest is Test {
veRAACToken veRaacToken;
RAACToken raacToken;
address RAAC_OWNER = makeAddr("RAAC_OWNER");
address RAAC_MINTER = makeAddr("RAAC_MINTER");
uint256 initialRaacSwapTaxRateInBps = 200; // 2%, 10000 - 100%
uint256 initialRaacBurnTaxRateInBps = 150; // 1.5%, 10000 - 100%
address VE_RAAC_OWNER = makeAddr("VE_RAAC_OWNER");
address ALICE = makeAddr("ALICE");
address BOB = makeAddr("BOB");
address CHARLIE = makeAddr("CHARLIE");
address DEVIL = makeAddr("DEVIL");
function setUp() public {
raacToken = new RAACToken(RAAC_OWNER, initialRaacSwapTaxRateInBps, initialRaacBurnTaxRateInBps);
vm.startPrank(VE_RAAC_OWNER);
veRaacToken = new veRAACToken(address(raacToken));
vm.stopPrank();
}
}
  1. Step 5: Add the following test PoC in the test file, after the setUp function.

function testUsersVotingPowerNeverExpires() public {
uint256 LOCK_AMOUNT = 10_000_000e18;
uint256 LOCK_DURATION = 1460 days;
vm.startPrank(RAAC_OWNER);
raacToken.setMinter(RAAC_MINTER);
vm.stopPrank();
vm.startPrank(RAAC_MINTER);
raacToken.mint(ALICE, LOCK_AMOUNT);
vm.stopPrank();
vm.startPrank(ALICE);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
uint256 aliceCurrentVotingPower = veRaacToken.getVotingPower(ALICE, block.timestamp);
console.log("current timestamp : ", block.timestamp);
console.log("aliceCurrentVotingPower: ", aliceCurrentVotingPower);
vm.warp(block.timestamp + LOCK_DURATION - 1);
aliceCurrentVotingPower = veRaacToken.getVotingPower(ALICE, block.timestamp);
console.log("current timestamp : ", block.timestamp);
console.log("aliceCurrentVotingPower: ", aliceCurrentVotingPower);
vm.startPrank(ALICE);
veRaacToken.extend(LOCK_DURATION - 1);
vm.stopPrank();
aliceCurrentVotingPower = veRaacToken.getVotingPower(ALICE, block.timestamp);
console.log("current timestamp : ", block.timestamp);
console.log("aliceCurrentVotingPower: ", aliceCurrentVotingPower);
vm.warp(block.timestamp + LOCK_DURATION - 2);
aliceCurrentVotingPower = veRaacToken.getVotingPower(ALICE, block.timestamp);
console.log("current timestamp : ", block.timestamp);
console.log("aliceCurrentVotingPower: ", aliceCurrentVotingPower);
vm.startPrank(ALICE);
veRaacToken.extend(LOCK_DURATION - 3);
vm.stopPrank();
aliceCurrentVotingPower = veRaacToken.getVotingPower(ALICE, block.timestamp);
console.log("current timestamp : ", block.timestamp);
console.log("aliceCurrentVotingPower: ", aliceCurrentVotingPower);
// and so on....
}
  1. Step 6: To run the test, execute the following commands in your terminal

forge test --mt testUsersVotingPowerNeverExpires -vv
  1. Step 7: Review the output.

[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/VeRAACTokenTest.t.sol:VeRAACTokenTest
[PASS] testUsersVotingPowerNeverExpires() (gas: 705679)
Logs:
current timestamp : 1
aliceCurrentVotingPower: 10000000000000000000000000
current timestamp : 126144000
aliceCurrentVotingPower: 79274479992307466
current timestamp : 126144000
aliceCurrentVotingPower: 10000000000000000000000000
current timestamp : 252287998
aliceCurrentVotingPower: 158548959951718932
current timestamp : 252287998
aliceCurrentVotingPower: 9999999920725520040588533
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.25ms (924.80µs CPU time)
Ran 1 test suite in 13.75ms (4.25ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

As demonstrated, the test confirms that users can extend their existing duration indefinitely.

Impact

  • Governance Manipulation:
    The ability to repeatedly reset the lock’s duration allows malicious actors to artificially inflate their voting power. This undermines the core design of time-weighted voting, which is intended to reward long-term commitment rather than opportunistic resets. As a result, governance decisions can be skewed, compromising the protocol’s fairness and stability.

  • Economic and Security Risks:
    Inflated voting power may enable attackers to push through proposals that benefit them or harm the protocol. Such manipulation could lead to economic losses for stakeholders and erode trust in the governance mechanism.

  • System Integrity:
    The flaw introduces an inconsistency between the locked amount and the actual voting power. This misalignment can have downstream effects on other protocol functions that rely on accurate ve token balances, potentially leading to broader systemic vulnerabilities.

Tools Used

  • Manual Review

  • Foundry

Recommendations

Below is a recommended diff to address the flaw in the duration extension logic. The core idea is to constrain the new unlock time relative to the original lock start time rather than merely the remaining duration. This may require storing the original lock start time (if not already stored).

function extendLock(
LockState storage state,
address user,
uint256 extensionDuration
) internal returns (uint256 newEnd) {
Lock storage lock = state.locks[user];
if (!lock.exists) revert LockNotFound();
if (lock.end <= block.timestamp) revert LockExpired();
- // Calculate remaining duration from current lock
- uint256 remainingDuration = lock.end - block.timestamp;
-
- // Calculate total new duration (remaining + extension)
- uint256 totalNewDuration = remainingDuration + extensionDuration;
-
- // Check if total duration exceeds max lock duration
- if (totalNewDuration > state.maxLockDuration) revert InvalidLockDuration();
-
- // Calculate new end time
- newEnd = block.timestamp + totalNewDuration;
+ // Optional: Ensure the lock structure stores the original lock start time (e.g., lock.start)
+ // Calculate total duration from the original lock start to the proposed new end time
+ uint256 originalDuration = lock.end - lock.start;
+ uint256 totalDuration = originalDuration + extensionDuration;
+
+ // Check if the total lock duration from the original start exceeds max lock duration
+ if (totalDuration > state.maxLockDuration) revert InvalidLockDuration();
+
+ // Calculate new end time based on the original start time and total allowed duration
+ newEnd = lock.start + totalDuration;
// Update lock end time
lock.end = newEnd;
emit LockExtended(user, newEnd);
return newEnd;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Appeal created

theirrationalone Submitter
4 months ago
theirrationalone Submitter
4 months ago
inallhonesty Lead Judge
4 months ago
inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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