Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: high
Invalid

users locked raac Tokens stuck in veRAACToken contract. Vulnerability lead to severe permanent potenitial Denical of Service (DoS).

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;
// Skip tax for whitelisted addresses or when fee collector disabled
if (
baseTax == 0 || from == address(0) || to == address(0) || whitelistAddress[from] || whitelistAddress[to]
|| feeCollector == address(0)
) {
super._update(from, to, amount);
return;
}
// All other cases where tax is applied
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();
}
// Do the transfer first - this will revert with ERC20InsufficientBalance if user doesn't have enough tokens
@> // @info: the amount transferred from msg.sender to this address after deducting some taxes
@> raacToken.safeTransferFrom(msg.sender, address(this), amount);
// Calculate unlock time
uint256 unlockTime = block.timestamp + duration;
// Create lock position
@> // @info: amount is passed to createLock and _updateBoostState functions without accounting deducted taxes
@> _lockState.createLock(msg.sender, amount, duration);
@> _updateBoostState(msg.sender, amount);
// Calculate initial voting power
@> // @info: here also same thing happened
(int128 bias, int128 slope) = _votingState.calculateAndUpdatePower(msg.sender, amount, unlockTime);
// Update checkpoints
uint256 newPower = uint256(uint128(bias));
_checkpointState.writeCheckpoint(msg.sender, newPower);
// Mint veTokens
_mint(msg.sender, newPower);
// @info: event is emitted with wrong amount (without taxes exclusion)
@> 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 {
// @info: missing first three checks as similar to above lock function
// Increase lock using LockManager
@> // @info: amount is passed to increaseLock and _updateBoostState functions without accounting deducted taxes
@> _lockState.increaseLock(msg.sender, amount);
@> _updateBoostState(msg.sender, locks[msg.sender].amount);
// Update voting power
LockManager.Lock memory userLock = _lockState.locks[msg.sender];
@> // @info: here also same thing happened
(int128 newBias, int128 newSlope) =
@> _votingState.calculateAndUpdatePower(msg.sender, userLock.amount + amount, userLock.end);
// Update checkpoints
uint256 newPower = uint256(uint128(newBias));
_checkpointState.writeCheckpoint(msg.sender, newPower);
// Transfer additional tokens and mint veTokens
@> // @info: the amount transferred from msg.sender to this address after deducting some taxes
@> 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);
// Clear lock data
delete _lockState.locks[msg.sender];
delete _votingState.points[msg.sender];
// Update checkpoints
_checkpointState.writeCheckpoint(msg.sender, 0);
// Burn veTokens and transfer RAAC
_burn(msg.sender, currentPower);
@> // @info: protocol may not have enough balance to transfer back to msg.sender due to
@> // the taxes charged in lock and increase functions
@> 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);
@> // @info: protocol may not have enough balance to transfer back to msg.sender due to
@> // the taxes charged in lock and increase functions
@> 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.

  1. Step 1: Create a Foundry project and place all the contracts in the src directory.

  2. Step 2: Create a test directory and a mocks folder within the src directory (or use an existing mocks folder).

  3. Step 3: Create all necessary mock contracts, if required.

  4. Step 4: Create a test file (with any name) in the test directory.

// SPDX-License-Identifier: MIT
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; // 2%, 10000 - 100%
uint256 initialRaacBurnTaxRateInBps = 150; // 1.5%, 10000 - 100%
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();
}
}
  1. 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();
}
  1. Step 6: To run the test, execute the following commands in your terminal:

forge test --mt testDenialOfServiceOnRaacTokensWithdrawalDueToTaxationOnTransfers -vv
  1. 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));
+ // Transfer additional tokens and mint veTokens
+ raacToken.safeTransferFrom(msg.sender, address(this), amount);
+ // Calculate the actual received amount after tax deductions
+ uint256 receivedAmount = raacToken.balanceOf(address(this)) - balanceBefore;
// Increase lock using LockManager
- _lockState.increaseLock(msg.sender, amount);
+ _lockState.increaseLock(msg.sender, recievedAmount);
+ _updateBoostState(msg.sender, locks[msg.sender].amount);
// Update voting power
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);
// Update checkpoints
uint256 newPower = uint256(uint128(newBias));
_checkpointState.writeCheckpoint(msg.sender, newPower);
- // Transfer additional tokens and mint veTokens
- 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);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

[INVALID] FoT RAAC breaks veRAACToken

Appeal created

theirrationalone Submitter
4 months ago
theirrationalone Submitter
4 months ago
inallhonesty Lead Judge
4 months ago
inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

[INVALID] FoT RAAC breaks veRAACToken

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.