Core Contracts

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

Missing Fee Adjustment for Transfer Taxes in FeeCollector::collectFee Causes Denial of Service

Summary

The FeeCollector contract is designed to collect fees from various protocol operations and later distribute them among stakeholders (e.g., veRAAC holders, burn mechanism, repair fund, and treasury) via the distributeCollectedFees function. Fees are initially collected using the collectFee function, which transfers RAAC tokens from a fee payer to the FeeCollector contract and then records the collected amount by updating internal fee counters.

However, RAAC tokens impose taxes on transfers. The collectFee function, as currently implemented, does not account for these tax deductions. It records the full amount specified by the caller even though the actual tokens received are less than that amount. As a result, when distributeCollectedFees is later invoked, the contract calculates total collected fees based on these inflated figures. Because the FeeCollector’s actual token balance is lower than the recorded fees, the distribution process fails and reverts with an InsufficientBalance error, causing a denial of service for fee distribution.

The provided test suite demonstrates this vulnerability by simulating multiple fee collections where each fee type is collected with a nominal amount, and then verifying that the actual token balance is less than the sum of recorded fees, resulting in a revert during distribution.

Vulnerability Details

How It Begins

  1. Token Transfer and Taxation:

    • The RAAC token charges transfer taxes on all transfers (e.g., a percentage of the transferred amount is deducted as tax, which is sent to a burn address or fee collector).

    • In the _update function of the RAAC token, the tax is applied as follows:

      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 implies that if a sender transfers amount tokens, the recipient (in this case, the FeeCollector) receives only amount - totalTax.

  2. Fee Collection Oversight:

    • The collectFee function in FeeCollector transfers tokens from the caller to itself:

      raacToken.safeTransferFrom(msg.sender, address(this), amount);
    • It then calls _updateCollectedFees(amount, feeType) using the full amount provided, not the net amount actually received. This means the internal state over-records the fees by the tax amount.

  3. Distribution Failure:

    • Later, when distributeCollectedFees is invoked, the contract calculates the total fees from its internal state:

      uint256 totalFees = collectedFees.protocolFees + collectedFees.lendingFees + ... + collectedFees.nftRoyalties;
    • It then checks:

      if (contractBalance < totalFees) revert InsufficientBalance();

      Because the FeeCollector’s actual RAAC token balance is lower than the recorded total (due to tax deductions on transfer), this check fails and the function reverts.

Test Suite Overview

The provided test suite performs the following steps:

  • Setup:

    • Deploys RAAC token, veRAAC token, Treasury, and FeeCollector contracts.

    • Mints RAAC tokens to several addresses and locks tokens for veRAAC eligibility.

    • A designated fee payer is funded with a large RAAC token balance and approves the FeeCollector.

  • Fee Collection Simulation:

    • A loop iterates over 8 fee types, and for each fee type, the fee payer calls collectFee with an amount of 1_000_000e18.

    • Due to transfer taxes, the FeeCollector receives less than 1_000_000e18 tokens for each call, but records the full amount.

  • Distribution Attempt:

    • The test calculates the total recorded fees and verifies that the FeeCollector's actual RAAC token balance is lower than the recorded fees.

    • Finally, when distributeCollectedFees is called, it is expected to revert with InsufficientBalance().

The test logs confirm that after fee collection, the recorded fee amounts are inflated relative to the actual balance, leading to distribution failure.

Proof of Concept

Scenario Walkthrough

  1. Fee Collection:

    • Suppose the fee payer calls collectFee with an amount of 1,000,000e18 tokens for each fee type.

    • Due to a transfer tax (for example, 5%), only 950,000e18 tokens are actually received.

    • The FeeCollector, however, updates its internal fee counters with 1,000,000e18 for each fee type.

  2. Fee Distribution:

    • The total recorded fees sum to 8,000,000e18 (assuming 8 fee types).

    • The actual token balance in the FeeCollector is less (e.g., 7,600,000e18 if each fee lost 50,000e18 to taxes).

    • When distributeCollectedFees is called, the check comparing contractBalance with totalFees fails, causing a revert with InsufficientBalance.

Test Suite Example

Below is the test suite provided in the prompt:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {FeeCollector} from "../src/core/collectors/FeeCollector.sol";
import {Treasury} from "../src/core/collectors/Treasury.sol";
import {RAACToken} from "../src/core/tokens/RAACToken.sol";
import {veRAACToken} from "../src/core/tokens/veRAACToken.sol";
import {IFeeCollector} from "../src/interfaces/core/collectors/IFeeCollector.sol";
import {Test, console} from "forge-std/Test.sol";
contract FeeCollectorTest is Test {
FeeCollector feeCollector;
Treasury treasury;
RAACToken raacToken;
veRAACToken veToken;
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 TREASURY_ADMIN = makeAddr("TREASURY_ADMIN");
address FEE_COLLECTOR_ADMIN = makeAddr("FEE_COLLECTOR_ADMIN");
address REPAIR_FUND_ADDRESS = makeAddr("REPAIR_FUND_ADDRESS");
address VE_RAAC_OWNER = makeAddr("VE_RAAC_OWNER");
address ALICE = makeAddr("ALICE");
address BOB = makeAddr("BOB");
address CHARLIE = makeAddr("CHARLIE");
address DEVIL = makeAddr("DEVIL");
address FEES_PAYER = makeAddr("FEES_PAYER");
function setUp() public {
raacToken = new RAACToken(RAAC_OWNER, initialRaacSwapTaxRateInBps, initialRaacBurnTaxRateInBps);
vm.startPrank(VE_RAAC_OWNER);
veToken = new veRAACToken(address(raacToken));
vm.stopPrank();
vm.startPrank(TREASURY_ADMIN);
treasury = new Treasury(TREASURY_ADMIN);
vm.stopPrank();
vm.startPrank(FEE_COLLECTOR_ADMIN);
feeCollector = new FeeCollector(
address(raacToken), address(veToken), address(treasury), REPAIR_FUND_ADDRESS, FEE_COLLECTOR_ADMIN
);
vm.stopPrank();
getveRaacTokenForProposer();
}
function getveRaacTokenForProposer() private {
uint256 LOCK_AMOUNT = 1_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);
raacToken.mint(BOB, LOCK_AMOUNT);
raacToken.mint(CHARLIE, LOCK_AMOUNT);
raacToken.mint(DEVIL, LOCK_AMOUNT);
raacToken.mint(FEES_PAYER, 100_000_000e18);
vm.stopPrank();
vm.startPrank(FEES_PAYER);
raacToken.approve(address(feeCollector), 100_000_000e18);
vm.stopPrank();
vm.startPrank(ALICE);
raacToken.approve(address(veToken), LOCK_AMOUNT);
veToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(BOB);
raacToken.approve(address(veToken), LOCK_AMOUNT);
veToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(CHARLIE);
raacToken.approve(address(veToken), LOCK_AMOUNT);
veToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(DEVIL);
raacToken.approve(address(veToken), LOCK_AMOUNT);
veToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
}
function testFeeDistributionFailsDueToTaxOmission() public {
IFeeCollector.CollectedFees memory collectedFee = feeCollector.getCollectedFees();
console.log("before collection...");
console.log("collectedFee.protocolFees : ", collectedFee.protocolFees);
console.log("collectedFee.lendingFees : ", collectedFee.lendingFees);
console.log("collectedFee.performanceFees: ", collectedFee.performanceFees);
console.log("collectedFee.insuranceFees : ", collectedFee.insuranceFees);
console.log("collectedFee.mintRedeemFees : ", collectedFee.mintRedeemFees);
console.log("collectedFee.vaultFees : ", collectedFee.vaultFees);
console.log("collectedFee.swapTaxes : ", collectedFee.swapTaxes);
console.log("collectedFee.nftRoyalties : ", collectedFee.nftRoyalties);
console.log("fee collector raac balance : ", raacToken.balanceOf(address(feeCollector)));
for (uint8 i = 0; i < 8; i++) {
vm.startPrank(FEES_PAYER);
feeCollector.collectFee(1_000_000e18, i);
vm.stopPrank();
}
collectedFee = feeCollector.getCollectedFees();
console.log("\nafter collection...");
console.log("collectedFee.protocolFees : ", collectedFee.protocolFees);
console.log("collectedFee.lendingFees : ", collectedFee.lendingFees);
console.log("collectedFee.performanceFees: ", collectedFee.performanceFees);
console.log("collectedFee.insuranceFees : ", collectedFee.insuranceFees);
console.log("collectedFee.mintRedeemFees : ", collectedFee.mintRedeemFees);
console.log("collectedFee.vaultFees : ", collectedFee.vaultFees);
console.log("collectedFee.swapTaxes : ", collectedFee.swapTaxes);
console.log("collectedFee.nftRoyalties : ", collectedFee.nftRoyalties);
console.log("fee collector raac balance : ", raacToken.balanceOf(address(feeCollector)));
uint256 totalFees = collectedFee.protocolFees + collectedFee.lendingFees + collectedFee.performanceFees
+ collectedFee.insuranceFees + collectedFee.mintRedeemFees + collectedFee.vaultFees + collectedFee.swapTaxes
+ collectedFee.nftRoyalties;
assert(raacToken.balanceOf(address(feeCollector)) < totalFees);
vm.startPrank(FEE_COLLECTOR_ADMIN);
vm.expectRevert(bytes4(keccak256("InsufficientBalance()")));
feeCollector.distributeCollectedFees();
vm.stopPrank();
}
}

Impact

  • Failure to Distribute Rewards:
    The FeeCollector records an inflated fee amount because it does not account for the RAAC token transfer taxes. Consequently, when distributing fees, the contract's actual token balance is insufficient, causing the distribution process to revert.

  • Economic Imbalance:
    This error disrupts the intended fee distribution mechanism, potentially leading to under-compensation of stakeholders such as veRAAC holders, the repair fund, or the treasury.

  • Protocol Instability:
    Frequent reverts in the fee distribution process can undermine trust in the protocol’s reward system, discouraging participation and destabilizing the overall ecosystem.

Tools Used

  • Manual Review

  • Foundry

Recommendations

To address this vulnerability, the collectFee function must be modified to account for the tax deductions applied during token transfers. This can be achieved by measuring the FeeCollector’s balance before and after the transfer, and recording the net amount received.

Proposed Diff for collectFee Function

function collectFee(uint256 amount, uint8 feeType) external override nonReentrant whenNotPaused returns (bool) {
if (amount == 0 || amount > MAX_FEE_AMOUNT) revert InvalidFeeAmount();
if (feeType > 7) revert InvalidFeeType();
- // Transfer tokens from sender (taxes will be deducted)
- raacToken.safeTransferFrom(msg.sender, address(this), amount);
+ // Record initial balance
+ uint256 initialBalance = raacToken.balanceOf(address(this));
+ // Transfer tokens from sender; RAAC token taxes will be applied
+ raacToken.safeTransferFrom(msg.sender, address(this), amount);
+ // Calculate net amount received after taxes
+ uint256 netAmount = raacToken.balanceOf(address(this)) - initialBalance;
- // Update collected fees using the full amount
- _updateCollectedFees(amount, feeType);
+ // Update collected fees using the net amount received
+ _updateCollectedFees(netAmount, feeType);
emit FeeCollected(feeType, amount);
return true;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Appeal created

theirrationalone Submitter
7 months ago
inallhonesty Lead Judge
7 months ago
inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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

Give us feedback!