Link to Affected Code:
https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/minters/RAACReleaseOrchestrator/RAACReleaseOrchestrator.sol#L126-L139
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);
}
Description:
The emergencyRevoke function in RAACReleaseOrchestrator contains a critical error where revoked tokens are transferred to the contract itself (address(this)). The contract lacks any function to withdraw or recover these tokens, resulting in them being permanently locked. This affects all categories including TEAM, ADVISOR, TREASURY, and SALE allocations which total 65.1% of token supply.
Impact: The vulnerability causes:
Permanent loss of revoked tokens with no recovery mechanism
Each emergency revocation increases locked tokens
Proof of Concept:
createVestingSchedule(
beneficiary,
TEAM_CATEGORY,
1_000_000 ether,
block.timestamp
);
emergencyRevoke(beneficiary);
unreleasedAmount = 1_000_000 - 200_000 = 800_000 tokens
raacToken.transfer(address(this), 800_000);
vestingSchedules[beneficiary] deleted
- No withdrawal functions
- No admin recovery methods
- Even EMERGENCY_ROLE cannot access
Recommended Mitigation:
Add recovery function:
function recoverLockedTokens(
address to
) external onlyRole(DEFAULT_ADMIN_ROLE) {
uint256 balance = raacToken.balanceOf(address(this));
raacToken.transfer(to, balance);
emit TokensRecovered(to, balance);
}
Or modify emergencyRevoke to transfer to a recovery address:
function emergencyRevoke(
address beneficiary,
address recoveryAddress
) external onlyRole(EMERGENCY_ROLE) {
require(recoveryAddress != address(0), "Invalid recovery address");
if (unreleasedAmount > 0) {
raacToken.transfer(recoveryAddress, unreleasedAmount);
emit EmergencyWithdraw(beneficiary, unreleasedAmount);
}
}