Core Contracts

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

Unnecessary self-transfer in `RAACReleaseOrchestrator::emergencyRevoke` leads to token loss via transfer fees

Summary

The RAACReleaseOrchestrator::emergencyRevoke function performs an unnecessary self-transfer of tokens that triggers the RAAC token's fee mechanism, resulting in permanent loss of tokens through swap and burn fees.

Vulnerability Details

The emergencyRevoke function contains a flaw where it transfers unreleased tokens to itself:

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); // @audit unnecessary self-transfer
emit EmergencyWithdraw(beneficiary, unreleasedAmount);
}
emit VestingScheduleRevoked(beneficiary);
}

The contract already owns these tokens, so transferring them to itself is unnecessary. However, this self-transfer triggers the RAAC token's fee mechanism:

  • 1% swap fee

  • 0.5% burn fee

  • Total of 1.5% tokens lost

POC

To use foundry in the codebase, follow the hardhat guide here: Foundry-Hardhat hybrid integration by Nomic foundation

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {FeeCollector} from "../../../../contracts/core/collectors/FeeCollector.sol";
import {RAACToken, PercentageMath} from "../../../../contracts/core/tokens/RAACToken.sol";
import {RAACReleaseOrchestrator} from
"../../../../contracts/core/minters/RAACReleaseOrchestrator/RAACReleaseOrchestrator.sol";
import {veRAACToken} from "../../../../contracts/core/tokens/veRAACToken.sol";
import {Test, console} from "forge-std/Test.sol";
contract TestSuite is Test {
FeeCollector feeCollector;
RAACReleaseOrchestrator orchestrator;
RAACToken raacToken;
veRAACToken veRAACTok;
address treasury;
address repairFund;
address admin;
uint256 initialSwapTaxRate = 100; //1%
uint256 initialBurnTaxRate = 50; //0.5%
function setUp() public {
treasury = makeAddr("treasury");
repairFund = makeAddr("repairFund");
admin = makeAddr("admin");
raacToken = new RAACToken(admin, initialSwapTaxRate, initialBurnTaxRate);
veRAACTok = new veRAACToken(address(raacToken));
feeCollector = new FeeCollector(address(raacToken), address(veRAACTok), treasury, repairFund, admin);
vm.startPrank(admin);
raacToken.setFeeCollector(address(feeCollector));
raacToken.setMinter(admin);
orchestrator = new RAACReleaseOrchestrator(address(raacToken));
vm.stopPrank();
}
function testEmergencyRevokeTokenLoss() public {
address beneficiary = address(0x1);
uint256 mintAmount = 1000000 ether;
uint256 vestingAmount = 1000 ether;
// Mint tokens to orchestrator and createVesting
vm.startPrank(admin);
raacToken.mint(address(orchestrator), mintAmount);
orchestrator.createVestingSchedule(beneficiary, orchestrator.TEAM_CATEGORY(), vestingAmount, block.timestamp);
vm.stopPrank();
// Initial balance
uint256 initialBalance = raacToken.balanceOf(address(orchestrator));
// Perform emergency revoke
vm.prank(admin);
orchestrator.emergencyRevoke(beneficiary);
// Final balance
uint256 finalBalance = raacToken.balanceOf(address(orchestrator));
// Due to unnecessary self-transfer and 1% swap fee + 0.5% burn fee:
// - Initial balance was 1000000 ether
// - After self-transfer of 1000 ether, 1.5% (15 ether) is lost as fee
// - Final balance should be 15 ether less
assertLt(finalBalance, initialBalance, "Balance decreases due to transfer fee");
assertEq(initialBalance - finalBalance, 15 ether, "Should lose exactly 1.5% (15 ether) to fees");
}
}

Impact

Loss of tokens through fees (1.5% of revoked amount). Tokens burnt are permanently lost. More severe with larger vesting amounts due to percentage-based loss.

Tools Used

Manual review, foundry test suite

Recommendations

Remove the unnecessary self-transfer since the contract already owns the tokens:

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);
}
Updates

Lead Judging Commences

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

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 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!