
Building a Time-Locked Token Vesting Contract in Solidity
Why token vesting matters
Vesting is useful whenever tokens should not be fully transferable at the moment of allocation. Typical use cases include:
- Founders and team allocations
- Advisor compensation
- Investor lockups
- Ecosystem grants
- Community reward programs
A vesting contract is preferable to manual distribution because it is deterministic, auditable, and resistant to operational mistakes. Once deployed, the release logic is enforced by code rather than by trust in a multisig operator or off-chain process.
Vesting model used in this tutorial
We will implement a single-beneficiary linear vesting contract with:
- a start time
- a cliff period
- a total vesting duration
- a release function that transfers vested tokens on demand
- protection against double claims
This design is intentionally focused. It is ideal for learning the mechanics of vesting and for many real-world one-off allocations.
Core behavior
| Parameter | Meaning |
|---|---|
start | Timestamp when vesting begins |
cliffDuration | Time before any tokens can be claimed |
duration | Total time until all tokens are vested |
beneficiary | Address that receives released tokens |
token | ERC-20 token being vested |
The contract computes how many tokens are vested at the current block timestamp and subtracts what has already been released.
Contract design overview
A vesting contract needs to answer three questions:
- How many tokens should be vested right now?
- How many tokens have already been claimed?
- How can the beneficiary safely withdraw the releasable amount?
To keep the implementation robust, we will use:
immutablevariables for configuration that never changesSafeERC20for token transfers- a simple accounting variable to track released tokens
- custom errors for gas-efficient reverts
Full Solidity example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract TokenVesting {
using SafeERC20 for IERC20;
error NotBeneficiary();
error InvalidSchedule();
error NothingToRelease();
IERC20 public immutable token;
address public immutable beneficiary;
uint64 public immutable start;
uint64 public immutable cliff;
uint64 public immutable duration;
uint256 public released;
event TokensReleased(uint256 amount);
constructor(
address tokenAddress,
address beneficiaryAddress,
uint64 startTimestamp,
uint64 cliffDuration,
uint64 vestingDuration
) {
if (tokenAddress == address(0) || beneficiaryAddress == address(0)) {
revert InvalidSchedule();
}
if (vestingDuration == 0 || cliffDuration > vestingDuration) {
revert InvalidSchedule();
}
token = IERC20(tokenAddress);
beneficiary = beneficiaryAddress;
start = startTimestamp;
cliff = startTimestamp + cliffDuration;
duration = vestingDuration;
}
modifier onlyBeneficiary() {
if (msg.sender != beneficiary) revert NotBeneficiary();
_;
}
function vestedAmount(uint256 timestamp) public view returns (uint256) {
uint256 totalAllocation = token.balanceOf(address(this)) + released;
if (timestamp < cliff) {
return 0;
}
if (timestamp >= start + duration) {
return totalAllocation;
}
uint256 elapsed = timestamp - start;
return (totalAllocation * elapsed) / duration;
}
function releasableAmount() public view returns (uint256) {
uint256 vested = vestedAmount(block.timestamp);
return vested - released;
}
function release() external onlyBeneficiary {
uint256 amount = releasableAmount();
if (amount == 0) revert NothingToRelease();
released += amount;
token.safeTransfer(beneficiary, amount);
emit TokensReleased(amount);
}
}How the contract works
1. Immutable configuration
The constructor stores the vesting parameters as immutable values. This is efficient because the values are embedded into bytecode rather than stored in contract storage.
The contract validates:
- token and beneficiary addresses are non-zero
- vesting duration is not zero
- cliff does not exceed total duration
These checks prevent broken schedules from being deployed.
2. Vesting calculation
The key function is vestedAmount(timestamp).
It uses a simple linear formula:
- before the cliff:
0 - after the full duration:
totalAllocation - in between: proportional vesting based on elapsed time
The totalAllocation is computed as:
token.balanceOf(address(this)) + releasedThis is a practical pattern because the contract may already have paid out some tokens. Adding released reconstructs the original total allocation.
3. Claiming vested tokens
The beneficiary calls release() to withdraw the currently vested amount. The function:
- checks that the caller is the beneficiary
- computes the releasable amount
- reverts if there is nothing to claim
- increments
released - transfers tokens using
SafeERC20
Updating state before the transfer is a standard reentrancy-safe pattern. Even though ERC-20 transfers are usually straightforward, this ordering is still a good habit.
Deploying the vesting contract
Before deployment, transfer the full token allocation into the vesting contract address. The contract cannot release tokens it does not hold.
A typical deployment flow looks like this:
- Deploy the ERC-20 token.
- Deploy the vesting contract with schedule parameters.
- Transfer the vesting allocation to the vesting contract.
- Wait for the cliff and vesting periods.
- Let the beneficiary call
release()whenever tokens are available.
Example schedule
Suppose a team member receives 120,000 tokens with:
- start: now
- cliff: 90 days
- duration: 365 days
Then:
- day 0 to day 89: no tokens can be claimed
- day 90: vesting begins
- day 180: roughly half of the allocation may be vested, depending on the exact schedule
- day 365: all tokens are vested
Practical improvements for production use
The basic contract is useful, but real deployments often need a few enhancements.
Add a revocation mechanism
Some vesting systems allow the owner to revoke unvested tokens if a contributor leaves early. This is common in employment-related vesting. If you add revocation, be careful to define:
- who can revoke
- whether vested tokens remain claimable
- where unvested tokens go
- whether revocation is permanent
A revocation feature increases complexity and should be tested thoroughly.
Support multiple beneficiaries
If you need to manage many vesting schedules, consider a factory contract or a registry-based design. Each beneficiary can have a separate vesting instance, or a single contract can store multiple schedules in a mapping.
A single-contract, multi-beneficiary design is more gas-efficient at scale, but it is also harder to audit. For many teams, one vesting contract per allocation is simpler and safer.
Use a standard library
This tutorial uses OpenZeppelin’s SafeERC20, which is a strong default for token transfers. It handles tokens that return false instead of reverting and reduces integration risk.
Common pitfalls
Incorrect time assumptions
Solidity uses Unix timestamps in seconds. Do not mix block numbers with timestamps unless your vesting logic is explicitly designed for that.
Forgetting to fund the contract
A vesting schedule is only meaningful if the contract actually holds the tokens. Deployment scripts should transfer the allocation immediately after deployment.
Using block.timestamp carelessly
block.timestamp is suitable for vesting, but it is not perfectly precise. Miners or validators can influence it slightly. For vesting schedules measured in days or months, this is acceptable. Do not use it for high-stakes sub-second precision.
Allowing arbitrary withdrawals
Never expose a generic withdraw() function unless it is tightly restricted. Vesting contracts should only release tokens according to the schedule.
Failing to account for rounding
Linear vesting uses integer division, so small rounding differences can occur. The final release should ensure the beneficiary receives the full allocation by the end of the duration.
Testing strategy
A vesting contract should be tested across the entire timeline. Focus on these cases:
| Test case | Expected result |
|---|---|
| Before cliff | releasableAmount() returns 0 |
| Exactly at cliff | Vesting begins |
| Midway through duration | Partial release available |
| After full duration | Entire remaining balance releasable |
| Multiple releases | Released amount is tracked correctly |
| Non-beneficiary caller | Transaction reverts |
Recommended test scenarios
- Initial state
- Deploy the contract
- Fund it with tokens
- Confirm no tokens are releasable before the cliff
- Partial vesting
- Advance time to a point halfway through the schedule
- Confirm the releasable amount matches the expected percentage
- Repeated claims
- Call
release() - Advance time again
- Call
release()a second time - Verify the beneficiary receives only newly vested tokens
- Final vesting
- Advance time beyond the duration
- Confirm the beneficiary can withdraw the full remaining balance
Extending the design
If your application needs more flexibility, you can extend the contract in several directions.
Milestone-based vesting
Instead of linear release, tokens can unlock when milestones are reached. This is useful for grants tied to product delivery or governance objectives. The tradeoff is that milestone verification often requires off-chain judgment or oracle input.
Multiple cliffs
Some schedules use an initial cliff followed by staged unlocks. For example, 25% may unlock after six months, then the remainder may vest monthly. This can be implemented by replacing the linear formula with a piecewise function.
Emergency recovery
You may want a controlled recovery path for accidentally sent non-vesting tokens. If you add such a function, restrict it carefully so it cannot drain vested funds.
Security best practices
A vesting contract manages valuable assets, so security should be treated as a first-class concern.
- Prefer audited libraries such as OpenZeppelin
- Keep the release logic simple and deterministic
- Validate constructor parameters thoroughly
- Update accounting before external token transfers
- Avoid unnecessary administrative powers
- Write tests for edge timestamps and repeated claims
- Document the schedule clearly for beneficiaries
If you introduce ownership or revocation, make sure the privilege model is explicit and minimal.
Summary
A time-locked vesting contract is one of the most practical building blocks in Solidity. It provides transparent, enforceable token distribution and removes the need for manual release operations. The example in this tutorial demonstrates a clean linear vesting model with a cliff, safe token transfers, and claim tracking.
For many projects, this pattern is enough to manage contributor allocations and token lockups with confidence. From here, you can extend the design to support multiple beneficiaries, revocation, or milestone-based unlocks while keeping the core accounting model intact.
