Summary
The veRAACToken::withdraw() function fails when attempting to return locked tokens due to the RAAC token's burn and fee mechanism, which reduces the actual amount stored in the contract.
Vulnerability Details
When a user locks their RAAC tokens, the contract records the full amount deposited. However, the RAAC token has a 1.5% burn/fee mechanism applied on every transfer. This means that the contract only receives 98.5% of the intended deposit. When the user later attempts to withdraw, the contract tries to send back 100% of the locked amount, but it only holds 98.5%, leading to a failed transaction.
The issue arises from the veRAACToken::withdraw() function:
function withdraw() external nonReentrant {
LockManager.Lock memory userLock = _lockState.locks[msg.sender];
if (userLock.amount == 0) revert LockNotFound();
if (block.timestamp < userLock.end) revert LockNotExpired();
uint256 amount = userLock.amount;
uint256 currentPower = balanceOf(msg.sender);
delete _lockState.locks[msg.sender];
delete _votingState.points[msg.sender];
_checkpointState.writeCheckpoint(msg.sender, 0);
_burn(msg.sender, currentPower);
raacToken.safeTransfer(msg.sender, amount);
emit Withdrawn(msg.sender, amount);
}
Since raacToken.safeTransfer(msg.sender, amount) attempts to send back the full deposit but the contract holds less due to the burn mechanism, this call fails.
Impact
Users are permanently unable to withdraw their locked funds, effectively locking their RAAC tokens in the contract forever.
Tools Used
Manual code review
Foundry for testing
Proof of Concept
contract BoostExploitTest is Test {
RAACToken raacToken;
veRAACToken veRAACTokenContract;
MockPool pool;
address owner;
BoostController boostContract;
address attacker = address(0xBEEF);
address public victim;
uint256 constant SWAP_TAX_RATE = 100;
uint256 constant BURN_TAX_RATE = 50;
function setUp() public {
owner = makeAddr("owner");
victim = makeAddr("vitcim");
vm.startPrank(owner);
raacToken = new RAACToken(owner, SWAP_TAX_RATE, BURN_TAX_RATE);
veRAACTokenContract = new veRAACToken(address(raacToken));
boostContract = new BoostController(address(veRAACTokenContract));
pool = new MockPool();
raacToken.setMinter(owner);
boostContract.modifySupportedPool(address(pool), true);
raacToken.mint(attacker, 100 ether);
vm.stopPrank();
}
function testExploit_CannotWithdraw() public {
vm.prank(attacker);
raacToken.approve(address(veRAACTokenContract), 100 ether);
vm.prank(attacker);
veRAACTokenContract.lock(100 ether, 365 * 24 * 3600);
vm.warp(block.timestamp + 365 days);
vm.prank(attacker);
vm.expectRevert();
veRAACTokenContract.withdraw();
}
}
Recommendations
Modify the veRAACToken::lock() function to account for the actual balance received after the burn fee.
Consider using an ERC20 token without a burn/fee mechanism.
If the burn mechanism must be kept, explicitly warn users that they will receive less than the initially locked amount.