Summary
The RAACToken contract sends tax fees directly to the FeeCollector through transfer()
, bypassing the FeeCollector's accounting system. This causes a mismatch between actual token balances and tracked fees, breaking the fee distribution mechanism as the FeeCollector's internal accounting shows zero fees collected despite having tokens.
Vulnerability Details
In RAACToken.sol, the _update
and burn
functions sends fees directly to the FeeCollector:
function _update(address from, address to, uint256 amount) internal virtual override {
.
.
.
@> super._update(from, feeCollector, totalTax - burnAmount);
super._update(from, address(0), burnAmount);
super._update(from, to, amount - totalTax);
}
function burn(uint256 amount) external {
uint256 taxAmount = amount.percentMul(burnTaxRate);
_burn(msg.sender, amount - taxAmount);
if (taxAmount > 0 && feeCollector != address(0)) {
@> _transfer(msg.sender, feeCollector, taxAmount);
}
}
However, FeeCollector only tracks fees through its collectFee function:
function collectFee(uint256 amount, uint8 feeType) external override nonReentrant whenNotPaused returns (bool) {
.
.
.
raacToken.safeTransferFrom(msg.sender, address(this), amount);
_updateCollectedFees(amount, feeType);
}
When RAACToken sends fees directly:
The tokens arrive at FeeCollector
No corresponding update to CollectedFees occurs
The distributeCollectedFees function sees zero tracked fees despite having tokens
No distribution occurs as _calculateTotalFees() returns 0
This is demonstrated in the POC where burning tokens sends fees to FeeCollector but shows zero tracked fees across all fee types.
POC
To use foundry in the codebase, follow the hardhat guide here: Foundry-Hardhat hybrid integration by Nomic foundation
pragma solidity ^0.8.19;
import {FeeCollector} from "../../../../contracts/core/collectors/FeeCollector.sol";
import {RAACToken} from "../../../../contracts/core/tokens/RAACToken.sol";
import {veRAACToken} from "../../../../contracts/core/tokens/veRAACToken.sol";
import {Test, console} from "forge-std/Test.sol";
contract TestSuite is Test {
FeeCollector feeCollector;
RAACToken raacToken;
veRAACToken veRAACTok;
address treasury;
address repairFund;
address admin;
uint256 initialSwapTaxRate = 100;
uint256 initialBurnTaxRate = 50;
function setUp() public {
treasury = makeAddr("treasury");
repairFund = makeAddr("repairFund");
admin = makeAddr("admin");
raacToken = new RAACToken(admin, initialSwapTaxRate, initialBurnTaxRate);
veRAACTok = new veRAACToken(address(raacToken));
feeCollector = new FeeCollector(address(raacToken), address(veRAACTok), treasury, repairFund, admin);
vm.startPrank(admin);
raacToken.setFeeCollector(address(feeCollector));
raacToken.setMinter(admin);
vm.stopPrank();
}
function testFundsSentDirectlyToFeeCollectorIsntTracked() public {
address user = makeAddr("user");
uint256 amount = 1e18;
deal(address(raacToken), user, amount);
vm.startPrank(user);
raacToken.approve(address(feeCollector), amount);
raacToken.burn(amount - 2e17);
vm.stopPrank();
uint256 feeCollectorBalanceAfterBurn = raacToken.balanceOf(address(feeCollector));
console.log("Fee collector balance after burn: ", feeCollectorBalanceAfterBurn);
FeeCollector.CollectedFees memory fees = feeCollector.getCollectedFees();
assertEq(fees.protocolFees, 0);
assertEq(fees.lendingFees, 0);
assertEq(fees.performanceFees, 0);
assertEq(fees.insuranceFees, 0);
assertEq(fees.mintRedeemFees, 0);
assertEq(fees.vaultFees, 0);
assertEq(fees.swapTaxes, 0);
assertEq(fees.nftRoyalties, 0);
}
}
Impact
All fees from RAACToken operations (burns, transfers) become stuck in FeeCollector. No distribution to veRAACToken holders, treasury, or repair fund occurs
Tools Used
Manual review, foundry test suite
Recommendations
Modify RAACToken to use FeeCollector's interface for fee handling