Summary
In veRAACToken
, it is possible to lock tokens beyond the MAX_LOCK_DURATION
Vunerability Details
When creating a lock position in veRAACToken::lock
, we ensure that the duration is always less than MAX_LOCK_DURATION
File: contracts/core/tokens/veRAACToken.sol#L212-L244
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);
}
To extend a locks duration, we make use of the veRAACToken::extend
function.
This function then calls the LockManager::extendLock
function which verifies that, the new duration is not beyond the maxLockDuration
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();
uint256 remainingDuration = lock.end - block.timestamp;
uint256 totalNewDuration = remainingDuration + extensionDuration;
if (totalNewDuration > state.maxLockDuration) revert InvalidLockDuration();
newEnd = block.timestamp + totalNewDuration;
lock.end = newEnd;
emit LockExtended(user, newEnd);
return newEnd;
}
However, it fails to account for the fact that, said lock might have already existed for a duration such that, (newEnd - lock.start)
might be greater than MAX_LOCK_DURATION
in which case, it should revert. This means that, it's technically technically possible for one to create a lock with an effective duration of more than 4 years.
Proof of Concept
At timestamp T
, create a lock with a duration of MAX_LOCK_DURATION
. Notice the start of the lock is T
At timestamp T + MAX_LOCK_DURATION - 1
, extend the duration of the lock by an additioanl duration of MAX_LOCK_DURATION - 2
totalNewDuration
of the lock will be (T + MAX_LOCK_DURATION) - (T + MAX_LOCK_DURATION - 1) + (MAX_LOCK_DURATION - 2)
or more simplified MAX_LOCK_DURATION - 1
Thus newEnd
of the lock position at timestmap T + MAX_LOCK_DURATION - 1
after the call to extend the duration will be T + (2 * MAX_LOCK_DURATION) - 2
which is greater than maxLockDuration
Thus, assuming this lock is not extended anymore, this lock will have a start of T
and an end of T + (2 * MAX_LOCK_DURATION) - 2
meaning that, the total duration of the lock has exceeded the maximum desired duration by the protocol of MAX_LOCK_DURATION
it("should extend the end to a date with an effective duration beyond MAX_LOCK_DURATION", async () => {
const amount = ethers.parseEther("1000");
const initialDuration = MAX_LOCK_DURATION;
await raacToken.mint(users[0].address, amount);
await raacToken.connect(users[0]).approve(await veRAACToken.getAddress(), amount);
await veRAACToken.connect(users[0]).lock(amount, initialDuration);
await time.increase(MAX_LOCK_DURATION - 1);
await expect(veRAACToken.connect(users[0]).extend(MAX_LOCK_DURATION - 2))
.to.emit(veRAACToken, "LockExtended")
});
Copy the above code in test/unit/core/tokens/veRAACToken.test.js
and run npx hardhat test test/unit/core/tokens/veRAACToken.test.js
in the terminal.
Impact
Users can indefinitely extend their locked positions thereby bypassing the MAX_LOCK_DURATION
constraint set by the protocol thereby hoarding the voting power for themselves given that, there's a limit to how much can be locked in the entire protocol
see here: https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/tokens/veRAACToken.sol#L215
Tools Used
Manual review.
Recommendations
Track when the lock got created in the lock.start
property of the lock and implement the below changes
// File LockManager.sol
struct Lock {
uint256 amount; // Amount of tokens locked
+ uint256 start; // Timestamp at which the lock got created
uint256 end; // Timestamp when lock expires
bool exists; // Flag indicating if lock exists
}
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();
+ if (lock.start - block.timestmap + totalNewDuration > state.maxLockDuration) revert InvalidLockDuration();
// Calculate new end time
newEnd = block.timestamp + totalNewDuration;
// Update lock end time
lock.end = newEnd;
emit LockExtended(user, newEnd);
return newEnd;
}
function createLock(
LockState storage state,
address user,
uint256 amount,
uint256 duration
) internal returns (uint256 end) {
// Validation logic remains the same
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,
+ start: block.timestamp,
end: end,
exists: true
});
state.totalLocked += amount;
emit LockCreated(user, amount, end);
return end;
}