Summary
In the veRAACToken
contract, users are enabled to lock their RAAC tokens through several functions, including lock
, increase
, and extend
. Once the lock duration has expired, users can withdraw their locked tokens. Additionally, if necessary, users can submit an emergency withdrawal request, and upon successful approval, they can immediately retrieve their locked tokens using the emergencyWithdraw
function.
However, veRAACToken isn't set as a whitelisted address initially in constructor and feeCollector
is also not as a zero address in the RAACToken
contract, due to this a critical issue arises from the fact that the RAAC token charges taxes on both locking and withdrawing (transferring raac tokens) operations. The tax mechanism is implemented within the RAAC token’s internal functions and affects the net amount of tokens transferred during these operations. Unfortunately, the functions in the veRAACToken
contract—specifically lock
, increase
, withdraw
, and emergencyWithdraw
—do not account for these tax deductions. As a result, users who lock their RAAC tokens may eventually face a permanent Denial of Service (DoS) when attempting to withdraw, because the contract operates based on the pre-tax token amounts rather than the actual amounts received after taxes are applied.
Vulnerability Details
RaacToken::_update:
(automatically trigger when transfer, mint, and burn happens)
The RAAC token contract contains an overridden _update
function that is automatically triggered during token transfers, minting, and burning. This function calculates a base tax by summing the swap tax rate and the burn tax rate. In situations where the sender or receiver is whitelisted or if the fee collector is disabled, the tax is skipped. Otherwise, the function computes the total tax on the transaction, calculates a portion to be burned, and updates the token balances accordingly by calling the parent _update
function. The relevant code snippet is as follows:
function _update(address from, address to, uint256 amount) internal virtual override {
uint256 baseTax = swapTaxRate + burnTaxRate;
if (
baseTax == 0 || from == address(0) || to == address(0) || whitelistAddress[from] || whitelistAddress[to]
|| feeCollector == address(0)
) {
super._update(from, to, amount);
return;
}
uint256 totalTax = amount.percentMul(baseTax);
uint256 burnAmount = totalTax * burnTaxRate / baseTax;
super._update(from, feeCollector, totalTax - burnAmount);
super._update(from, address(0), burnAmount);
super._update(from, to, amount - totalTax);
}
This function clearly demonstrates that taxes are deducted from token transfers, meaning that the actual token amount received by the contract is lower than the amount sent by the user.
veRAACToken::lock:
In the veRAACToken::lock
function, the process begins by transferring tokens from the user to the contract. The transfer occurs using raacToken.safeTransferFrom(msg.sender, address(this), amount)
, where the amount transferred is the gross amount, prior to tax deductions. However, after this transfer, the function creates the lock position and updates the boost state using the original amount
value. This value is then passed to both _lockState.createLock
and _updateBoostState
, as well as to the voting power calculation in _votingState.calculateAndUpdatePower
. Consequently, the calculations for initial voting power and the subsequent minting of veTokens are based on the unadjusted, pre-tax token amount. The event emitted at the end of the function also reflects the original amount, ignoring the tax effects.
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);
}
veRAACToken::increase:
Similarly, the veRAACToken::increase
function suffers from this same oversight. When users attempt to increase their lock, the function calls _lockState.increaseLock
and _updateBoostState
with an amount that does not account for the tax deductions. Additionally, the function calculates new voting power by adding the additional token amount to the current lock, again using an amount that has not been adjusted for taxes. After transferring the additional tokens, the function mints new veTokens based on the difference between the new calculated power and the current balance, all while relying on the incorrect, pre-tax value.
function increase(uint256 amount) external nonReentrant whenNotPaused {
@>
@> _lockState.increaseLock(msg.sender, amount);
@> _updateBoostState(msg.sender, locks[msg.sender].amount);
LockManager.Lock memory userLock = _lockState.locks[msg.sender];
@>
(int128 newBias, int128 newSlope) =
@> _votingState.calculateAndUpdatePower(msg.sender, userLock.amount + amount, userLock.end);
uint256 newPower = uint256(uint128(newBias));
_checkpointState.writeCheckpoint(msg.sender, newPower);
@>
@> raacToken.safeTransferFrom(msg.sender, address(this), amount);
_mint(msg.sender, newPower - balanceOf(msg.sender));
@> emit LockIncreased(msg.sender, amount);
}
veRAACToken::withdraw:
Both the withdraw
and emergencyWithdraw
functions in the veRAACToken
contract exhibit similar issues. After a user’s lock has expired or an emergency withdrawal is initiated, the contract attempts to clear the user’s lock data and then transfer the locked tokens back to the user. However, these functions use the original userLock.amount
for the transfer, without adjusting for the fact that the actual token balance held by the contract is lower due to taxes applied during the locking process. As a result, when the contract calls raacToken.safeTransfer(msg.sender, amount)
, there may not be a sufficient token balance available to cover the withdrawal, leading to a denial of service for the user.
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);
}
veRAACToken::emergencyWithdraw:
function emergencyWithdraw() external nonReentrant {
if (emergencyWithdrawDelay == 0 || block.timestamp < emergencyWithdrawDelay) {
revert EmergencyWithdrawNotEnabled();
}
LockManager.Lock memory userLock = _lockState.locks[msg.sender];
if (userLock.amount == 0) revert NoTokensLocked();
uint256 amount = userLock.amount;
uint256 currentPower = balanceOf(msg.sender);
delete _lockState.locks[msg.sender];
delete _votingState.points[msg.sender];
_burn(msg.sender, currentPower);
@>
@>
@> raacToken.safeTransfer(msg.sender, amount);
emit EmergencyWithdrawn(msg.sender, amount);
}
The core of this vulnerability lies in the failure of the veRAACToken
contract to account for tax deductions when processing locks and withdrawals. The functions lock
, increase
, withdraw
, and emergencyWithdraw
use the gross token amounts for internal calculations and state updates, disregarding the taxes applied during token transfers. This oversight results in discrepancies between the token amounts expected by the contract and the actual amounts available, ultimately leading to a permanent Denial of Service (DoS) on locked token withdrawals. Users, therefore, may find themselves unable to retrieve their tokens as intended, which undermines both the fairness and reliability of the protocol.
Proof of Concept
To demonstrate this vulnerability, the following Proof of Concept (PoC) is provided. The PoC is written using the Foundry tool.
-
Step 1: Create a Foundry project and place all the contracts in the src
directory.
-
Step 2: Create a test
directory and a mocks
folder within the src
directory (or use an existing mocks folder).
-
Step 3: Create all necessary mock contracts, if required.
-
Step 4: Create a test file (with any name) in the test
directory.
pragma solidity ^0.8.19;
import {Test, console} from "forge-std/Test.sol";
import {veRAACToken} from "../src/core/tokens/veRAACToken.sol";
import {RAACToken} from "../src/core/tokens/RAACToken.sol";
import {TimeWeightedAverage} from "../src/libraries/math/TimeWeightedAverage.sol";
import {LockManager} from "../src/libraries/governance/LockManager.sol";
import {IveRAACToken} from "../src/interfaces/core/tokens/IveRAACToken.sol";
contract VeRAACTokenTest is Test {
veRAACToken veRaacToken;
RAACToken raacToken;
address RAAC_OWNER = makeAddr("RAAC_OWNER");
address RAAC_MINTER = makeAddr("RAAC_MINTER");
uint256 initialRaacSwapTaxRateInBps = 200;
uint256 initialRaacBurnTaxRateInBps = 150;
address VE_RAAC_OWNER = makeAddr("VE_RAAC_OWNER");
address ALICE = makeAddr("ALICE");
address BOB = makeAddr("BOB");
address CHARLIE = makeAddr("CHARLIE");
address DEVIL = makeAddr("DEVIL");
function setUp() public {
raacToken = new RAACToken(RAAC_OWNER, initialRaacSwapTaxRateInBps, initialRaacBurnTaxRateInBps);
vm.startPrank(VE_RAAC_OWNER);
veRaacToken = new veRAACToken(address(raacToken));
vm.stopPrank();
}
}
Step 5: Add the following test PoC in the test file, after the setUp
function.
function testDenialOfServiceOnRaacTokensWithdrawalDueToTaxationOnTransfers() public {
uint256 LOCK_AMOUNT = 10_000_000e18;
uint256 LOCK_DURATION = 365 days;
vm.startPrank(RAAC_OWNER);
raacToken.setMinter(RAAC_MINTER);
vm.stopPrank();
vm.startPrank(RAAC_MINTER);
raacToken.mint(ALICE, LOCK_AMOUNT);
vm.stopPrank();
uint256 veRaacRaacTokenBalance = raacToken.balanceOf(address(veRaacToken));
console.log("Initial veRaac RaacToken Balance : ", veRaacRaacTokenBalance);
vm.startPrank(ALICE);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT / 2, LOCK_DURATION);
vm.stopPrank();
veRaacRaacTokenBalance = raacToken.balanceOf(address(veRaacToken));
console.log("veRaac RaacToken Balance after Alice locks her raacToken : ", veRaacRaacTokenBalance);
IveRAACToken.LockPosition memory aliceLockPosition = veRaacToken.getLockPosition(ALICE);
console.log("Alice lock balance inside veRAACToken contract : ", aliceLockPosition.amount);
vm.warp(block.timestamp + 1 days);
vm.startPrank(ALICE);
veRaacToken.increase(LOCK_AMOUNT / 2);
vm.stopPrank();
vm.warp(block.timestamp + 366 days);
vm.roll(block.number + 1000);
veRaacRaacTokenBalance = raacToken.balanceOf(address(veRaacToken));
console.log("veRaac RaacToken Balance after Alice increases her raacToken: ", veRaacRaacTokenBalance);
aliceLockPosition = veRaacToken.getLockPosition(ALICE);
console.log("Alice lock increased balance inside veRAACToken contract : ", aliceLockPosition.amount);
vm.startPrank(ALICE);
vm.expectRevert(
abi.encodeWithSelector(
bytes4(keccak256("ERC20InsufficientBalance(address,uint256,uint256)")),
address(veRaacToken),
veRaacRaacTokenBalance - (aliceLockPosition.amount - veRaacRaacTokenBalance),
veRaacRaacTokenBalance
)
);
veRaacToken.withdraw();
vm.stopPrank();
}
Step 6: To run the test, execute the following commands in your terminal:
forge test --mt testDenialOfServiceOnRaacTokensWithdrawalDueToTaxationOnTransfers -vv
Step 7: Review the output. The expected output should indicate that users face a Denail of Service when they try to withdraw their locked raac token balance.
[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/VeRAACTokenTest.t.sol:VeRAACTokenTest
[PASS] testDenialOfServiceOnRaacTokensWithdrawalDueToTaxationOnTransfers() (gas: 627479)
Logs:
Initial veRaac RaacToken Balance : 0
veRaac RaacToken Balance after Alice locks her raacToken : 4825000000000000000000000
Alice lock balance inside veRAACToken contract : 5000000000000000000000000
veRaac RaacToken Balance after Alice increases her raacToken: 9650000000000000000000000
Alice lock increased balance inside veRAACToken contract : 10000000000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.39ms (535.20µs CPU time)
Ran 1 test suite in 10.75ms (2.39ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
As demonstrated, the test confirms that the users are facing a Denail of Service (DoS) on withdrawing their locked raac token balance.
Impact
The failure to account for tax deductions in the veRAACToken
contract introduces a critical Denial of Service (DoS) vulnerability, which affects users attempting to withdraw their locked raacTokens
. Since the contract records the pre-tax token amounts for lock positions but only receives the post-tax amount after deductions, a mismatch occurs between the expected and actual token balances within the contract.
As a result, when users attempt to withdraw their locked tokens via withdraw
or emergencyWithdraw
, the contract may not have enough tokens available to fully refund them. This situation can permanently lock users’ tokens in the contract, preventing them from ever retrieving their staked assets. The severity of the impact depends on the tax rate, as higher tax percentages will result in a more significant shortfall in the contract’s token reserves.
Furthermore, this issue may lead to unexpected contract failures and trust erosion among users. Since users might be aware about the tax deductions when locking their tokens, they may expect a deducted refund upon withdrawal, only to find themselves unable to retrieve their funds. This could damage the credibility of the protocol, leading to reduced participation and capital lock-in reluctance from the community.
Tools Used
Foundry
Console Log (foundry)
Recommendations
To mitigate the Denial of Service (DoS) vulnerability caused by unaccounted tax deductions in the veRAACToken
contract, the following improvements should be implemented:
When users lock tokens via lock
or increase their locked balance via increase
, the contract should record the actual received amount (post-tax) instead of the intended pre-tax amount. This ensures that the correct balance is stored in the contract’s records and avoids discrepancies during withdrawal. The solution below works for all scenarios, whether veRAACToken
is whitelisted, feeCollector
is set to some address or not in RAACToken
contract.
Fix: Modify lock
to retrieve the exact received amount and store it properly:
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();
+ uint256 balanceBefore = raacToken.balanceOf(address(this));
raacToken.safeTransferFrom(msg.sender, address(this), amount);
uint256 unlockTime = block.timestamp + duration;
+ // Calculate the actual received amount after tax deductions
+ uint256 receivedAmount = raacToken.balanceOf(address(this)) - balanceBefore;
- _lockState.createLock(msg.sender, amount, duration);
// Ensure that the received amount is properly recorded
+ _lockState.createLock(msg.sender, receivedAmount, duration);
- _updateBoostState(msg.sender, amount);
+ _updateBoostState(msg.sender, receivedAmount);
- (int128 bias, int128 slope) = _votingState.calculateAndUpdatePower(msg.sender, amount, unlockTime);
+ (int128 bias, int128 slope) = _votingState.calculateAndUpdatePower(msg.sender, receivedAmount, unlockTime);
uint256 newPower = uint256(uint128(bias));
_checkpointState.writeCheckpoint(msg.sender, newPower);
_mint(msg.sender, newPower);
- emit LockCreated(msg.sender, amount, unlockTime);
+ emit LockCreated(msg.sender, receivedAmount, unlockTime);
}
Fix: Modify increase
to retrieve the exact received amount and store it properly:
function increase(uint256 amount) external nonReentrant whenNotPaused {
+ uint256 balanceBefore = raacToken.balanceOf(address(this));
+
+ raacToken.safeTransferFrom(msg.sender, address(this), amount);
+
+ uint256 receivedAmount = raacToken.balanceOf(address(this)) - balanceBefore;
- _lockState.increaseLock(msg.sender, amount);
+ _lockState.increaseLock(msg.sender, recievedAmount);
+ _updateBoostState(msg.sender, locks[msg.sender].amount);
LockManager.Lock memory userLock = _lockState.locks[msg.sender];
- (int128 newBias, int128 newSlope) =
- _votingState.calculateAndUpdatePower(msg.sender, userLock.amount + amount, userLock.end);
+ (int128 newBias, int128 newSlope) =
+ _votingState.calculateAndUpdatePower(msg.sender, amount, userLock.end);
uint256 newPower = uint256(uint128(newBias));
_checkpointState.writeCheckpoint(msg.sender, newPower);
-
- raacToken.safeTransferFrom(msg.sender, address(this), amount);
_mint(msg.sender, newPower - balanceOf(msg.sender));
- emit LockIncreased(msg.sender, amount);
+ emit LockIncreased(msg.sender, receivedAmount);
}
Fix: Modify withdraw
and emergencyWithdraw
to correctly transfer the available amount:
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);
+ uint256 contractBalance = raacToken.balanceOf(address(this));
+ // Ensure there are enough tokens to transfer after tax deduction
+ uint256 transferableAmount = contractBalance >= amount ? amount : contractBalance;
// Clear lock data
delete _lockState.locks[msg.sender];
delete _votingState.points[msg.sender];
// Update checkpoints and burn veTokens
_checkpointState.writeCheckpoint(msg.sender, 0);
_burn(msg.sender, currentPower);
- raacToken.safeTransfer(msg.sender, amount);
+ // Transfer only the available amount or we can revert if it's not the amount user expects
+ raacToken.safeTransfer(msg.sender, transferableAmount);
- emit Withdrawn(msg.sender, amount);
+ emit Withdrawn(msg.sender, transferableAmount);
}
Fix: Modify emergencyWithdraw
function as follows:
function emergencyWithdraw() external nonReentrant {
if (emergencyWithdrawDelay == 0 || block.timestamp < emergencyWithdrawDelay) {
revert EmergencyWithdrawNotEnabled();
}
LockManager.Lock memory userLock = _lockState.locks[msg.sender];
if (userLock.amount == 0) revert NoTokensLocked();
uint256 amount = userLock.amount;
uint256 currentPower = balanceOf(msg.sender);
+ uint256 contractBalance = raacToken.balanceOf(address(this));
+ // Ensure there are enough tokens to transfer after tax deduction
+ uint256 transferableAmount = contractBalance >= amount ? amount : contractBalance;
delete _lockState.locks[msg.sender];
delete _votingState.points[msg.sender];
_burn(msg.sender, currentPower);
- raacToken.safeTransfer(msg.sender, amount);
+ // Transfer only the available amount or we can revert if it's not the amount user expects
+ raacToken.safeTransfer(msg.sender, transferableAmount);
- emit EmergencyWithdrawn(msg.sender, amount);
+ emit EmergencyWithdrawn(msg.sender, transferableAmount);
}