Core Contracts

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

Treasury Balance Tracking Bypass in FeeCollector

Summary

The FeeCollector performs direct token transfers to the Treasury address instead of utilizing the Treasury's deposit function, resulting in a complete bypass of the Treasury's accounting system. This creates a significant discrepancy between actual token balances and recorded balances in the Treasury contract and stuck funds that can't be withdrawn anymore.

As far as I can tell the Treasury contract is responsible to receive funds from the FeeCollectorbut the FeeCollector transfers tokens directly to the Treasury without calling the desired deposit() function.

Vulnerability Details

The FeeCollector has a storage variable treasury which receives tokens with a safeTransfer() function call instead of the desired deposit() function:

/**
* @notice Core protocol contract references
* @dev Immutable addresses for core token contracts and mutable treasury addresses
* - treasury Protocol treasury for fee collection
*/
address public treasury;
function _processDistributions(uint256 totalFees, uint256[4] memory shares) internal {
// Direct transfer bypassing Treasury's deposit function
if (shares[3] > 0) raacToken.safeTransfer(treasury, shares[3]);
}
function emergencyWithdraw(address token) external override whenPaused {
// Direct transfer bypassing Treasury's deposit function
if (token == address(raacToken)) {
balance = raacToken.balanceOf(address(this));
@> raacToken.safeTransfer(treasury, balance);
} else {
balance = IERC20(token).balanceOf(address(this));
@> SafeERC20.safeTransfer(IERC20(token), treasury, balance);
}
}

Treasury's Tracking System is handled only via deposit():

mapping(address => uint256) private _balances; // Token balances
uint256 private _totalValue; // Total value across all tokens
// This function is never called by FeeCollector
function deposit(address token, uint256 amount) external override nonReentrant {
if (token == address(0)) revert InvalidAddress();
if (amount == 0) revert InvalidAmount();
IERC20(token).transferFrom(msg.sender, address(this), amount);
_balances[token] += amount;
_totalValue += amount;
emit Deposited(token, amount);
}

If the treasury address is set to the Treasury contract and not an Externaly Owner Account this will result in locked funds because there is no way to withdraw the tokens from the Treasury contract anymore.

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 {Treasury} from "../../contracts/core/collectors/Treasury.sol";
import {ITreasury} from "../../contracts/interfaces/core/collectors/ITreasury.sol";
import "forge-std/console2.sol";
contract FoundryTest is Test {
using PercentageMath for uint256;
RAACToken public raacToken;
FeeCollector public feeCollector;
veRAACToken public VeRaacToken;
Treasury public treasury;
address public owner;
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");
repairFund = makeAddr("repairFund");
// Initial tax rates (1% swap tax, 0.5% burn tax)
uint256 initialSwapTaxRate = 100;
uint256 initialBurnTaxRate = 50;
// Deploy treasury
treasury = new Treasury(owner);
// 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 testInitialState() public view {
assertEq(feeCollector.treasury(), address(treasury));
}
function test_LockedFundsInTreasury() public {
uint8 feeType = 0;
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.protocolFees);
// now distribute fees
vm.prank(owner);
feeCollector.distributeCollectedFees();
// Fee collector should have 0 balance
assertEq(raacToken.balanceOf(address(feeCollector)), 0);
uint256 treasuryBalanceAccountedBalance = treasury.getBalance(address(raacToken));
uint256 treasuryActualBalance = raacToken.balanceOf(address(treasury));
uint256 diff = treasuryActualBalance - treasuryBalanceAccountedBalance;
console2.log("\nTest:");
console2.log("treasury balance", treasuryBalanceAccountedBalance);
console2.log("treasury actual balance", treasuryActualBalance);
console2.log("diff", diff);
// now try to withdraw from treasury fails because of insufficient balance
vm.prank(owner);
vm.expectRevert(ITreasury.InsufficientBalance.selector);
treasury.withdraw(address(raacToken), treasuryActualBalance, owner);
}
}
Logs:
treasury balance 0
treasury actual balance 1000000000000000000000
diff 1000000000000000000000

Impact

  • Loss of funds for protocol (no way to access those tokens anymore)

  • Protocol needs to redeploy and fix the FeeCollector contract and all related contracts

Tools Used

  • Foundry

  • Manual Review

Recommendations

  • Update FeeCollector Implementation:

// Update constructor
constructor(address _raacToken, address _veRAACToken, ITreasury _treasury, ...) {
treasury = _treasury;
...
}
// Update _processDistributions
function _processDistributions(uint256 totalFees, uint256[4] memory shares) internal {
if (shares[3] > 0) {
raacToken.safeApprove(address(treasury), shares[3]);
treasury.deposit(address(raacToken), shares[3]);
}
}
// Update emergencyWithdraw
function emergencyWithdraw(address token) external override whenPaused {
uint256 balance = IERC20(token).balanceOf(address(this));
IERC20(token).safeApprove(address(treasury), balance);
treasury.deposit(token, balance);
}
  • You could also add an emergency withdraw function to the Treasury if needed

Updates

Lead Judging Commences

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

FeeCollector::_processDistributions and emergencyWithdraw directly transfer funds to Treasury where they get permanently stuck

Support

FAQs

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

Give us feedback!