Core Contracts

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

`RAACReleaseOrchestrator::emergencyRevoke()` fails to update `categoryUsed`, leading to token lockup and incorrect accounting

Summary

The emergencyRevoke function doesn't decrease categoryUsed[category] when revoking a vesting schedule, causing tokens to remain locked and preventing new schedule creation within category limits.

Vulnerability Details

When RAACReleaseOrchestrator::emergencyRevoke() is called, it deletes the vesting schedule but fails to update categoryUsed[category]. This leads to two issues:

  1. The tokens are locked in the contract but remain unavailable for new schedules (in the case the category is fully allocated)

  2. categoryUsed maintains an artificially high value, preventing new schedule creation up to the category limit

The issue occurs because while the function handles the token transfer and schedule deletion, it doesn't properly update the category accounting:

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) {
@> // categoryUsed is not decreased here
raacToken.transfer(address(this), unreleasedAmount);
emit EmergencyWithdraw(beneficiary, unreleasedAmount);
}
emit VestingScheduleRevoked(beneficiary);
}

Impact

  1. Permanent lockup of tokens in the contract as they can't be reassigned to new schedules if the category is fully allocated

  2. Prevents ORCHESTRATOR_ROLE from creating new schedules up to the intended category limit

  3. Incorrect accounting affects integrating systems relying on categoryUsed values

  4. Only remediable by DEFAULT_ADMIN_ROLE increasing category allocations via updateCategoryAllocation(), which defeats the purpose of category limits

Tools Used

Manual review

Proof of Concept

Create a new file test/unit/core/minters/RAACReleaseOrchestrator.test.js and add the following test case:

import { expect } from "chai";
import hre from "hardhat";
const { ethers } = hre;
import { time } from "@nomicfoundation/hardhat-network-helpers";
describe("RAACReleaseOrchestrator", function () {
it('should demonstrate categoryUsed accounting issue', async function () {
const [owner, user1, user2] = await ethers.getSigners();
const RAACTokenFactory = await ethers.getContractFactory("RAACToken");
const raacToken = await RAACTokenFactory.deploy(owner.address, 0, 0);
await raacToken.connect(owner).setMinter(owner.address);
const RAACReleaseOrchestratorFactory = await ethers.getContractFactory("RAACReleaseOrchestrator");
const raacReleaseOrchestrator = await RAACReleaseOrchestratorFactory.deploy(await raacToken.getAddress());
// Add the orchestrator to the whitelist so it does not suffer a fee cut in the revoke call
await raacToken.connect(owner).manageWhitelist(raacReleaseOrchestrator.target, true);
const TEAM_CATEGORY = await raacReleaseOrchestrator.TEAM_CATEGORY();
const initialCategoryAllocations = await raacReleaseOrchestrator.categoryAllocations(TEAM_CATEGORY);
const initialCategoryUsed = await raacReleaseOrchestrator.categoryUsed(TEAM_CATEGORY);
expect(initialCategoryAllocations).to.equal(ethers.parseEther("18000000"));
expect(initialCategoryUsed).to.equal(0);
const startTime = await time.latest();
// Allocate all the category to user1
await raacReleaseOrchestrator.connect(owner).createVestingSchedule(user1.address, TEAM_CATEGORY, initialCategoryAllocations, startTime);
// Transfer raacToken to the orchestrator contract so user1 can release it
await raacToken.connect(owner).mint(raacReleaseOrchestrator.target, initialCategoryAllocations);
// Check that the categoryUsed is now the initialCategoryAllocations
const categoryUsedAfterAllocation = await raacReleaseOrchestrator.categoryUsed(TEAM_CATEGORY);
expect(categoryUsedAfterAllocation).to.equal(initialCategoryAllocations);
// Advance half of the vesting duration
const VESTING_DURATION = await raacReleaseOrchestrator.VESTING_DURATION();
await time.increase(VESTING_DURATION / 2n);
// Emergency revoke the vesting schedule
await raacReleaseOrchestrator.connect(owner).emergencyRevoke(user1.address);
// Check that the user1 has none tokens, everything was revoked
const user1Balance = await raacToken.balanceOf(user1.address);
expect(user1Balance).to.equal(0);
// Check that the categoryUsed DID NOT UPDATE and continues to be the initialCategoryAllocations
const categoryUsedAfterEmergencyRevoke = await raacReleaseOrchestrator.categoryUsed(TEAM_CATEGORY);
expect(categoryUsedAfterEmergencyRevoke).to.equal(initialCategoryAllocations);
const orchestratorBalance = await raacToken.balanceOf(raacReleaseOrchestrator.target);
expect(orchestratorBalance).to.equal(initialCategoryAllocations);
// Now categoryUsed is still the initialCategoryAllocations but the orchestrator has the half of the initialCategoryAllocations
// So it should be able to allocate the amount to user2, but it fails!
await expect(raacReleaseOrchestrator.connect(owner).createVestingSchedule(user2.address, TEAM_CATEGORY, orchestratorBalance, startTime)).to.be.revertedWithCustomError(raacReleaseOrchestrator, "CategoryAllocationExceeded");
// The only way to fix this is to increase the category allocation limit
await raacReleaseOrchestrator.connect(owner).updateCategoryAllocation(TEAM_CATEGORY, initialCategoryAllocations + orchestratorBalance);
// Now the orchestrator can allocate the amount to user2
await raacReleaseOrchestrator.connect(owner).createVestingSchedule(user2.address, TEAM_CATEGORY, orchestratorBalance, startTime);
// Check that the categoryUsed is now the initialCategoryAllocations + orchestratorBalance but in reality only the initialCategoryAllocations was distributed
const categoryUsedAfterUpdate = await raacReleaseOrchestrator.categoryUsed(TEAM_CATEGORY);
expect(categoryUsedAfterUpdate).to.equal(initialCategoryAllocations + orchestratorBalance);
});
});

And run the test with:

npx hardhat test test/unit/core/minters/RAACReleaseOrchestrator.test.js

Recommendations

Update categoryUsed when revoking a schedule:

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) {
+ categoryUsed[category] -= unreleasedAmount;
raacToken.transfer(address(this), unreleasedAmount);
emit EmergencyWithdraw(beneficiary, unreleasedAmount);
}
emit VestingScheduleRevoked(beneficiary);
}
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

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

Support

FAQs

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