Core Contracts

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

Direct Token Transfers to Treasury Contract Result in Permanently Locked Funds

Summary

The Treasury contract's accounting system only tracks balances deposited through the deposit function. Any tokens sent directly to the contract via regular transfer or transfer become permanently locked as the withdrawal mechanism relies on internally tracked balances rather than actual token balances.

Vulnerability Details

The issue stems from the Treasury contract's balance tracking mechanism that fails to account for direct token transfers. Here's how the vulnerability manifests:

  1. Balance Tracking System:

// Treasury.sol
mapping(address => uint256) private _balances;
function withdraw(address token, uint256 amount) external onlyOwner {
if (amount == 0) revert InvalidAmount();
if (token == address(0)) revert InvalidAddress();
_balances[token] -= amount; // Will revert if amount > _balances[token]
IERC20(token).safeTransfer(msg.sender, amount);
emit Withdrawn(token, msg.sender, amount);
}
  1. Core Issue:

    • The contract uses _balances[token] to track deposited tokens

    • Withdrawals check against and decrease this internal balance

    • Direct token transfers bypass the deposit function and aren't recorded in _balances

    • When withdrawal is attempted, _balances[token] -= amount underflows since _balances[token] is 0

The FeeCollector contract has multiple functions that directly transfer tokens to the Treasury without using the deposit function:

  1. Emergency Withdrawals:

// FeeCollector.sol
function emergencyWithdraw(address token) external override whenPaused {
if (!hasRole(EMERGENCY_ROLE, msg.sender)) revert UnauthorizedCaller();
if (token == address(0)) revert InvalidAddress();
uint256 balance;
if (token == address(raacToken)) {
balance = raacToken.balanceOf(address(this));
raacToken.safeTransfer(treasury, balance); // Direct transfer!
} else {
balance = IERC20(token).balanceOf(address(this));
SafeERC20.safeTransfer(IERC20(token), treasury, balance); // Direct transfer!
}
}
  1. Fee Distribution:

// FeeCollector.sol
function _processDistributions(uint256 totalFees, uint256[4] memory shares) internal {
// ...
if (shares[0] > 0) {
uint256 totalVeRAACSupply = veRAACToken.getTotalVotingPower();
if (totalVeRAACSupply > 0) {
// ... distribution logic
} else {
shares[3] += shares[0]; // Add to treasury if no veRAAC holders
raacToken.safeTransfer(treasury, shares[3]); // Direct transfer!
}
}
}

In both cases:

  1. Tokens are sent directly to Treasury

  2. These transfers bypass Treasury's deposit function

  3. The Treasury's internal _balances mapping isn't updated

  4. The funds become permanently locked

Impact

Any tokens sent directly to the Treasury become permanently locked

Tool Used

  • Foundry

  • Manual Review

Recommendations

  1. Use actual token balances instead of internal accounting:

function withdraw(address token, uint256 amount) external onlyOwner {
if (amount == 0) revert InvalidAmount();
if (token == address(0)) revert InvalidAddress();
- _balances[token] -= amount;
+ uint256 actualBalance = IERC20(token).balanceOf(address(this));
+ if (amount > actualBalance) revert InsufficientBalance();
IERC20(token).safeTransfer(msg.sender, amount);
emit Withdrawn(token, msg.sender, amount);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 6 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.