Summary
The veRAACToken
contract provides various functionalities, including locking, unlocking, increasing, withdrawing, and extending both the locked amount and duration of RAAC tokens. When a user locks RAAC tokens, they receive a proportionate amount of veRAACTokens
. This locking mechanism ensures that users can increase the amount and duration of their existing locks but cannot create multiple separate locks. The protocol's documentation explicitly states that users must not be able to create more than one lock, reinforcing this restriction to maintain consistency in the system.
However, due to a bug in the contract, users can bypass this restriction and overwrite or destroy their existing locks by creating a new lock using the veRAACToken::lock
function. This flaw allows users to circumvent the intended locking behavior, leading to unintended consequences that compromise the integrity of the protocol. Instead of reinforcing a single lock, the system mistakenly permits the replacement of previously locked tokens, introducing inconsistencies in the contract’s state.
This vulnerability has severe implications for the protocol’s core mechanisms. It disrupts fundamental components such as voting power calculations, the total supply of veRAACTokens
, boost states, checkpoint states, and lock durations. The ambiguity in these values creates uncertainty across multiple protocol functionalities, rendering critical processes unreliable.
Furthermore, the flawed locking mechanism undermines key aspects of the protocol, including the rewards distribution mechanism (gauge system), the governance voting system, and historical data tracking. The inability to maintain a consistent lock state leads to an inaccurate representation of voting power and token supply, distorting governance participation and reward allocation. Over time, these inconsistencies erode trust in the protocol and may create opportunities for malicious exploitation.
Vulnerability Details
veRAACToken::lock:
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();
}
@>
raacToken.safeTransferFrom(msg.sender, address(this), amount);
uint256 unlockTime = block.timestamp + duration;
@> _lockState.createLock(msg.sender, amount, duration);
@> _updateBoostState(msg.sender, amount);
@> (int128 bias, int128 slope) = _votingState.calculateAndUpdatePower(msg.sender, amount, unlockTime);
uint256 newPower = uint256(uint128(bias));
@> _checkpointState.writeCheckpoint(msg.sender, newPower);
@> _mint(msg.sender, newPower);
emit LockCreated(msg.sender, amount, unlockTime);
}
On deeper inspection, it was discovered that a struct exists with a member intended to indicate whether a lock exists. However, this Lock
struct is not actually used to determine the presence of a lock in the system.
struct Lock {
uint256 amount;
uint256 end;
bool exists;
}
Furthermore, this struct is incorporated as a member of the _lockState
struct (or library). Under the hood, the LockManager
library does not check for the existence of a lock during lock creation; it simply creates a new lock and overwrites any existing lock without performing any validation.
LockManager::createLock:
(Out of Scope)
function createLock(
LockState storage state,
address user,
uint256 amount,
uint256 duration
) internal returns (uint256 end) {
if (state.minLockDuration != 0 && state.maxLockDuration != 0) {
if (duration < state.minLockDuration || duration > state.maxLockDuration)
revert InvalidLockDuration();
}
if (amount == 0) revert InvalidLockAmount();
end = block.timestamp + duration;
state.locks[user] = Lock({
amount: amount,
end: end,
exists: true
});
state.totalLocked += amount;
emit LockCreated(user, amount, end);
return end;
}
Proof of Concept
To demonstrate this vulnerability, the following Proof of Concept (PoC) is provided. The PoC is written using the Foundry tool.
-
Step 1: Create a Foundry project and place all the contracts in the src
directory.
-
Step 2: Create a test
directory and a mocks
folder within the src
directory (or use an existing mocks folder).
-
Step 3: Create all necessary mock contracts, if required.
-
Step 4: Create a test file (with any name) in the test
directory.
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;
uint256 initialRaacBurnTaxRateInBps = 150;
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();
}
}
Step 5: Add the following test PoC in the test file, after the setUp
function.
function testLockFunctionBugAllowsUsersToSelfDestroyTheirLock() public {
uint256 LOCK_AMOUNT = 10_000_000e18;
uint256 LOCK_DURATION = 365 days;
vm.startPrank(RAAC_OWNER);
raacToken.setMinter(RAAC_MINTER);
vm.stopPrank();
console.log("total supply max : ", veRaacToken.MAX_TOTAL_SUPPLY());
console.log("total supply : ", veRaacToken.getTotalVotingPower());
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();
console.log("\nafter alice locked her raac tokens...");
console.log("total supply max : ", veRaacToken.MAX_TOTAL_SUPPLY());
console.log("total supply : ", veRaacToken.getTotalVotingPower());
console.log("alice veRaac balance : ", veRaacToken.balanceOf(ALICE));
uint256 aliceCurrentVotingPower = veRaacToken.getVotingPower(ALICE);
console.log("alice current Voting Power: ", aliceCurrentVotingPower);
vm.startPrank(RAAC_MINTER);
raacToken.mint(ALICE, LOCK_AMOUNT / 2);
vm.stopPrank();
vm.startPrank(ALICE);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT / 2);
veRaacToken.lock(LOCK_AMOUNT / 2, LOCK_DURATION);
vm.stopPrank();
console.log("\nafter alice locked again/increased her raac tokens...");
console.log("total supply max : ", veRaacToken.MAX_TOTAL_SUPPLY());
console.log("total supply : ", veRaacToken.getTotalVotingPower());
console.log("alice veRaac balance : ", veRaacToken.balanceOf(ALICE));
aliceCurrentVotingPower = veRaacToken.getVotingPower(ALICE);
console.log("alice current Voting Power: ", aliceCurrentVotingPower);
assertNotEq(aliceCurrentVotingPower, veRaacToken.balanceOf(ALICE));
assertNotEq(aliceCurrentVotingPower, veRaacToken.getTotalVotingPower());
}
Step 6: To run the test, execute the following commands in your terminal:
forge test --mt testLockFunctionBugAllowsUsersToSelfDestroyTheirLock -vv
Step 7: Review the output. The expected output should indicate that users were successful to overwrite their old locks and this vulnerability creates severe ambiguities in the porotocol functionalities.
[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/VeRAACTokenTest.t.sol:VeRAACTokenTest
[PASS] testLockFunctionBugAllowsUsersToSelfDestroyTheirLock() (gas: 713757)
Logs:
total supply max : 100000000000000000000000000
total supply : 0
after alice locked her raac tokens...
total supply max : 100000000000000000000000000
total supply : 2500000000000000000000000
alice veRaac balance : 2500000000000000000000000
alice current Voting Power: 2500000000000000000000000
after alice locked again/increased her raac tokens...
total supply max : 100000000000000000000000000
total supply : 3750000000000000000000000
alice veRaac balance : 3750000000000000000000000
alice current Voting Power: 1250000000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.57ms (1.31ms CPU time)
Ran 1 test suite in 19.53ms (3.57ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
As demonstrated, the test confirms that the veRAACToken::lock
function hasn't any existence check for already existing locks and this flaw severely disrupts the intended functionality of the protocol.
Impact
-
Inaccurate Lock State Determination:
The inability to correctly determine whether a lock exists leads to unreliable tracking of users' locked tokens, resulting in erroneous data regarding locked amounts and durations.
-
Disrupted Voting Power Calculations:
Inaccurate lock states directly affect the computation of voting power, potentially causing users to lose their rightful influence in governance decisions.
-
Compromised Rewards Distribution:
Since rewards and boost mechanisms rely on accurate lock information, overwritten locks can lead to improper reward allocation, undermining the fairness of the incentive system.
-
Increased Vulnerability to Malicious Exploitation:
The lack of validation in the LockManager
library allows adversaries to deliberately overwrite existing locks, manipulating voting power and reward distribution for malicious gain.
-
Erosion of Trust in the Protocol:
These inconsistencies and vulnerabilities undermine the integrity of core protocol functionalities, potentially leading to reputational damage and reduced user confidence in the platform.
Tools Used
Recommendations
-
Implement Existence Checks: (Out of scope)
Modify the LockManager
library to utilize the lock existence indicator in the Lock
struct. Before creating a new lock, the contract should check whether an active lock already exists for the user.
-
Prevent Overwriting of Existing Locks:
Update the lock creation logic in the veRAACToken
contract to either disallow the creation of a new lock if one already exists or enforce that users can only update (increase or extend) their existing lock.
-
Improve Error Handling:
Introduce clear and informative error messages when a user attempts to create a new lock while an active lock already exists, guiding them to use the appropriate functions (e.g., increase
or extend
).
One Possible solution is as follows:
veRAACToken::lock:
function lock(uint256 amount, uint256 duration) external nonReentrant whenNotPaused {
+ if (_lockState.locks[msg.sender].exists) {
+ revert LockAlreadyExists();
+ }
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);
}