Summary
A critical vulnerability in RAACReleaseOrchestrator.sol
allows permanent locking of vested tokens by manipulating the startTime
parameter. An admin can accidentally or maliciously set the startTime
to a past timestamp, causing the vesting schedule to expire before tokens can be claimed, effectively locking them forever.
Vulnerability Details
The vulnerability exists in createVestingSchedule()
:
function createVestingSchedule(
address beneficiary,
bytes32 category,
uint256 amount,
uint256 startTime
) external onlyRole(ORCHESTRATOR_ROLE) whenNotPaused {
if (beneficiary == address(0)) revert InvalidAddress();
if (amount == 0) revert InvalidAmount();
if (vestingSchedules[beneficiary].initialized) revert VestingAlreadyInitialized();
if (categoryAllocations[category] == 0) revert InvalidCategory();
uint256 newCategoryTotal = categoryUsed[category] + amount;
if (newCategoryTotal > categoryAllocations[category]) revert CategoryAllocationExceeded();
categoryUsed[category] = newCategoryTotal;
VestingSchedule storage schedule = vestingSchedules[beneficiary];
schedule.totalAmount = amount;
schedule.startTime = startTime;
schedule.duration = VESTING_DURATION;
schedule.initialized = true;
}
The issue becomes critical when combined with _calculateReleasableAmount()
:
function _calculateReleasableAmount(
VestingSchedule memory schedule
) internal view returns (uint256) {
if (block.timestamp < schedule.startTime + VESTING_CLIFF) return 0;
if (block.timestamp < schedule.lastClaimTime + MIN_RELEASE_INTERVAL) return 0;
uint256 timeFromStart = block.timestamp - schedule.startTime;
if (timeFromStart >= schedule.duration) {
return schedule.totalAmount - schedule.releasedAmount;
}
uint256 vestedAmount = (schedule.totalAmount * timeFromStart) / schedule.duration;
return vestedAmount - schedule.releasedAmount;
}
Impact
Up to 65% of total token supply could be permanently locked
Affects critical stakeholder categories:
Team tokens (18%)
Advisor tokens (10.3%)
Treasury (5%)
Private Sale (10%)
Public Sale (15%)
Liquidity (6.8%)
No recovery mechanism exists
Emergency functions cannot rescue locked tokens
Proof of Concept
Detailed Exploitation Scenario
To demonstrate this vulnerability, we'll show how tokens can be permanently locked through the following steps:
-
Initial Setup
A team member is allocated 1,000,000 RAAC tokens for vesting
The vesting duration is set to 700 days
The cliff period is 90 days
-
Attack Vectors
An admin (either maliciously or by mistake) can set the startTime
to any past timestamp
If startTime + VESTING_DURATION
< current time, tokens become locked
No validation exists to prevent this scenario
-
Why Tokens Get Locked
When release()
is called, _calculateReleasableAmount()
detects the schedule has "expired"
The vesting cliff is already passed
The full duration has elapsed
But since no tokens were ever released, they remain locked in the contract
The emergency functions cannot rescue these tokens
-
Key Test Scenarios
Create a vesting schedule with start time 800 days in the past
Attempt to release tokens immediately
Try using emergency functions to recover tokens
Compare with a normal vesting schedule using future start time
-
Real World Impact
Team members could lose their allocated tokens
Project launches could be severely impacted
Loss of investor confidence
Potential legal implications
The following POC demonstrates these scenarios using Hardhat testing framework...
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("RAACReleaseOrchestrator Lock Vulnerability", function() {
let orchestrator, raacToken;
let owner, teamMember;
const AMOUNT = ethers.utils.parseEther("1000000");
beforeEach(async function() {
[owner, teamMember] = await ethers.getSigners();
const RAACToken = await ethers.getContractFactory("RAACToken");
raacToken = await RAACToken.deploy(
owner.address,
100,
50
);
const RAACReleaseOrchestrator = await ethers.getContractFactory("RAACReleaseOrchestrator");
orchestrator = await RAACReleaseOrchestrator.deploy(raacToken.address);
await orchestrator.grantRole(await orchestrator.ORCHESTRATOR_ROLE(), owner.address);
await raacToken.mint(orchestrator.address, AMOUNT);
});
it("Should demonstrate permanent token lock through past startTime", async function() {
const VESTING_DURATION = 700 * 24 * 60 * 60;
const PAST_START = (await ethers.provider.getBlock('latest')).timestamp - (VESTING_DURATION + 100);
const teamCategory = ethers.utils.keccak256(ethers.utils.toUtf8Bytes("TEAM"));
await orchestrator.createVestingSchedule(
teamMember.address,
teamCategory,
AMOUNT,
PAST_START
);
const schedule = await orchestrator.getVestingSchedule(teamMember.address);
expect(schedule.totalAmount).to.equal(AMOUNT);
expect(schedule.startTime).to.equal(PAST_START);
await expect(
orchestrator.connect(teamMember).release()
).to.be.revertedWith("NothingToRelease");
expect(await raacToken.balanceOf(teamMember.address)).to.equal(0);
await orchestrator.grantRole(
ethers.utils.keccak256(ethers.utils.toUtf8Bytes("EMERGENCY_ROLE")),
owner.address
);
await orchestrator.emergencyRevoke(teamMember.address);
expect(await raacToken.balanceOf(orchestrator.address)).to.equal(AMOUNT);
});
it("Should allow normal vesting with future startTime", async function() {
const FUTURE_START = (await ethers.provider.getBlock('latest')).timestamp + 3600;
const teamCategory = ethers.utils.keccak256(ethers.utils.toUtf8Bytes("TEAM"));
await orchestrator.createVestingSchedule(
teamMember.address,
teamCategory,
AMOUNT,
FUTURE_START
);
await ethers.provider.send("evm_increaseTime", [91 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine");
await orchestrator.connect(teamMember).release();
expect(await raacToken.balanceOf(teamMember.address)).to.be.gt(0);
});
});
Tools Used
Manual code review
Hardhat for testing
Ethers.js
Recommended Mitigation
Add startTime validation to createVestingSchedule()
:
function createVestingSchedule(
address beneficiary,
bytes32 category,
uint256 amount,
uint256 startTime
) external onlyRole(ORCHESTRATOR_ROLE) whenNotPaused {
if (beneficiary == address(0)) revert InvalidAddress();
if (amount == 0) revert InvalidAmount();
if (vestingSchedules[beneficiary].initialized) revert VestingAlreadyInitialized();
if (categoryAllocations[category] == 0) revert InvalidCategory();
if (startTime <= block.timestamp) revert StartTimeInPast();
}
Add new error to interface:
-
Add emergency recovery function that can reset expired vesting schedules.
-
Consider adding minimum and maximum bounds for startTime to prevent extreme timestamp manipulation.
Risk Breakdown
Recommendation Status
The fix is straightforward and should be implemented before mainnet deployment to prevent potential permanent loss of a significant portion of the token supply.