Core Contracts

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

RAACToken transferred from FeeCollector to Treasury or Repair Fund cannot be withdrawn

Summary

Due to Treasury.sol's internal accounting mechanism, the FeeCollector sending RAACToken via safeTransfer will not be accounted in Treasury.sol and funds will be stuck.

Vulnerability Details

  1. The Repair Fund and Treasury are both Treasury.sol contracts.

  2. The Treasury.sol contract uses an internal accounting to track token balances.

  3. Instead of using external calls via balanceOf, tokens balances are only incremented when tokens are transferred via the deposit() and reduced via withdraw()

  4. In FeeCollector::_processDistributions, RAACToken are transferred via safeTransfer to the Treasury and Repair Fund. This does not increment the internal balances of Treasury and Repair Fund.

  5. As there is no alternative functions to move tokens in Treasury.sol, the RAACToken transferred from FeeCollector are stuck.

Impact

RAACToken transferred from FeeCollector to Treasury or Repair Fund are stuck, and cannot be withdrawn.

Tools Used

PoC

Below is the Foundry Test:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import {IERC20, ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/math/SafeCast.sol";
import "../../contracts/libraries/math/PercentageMath.sol";
import "../../contracts/libraries/math/WadRayMath.sol";
import {BaseSetup} from "./BaseSetup.t.sol";
import "../../contracts/interfaces/core/pools/LendingPool/ILendingPool.sol";
import "../../contracts/interfaces/core/collectors/ITreasury.sol";
import "../../contracts/core/collectors/FeeCollector.sol";
contract FeeCollectorTest is BaseSetup {
using WadRayMath for uint256;
using PercentageMath for uint256;
using SafeCast for uint256;
using SafeERC20 for IERC20;
IERC20 mainnetUSDC = IERC20(address(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48));
address mainnetUsdcCurveUSDVault = address(0x7cA00559B978CFde81297849be6151d3ccB408A9);
address curveUSDWhale = address(0x4e59541306910aD6dC1daC0AC9dFB29bD9F15c67);
address newUser = makeAddr("new user");
address newUser2 = makeAddr("new user2");
address newUser3 = makeAddr("new user3");
address newUser4 = makeAddr("new user4");
function setUp() public override {
string memory MAINNET_RPC_URL = vm.envString("MAINNET_RPC_URL");
uint256 mainnetFork = vm.createFork(MAINNET_RPC_URL, 19614507);
vm.selectFork(mainnetFork);
super.setUp();
vm.startPrank(owner);
lendingPool.setParameter(ILendingPool.OwnerParameter.LiquidationThreshold, 8000); // 80%
lendingPool.setParameter(ILendingPool.OwnerParameter.HealthFactorLiquidationThreshold, 1e18); // 1.0e18
lendingPool.setParameter(ILendingPool.OwnerParameter.LiquidationGracePeriod, 3 days);
lendingPool.setParameter(ILendingPool.OwnerParameter.LiquidityBufferRatio, 2000); // 20%
lendingPool.setParameter(ILendingPool.OwnerParameter.WithdrawalStatus, 0); // Allow withdrawals
lendingPool.setParameter(ILendingPool.OwnerParameter.CanPaybackDebt, 1); // Enable payback
// house is 1000 with 18 decimals, same as crvUSD
uint256 housePrice = 1_000_000e18;
housePrices.setHousePrice(1, housePrice);
housePrices.setHousePrice(2, housePrice);
housePrices.setHousePrice(3, housePrice);
housePrices.setHousePrice(4, housePrice);
housePrices.setHousePrice(5, housePrice);
housePrices.setHousePrice(6, housePrice);
housePrices.setHousePrice(7, housePrice);
housePrices.setHousePrice(8, housePrice);
vm.stopPrank();
vm.startPrank(curveUSDWhale);
crvUSD.transfer(newUser, 15_000_000e18);
crvUSD.transfer(newUser2, 1_000_000e18);
crvUSD.transfer(newUser3, 5_000_000e18);
crvUSD.transfer(newUser4, 5_000_000e18);
crvUSD.transfer(address(stabilityPool), 10_000_000e18);
vm.stopPrank();
}
function test_tryWithdrawFromTreasury() public {
//assert that treasury that repair fund has 0 raacTokens
assertTrue(raacToken.balanceOf(address(treasury)) == 0);
assertTrue(raacToken.balanceOf(address(repairFund)) == 0);
// give newUser role as distributor
vm.startPrank(owner);
feeCollector.grantRole(feeCollector.DISTRIBUTOR_ROLE(), newUser);
vm.stopPrank();
uint256 ownerMinLiq = 2000e18;
// start prank as the updater
// owner is distributor role
assertTrue(feeCollector.hasRole(feeCollector.DISTRIBUTOR_ROLE(), owner));
assertTrue(feeCollector.hasRole(feeCollector.DISTRIBUTOR_ROLE(), newUser));
// Step 1. give owner 8000e18 raacTokens
uint256 startingWeiForRaacTokens = 8000e18;
vm.startPrank(address(minter));
raacToken.mint(newUser, startingWeiForRaacTokens);
vm.stopPrank();
assertEq(raacToken.balanceOf(newUser), startingWeiForRaacTokens);
uint256 raacTokenTotalSupply = raacToken.totalSupply();
assertEq(raacTokenTotalSupply, startingWeiForRaacTokens + ownerMinLiq); // 10_000
// Step 2. get distrubtor some raac tokens by calling collect Fee
// approve
vm.startPrank(newUser);
raacToken.approve(address(feeCollector), 8000e18);
// for each feeType , indices 0-7
for (uint256 i = 0; i < 8; i++) {
feeCollector.collectFee(1000e18, uint8(i));
}
// collect Fee for 1000e18
//Step 3. Distribute Fee
feeCollector.distributeCollectedFees();
// Step 4. assert that Treasury and Repair Fund has Non Zero Balance
uint256 treasuryBal = raacToken.balanceOf(address(treasury));
uint256 repairFundBal = raacToken.balanceOf(address(repairFund));
assertTrue(treasuryBal>0);
assertTrue(repairFundBal>0);
vm.stopPrank();
vm.startPrank(owner);
// Step 5. expect Insufficient Balance Revert even by withdrawing 1 wei of token
vm.expectRevert(ITreasury.InsufficientBalance.selector);
treasury.withdraw(address(raacToken), 1, owner);
}
}

Please see below for BaseSetup:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
// OpenZeppelin Imports
// import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
// import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
// Primitives Import
import {RAACHousePrices} from "../../contracts/core/primitives/RAACHousePrices.sol";
// Token Imports
import {DebtToken} from "../../contracts/core/tokens/DebtToken.sol";
import {DEToken} from "../../contracts/core/tokens/DEToken.sol";
import {IndexToken} from "../../contracts/core/tokens/IndexToken.sol";
import {LPToken} from "../../contracts/core/tokens/LPToken.sol";
import {RAACNFT} from "../../contracts/core/tokens/RAACNFT.sol";
import {RAACToken} from "../../contracts/core/tokens/RAACToken.sol";
import {RToken} from "../../contracts/core/tokens/RToken.sol";
import {veRAACToken} from "../../contracts/core/tokens/veRAACToken.sol";
// Collector Imports
import {Treasury} from "../../contracts/core/collectors/Treasury.sol";
import {FeeCollector} from "../../contracts/core/collectors/FeeCollector.sol";
// Governance Imports
import {Governance} from "../../contracts/core/governance/proposals/Governance.sol";
import {TimelockController} from "../../contracts/core/governance/proposals/TimelockController.sol";
// Gauge Imports
import {BaseGauge} from "../../contracts/core/governance/gauges/BaseGauge.sol";
import {GaugeController} from "../../contracts/core/governance/gauges/GaugeController.sol";
import {IGaugeController} from "../../contracts/interfaces/core/governance/gauges/IGaugeController.sol";
import {RAACGauge} from "../../contracts/core/governance/gauges/RAACGauge.sol";
import {RWAGauge} from "../../contracts/core/governance/gauges/RWAGauge.sol";
// Boost Import
import {BoostController} from "../../contracts/core/governance/boost/BoostController.sol";
// Minter Imports
import {RAACMinter} from "../../contracts/core/minters/RAACMinter/RAACMinter.sol";
import {RAACReleaseOrchestrator} from "../../contracts/core/minters/RAACReleaseOrchestrator/RAACReleaseOrchestrator.sol";
// Oracle Imports
import {BaseChainlinkFunctionsOracle} from "../../contracts/core/oracles/BaseChainlinkFunctionsOracle.sol";
import {RAACHousePriceOracle} from "../../contracts/core/oracles/RAACHousePriceOracle.sol";
import {RAACPrimeRateOracle} from "../../contracts/core/oracles/RAACPrimeRateOracle.sol";
// Pool Imports
import {LendingPool} from "../../contracts/core/pools/LendingPool/LendingPool.sol";
import {StabilityPool} from "../../contracts/core/pools/StabilityPool/StabilityPool.sol";
import {MarketCreator} from "../../contracts/core/pools/StabilityPool/MarketCreator.sol";
import {NFTLiquidator} from "../../contracts/core/pools/StabilityPool/NFTLiquidator.sol";
contract StakingToken is ERC20 {
constructor() ERC20("ST","ST"){
}
function mint(address account, uint256 value) public {
_mint(account, value);
}
function burn(address account, uint256 value) public {
_burn(account, value);
}
}
contract BaseSetup is Test {
IERC20 baseUSDC = IERC20(address(0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913));
address baseUSDCWhale = address(0x20FE51A9229EEf2cF8Ad9E89d91CAb9312cF3b7A);
// Collectors
Treasury treasury;
Treasury repairFund;
FeeCollector feeCollector;
// Tokens
IERC20 crvUSD = IERC20(address(0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E)); // main
IERC20 basecrvUSD = IERC20(address(0x417Ac0e078398C154EdFadD9Ef675d30Be60Af93)); // base
RAACToken raacToken;
veRAACToken veRaacToken;
RAACNFT raacNFT;
RToken rToken;
DebtToken debtToken;
DEToken deToken;
address mainnetChainlinkRouter = address(0x65Dcc24F8ff9e51F10DCc7Ed1e4e2A61e6E14bd6);
//m Orchestatro
RAACReleaseOrchestrator raacReleaseOrchestrator;
RAACHousePrices housePrices;
LendingPool lendingPool;
StabilityPool stabilityPool;
RAACMinter minter;
// Governance
Governance public governance;
TimelockController public timelockController;
BoostController public boostController;
GaugeController public gaugeController;
RAACGauge public raacGauge;
RWAGauge public rwaGauge;
address owner;
address user1;
address user2;
address user3;
StakingToken stakingToken;
function setUp() public virtual {
stakingToken = new StakingToken();
owner = makeAddr("owner");
user1 = makeAddr("user1");
user2 = makeAddr("user2");
user3 = makeAddr("user3");
vm.startPrank(owner);
// collectors
//
// Setup Tokens
raacToken = new RAACToken(owner, 100, 50);
veRaacToken = new veRAACToken(address(raacToken));
// veRAACToken
// RAACNFT
// DeToken
// Setup contracts
raacReleaseOrchestrator = new RAACReleaseOrchestrator(address(raacToken));
housePrices = new RAACHousePrices(owner);
housePrices.setOracle(owner);
raacNFT = new RAACNFT(address(crvUSD), address(housePrices), owner);
rToken = new RToken("RToken", "RT", owner, address(crvUSD));
debtToken = new DebtToken("DebtToken", "DT", owner);
lendingPool = new LendingPool(
address(crvUSD), address(rToken), address(debtToken), address(raacNFT), address(housePrices), 0.1e27
);
console.log("lendingPool:", address(lendingPool));
treasury = new Treasury(owner);
repairFund = new Treasury(owner);
deToken = new DEToken("DEToken", "DEToken", owner, address(rToken));
stabilityPool = new StabilityPool(owner);
minter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), owner); // should be owner
feeCollector =
new FeeCollector(address(raacToken), address(veRaacToken), address(treasury), address(repairFund), owner);
raacToken.setFeeCollector(address(feeCollector));
raacToken.manageWhitelist(address(feeCollector), true);
raacToken.manageWhitelist(address(veRaacToken), true);
raacToken.manageWhitelist(owner, true);
raacToken.setMinter(address(owner));
raacToken.mint(user2, 1000e18);
raacToken.mint(user3, 1000e18);
feeCollector.grantRole(feeCollector.FEE_MANAGER_ROLE(), owner);
feeCollector.grantRole(feeCollector.EMERGENCY_ROLE(), owner);
feeCollector.grantRole(feeCollector.DISTRIBUTOR_ROLE(), owner);
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
//
raacToken.setMinter(address(minter));
raacToken.transferOwnership(address(minter));
rToken.transferOwnership(address(lendingPool));
debtToken.transferOwnership(address(lendingPool));
stabilityPool.initialize(
address(rToken),
address(deToken),
address(raacToken),
address(minter),
address(crvUSD),
address(lendingPool)
);
lendingPool.setStabilityPool(address(stabilityPool));
// Governance Components
// Timelock controller
address[] memory proposers = new address[](1);
proposers[0] = owner;
address[] memory executors = new address[](1);
executors[0] = owner;
timelockController = new TimelockController(2 days, proposers, executors, owner);
// Governance
governance = new Governance(address(veRaacToken), address(timelockController));
timelockController.grantRole(timelockController.PROPOSER_ROLE(), address(governance));
timelockController.grantRole(timelockController.EXECUTOR_ROLE(), address(governance));
timelockController.grantRole(timelockController.CANCELLER_ROLE(), address(governance));
//Gauge
gaugeController = new GaugeController(address(veRaacToken));
boostController = new BoostController(address(veRaacToken));
rwaGauge = new RWAGauge(address(raacToken), address(stakingToken), address(gaugeController));
raacGauge = new RAACGauge(address(raacToken), address(stakingToken), address(gaugeController));
gaugeController.addGauge(address(rwaGauge),IGaugeController.GaugeType.RWA , 0);
gaugeController.addGauge(address(raacGauge),IGaugeController.GaugeType.RAAC , 0);
rwaGauge.grantRole(rwaGauge.CONTROLLER_ROLE(),owner);
raacGauge.grantRole(rwaGauge.CONTROLLER_ROLE(),owner);
vm.stopPrank();
}
}

Recommendations

contract FeeCollector is IFeeCollector, AccessControl, ReentrancyGuard, Pausable {
// ... omitted for brevity
function _processDistributions(uint256 totalFees, uint256[4] memory shares) internal {
uint256 contractBalance = raacToken.balanceOf(address(this));
if (contractBalance < totalFees) revert InsufficientBalance();
if (shares[0] > 0) {
uint256 totalVeRAACSupply = veRAACToken.getTotalVotingPower();
if (totalVeRAACSupply > 0) {
TimeWeightedAverage.createPeriod(
distributionPeriod,
block.timestamp + 1,
7 days,
shares[0],
totalVeRAACSupply
);
totalDistributed += shares[0];
} else {
shares[3] += shares[0]; // Add to treasury if no veRAAC holders
}
}
if (shares[1] > 0) raacToken.burn(shares[1]);
- if (shares[2] > 0) raacToken.safeTransfer(repairFund, shares[2]);
+ if (shares[2]>0){
+ raacToken.approve(repairFund, shares[2])
+ ITreasury(repairFund).deposit(address(raacToken), shares[2]);
+ }
- if (shares[3] > 0) raacToken.safeTransfer(treasury, shares[3]);
+ if(shares[3]>0){
+ raacToken.approve(treasury, shares[3])
+ ITreasury(treasury).deposit(address(raacToken), shares[3]);
+ }
}
}
Updates

Lead Judging Commences

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