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:
There is no check in either the veRAACToken contract or the LockManager library to prevent overwriting an existing lock.
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.
pragma solidity ^0.8.19;
import "contracts/core/tokens/RToken.sol";
import "contracts/core/tokens/RAACToken.sol";
import "contracts/core/tokens/veRAACToken.sol";
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";
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;
MockERC20 public mockCrvUSD;
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");
address public Alice = makeAddr("Alice");
address public Bob = makeAddr("Bob");
function setUp() public {
vm.startPrank(protocolOwner);
mockCrvUSD = new MockERC20();
rToken = new RToken(
"RToken",
"RTKN",
protocolOwner,
address(mockCrvUSD)
);
rToken.setMinter(rTokenMinter);
rToken.setBurner(rTokenBurner);
raacToken = new RAACToken(
protocolOwner,
100,
50
);
raacToken.setMinter(raacTokenMinter);
veRaacToken = new veRAACToken(address(raacToken));
veRaacToken.setMinter(veRaacTokenMinter);
raacToken.manageWhitelist(address(veRaacToken), true);
vm.stopPrank();
}
function test_OverwriteExistingLock() public {
vm.startPrank(raacTokenMinter);
raacToken.mint(Alice, 1000e18);
vm.stopPrank();
vm.startPrank(Alice);
raacToken.approve(address(veRaacToken), type(uint256).max);
uint256 initialLockAmount = 500e18;
uint256 initialLockDuration = 365 days;
veRaacToken.lock(initialLockAmount, initialLockDuration);
uint256 firstLockVeBalance = veRaacToken.balanceOf(Alice);
console.log("Alice's veRAAC balance after first lock:", firstLockVeBalance);
IveRAACToken.LockPosition memory firstLockPosition = veRaacToken.getLockPosition(Alice);
console.log("First lock - Amount:", firstLockPosition.amount);
console.log("First lock - End time:", firstLockPosition.end);
uint256 initialContractRAACBalance = raacToken.balanceOf(address(veRaacToken));
console.log("veRAAC contract RAAC balance after first lock:", initialContractRAACBalance);
vm.warp(100 days);
uint256 secondLockAmount = 200e18;
uint256 secondLockDuration = 365 days;
veRaacToken.lock(secondLockAmount, secondLockDuration);
uint256 secondLockVeBalance = veRaacToken.balanceOf(Alice);
console.log("Alice's veRAAC balance after second lock:", secondLockVeBalance);
IveRAACToken.LockPosition memory secondLockPosition = veRaacToken.getLockPosition(Alice);
console.log("Second lock - Amount:", secondLockPosition.amount);
console.log("Second lock - End time:", secondLockPosition.end);
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();
assertEq(secondLockPosition.amount, secondLockAmount, "Lock amount should be overwritten");
assertTrue(secondLockPosition.amount < firstLockPosition.amount, "Second lock amount should be less than first");
assertTrue(finalContractRAACBalance > initialContractRAACBalance, "Contract balance should increase");
uint256 expectedLostTokens = firstLockPosition.amount * 985 / 1000;
assertTrue(
finalContractRAACBalance - secondLockPosition.amount > expectedLostTokens * 9 / 10,
"Tokens from first lock are lost but still in contract"
);
IveRAACToken.LockPosition memory lockPosition = veRaacToken.getLockPosition(Alice);
uint256 lockEndTime = lockPosition.end;
vm.warp(lockEndTime + 1);
vm.startPrank(Alice);
veRaacToken.withdraw();
vm.stopPrank();
uint256 aliceBalanceAfterWithdraw = raacToken.balanceOf(Alice);
console.log("Alice's RAAC balance after withdraw:", aliceBalanceAfterWithdraw);
assertEq(aliceBalanceAfterWithdraw, 500e18, "Alice should only get back the second lock amount");
}
implement a check in the lock() function to prevent overwriting existing active locks: