Core Contracts

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

Unreleased RAACTokens for a beneficiary will be stuck if `emergencyRevoke()` is called

Summary

Unreleased RAACTokens for a beneficiary will be stuck if emergencyRevoke() is called, due to failure to reset internal accounting of tokens to be released.

Vulnerability Details

  1. When emergencyRevoke() is called, instead of sending the RAACTokens to a trusted address, it perform a transfer to itself.

  2. Additionally, the categoryUsed mapping ensures that claimers of a category, do not receive more than the allocation amount defined during construction.

  3. When emergencyRevoke() is called, the function does not reset/reduce the value in the categoryUsed mapping by the unclaimed amount.

  4. This causes the RAACToken to be stuck, as we cannot create another vestingSchedule to release the unclaimed tokens.

Impact

RAACTokens that were meant to be released to allocated beneficiaries will be stuck in event where emergencyRevoke() is called

Tools Used

Manual Review and Foundry

PoC

Please see below for 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/governance/proposals/IGovernance.sol";
import "../../contracts/interfaces/core/minters/RAACReleaseOrchestrator/IReleaseOrchestrator.sol";
import "../../contracts/core/collectors/FeeCollector.sol";
import "../../contracts/mocks/core/governance/proposals/TimelockTestTarget.sol";
import "../../contracts/core/governance/proposals/TimelockController.sol";
contract RAACReleaseOrchestratorTest 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); // newUser2 has 1M crvUSD
crvUSD.transfer(newUser3, 5_000_000e18);
crvUSD.transfer(newUser4, 5_000_000e18);
crvUSD.transfer(address(stabilityPool), 10_000_000e18);
vm.stopPrank();
}
function test_emergencyRevokeOrchestrator() public {
// Setup: RAACReleaseOrchestor to have 65.1M RAACTokens
vm.startPrank(address(minter));
raacToken.mint(address(raacReleaseOrchestrator),65_100_000e18);
uint256 orchestratorOriginalBalance = raacToken.balanceOf(address(raacReleaseOrchestrator));
assertEq(orchestratorOriginalBalance, 65_100_000e18);
assertTrue(raacReleaseOrchestrator.hasRole(raacReleaseOrchestrator.ORCHESTRATOR_ROLE(), owner));
vm.startPrank(owner);
uint256 currentRaacBalOfOwner = raacToken.balanceOf(owner); // owner has some minimum liquidity
// Step 1: Create a Vesting Schedule For beneficiary1 with the maximum categoryAlloacation for TEAM
address beneficiary1 = makeAddr("beneficiary1");
bytes32 teamCategory = raacReleaseOrchestrator.TEAM_CATEGORY();
uint256 amount = raacReleaseOrchestrator.categoryAllocations(teamCategory);// amount of tokens to vest
uint256 startTime = block.timestamp; // start time of vestint => now
raacReleaseOrchestrator.createVestingSchedule(beneficiary1, teamCategory, amount, startTime);
// Step 2: Call Emergency Revoke to revoke beneficiary1
raacReleaseOrchestrator.emergencyRevoke(beneficiary1);
// Step 3: Assert that the raac tokens are stuck in the RaacReleaseOrchestrator
// These tokens are slightly less due to the raacToken's tax in the overriden
// _update function, where transfers to a non-whitelisted address incurs tax.
uint256 baseTax = raacToken.swapTaxRate() + raacToken.burnTaxRate();
// amount here is categoryAllocations(TEAM_CATEGORY)
uint256 totalTax = amount.percentMul(baseTax);
uint256 orchestratorBalanceAfterRevoke = orchestratorOriginalBalance - totalTax;
assertEq(raacToken.balanceOf(address(raacReleaseOrchestrator)),orchestratorBalanceAfterRevoke);
// Step 4. We cannot retrieve these stuck tokens by creating another vesting schedule
// because categoryUsed[TEAM_CATEGORY] is still at 18_000_000e18
assertEq(raacReleaseOrchestrator.categoryUsed(teamCategory), raacReleaseOrchestrator.categoryAllocations(teamCategory));
// Can't create anymore vesting schedule for TEAM_CATEGORY
vm.expectRevert(IReleaseOrchestrator.CategoryAllocationExceeded.selector);
raacReleaseOrchestrator.createVestingSchedule(beneficiary1, teamCategory, 1, startTime);
}
}

Recommendations

contract RAACReleaseOrchestrator is IReleaseOrchestrator, ReentrancyGuard, AccessControl, Pausable {
+ address public deployer;
// ...omitted for brevity
constructor(address _raacToken) {
+ deployer = msg.sender;
// ...omitted for brevity
function emergencyRevoke(address beneficiary) external onlyRole(EMERGENCY_ROLE) {
VestingSchedule storage schedule = vestingSchedules[beneficiary];
if (!schedule.initialized) revert NoVestingSchedule();
uint256 unreleasedAmount = schedule.totalAmount - schedule.releasedAmount;
delete vestingSchedules[beneficiary];
if (unreleasedAmount > 0) {
- raacToken.transfer(address(this), unreleasedAmount);
+ raacToken.transfer(deployer, unreleasedAmount); // transfer to any trusted EOA
emit EmergencyWithdraw(beneficiary, unreleasedAmount);
}
emit VestingScheduleRevoked(beneficiary);
}
Updates

Lead Judging Commences

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

RAACReleaseOrchestrator::emergencyRevoke fails to decrement categoryUsed, causing artificial category over-allocation and rejection of valid vesting schedules

RAACReleaseOrchestrator::emergencyRevoke sends revoked tokens to contract address with no withdrawal mechanism, permanently locking funds

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

RAACReleaseOrchestrator::emergencyRevoke fails to decrement categoryUsed, causing artificial category over-allocation and rejection of valid vesting schedules

RAACReleaseOrchestrator::emergencyRevoke sends revoked tokens to contract address with no withdrawal mechanism, permanently locking funds

Support

FAQs

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

Give us feedback!