Summary
A flaw in the LockManager::extendLock function allows users to lock tokens indefinitely, bypassing the MAX_LOCK_DURATION constraint and retaining voting power forever.
Vunerability Details
When locking RAAC tokens, users should not be able to lock for a duration beyond MAX_LOCK_DURATION as shows the check in veRAACToken.sol#L216
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();
}
The protocol allows users to extend the duration of their lock position using the veRAACToken::extend function which invokes the LockManager::extendLock function which increases the lock duratio without changing amount.
File: contracts/libraries/governance/LockManager.sol#L180-L205
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;
}
The issue here is that the the total duration check does not take into account the fact that the lock position may have already existed for some time. This can result in the overall duration of the lock position (new expiry time - lock creation time) exceeding MAX_LOCK_DURATION and the transaction not reverting in such a case.
Impact
Users can extend the duration of their locks indefinitely and retain their voting powers forever.
Proof of Concept
Copy the following code in test/unit/core/tokens/veRAACToken.test.js.
it.only("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 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")
});
Run npx hardhat test test/unit/core/tokens/veRAACToken.test.js in the terminal.
Tools Used
Manual review.
Recommendations
Consider storing the time at which the lock has been created and making the following changes where it is needed in LockManager.sol
File: contracts/libraries/governance/LockManager.sol
struct Lock {
uint256 amount; // Amount of tokens locked
+ uint256 start; // Timestamp at which lock is created
uint256 end; // Timestamp when lock expires
bool exists; // Flag indicating if lock exists
}
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;
}
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;
}