Core Contracts

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

Loss of Vested Tokens Due to Flawed Emergency Revocation in RAACReleaseOrchestrator

Summary

The RAACReleaseOrchestrator contract contains a vulnerability in its emergency revocation mechanism. When the emergencyRevoke function is called, it revokes all tokens from a beneficiary's vesting schedule, including tokens that have already vested but haven't been claimed. This behavior violates the principle of vesting contracts where vested tokens should be guaranteed to beneficiaries, potentially leading to significant financial losses for stakeholders.

Vulnerability Details

The vulnerability exists in the emergencyRevoke function:

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);
emit EmergencyWithdraw(beneficiary, unreleasedAmount);
}
emit VestingScheduleRevoked(beneficiary);
}

Key issues:

  1. No distinction between vested and unvested tokens

  2. All unreleased tokens (including vested ones) are transferred to the contract itself

  3. Schedule is deleted before vested tokens can be claimed

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 {RAACReleaseOrchestrator} from "../../contracts/core/minters/RAACReleaseOrchestrator/RAACReleaseOrchestrator.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.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;
RAACReleaseOrchestrator public raacReleaseOrchestrator;
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 release orchestrator
raacReleaseOrchestrator = new RAACReleaseOrchestrator(address(raacToken));
// 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 test_getTotalAllocation() public view {
uint256 expectedTotalAllocation = 18_000_000 ether +
10_300_000 ether +
5_000_000 ether +
10_000_000 ether +
15_000_000 ether +
6_800_000 ether;
uint256 totalAllocation = raacReleaseOrchestrator.getTotalAllocation();
assertEq(totalAllocation, expectedTotalAllocation);
}
function test_emergencyFlow() public {
address beneficiary = user1;
bytes32 category = raacReleaseOrchestrator.PUBLIC_SALE_CATEGORY();
uint256 amount = 1000 ether;
uint256 startTime = block.timestamp + 30 days;
// mint raac to owner and transfer to release orchestrator
vm.prank(minter);
raacToken.mint(owner, amount);
vm.startPrank(owner);
raacToken.manageWhitelist(address(raacReleaseOrchestrator), true);
raacToken.approve(address(raacReleaseOrchestrator), amount);
raacToken.transfer(address(raacReleaseOrchestrator), amount);
assertEq(raacToken.balanceOf(address(raacReleaseOrchestrator)), amount);
// create the vesting
raacReleaseOrchestrator.createVestingSchedule(beneficiary, category, amount, startTime);
RAACReleaseOrchestrator.VestingSchedule memory schedule = raacReleaseOrchestrator.getVestingSchedule(
beneficiary
);
assertEq(schedule.totalAmount, amount);
assertEq(schedule.releasedAmount, 0);
vm.warp(startTime + 30 days);
// emergencyRevoke happened before the beneficiary claimed
raacReleaseOrchestrator.emergencyRevoke(beneficiary);
assertEq(raacToken.balanceOf(address(raacReleaseOrchestrator)), amount);
vm.stopPrank();
// beneficiary looses all his tokens including the vested ones
vm.prank(beneficiary);
vm.expectRevert();
raacReleaseOrchestrator.release();
}
//⚠️ Uncomment this test to test the fix ⚠️
// function test_emergencyFlowFix() public {
// address beneficiary = user1;
// bytes32 category = raacReleaseOrchestrator.PUBLIC_SALE_CATEGORY();
// uint256 amount = 1000 ether;
// uint256 startTime = block.timestamp + 30 days;
// // mint raac to owner and transfer to release orchestrator
// vm.prank(minter);
// raacToken.mint(owner, amount);
// vm.startPrank(owner);
// raacToken.manageWhitelist(address(raacReleaseOrchestrator), true);
// raacToken.approve(address(raacReleaseOrchestrator), amount);
// raacToken.transfer(address(raacReleaseOrchestrator), amount);
// assertEq(raacToken.balanceOf(address(raacReleaseOrchestrator)), amount);
// raacReleaseOrchestrator.createVestingSchedule(beneficiary, category, amount, startTime);
// RAACReleaseOrchestrator.VestingSchedule memory schedule = raacReleaseOrchestrator.getVestingSchedule(
// beneficiary
// );
// assertEq(schedule.totalAmount, amount);
// assertEq(schedule.releasedAmount, 0);
// vm.warp(startTime + 100 days);
// assertEq(raacToken.balanceOf(address(this)), 0);
// assertEq(raacToken.balanceOf(beneficiary), 0);
// // emergencyRevoke
// raacReleaseOrchestrator.emergencyRevoke(beneficiary, address(this));
// assertGt(raacToken.balanceOf(address(this)), 0);
// vm.stopPrank();
// assertGt(raacToken.balanceOf(beneficiary), 0);
// vm.prank(beneficiary);
// vm.expectRevert();
// raacReleaseOrchestrator.release();
// }
}

Impact

  • Beneficiaries lose access to tokens they have rightfully vested

  • Violates the fundamental principle of vesting contracts

Tools Used

  • Manual Review

  • Foundry

Recommendations

Implemenet a fair revocation logic:

interface IReleaseOrchestrator {
// ...
function audit_emergencyRevoke(address beneficiary, address recoveryAddress) external;
}
function audit_emergencyRevoke(address beneficiary, address recoveryAddress) external override onlyRole(EMERGENCY_ROLE) {
require(recoveryAddress != address(0), "Invalid recovery address");
VestingSchedule storage schedule = vestingSchedules[beneficiary];
if (!schedule.initialized) revert NoVestingSchedule();
// Calculate and transfer vested tokens
uint256 vestedAmount = _calculateReleasableAmount(schedule);
if (vestedAmount > 0) {
raacToken.safeTransfer(beneficiary, vestedAmount);
emit TokensReleased(beneficiary, vestedAmount);
}
// Handle unvested tokens
uint256 unvestedAmount = schedule.totalAmount - schedule.releasedAmount - vestedAmount;
if (unvestedAmount > 0) {
raacToken.safeTransfer(recoveryAddress, unvestedAmount);
}
delete vestingSchedules[beneficiary];
emit VestingScheduleRevoked(beneficiary);
}
Updates

Lead Judging Commences

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

RAACReleaseOrchestrator::emergencyRevoke revokes all unreleased tokens including vested ones, causing beneficiaries to lose rightfully vested tokens

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

RAACReleaseOrchestrator::emergencyRevoke revokes all unreleased tokens including vested ones, causing beneficiaries to lose rightfully vested tokens

Appeal created

anonymousjoe Auditor
7 months ago
mill1995 Submitter
7 months ago
inallhonesty Lead Judge
7 months ago
anonymousjoe Auditor
7 months ago
inallhonesty Lead Judge
6 months ago
inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Validated
Assigned finding tags:

RAACReleaseOrchestrator::emergencyRevoke revokes all unreleased tokens including vested ones, causing beneficiaries to lose rightfully vested tokens

Support

FAQs

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

Give us feedback!