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);
}
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);
}
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();
const RAACToken = await ethers.getContractFactory("RAACToken");
raacToken = await RAACToken.deploy(
owner.address,
200n,
50n
);
await raacToken.waitForDeployment();
const RAACReleaseOrchestrator = await ethers.getContractFactory("RAACReleaseOrchestrator");
orchestrator = await RAACReleaseOrchestrator.deploy(await raacToken.getAddress());
await orchestrator.waitForDeployment();
await orchestrator.grantRole(EMERGENCY_ROLE, emergencyRole.address);
await raacToken.setMinter(owner.address);
await raacToken.mint(await orchestrator.getAddress(), ethers.parseEther("20000000"));
});
describe("Emergency Revoke Vulnerabilities", () => {
it("POC 1: Unnecessary token loss due to self-transfer tax", async () => {
const vestAmount = ethers.parseEther("1000");
await orchestrator.createVestingSchedule(
beneficiary.address,
TEAM_CATEGORY,
vestAmount,
await time.latest()
);
await time.increase(time.duration.days(91));
const orchestratorAddress = await orchestrator.getAddress();
const balanceBefore = await raacToken.balanceOf(orchestratorAddress);
await orchestrator.connect(emergencyRole).emergencyRevoke(beneficiary.address);
const balanceAfter = await raacToken.balanceOf(orchestratorAddress);
const totalTaxRate = 250n;
const expectedTaxAmount = vestAmount * totalTaxRate / 10000n;
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 () => {
const teamAllocation = await orchestrator.categoryAllocations(TEAM_CATEGORY);
console.log("Team category allocation:", ethers.formatEther(teamAllocation));
const vestAmount = teamAllocation - ethers.parseEther("1000");
console.log("Creating vesting schedule with:", ethers.formatEther(vestAmount));
await orchestrator.createVestingSchedule(
beneficiary.address,
TEAM_CATEGORY,
vestAmount,
await time.latest()
);
const categoryUsedBefore = await orchestrator.categoryUsed(TEAM_CATEGORY);
console.log("Category used before revoke:", ethers.formatEther(categoryUsedBefore));
await orchestrator.connect(emergencyRole).emergencyRevoke(beneficiary.address);
const categoryUsedAfter = await orchestrator.categoryUsed(TEAM_CATEGORY);
expect(categoryUsedAfter).to.equal(categoryUsedBefore);
console.log("Category used after revoke:", ethers.formatEther(categoryUsedAfter));
await expect(
orchestrator.createVestingSchedule(
treasury.address,
TEAM_CATEGORY,
vestAmount,
await time.latest()
)
).to.be.revertedWithCustomError(
orchestrator,
"CategoryAllocationExceeded"
);
const orchestratorBalance = await raacToken.balanceOf(await orchestrator.getAddress());
expect(orchestratorBalance).to.be.gt(vestAmount);
console.log("Actual tokens available:", ethers.formatEther(orchestratorBalance));
});
});
});