Core Contracts

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

`RAACReleaseOrchestrator::emergencyRevoke` is broken

Summary

The emergencyRevoke function in RAACReleaseOrchestrator contains two significant issues: unnecessary token loss through self-transfers and broken category allocation accounting.

Vulnerability Details

emergencyRevoke

// no category updates
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); // self transfer
emit EmergencyWithdraw(beneficiary, unreleasedAmount);
}
emit VestingScheduleRevoked(beneficiary);
}

Two key issues:

  1. The function transfers tokens to itself (address(this)), triggering unnecessary tax fees (2.5% loss)

  2. It fails to update categoryUsed when revoking tokens, leading to inaccurate allocation tracking

Impact

The combination of these issues results in:

  1. Misplacement of tokens (2.5%) on each revocation due to unnecessary tax

  2. Broken category allocation accounting prevents reuse of revoked tokens for new vesting schedules

Tools Used

manual review

Recommendations

Remove unnecessary self-transfer to prevent token loss - tokens can be reallocated or withdraw by creating a new vesting schedule. Properly update category accounting to allow reuse of revoked allocations.

function emergencyRevoke(address beneficiary) external onlyRole(EMERGENCY_ROLE) {
VestingSchedule storage schedule = vestingSchedules[beneficiary];
if (!schedule.initialized) revert NoVestingSchedule();
uint256 unreleasedAmount = schedule.totalAmount - schedule.releasedAmount;
+ bytes32 category = schedule.category;
delete vestingSchedules[beneficiary];
if (unreleasedAmount > 0) {
- raacToken.transfer(address(this), unreleasedAmount);
+ categoryUsed[category] -= unreleasedAmount;
emit EmergencyWithdraw(beneficiary, unreleasedAmount);
}
emit VestingScheduleRevoked(beneficiary);
}

PoC

import { expect } from "chai";
import hre from "hardhat";
const { ethers } = hre;
import { time } from "@nomicfoundation/hardhat-network-helpers";
describe("RAACReleaseOrchestrator Vulnerability PoC", () => {
let raacToken;
let orchestrator;
let owner;
let emergencyRole;
let beneficiary;
let treasury;
const EMERGENCY_ROLE = ethers.keccak256(ethers.toUtf8Bytes("EMERGENCY_ROLE"));
const TEAM_CATEGORY = ethers.keccak256(ethers.toUtf8Bytes("TEAM"));
beforeEach(async () => {
[owner, emergencyRole, beneficiary, treasury] = await ethers.getSigners();
// Deploy RAAC Token with 2% swap tax and 0.5% burn tax
const RAACToken = await ethers.getContractFactory("RAACToken");
raacToken = await RAACToken.deploy(
owner.address, // initial owner
200n, // 2% swap tax
50n // 0.5% burn tax
);
await raacToken.waitForDeployment();
// Deploy Orchestrator
const RAACReleaseOrchestrator = await ethers.getContractFactory("RAACReleaseOrchestrator");
orchestrator = await RAACReleaseOrchestrator.deploy(await raacToken.getAddress());
await orchestrator.waitForDeployment();
// Setup roles
await orchestrator.grantRole(EMERGENCY_ROLE, emergencyRole.address);
await raacToken.setMinter(owner.address);
// Fund orchestrator with enough tokens
await raacToken.mint(await orchestrator.getAddress(), ethers.parseEther("20000000")); // 20M tokens
});
describe("Emergency Revoke Vulnerabilities", () => {
it("POC 1: Unnecessary token loss due to self-transfer tax", async () => {
// Setup initial vesting schedule
const vestAmount = ethers.parseEther("1000");
await orchestrator.createVestingSchedule(
beneficiary.address,
TEAM_CATEGORY,
vestAmount,
await time.latest()
);
// Move past cliff period
await time.increase(time.duration.days(91));
// Get balances before revoke
const orchestratorAddress = await orchestrator.getAddress();
const balanceBefore = await raacToken.balanceOf(orchestratorAddress);
// Execute emergency revoke
await orchestrator.connect(emergencyRole).emergencyRevoke(beneficiary.address);
// Check balance after - should be less due to tax
const balanceAfter = await raacToken.balanceOf(orchestratorAddress);
// Calculate expected tax (2.5% of vested amount)
const totalTaxRate = 250n; // 2% swap + 0.5% burn = 2.5%
const expectedTaxAmount = vestAmount * totalTaxRate / 10000n;
// Verify tax was taken despite tokens never leaving contract
expect(balanceBefore - balanceAfter).to.equal(expectedTaxAmount);
console.log("Tokens lost to tax:", ethers.formatEther(expectedTaxAmount));
});
it("POC 2: Category allocation remains locked after revoke", async () => {
// Get team category allocation limit
const teamAllocation = await orchestrator.categoryAllocations(TEAM_CATEGORY);
console.log("Team category allocation:", ethers.formatEther(teamAllocation));
// Create vesting schedule using most of the team allocation
const vestAmount = teamAllocation - ethers.parseEther("1000"); // Leave small buffer
console.log("Creating vesting schedule with:", ethers.formatEther(vestAmount));
await orchestrator.createVestingSchedule(
beneficiary.address,
TEAM_CATEGORY,
vestAmount,
await time.latest()
);
// Get category usage before revoke
const categoryUsedBefore = await orchestrator.categoryUsed(TEAM_CATEGORY);
console.log("Category used before revoke:", ethers.formatEther(categoryUsedBefore));
// Emergency revoke the schedule
await orchestrator.connect(emergencyRole).emergencyRevoke(beneficiary.address);
// Verify category used remains the same after revoke
const categoryUsedAfter = await orchestrator.categoryUsed(TEAM_CATEGORY);
expect(categoryUsedAfter).to.equal(categoryUsedBefore);
console.log("Category used after revoke:", ethers.formatEther(categoryUsedAfter));
// Try to create new schedule with same amount - should fail due to category still being marked as used
await expect(
orchestrator.createVestingSchedule(
treasury.address,
TEAM_CATEGORY,
vestAmount,
await time.latest()
)
).to.be.revertedWithCustomError(
orchestrator,
"CategoryAllocationExceeded"
);
// Show that contract has the tokens (minus tax loss) but can't use them
const orchestratorBalance = await raacToken.balanceOf(await orchestrator.getAddress());
expect(orchestratorBalance).to.be.gt(vestAmount);
console.log("Actual tokens available:", ethers.formatEther(orchestratorBalance));
});
});
});
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!