Summary
When the RAACReleaseOrchestrator::emergencyRevoke is used, the self transferring of raacToken leads to loss of funds due to tax mechanism and an incorrect event emission.
Vulnerability Details
The RAACReleaseOrchestrator::emergencyRevoke function is design to revoke a beneficiary's vesting when deemed necessary by the admin / emergency role holder.
However, this function unnecessarily transfers tokens to itself, which leads to the contract receiving less raacToken than before due to taxation.
The tax here comprises of burning tokens and transferring to fee collector.
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);
}
The event emitted is also incorrect as the emergency withdraw actually withdrew fewer tokens than expected.
Impact
Taxation burns tokens, which is clear loss of funds, however, the funds sent to the fee collector can be considered retrievable but inefficient and sub-optimal.
Actual token holdings of the RAACReleaseOrchestrator contract is reduced, so if the deposits were made to be accurate to the amount allocated, some users may face DoS due to lack of funds.
Incorret event parameters in EmergencyWithdraw event as the amount withdrawn is technically lesser.
Proof of Concept
Create a new file with name RAACReleaseOrchestrator.test.js and run the test case shown below:
import { expect } from "chai";
import { keccak256, toUtf8Bytes } from "ethers";
import hre from "hardhat";
const { ethers } = hre;
describe("RAACReleaseOrchestrator", function () {
let RAACToken, RAACReleaseOrchestrator;
let raacToken, releaseOrchestrator;
let owner, user1;
beforeEach(async function () {
[owner, user1] = await ethers.getSigners();
RAACToken = await ethers.getContractFactory("RAACToken");
raacToken = await RAACToken.deploy(owner.address, 100, 50);
RAACReleaseOrchestrator = await ethers.getContractFactory("RAACReleaseOrchestrator");
releaseOrchestrator = await RAACReleaseOrchestrator.connect(user1).deploy(raacToken.target);
await raacToken.setMinter(await user1.getAddress());
await raacToken.connect(user1).mint(await releaseOrchestrator.getAddress(), ethers.parseEther("1000000"));
});
it('should pay tax on emergency revoke', async function () {
const currentTimestamp = Math.floor(Date.now() / 1000);
await releaseOrchestrator.connect(user1).createVestingSchedule(user1.address, keccak256(toUtf8Bytes("PUBLIC_SALE")), ethers.parseEther('100'), currentTimestamp);
const orchestratorBalance = await raacToken.balanceOf(await releaseOrchestrator.getAddress());
expect(orchestratorBalance).to.equal(ethers.parseEther('1000000'));
await releaseOrchestrator.connect(user1).emergencyRevoke(user1.address);
const orchestratorBalanceAfterRevoke = await raacToken.balanceOf(await releaseOrchestrator.getAddress());
console.log("orchestratorBalanceAfterRevoke", ethers.formatUnits(orchestratorBalanceAfterRevoke, 18));
expect(orchestratorBalanceAfterRevoke).to.be.lessThan(ethers.parseEther('999999'));
});
});
Tools Used
Manual Review
/
Hardhat
Recommendations
It is recommended to remove that transfer function:
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);
}