Core Contracts

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

Users Can Overwrite Existing Locks in veRAACToken Resulting in Permanent Loss of Funds

Summary

The veRAACToken.sol contract allows users to lock RAAC tokens for a specified duration to receive veRAAC tokens (voting power). However, there is a critical vulnerability where users can call the lock() function multiple times, overwriting their existing lock position. When this happens, the tokens from the original lock remain in the contract but are disassociated from the user's lock position, resulting in permanent loss of user funds.

Vulnerability Details

The vulnerability exists in the lock() function of the veRAACToken.sol contract, which calls into the LockManager library's createLock() function. When a user already has an active lock and calls lock() again with a new amount and duration, the function:

  1. Takes the new RAAC tokens from the user

  2. Completely overwrites the user's existing lock record

  3. Keeps the original locked tokens in the contract

  4. Does not return the original locked tokens to the user

The root cause is in the LockManager library's createLock() function, which simply overwrites the existing lock record without checking if a user already has tokens locked:

function createLock(
LockState storage state,
address user,
uint256 amount,
uint256 duration
) internal returns (uint256 end) {
// ...
state.locks[user] = Lock({
amount: amount,
end: end,
exists: true
});
state.totalLocked += amount;
emit LockCreated(user, amount, end);
return end;
}

There is no check in either the veRAACToken contract or the LockManager library to prevent overwriting an existing lock.

Impact

High - Users can permanently lose all the RAAC tokens they initially locked if they call lock() again instead of using the increase() function.
Although an emergencyWithdraw can be called, a protocol wide reset occurs which would not be deemed logical. Therefore, the tokens are essentially locked.

Likelihood

Low - Requires user to interact with lock() again after having already locked tokens.

Severity

High x Low = Medium overall

Proof of Concept

  1. Convert the project into a foundry project, ensuring test in foundry.toml points to a designated test directory.

  2. Comment out the forking object from the hardhat.congif.cjs file:

networks: {
hardhat: {
mining: {
auto: true,
interval: 0
},
//forking: {
// url: process.env.BASE_RPC_URL,
//},
  1. Copy the following code into the test folder:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
//Tokens
import "contracts/core/tokens/RToken.sol";
import "contracts/core/tokens/RAACToken.sol";
import "contracts/core/tokens/veRAACToken.sol";
//Governance
import "contracts/core/collectors/FeeCollector.sol";
import "contracts/core/governance/boost/BoostController.sol";
import "contracts/core/governance/gauges/BaseGauge.sol";
import "contracts/core/governance/gauges/GaugeController.sol";
import "contracts/core/governance/gauges/RAACGauge.sol";
import "contracts/core/governance/gauges/RWAGauge.sol";
import "contracts/core/governance/proposals/Governance.sol";
import "contracts/core/governance/proposals/TimelockController.sol";
//3rd Party
import {Test, console} from "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract MockERC20 is ERC20 {
constructor() ERC20("Mock", "MCK") {}
function mint(address to, uint256 amount) public {
_mint(to, amount);
}
}
contract MasterGovernanceTest is Test {
RToken public rToken;
RAACToken public raacToken;
veRAACToken public veRaacToken;
// Mock token
MockERC20 public mockCrvUSD;
// Admin actors
address public protocolOwner = makeAddr("ProtocolOwner");
address public rTokenMinter = makeAddr("rTokenMinter");
address public rTokenBurner = makeAddr("rTokenBurner");
address public raacTokenMinter = makeAddr("RAACTokenMinter");
address public veRaacTokenMinter = makeAddr("veRaacTokenMinter");
// Users
address public Alice = makeAddr("Alice");
address public Bob = makeAddr("Bob");
// SetUp
function setUp() public {
vm.startPrank(protocolOwner);
// Set up RToken
mockCrvUSD = new MockERC20();
rToken = new RToken(
"RToken",
"RTKN",
protocolOwner,
address(mockCrvUSD)
);
rToken.setMinter(rTokenMinter);
rToken.setBurner(rTokenBurner);
// Set up RAACToken
raacToken = new RAACToken(
protocolOwner, // initialOwner
100, // initialSwapTaxRate - 1%
50 // initialBurnTaxRate - 0.5%
);
raacToken.setMinter(raacTokenMinter);
// Set up veRAACToken
veRaacToken = new veRAACToken(address(raacToken));
veRaacToken.setMinter(veRaacTokenMinter);
// Whitelist veRAACToken address so that fees are not issued on transfers
raacToken.manageWhitelist(address(veRaacToken), true);
vm.stopPrank();
}
function test_OverwriteExistingLock() public {
// Set up RAAC tokens for Alice
vm.startPrank(raacTokenMinter);
raacToken.mint(Alice, 1000e18);
vm.stopPrank();
// Alice approves veRAACToken contract to spend her RAAC tokens
vm.startPrank(Alice);
raacToken.approve(address(veRaacToken), type(uint256).max);
// Alice creates initial lock - 500 RAAC for 1 year
uint256 initialLockAmount = 500e18;
uint256 initialLockDuration = 365 days;
veRaacToken.lock(initialLockAmount, initialLockDuration);
// Get Alice's veRAAC balance after first lock
uint256 firstLockVeBalance = veRaacToken.balanceOf(Alice);
console.log("Alice's veRAAC balance after first lock:", firstLockVeBalance);
// Get Alice's lock details
IveRAACToken.LockPosition memory firstLockPosition = veRaacToken.getLockPosition(Alice);
console.log("First lock - Amount:", firstLockPosition.amount);
console.log("First lock - End time:", firstLockPosition.end);
// Get initial RAAC balance in veRAACToken contract
uint256 initialContractRAACBalance = raacToken.balanceOf(address(veRaacToken));
console.log("veRAAC contract RAAC balance after first lock:", initialContractRAACBalance);
// Warp some time to simulate passing days
vm.warp(100 days);
// Now Alice calls lock again with a different amount/duration
uint256 secondLockAmount = 200e18;
uint256 secondLockDuration = 365 days;
veRaacToken.lock(secondLockAmount, secondLockDuration);
// Get Alice's veRAAC balance after second lock
uint256 secondLockVeBalance = veRaacToken.balanceOf(Alice);
console.log("Alice's veRAAC balance after second lock:", secondLockVeBalance);
// Get Alice's updated lock details
IveRAACToken.LockPosition memory secondLockPosition = veRaacToken.getLockPosition(Alice);
console.log("Second lock - Amount:", secondLockPosition.amount);
console.log("Second lock - End time:", secondLockPosition.end);
// Check RAAC token balances
uint256 raacBalanceAfterLocks = raacToken.balanceOf(Alice);
console.log("Alice's RAAC balance after locks:", raacBalanceAfterLocks);
uint256 finalContractRAACBalance = raacToken.balanceOf(address(veRaacToken));
console.log("veRAAC contract RAAC balance after second lock:", finalContractRAACBalance);
vm.stopPrank();
// Assertions to verify the issue
assertEq(secondLockPosition.amount, secondLockAmount, "Lock amount should be overwritten");
// Check that the original tokens are not accounted for in the user's lock
assertTrue(secondLockPosition.amount < firstLockPosition.amount, "Second lock amount should be less than first");
// The key issue: contract keeps accumulating RAAC tokens but user's lock only shows the latest amount
assertTrue(finalContractRAACBalance > initialContractRAACBalance, "Contract balance should increase");
// The lost tokens amount should be approximately the first lock amount (minus any tax)
uint256 expectedLostTokens = firstLockPosition.amount * 985 / 1000; // Accounting for 1.5% tax
assertTrue(
finalContractRAACBalance - secondLockPosition.amount > expectedLostTokens * 9 / 10,
"Tokens from first lock are lost but still in contract"
);
// Get lock end time through getLockPosition instead
IveRAACToken.LockPosition memory lockPosition = veRaacToken.getLockPosition(Alice);
uint256 lockEndTime = lockPosition.end;
// Warp to end of lock so that Alice can withdraw
vm.warp(lockEndTime + 1);
// Withdraw RAAC
vm.startPrank(Alice);
veRaacToken.withdraw();
vm.stopPrank();
// Check how much RAAC Alice received back
uint256 aliceBalanceAfterWithdraw = raacToken.balanceOf(Alice);
console.log("Alice's RAAC balance after withdraw:", aliceBalanceAfterWithdraw);
// Assert RAAC balance of Alice is only the amount from the second lock (200e18) + remaining initial balance (300e18)
assertEq(aliceBalanceAfterWithdraw, 500e18, "Alice should only get back the second lock amount");
}
  1. Run forge test -vvvv

  2. output:

Logs:
Alice's veRAAC balance after first lock: 125000000000000000000
First lock - Amount: 500000000000000000000
First lock - End time: 31536001
veRAAC contract RAAC balance after first lock: 500000000000000000000
Alice's veRAAC balance after second lock: 175000000000000000000
Second lock - Amount: 200000000000000000000
Second lock - End time: 40176000
Alice's RAAC balance after locks: 300000000000000000000
veRAAC contract RAAC balance after second lock: 700000000000000000000
Alice's RAAC balance after withdraw: 500000000000000000000

Recommendations

implement a check in the lock() function to prevent overwriting existing active locks:

function lock(uint256 amount, uint256 duration) external nonReentrant whenNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount > MAX_LOCK_AMOUNT) revert AmountExceedsLimit();
// Check if user already has an active lock
LockManager.Lock memory userLock = _lockState.getLock(msg.sender);
if (userLock.exists && userLock.end > block.timestamp) {
revert ExistingActiveLock("Use increase() or extend() to modify your lock");
}
// Rest of the function...
}
Updates

Lead Judging Commences

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

veRAACToken::lock called multiple times, by the same user, leads to loss of funds

Support

FAQs

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