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 4 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 4 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.