Summary
veRAACToken allows people to enter using the lock(..)
function. Once entered, it's expected that users will increase(..)
or extend(..)
their position, however if they instead call lock(..)
again then their original balance is locked in escrow.
Finding Description
veRAACToken.sol (in scope) uses LockManager.sol (out of scope) to help track state which has methods to createLock
, increaseLock
or extendLock
. Looking at the create flow we see that it will write to storage without first considering existing values (which is reasonable to do on "create"):
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
});
...
}
Source: LockManager.sol#L133-L137
In veRAACToken, we see that the lock
function will use that createLock
function without first checking to see if one has already been created:
function lock(uint256 amount, uint256 duration) external nonReentrant whenNotPaused {
...
_lockState.createLock(msg.sender, amount, duration);
...
}
Source: veRAACToken.sol#L226
Once a lock has been created, veRAACToken.sol includes increase
and extend
functions which are meant to be used instead of calling lock
again:
function increase(uint256 amount) external nonReentrant whenNotPaused {
_lockState.increaseLock(msg.sender, amount);
...
}
function extend(uint256 newDuration) external nonReentrant whenNotPaused {
uint256 newUnlockTime = _lockState.extendLock(msg.sender, newDuration);
...
}
Source: veRAACToken.sol#L251-L305
However if lock
is called multiple times, data is overwritten and the original deposit is lost. This surfaces as a problem when a user attempts to withdraw
after the lock expired, which uses the amount stored to know how many tokens to return to the user:
function withdraw() external nonReentrant {
LockManager.Lock memory userLock = _lockState.locks[msg.sender];
...
uint256 amount = userLock.amount;
...
delete _lockState.locks[msg.sender];
...
raacToken.safeTransfer(msg.sender, amount);
...
}
Source: veRAACToken.sol#L329
As a result, the user can only withdraw their most recent deposit. Funds from their original lock
call are locked in the contract's escrow. Even emergencyWithdraw
will fail to access these funds for the same reason:
function emergencyWithdraw() external nonReentrant {
...
LockManager.Lock memory userLock = _lockState.locks[msg.sender];
...
uint256 amount = userLock.amount;
...
delete _lockState.locks[msg.sender];
...
raacToken.safeTransfer(msg.sender, amount);
...
}
Source: veRAACToken.sol#L367-L383
Impact Explanation
The user loses their RAAC tokens and those tokens are effectively burned due to being locked in escrow. The escrow contract is not upgradeable and emergency controls cannot recover the funds either.
Likelihood Explanation
Even if the frontend UI attempts to avoid this issue by always using the increase
or extend
functions, it's possible for it to incorrectly suggest using lock
again. For example, maybe the first lock is still a transaction in the mempool or maybe the app's backend is behind on processing events. It seems likely that some people will be impacted by this bug.
Proof of Concept
This git patch shows adding a test to the e2e
suite, taking advantage of the before
steps there. Run with npm run test:e2e
.
Here we have a user lock
tokens twice and then show that they are unable to withdraw tokens from their first lockup:
diff --git a/test/e2e/protocols-tests.js b/test/e2e/protocols-tests.js
index 3040a7e..ffcb7ce 100644
--- a/test/e2e/protocols-tests.js
+++ b/test/e2e/protocols-tests.js
@@ -32,6 +32,50 @@ describe('Protocol E2E Tests', function () {
}
});
+ it.only('BUG', async function () {
+ const LARGE_STAKE_AMOUNT = ethers.parseEther('500');
+ const SMALL_REUP_STAKE_AMOUNT = ethers.parseEther('20');
+
+
+ await contracts.raacToken.connect(user2).approve(contracts.veRAACToken.target, LARGE_STAKE_AMOUNT * 2n);
+
+
+ await contracts.veRAACToken.connect(user2).lock(LARGE_STAKE_AMOUNT, ONE_YEAR);
+
+
+
+ await contracts.veRAACToken.connect(user2).lock(SMALL_REUP_STAKE_AMOUNT, ONE_YEAR);
+
+
+
+ {
+
+ expect(await contracts.raacToken.balanceOf(user2)).to.eq(INITIAL_MINT_AMOUNT - LARGE_STAKE_AMOUNT - SMALL_REUP_STAKE_AMOUNT);
+
+ expect(await contracts.veRAACToken.balanceOf(user2)).to.eq(130n * 10n ** 18n);
+ }
+
+
+ await time.increase(ONE_YEAR);
+
+
+ const tx = await contracts.veRAACToken.connect(user2).withdraw();
+
+
+ await expect(tx).to.changeTokenBalance(contracts.veRAACToken, user2, -130n * 10n ** 18n);
+ expect(await contracts.veRAACToken.balanceOf(user2)).to.eq(0);
+
+
+ await expect(tx).to.changeTokenBalance(contracts.raacToken, user2, SMALL_REUP_STAKE_AMOUNT);
+ expect(await contracts.raacToken.balanceOf(user2)).to.eq(INITIAL_MINT_AMOUNT - LARGE_STAKE_AMOUNT);
+
+
+ expect(await contracts.raacToken.balanceOf(contracts.veRAACToken.target)).to.eq(LARGE_STAKE_AMOUNT);
+
+
+ });
+
+
describe('RAAC Token', function () {
const TRANSFER_AMOUNT = ethers.parseEther('100');
Recommendation
The following patch adds a check so that calling lock
reverts when one already exists. This way users are forced to use increase
or extend
as expected, until they are able to withdraw
. After withdraw
is called, they are free to start over and call lock
once more.
When applied, the POC test above fails (since the second call to lock
now reverts).
@@ -166,6 +166,8 @@ contract veRAACToken is ERC20, Ownable, ReentrancyGuard, IveRAACToken {
delete _emergencyTimelock[actionId];
}
+ error LockAlreadyExists();
+
/**
* @notice Initializes the veRAACToken contract
* @param _raacToken Address of the RAAC token contract
@@ -215,6 +217,7 @@ contract veRAACToken is ERC20, Ownable, ReentrancyGuard, IveRAACToken {
if (totalSupply() + amount > MAX_TOTAL_SUPPLY) revert TotalSupplyLimitExceeded();
if (duration < MIN_LOCK_DURATION || duration > MAX_LOCK_DURATION)
revert InvalidLockDuration();
+ if (_lockState.getLock(msg.sender).exists) revert LockAlreadyExists();
// Do the transfer first - this will revert with ERC20InsufficientBalance if user doesn't have enough tokens
raacToken.safeTransferFrom(msg.sender, address(this), amount);