Core Contracts

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

Incorrect Fee Distribution Due to Tax Application on FeeCollector's Burn Operation

Summary

When the FeeCollector contract calls distributeCollectedFees(), it attempts to burn tokens through raacToken.burn(shares[1]). However, the RAAC token's burn function applies a tax on the burn amount and sends this tax back to the FeeCollector. This creates an accounting discrepancy where not all tokens intended for burning are actually burned, and some remain in the FeeCollector contract unaccounted for.

Vulnerability Details

The issue occurs in the following sequence:

  1. Fees are collected via FeeCollector::collectFee()for the feeType 6 (swapTaxes)

  2. distributeCollectedFees()gets called to distribute the collected fees

  3. distributeCollectedFees() calls _processDistributions()

  4. _processDistributions() function calls raacToken.burn(shares[1]) to burn a portion of collected fees

  5. Even though FeeCollector is whitelisted, the burn tax is still applied

  6. These returned tokens are not accounted for in the FeeCollector's accounting system

function distributeCollectedFees() external override nonReentrant whenNotPaused {
// ...
uint256[4] memory shares = _calculateDistribution(totalFees);
_processDistributions(totalFees, shares);
delete collectedFees;
}
function _processDistributions(uint256 totalFees, uint256[4] memory shares) internal {
//... calls burn()
@> if (shares[1] > 0) raacToken.burn(shares[1]);
}

Inside RAACToken's burn() function:

function burn(uint256 amount) external {
uint256 taxAmount = amount.percentMul(burnTaxRate);
// applies burn tax and transfers taxAmount back to feeCollector
@> _burn(msg.sender, amount - taxAmount);
if (taxAmount > 0 && feeCollector != address(0)) {
// feeCollector is msg.sender and receives the taxAmount back
@> _transfer(msg.sender, feeCollector, taxAmount);
}
}

The test demonstrate this behavior:

PoC

In order to run the test you need to:

  1. Run foundryup to get the latest version of Foundry

  2. Install hardhat-foundry: npm install --save-dev @nomicfoundation/hardhat-foundry

  3. Import it in your Hardhat config: require("@nomicfoundation/hardhat-foundry");

  4. Make sure you've set the BASE_RPC_URL in the .env file or comment out the forking option in the hardhat config.

  5. Run npx hardhat init-foundry

  6. There is one file in the test folder that will throw an error during compilation so rename the file in test/unit/libraries/ReserveLibraryMock.sol to => ReserveLibraryMock.sol_broken so it doesn't get compiled anymore (we don't need it anyways).

  7. Create a new folder test/foundry

  8. Paste the below code into a new test file i.e.: FoundryTest.t.sol

  9. Run the test: forge test --mc FoundryTest -vvvv

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import {Test} from "forge-std/Test.sol";
import {RAACToken} from "../../contracts/core/tokens/RAACToken.sol";
import {FeeCollector} from "../../contracts/core/collectors/FeeCollector.sol";
import {veRAACToken} from "../../contracts/core/tokens/veRAACToken.sol";
import {PercentageMath} from "../../contracts/libraries/math/PercentageMath.sol";
import {IFeeCollector} from "../../contracts/interfaces/core/collectors/IFeeCollector.sol";
import "forge-std/console2.sol";
contract FoundryTest is Test {
using PercentageMath for uint256;
RAACToken public raacToken;
FeeCollector public feeCollector;
veRAACToken public VeRaacToken;
address public owner;
address public treasury;
address public repairFund;
address public minter;
address public user1;
address public user2;
function setUp() public {
// Setup accounts
owner = address(this);
minter = makeAddr("minter");
user1 = makeAddr("user1");
user2 = makeAddr("user2");
treasury = makeAddr("treasury");
repairFund = makeAddr("repairFund");
// Initial tax rates (1% swap tax, 0.5% burn tax)
uint256 initialSwapTaxRate = 100;
uint256 initialBurnTaxRate = 50;
// Deploy token
raacToken = new RAACToken(owner, initialSwapTaxRate, initialBurnTaxRate);
// Deploy veRAACToken
VeRaacToken = new veRAACToken(address(raacToken));
// Deploy fee collector
feeCollector = new FeeCollector(
address(raacToken),
address(VeRaacToken),
address(treasury),
address(repairFund),
owner
);
// Setup minter
vm.prank(owner);
raacToken.setMinter(minter);
// Setup fee collector
vm.prank(owner);
raacToken.setFeeCollector(address(feeCollector));
}
function test_feeCollectorDistributeCollectedFees() public {
uint8 feeType = 6; // swapTaxes
uint256 amount = 1000e18;
vm.prank(minter);
raacToken.mint(user1, amount);
// Add fee collector to whitelist to exclude it from _update function tax calculation
raacToken.manageWhitelist(address(feeCollector), true);
vm.startPrank(user1);
raacToken.approve(address(feeCollector), amount);
feeCollector.collectFee(amount, feeType);
vm.stopPrank();
// get collected fees
IFeeCollector.CollectedFees memory collectedFees = feeCollector.getCollectedFees();
uint256 balanceOfFeeCollector = raacToken.balanceOf(address(feeCollector));
assertEq(balanceOfFeeCollector, collectedFees.swapTaxes);
console2.log("\nTest:");
console2.log("recorded collectedFees before distribute", collectedFees.swapTaxes);
console2.log("balanceOfFeeCollector before distribute", balanceOfFeeCollector);
// now distribute fees
vm.prank(owner);
feeCollector.distributeCollectedFees();
// get collected fees again
collectedFees = feeCollector.getCollectedFees();
console2.log("recorded collectedFees after distribute", collectedFees.swapTaxes);
console2.log("balanceOfFeeCollector after distribute", raacToken.balanceOf(address(feeCollector)));
assertEq(collectedFees.swapTaxes, 0);
// Fee collector has some balance which is wrong
assertGt(balanceOfFeeCollector, 0);
}
}

Impact

  • Not all tokens intended for burning are actually burned

  • Creates accounting discrepancies in the FeeCollector contract

  • Results in accumulation of unaccounted tokens in the FeeCollector contract

  • May affect protocol tokenomics as burn mechanism is not working as intended

The severity is considered LOW because:

  • The FeeCollector has an emergency withdrawal mechanism that can recover stuck tokens (but only when the contract is paused)

  • The tokens remain within the protocol's control

  • No direct financial loss to users

  • The issue doesn't affect core protocol functionality

Tools Used

  • Manual Review

  • Foundry

Recommendations

Modify the burn function to skip tax application when the caller is the feeCollector or add a separate burnWithoutTax function that can only be called by the FeeCollector

Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

RAACToken::burn applies burn tax twice when transferring to feeCollector, causing excess tokens to be burned and reduced fees to be collected

This is by design, sponsor's words: Yes, burnt amount, done by whitelisted contract or not always occur the tax. The feeCollector is intended to always be whitelisted and the address(0) is included in the _transfer as a bypass of the tax amount, so upon burn->_burn->_update it would have not applied (and would also do another burn...). For this reason, to always apply such tax, the burn function include the calculation (the 2 lines that applies) and a direct transfer to feeCollector a little bit later. This is done purposefully

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

RAACToken::burn applies burn tax twice when transferring to feeCollector, causing excess tokens to be burned and reduced fees to be collected

This is by design, sponsor's words: Yes, burnt amount, done by whitelisted contract or not always occur the tax. The feeCollector is intended to always be whitelisted and the address(0) is included in the _transfer as a bypass of the tax amount, so upon burn->_burn->_update it would have not applied (and would also do another burn...). For this reason, to always apply such tax, the burn function include the calculation (the 2 lines that applies) and a direct transfer to feeCollector a little bit later. This is done purposefully

Appeal created

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

RAACToken::burn applies burn tax twice when transferring to feeCollector, causing excess tokens to be burned and reduced fees to be collected

This is by design, sponsor's words: Yes, burnt amount, done by whitelisted contract or not always occur the tax. The feeCollector is intended to always be whitelisted and the address(0) is included in the _transfer as a bypass of the tax amount, so upon burn->_burn->_update it would have not applied (and would also do another burn...). For this reason, to always apply such tax, the burn function include the calculation (the 2 lines that applies) and a direct transfer to feeCollector a little bit later. This is done purposefully

Support

FAQs

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

Give us feedback!