Summary
The veRaacToken
contract allows users to lock RAAC tokens to receive voting power and boost capabilities. The system is designed to allow users to open a single lock position that can be adjusted/managed via the increase
and extend
functions. This is reinforced by the fact that the LockManager::LockState
struct contains a mapping mapping(address => Lock) locks;
that holds a reference from the user's address to their Lock
position that looks like this
struct Lock {
uint256 amount;
uint256 end;
bool exists;
}
Vulnerability Details
The issue is that there are no checks that prevent the same user from calling the veRAACToken::lock
function multiple times. If a user calls the lock
function multiple times, the amount
field stored in the Lock
struct will be overridden every time. Although the user gets the right amount of new veRaacToken
tokens minted to their address and their voting power is incremented correctly, this will lead to a direct loss of funds because the user is unable to withdraw previously locked amounts.
The veRAACToken::withdraw
function burns all the tokens that the user holds, but only transfers back to the user whatever value was stored in the Lock::amount
field during the last deposit.
function withdraw() external nonReentrant {
uint256 amount = userLock.amount;
uint256 currentPower = balanceOf(msg.sender);
_burn(msg.sender, currentPower);
raacToken.safeTransfer(msg.sender, amount);
emit Withdrawn(msg.sender, amount);
}
Root cause
The current veRAACToken::lock
function doesn't check if the user already has an active locked position.
function lock(uint256 amount, uint256 duration) external nonReentrant whenNotPaused {
_lockState.createLock(msg.sender, amount, duration);
_updateBoostState(msg.sender, amount);
}
Impact
Direct loss of funds for users. Users are unable to withdraw their previously locked RAAC
tokens. When attempting to withdraw, the withdraw function will burn all the veRAACToken
tokens that the user holds, but it will only transfer back the last amount
of RAAC
tokens that was stored in the contract's state.
PoC
Put this test into the veRAACToken.test.js
file and run it.
it.only("prove users override their lock", async () => {
const amount = ethers.parseEther("1000");
const duration = 365 * 24 * 3600;
const tx = await veRAACToken.connect(users[0]).lock(amount, duration);
const receipt = await tx.wait();
const event = receipt.logs.find(
(log) => log.fragment && log.fragment.name === "LockCreated"
);
const actualUnlockTime = event.args[2];
const position = await veRAACToken.getLockPosition(users[0].address);
expect(position.amount).to.equal(amount);
expect(position.end).to.equal(actualUnlockTime);
expect(position.power).to.be.gt(0);
console.log("Initial lock position amount = ", position.amount);
console.log("Initial lock power = ", position.power);
const currentTime = await time.latest();
expect(actualUnlockTime).to.be.closeTo(currentTime + duration, 5);
const newAmount = ethers.parseEther("500");
const newDuration = 365 * 24 * 3600;
const newTx = await veRAACToken
.connect(users[0])
.lock(newAmount, newDuration);
const newReceipt = await newTx.wait();
const event2 = newReceipt.logs.find(
(log) => log.fragment && log.fragment.name === "LockCreated"
);
const newActualUnlockTime = event2.args[2];
const newPosition = await veRAACToken.getLockPosition(users[0].address);
expect(newPosition.amount).to.equal(newAmount);
expect(newPosition.end).to.equal(newActualUnlockTime);
expect(newPosition.power).to.be.gt(0);
console.log("Position amount after second lock = ", newPosition.amount);
console.log("Position power after second lock = ", newPosition.power);
const newCurrentTime = await time.latest();
expect(newActualUnlockTime).to.be.closeTo(
newCurrentTime + newDuration,
5
);
});
Test output
npm run test:unit:tokens
> @raac/core@1.0.0 test:unit:tokens
> npx hardhat test test/unit/core/tokens
veRAACToken
Lock Mechanism
Initial lock position amount = 1000000000000000000000n
Initial lock power = 250000000000000000000n
Position amount after second lock = 500000000000000000000n
Position power after second lock = 375000000000000000000n
✔ prove users override their lock (66ms)
1 passing (2s)
Tools Used
Manual review
Recommended Mitigation
Check if a user already has a lock position created and revert the transaction if the lock already exists. require(!_lockState.locks[msg.sender].exists, "User already has a locked position open");