Token vesting contracts are high-value targets. They hold team, investor, and advisor allocations that often represent 20–40% of a project's total supply — locked for 1–4 years. A vulnerability here isn't a hypothetical: it means an attacker can drain months of locked tokens, or the project owner can rug beneficiaries with a single transaction.
This post covers six security vulnerabilities found in production vesting contracts, with vulnerable and fixed code for each.
How Token Vesting Contracts Work
A vesting contract holds tokens on behalf of a beneficiary and releases them on a schedule. The dominant pattern is cliff + linear: no tokens are claimable before the cliff date, then they vest linearly until the end of the duration.
function vestedAmount(VestingSchedule memory s) public view returns (uint256) {
if (block.timestamp < s.start + s.cliff) return 0;
uint256 elapsed = block.timestamp - s.start;
if (elapsed >= s.duration) return s.total;
return s.total * elapsed / s.duration;
}
function release(uint256 scheduleId) external {
VestingSchedule storage s = schedules[scheduleId];
uint256 releasable = vestedAmount(s) - s.released;
s.released += releasable;
IERC20(token).transfer(s.beneficiary, releasable);
}
This is the pattern used by OpenZeppelin's VestingWallet and most production implementations. It looks simple — which is why its failure modes are surprising.
Vulnerability 1: Missing Access Control on release()
The release() function sends tokens to the beneficiary. Because the recipient is hardcoded, most implementations allow anyone to call release() on behalf of a beneficiary. This is usually intentional — letting a third party trigger a release on schedule is a feature, not a bug.
The problem arises when the recipient is configurable:
// VULNERABLE: recipient is a parameter, not hardcoded
function release(uint256 scheduleId, address recipient) external {
VestingSchedule storage s = schedules[scheduleId];
require(msg.sender == s.beneficiary || msg.sender == owner(), "Unauthorized");
uint256 releasable = vestedAmount(s) - s.released;
s.released += releasable;
// Attacker with owner access can redirect tokens to any address
IERC20(token).safeTransfer(recipient, releasable);
}
If the owner key is compromised, or if onlyOwner is mistakenly absent, an attacker redirects all releases to an arbitrary address. A related variant: in proxy deployments where initialize() was never called, owner is address(0) — making the msg.sender == owner() branch trivially satisfiable by any caller using a zero-address trick.
// SECURE: hardcode recipient as beneficiary, no redirect possible
function release(uint256 scheduleId) external {
VestingSchedule storage s = schedules[scheduleId];
uint256 releasable = vestedAmount(s) - s.released;
require(releasable > 0, "Nothing to release");
s.released += releasable;
IERC20(token).safeTransfer(s.beneficiary, releasable); // Always goes to beneficiary
}
Vulnerability 2: Integer Truncation in Linear Vesting
The standard linear vesting formula is:
uint256 vested = total * elapsed / duration;
This has two distinct precision problems.
Problem A — Precision loss when elapsed is small:
If total = 1000 tokens and duration = 31,536,000 seconds (1 year), a release call after 1 second yields 1000 * 1 / 31536000 = 0. Integer division truncates to zero. At 18-decimal scale this is usually non-zero, but tokens with fewer decimals or small allocations silently lose fractional vesting per call.
Problem B — Multiplication overflow:
total * elapsed overflows uint256 only with absurd token amounts (36+ decimals or custom fixed-point), but it remains a valid audit finding. Solidity 0.8+ reverts on overflow automatically — the contract becomes unusable rather than silently corrupted — but the fix is the same: cap total at schedule creation.
Fix: cap total at schedule creation and keep multiplication before division:
// SECURE: cap prevents overflow; multiplication before division preserves precision
function vestedAmount(VestingSchedule memory s) public view returns (uint256) {
if (block.timestamp < s.start + s.cliff) return 0;
uint256 elapsed = block.timestamp - s.start;
if (elapsed >= s.duration) return s.total;
return (s.total * elapsed) / s.duration;
}
Vulnerability 3: Broken revoke() Doesn't Update released Tracking
Revocable vesting lets the owner cancel a schedule and return unvested tokens. The naive implementation has a critical accounting error:
// VULNERABLE: sends unvested back to owner but never updates s.released
function revoke(uint256 scheduleId) external onlyOwner {
VestingSchedule storage s = schedules[scheduleId];
require(s.revocable && !s.revoked, "Cannot revoke");
uint256 unvested = s.total - vestedAmount(s);
s.revoked = true;
IERC20(token).safeTransfer(owner(), unvested);
// BUG: s.released never updated — s.total and s.released now inconsistent
}
The double-spend path: owner calls revoke() (unvested returned), but s.released is never updated. A beneficiary who front-runs or calls release() afterward sees vestedAmount(s) - s.released return the same positive amount — tokens that were already accounted for in the revocation transfer.
// SECURE: revoke() correctly finalizes accounting
function revoke(uint256 scheduleId) external onlyOwner {
VestingSchedule storage s = schedules[scheduleId];
require(s.revocable && !s.revoked, "Cannot revoke");
uint256 vested = vestedAmount(s);
uint256 alreadyReleased = s.released;
uint256 releasableToUser = vested - alreadyReleased;
uint256 returnToOwner = s.total - vested;
s.revoked = true;
s.released = s.total; // Mark all as "accounted for" — prevents future release() calls
// Send vested-but-unclaimed to beneficiary
if (releasableToUser > 0) {
IERC20(token).safeTransfer(s.beneficiary, releasableToUser);
}
// Return unvested to owner
if (returnToOwner > 0) {
IERC20(token).safeTransfer(owner(), returnToOwner);
}
}
Setting s.released = s.total upon revocation ensures that any future call to release() computes vestedAmount - s.released = 0, even if the s.revoked check is somehow bypassed.
Vulnerability 4: Reentrancy in release() with ERC-777 or Callback Tokens
ERC-777 tokens call a tokensReceived hook on the recipient during transfer. If the vesting contract hasn't updated state before sending, that hook re-enters release() with stale accounting.
// VULNERABLE: effects happen after interaction
function release(uint256 scheduleId) external {
VestingSchedule storage s = schedules[scheduleId];
uint256 releasable = vestedAmount(s) - s.released;
require(releasable > 0, "Nothing to release");
// ERC-777 token: recipient hook fires HERE, before s.released is updated
IERC20(token).transfer(s.beneficiary, releasable);
s.released += releasable; // Updated AFTER transfer — reentrancy window open above
}
The hook calls release() again. s.released is still the old value, so vestedAmount(s) - s.released returns the same positive amount and tokens are sent a second time.
// SECURE: effects-before-interactions + nonReentrant
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract SecureVesting is ReentrancyGuard {
using SafeERC20 for IERC20;
function release(uint256 scheduleId) external nonReentrant {
VestingSchedule storage s = schedules[scheduleId];
uint256 releasable = vestedAmount(s) - s.released;
require(releasable > 0, "Nothing to release");
s.released += releasable; // State updated FIRST (effects)
IERC20(token).safeTransfer(s.beneficiary, releasable); // Then interaction
}
}
nonReentrant is the belt. Effects-before-interactions is the suspenders. Use both.
Vulnerability 5: Hardcoded Beneficiary With No Recovery Path
Immutable beneficiary addresses prevent redirection attacks but create a key-loss risk: if the beneficiary loses their private key, all tokens are locked forever. Hardware wallet failures, lost seeds, and deprecated exchange wallets are real events — and a 3–4 year vesting schedule is a long time to guarantee key availability.
// No way to update beneficiary after deployment
constructor(address _beneficiary, ...) {
schedule.beneficiary = _beneficiary; // Immutable, no setter
}
Fix: Allow beneficiary to transfer their own vesting rights
// SECURE: beneficiary can nominate a new address (two-step for safety)
mapping(uint256 => address) public pendingBeneficiary;
function nominateBeneficiary(uint256 scheduleId, address newBeneficiary) external {
require(msg.sender == schedules[scheduleId].beneficiary, "Not beneficiary");
require(newBeneficiary != address(0), "Invalid address");
pendingBeneficiary[scheduleId] = newBeneficiary;
emit BeneficiaryNominated(scheduleId, newBeneficiary);
}
function acceptBeneficiary(uint256 scheduleId) external {
require(msg.sender == pendingBeneficiary[scheduleId], "Not nominee");
schedules[scheduleId].beneficiary = msg.sender;
delete pendingBeneficiary[scheduleId];
emit BeneficiaryTransferred(scheduleId, msg.sender);
}
Two-step transfer (nominate then accept) prevents the beneficiary from accidentally transferring rights to a mistyped address.
Vulnerability 6: Admin Rug via revoke() Without Timelock
A revocable vesting schedule with no timelock lets the owner revoke instantly — including the block before a cliff unlocks. An investor agrees to a 1-year cliff and the team revokes at month 11, returning 100% of tokens to themselves. On-chain, it's legal; economically, it's a rug.
// VULNERABLE: instant revocation, no delay, no minimum vested period
function revoke(uint256 scheduleId) external onlyOwner {
VestingSchedule storage s = schedules[scheduleId];
require(s.revocable && !s.revoked, "Cannot revoke");
s.revoked = true;
uint256 unvested = s.total - vestedAmount(s);
IERC20(token).safeTransfer(owner(), unvested);
}
Mitigations (in order of strength):
-
Remove revocability entirely — if schedule terms are fixed, make the contract irrevocable. Investors can verify this on-chain.
-
Guaranteed minimum floor — even on revocation, the beneficiary keeps at least the cliff allocation (
s.total * s.cliff / s.duration). The owner can only claw back tokens beyond that floor. -
Timelock on revoke() — revocation only takes effect after a 7–30 day delay, giving the beneficiary time to react:
mapping(uint256 => uint256) public revokeRequestTime;
function requestRevoke(uint256 scheduleId) external onlyOwner {
revokeRequestTime[scheduleId] = block.timestamp;
emit RevocationRequested(scheduleId, block.timestamp + REVOKE_DELAY);
}
function executeRevoke(uint256 scheduleId) external onlyOwner {
require(
block.timestamp >= revokeRequestTime[scheduleId] + REVOKE_DELAY,
"Timelock active"
);
// ... execute revocation with correct accounting (see secure contract below)
}
What Automated Scanners Find
| Vulnerability | Slither | Semgrep | AI (ContractScan) |
|---|---|---|---|
| Missing nonReentrant on release() | ✅ | ⚠️ | ✅ |
| Effects after interaction in release() | ✅ | ✅ | ✅ |
| Missing SafeERC20 | ✅ | ✅ | ✅ |
| revoke() not updating released tracking | ❌ | ❌ | ✅ |
| No beneficiary recovery path | ❌ | ❌ | ✅ |
| Instant revoke without timelock (rug vector) | ❌ | ❌ | ✅ |
| Integer truncation in linear formula | ⚠️ | ❌ | ✅ |
| Uninitialized proxy owner (address(0)) | ✅ | ⚠️ | ✅ |
Slither and Semgrep cover low-level patterns well. Business logic issues — broken revoke() accounting, missing key-loss recovery, admin rug vectors — require reasoning about intent, which static rules cannot encode. AI analysis flags these by tracing relationships between state variables and control flow.
Minimal Secure Vesting Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/access/Ownable2Step.sol";
contract SecureVesting is ReentrancyGuard, Ownable2Step {
using SafeERC20 for IERC20;
IERC20 public immutable token;
uint256 public constant REVOKE_DELAY = 7 days;
struct VestingSchedule {
address beneficiary;
uint256 start;
uint256 cliff;
uint256 duration;
uint256 total;
uint256 released;
bool revocable;
bool revoked;
}
mapping(uint256 => VestingSchedule) public schedules;
mapping(uint256 => address) public pendingBeneficiary;
mapping(uint256 => uint256) public revokeRequestTime;
uint256 public scheduleCount;
event Released(uint256 indexed scheduleId, address indexed beneficiary, uint256 amount);
event Revoked(uint256 indexed scheduleId);
event BeneficiaryTransferred(uint256 indexed scheduleId, address newBeneficiary);
constructor(address _token) Ownable(msg.sender) {
require(_token != address(0), "Zero address");
token = IERC20(_token);
}
function createSchedule(
address beneficiary,
uint256 start,
uint256 cliffDuration,
uint256 totalDuration,
uint256 amount,
bool revocable
) external onlyOwner {
require(beneficiary != address(0), "Zero beneficiary");
require(totalDuration > 0 && totalDuration >= cliffDuration, "Bad duration");
require(amount > 0 && amount <= type(uint128).max, "Bad amount");
uint256 id = scheduleCount++;
schedules[id] = VestingSchedule(beneficiary, start, cliffDuration, totalDuration, amount, 0, revocable, false);
token.safeTransferFrom(msg.sender, address(this), amount);
}
function vestedAmount(uint256 scheduleId) public view returns (uint256) {
VestingSchedule memory s = schedules[scheduleId];
if (s.revoked) return s.released;
if (block.timestamp < s.start + s.cliff) return 0;
uint256 elapsed = block.timestamp - s.start;
if (elapsed >= s.duration) return s.total;
return s.total * elapsed / s.duration;
}
function release(uint256 scheduleId) external nonReentrant {
VestingSchedule storage s = schedules[scheduleId];
uint256 releasable = vestedAmount(scheduleId) - s.released;
require(releasable > 0, "Nothing to release");
s.released += releasable; // effect first
emit Released(scheduleId, s.beneficiary, releasable);
token.safeTransfer(s.beneficiary, releasable); // then interaction
}
function nominateBeneficiary(uint256 scheduleId, address nominee) external {
require(msg.sender == schedules[scheduleId].beneficiary, "Not beneficiary");
require(nominee != address(0), "Zero address");
pendingBeneficiary[scheduleId] = nominee;
}
function acceptBeneficiary(uint256 scheduleId) external {
require(msg.sender == pendingBeneficiary[scheduleId], "Not nominee");
schedules[scheduleId].beneficiary = msg.sender;
delete pendingBeneficiary[scheduleId];
emit BeneficiaryTransferred(scheduleId, msg.sender);
}
function requestRevoke(uint256 scheduleId) external onlyOwner {
VestingSchedule storage s = schedules[scheduleId];
require(s.revocable && !s.revoked, "Cannot revoke");
revokeRequestTime[scheduleId] = block.timestamp;
}
function executeRevoke(uint256 scheduleId) external onlyOwner nonReentrant {
VestingSchedule storage s = schedules[scheduleId];
require(s.revocable && !s.revoked, "Cannot revoke");
require(revokeRequestTime[scheduleId] > 0 &&
block.timestamp >= revokeRequestTime[scheduleId] + REVOKE_DELAY, "Timelock active");
uint256 vested = vestedAmount(scheduleId);
uint256 toUser = vested - s.released;
uint256 toOwner = s.total - vested;
s.revoked = true;
s.released = s.total; // freeze: future release() calls compute zero
emit Revoked(scheduleId);
if (toUser > 0) token.safeTransfer(s.beneficiary, toUser);
if (toOwner > 0) token.safeTransfer(owner(), toOwner);
}
}
Key decisions: uint128 cap eliminates multiplication overflow; Ownable2Step prevents accidental ownership transfer to address(0); s.released = s.total in executeRevoke() freezes accounting so future release() calls return zero even if the revoked flag is somehow bypassed.
Scan your vesting contract at ContractScan — the AI engine checks reentrancy patterns, revoke accounting, beneficiary recovery paths, and admin rug vectors in a single pass.
Related: ERC-20 Token Security Vulnerabilities in Solidity — fee-on-transfer traps, approve race conditions, and SafeERC20 patterns.
Related: Solidity Access Control Patterns: onlyOwner, Roles, and Multisig — how to structure admin privileges to avoid single points of failure.
Important Notes
This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.