Core Contracts

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

Self-transfer in `RAACReleaseOrchestrator::emergencyRevoke()` can permanently reduce beneficiary tokens due to transfer fees

Summary

The emergencyRevoke() function unnecessarily transfers tokens to itself, which triggers the RAAC token's transfer fee mechanism if the contract is not whitelisted, permanently reducing the amount available for future beneficiaries.

Vulnerability Details

In RAACReleaseOrchestrator::emergencyRevoke() function performs a self-transfer of unreleased tokens:

function emergencyRevoke(address beneficiary) external onlyRole(EMERGENCY_ROLE) {
// ... existing code ...
if (unreleasedAmount > 0) {
raacToken.transfer(address(this), unreleasedAmount);
emit EmergencyWithdraw(beneficiary, unreleasedAmount);
}
// ... existing code ...
}

This self-transfer is unnecessary since the tokens are already held by the contract. More critically, if the orchestrator contract is not whitelisted in the RAAC token contract, this transfer will incur fees, permanently reducing the token amount available for future beneficiaries.

Please note that the only way to skip the transfer fee is to be whitelisted in the RAAC token contract. However, in the documentation it does not mention that the orchestrator contract will be whitelisted. Moreover, the deployment script does not whitelist the orchestrator contract. Here is the whitelis setup in the deployment script in the scripts/deploy/03_setup_parameters.js file:

// Set up whitelist
const whitelistAddresses = [
veRAACTokenAddress,
feeCollectorAddress,
treasuryAddress
];
for (const address of whitelistAddresses) {
await raacToken.manageWhitelist(address, true);
console.log(`Whitelisted: ${address}`);
}

So, if is not whitelisted during deployment, the only way would be to whitelist it after deployment. This would require invoking the RAACToken::manageWhitelist() function which is only callable by the owner that is the RAACMinter contract. However, the RAACMinter contract does not have a function call RAACToken::manageWhitelist().

Impact

When emergency revocation occurs, a percentage of tokens are permanently lost due to transfer fees, directly reducing the amount available for reassignment to new beneficiaries. This creates a loss of assets that cannot be recovered and prevents users from being able to release their tokens.

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('shows token loss during emergency revoke due to transfer fees', 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());
const TEAM_CATEGORY = await raacReleaseOrchestrator.TEAM_CATEGORY();
const vestingAmount = ethers.parseEther("9000000");
const startTime = await time.latest();
// Allocate all the category to user1
await raacReleaseOrchestrator.connect(owner).createVestingSchedule(user1.address, TEAM_CATEGORY, vestingAmount, startTime);
// Transfer raacToken to the orchestrator contract so user1 can release it
await raacToken.connect(owner).mint(raacReleaseOrchestrator.target, vestingAmount);
// Advance half of the vesting duration
const VESTING_DURATION = await raacReleaseOrchestrator.VESTING_DURATION();
await time.increase(VESTING_DURATION / 2n);
// Emergency revoke the vesting schedule from user1 because it wants to leave the project
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 orchestrator has less tokens than the initialCategoryAllocations because of the transfer fees
const orchestratorBalance = await raacToken.balanceOf(raacReleaseOrchestrator.target);
expect(orchestratorBalance).to.lt(vestingAmount);
// Allocate the same amount revoked from user1 to user2 who is the new team member
await raacReleaseOrchestrator.connect(owner).createVestingSchedule(user2.address, TEAM_CATEGORY, vestingAmount, startTime);
// Advance the whole vesting duration
await time.increase(VESTING_DURATION);
// Now user2 should be able to release the tokens, but it fails because the orchestrator has less tokens than the allocated amount because of the unnecessary transfer in the revoke call
await expect(raacReleaseOrchestrator.connect(user2).release()).to.be.revertedWithCustomError(raacToken, "ERC20InsufficientBalance");
});
});

And run the test with:

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

Recommendations

Remove the unnecessary self-transfer in the emergencyRevoke() 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);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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