Insights
Security checklist for ERC-20 vesting contracts
Vesting math, revocation, admin keys, token handling, Foundry invariant tests and deployment checks for secure ERC-20 vesting contracts.
sigmacode.io engineering team9 min read
On this page (14)
- 1. Vesting math
- 2. Beneficiaries and revocation
- 3. Access control and admin keys
- 4. Token transfer handling
- 5. Reentrancy and call ordering
- 6. Timestamps
- 7. Events and transparency
- 8. Upgradeability trade-offs
- 9. Emergency controls
- 10. Testing
- 11. Static analysis
- 12. Deployment and verification
- 13. Operational checks
- Final note
Vesting contracts look simple: lock tokens, release them over time, done. In practice they hold a large share of a project's supply for years, they are touched by founders, investors, employees and multisigs, and they are rarely revisited once deployed. A small mistake in the math or in the permission model stays live for the entire vesting period. This checklist collects the questions we ask when we design or review an ERC-20 vesting contract, from the arithmetic up to deployment and day-to-day operations.
1. Vesting math#
The core of every vesting contract is one function that answers "how many tokens are vested at time t?". Almost every serious vesting bug lives here.
Linear schedule and cliff#
- Define the schedule with explicit parameters:
start,cliff,duration,totalAllocation. Avoid implicit values derived fromblock.timestampat deployment. - Decide what the cliff means. Common models are "nothing before the cliff, then catch up linearly from
start" and "nothing before the cliff, then a lump sum, then linear". Write the chosen model down in NatSpec and in tests. - Validate at creation time:
durationgreater than zero,cliffnot afterstart + duration,totalAllocationgreater than zero, beneficiary not the zero address. - After
start + duration, the vested amount must equal exactlytotalAllocation, not "approximately".
Rounding#
- Multiply before you divide.
total * elapsed / durationis correct;total / duration * elapsedsilently loses tokens for every release. - Rounding should always favour the contract: round down what the beneficiary can claim, never up. The final release at the end of the schedule sweeps any remaining dust.
- Check for overflow on large allocations with 18-decimal tokens. Solidity 0.8.x reverts on overflow, but a revert inside
vestedAmountcan lock every claim. UseMath.mulDivfrom OpenZeppelin if the product can get large.
Start in the past or the future#
- A
startin the past is legitimate (backdated employee grants) but means a large amount is claimable immediately. Make that an explicit, reviewed decision, not an accident of a wrong parameter. - A
startfar in the future can be a typo (milliseconds instead of seconds is a classic). Add sanity bounds in the constructor or factory and in the deployment script.
A compact reference implementation of the schedule:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
function vestedAmount(
uint256 total,
uint64 start,
uint64 cliff,
uint64 duration,
uint64 timestamp
) pure returns (uint256) {
if (timestamp < cliff) return 0;
if (timestamp >= start + duration) return total;
// Multiply before divide; mulDiv avoids intermediate overflow.
return Math.mulDiv(total, timestamp - start, duration);
}
2. Beneficiaries and revocation#
- Who can claim? Only the beneficiary, or anyone on the beneficiary's behalf? Allowing anyone to trigger
release()is fine as long as tokens always go to the beneficiary; it helps with lost-key recovery and automation. - Can the beneficiary change? If yes, require the current beneficiary to initiate it (ideally a two-step transfer with acceptance), and emit an event. A beneficiary that is a contract must be able to receive and use tokens.
- Revocation model. Decide per schedule whether it is revocable. On revocation, the already vested but unreleased amount should remain claimable by the beneficiary; only the unvested remainder returns to the treasury. Revoking vested tokens is a trust problem, not just a code problem.
- Revocation must be final. A revoked schedule must not be revocable twice, must not continue vesting, and must not let the admin withdraw more than the unvested remainder.
- Multiple schedules per beneficiary. Key schedules by an id, not by address alone, so a second grant does not overwrite the first.
3. Access control and admin keys#
- List every privileged function: creating schedules, revoking, pausing, withdrawing surplus, upgrading. Each one is an attack surface if the key is compromised.
- Use
Ownable2SteporAccessControlwith separate roles instead of a single all-powerful owner. The role that creates schedules does not need to be the role that can withdraw funds. - Hold admin roles in a multisig, and consider a timelock for anything that moves tokens out of the contract.
- The admin should never be able to withdraw tokens that are committed to schedules. Track
totalCommittedand only allow withdrawal ofbalance - totalCommittedfor surplus. - Plan for the end state: can admin rights be renounced once all schedules are created? Fewer live keys means fewer failure modes.
4. Token transfer handling#
- Use OpenZeppelin
SafeERC20for every transfer. Some tokens do not return a boolean, others returnfalseinstead of reverting. - Fee-on-transfer tokens. If the contract is funded with a token that takes a fee, it receives less than the nominal amount. Measure the balance before and after funding and record what actually arrived, or explicitly reject such tokens.
- Rebasing tokens. Balances that change on their own break the assumption that
balance == committed + surplus. Either document that rebasing tokens are unsupported or design the accounting in shares. - Pin the token address as
immutableif the contract serves a single token. Accepting arbitrary token addresses per schedule widens the attack surface considerably. - Never allow the vested token to be "rescued" via a generic
recoverERC20function without subtracting committed amounts.
5. Reentrancy and call ordering#
- Follow checks-effects-interactions: update
releasedbefore callingsafeTransfer. - Add
nonReentranttorelease,revokeand any withdrawal function. ERC-777-style hooks or a malicious token can call back into the contract. - Keep external calls to the minimum. A vesting contract has no reason to call arbitrary addresses.
function release(uint256 scheduleId) external nonReentrant {
Schedule storage s = schedules[scheduleId];
uint256 amount = _releasable(s);
require(amount > 0, "Vesting: nothing to release");
s.released += amount; // effects first
totalCommitted -= amount;
token.safeTransfer(s.beneficiary, amount); // interaction last
emit TokensReleased(scheduleId, s.beneficiary, amount);
}
6. Timestamps#
- Use
block.timestamp, not block numbers. Block times differ between chains and change over time; the same contract may be deployed on an L2 later. - Validator influence on timestamps is limited to seconds. That is irrelevant for schedules measured in months, but do not build logic that depends on second-level precision.
- Store timestamps as
uint64. It is enough for any realistic schedule and packs well in storage. - Test the boundaries explicitly: one second before the cliff, exactly at the cliff, exactly at the end, and long after the end.
7. Events and transparency#
Every state change should emit an event: ScheduleCreated, TokensReleased, ScheduleRevoked, BeneficiaryChanged, role changes and pauses. Events are what indexers, dashboards and your own support team rely on. Include the schedule id and the amounts, not just addresses. Investors and employees will ask how much is vested, and on-chain events are the most credible answer.
8. Upgradeability trade-offs#
| Option | Advantage | Risk |
|---|---|---|
| Immutable contract | Strongest guarantee for beneficiaries, simpler audit | Bugs cannot be fixed; migration needs a new contract and funds |
| Upgradeable proxy | Bugs can be patched | Upgrade key can change any rule, storage layout errors, larger audit scope |
| Immutable plus factory | Each schedule set is isolated, new versions for new grants | Old instances keep old bugs |
For vesting, immutability is often the better default: the entire point of the contract is that nobody can change the deal later. If you choose a proxy, put the upgrade role behind a multisig and a timelock, use storage gaps or namespaced storage, and run the OpenZeppelin upgrade safety checks in CI.
9. Emergency controls#
- A pause can protect against an unknown bug, but a pause that blocks
releaseforever is also a way to freeze beneficiaries. Consider limiting how long a pause can last, or allowing releases even while new schedule creation is paused. - Document who can pause, under which conditions, and how the community will be informed.
- Avoid "emergency withdraw everything" functions. If one is unavoidable, it must be behind a timelock and must be visible in the documentation investors read.
10. Testing#
Unit tests are the minimum. For vesting contracts, property-based tests add a lot of value, because the math must hold for any time and any amount.
- Unit tests: each revert path, each boundary timestamp, revocation before the cliff, revocation after full vesting, multiple schedules for the same beneficiary.
- Fuzz tests: random
total,durationandtimestamp; assert thatvestedAmountis monotonic and never exceedstotal. - Invariant tests: let Foundry call
release,revoke,createScheduleandvm.warpin random order, then check global properties.
Useful invariants:
- The sum of released amounts per schedule never exceeds its allocation.
- The contract's token balance is always at least
totalCommitted. - A revoked schedule never gains vested amount afterwards.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
contract VestingInvariants is Test {
VestingHandler handler;
function setUp() public {
handler = new VestingHandler(); // deploys token + vesting, exposes bounded actions
targetContract(address(handler));
}
function invariant_releasedNeverExceedsAllocation() public view {
uint256 n = handler.vesting().scheduleCount();
for (uint256 i; i < n; i++) {
(, uint256 total, uint256 released) = handler.vesting().scheduleInfo(i);
assertLe(released, total);
}
}
function invariant_balanceCoversCommitments() public view {
assertGe(
handler.token().balanceOf(address(handler.vesting())),
handler.vesting().totalCommitted()
);
}
}
11. Static analysis#
Run Slither on every change and treat its output as a review queue, not as a pass/fail signal. For vesting contracts, pay attention to reentrancy findings, unchecked transfers, dangerous strict equalities on balances and missing events.
slither . --filter-paths "lib|test" --exclude-dependencies
forge test --fuzz-runs 10000
forge coverage --report summary
An AI-assisted pre-review, such as our Smart Contract AI Reviewer, is another fast first pass that highlights suspicious patterns before a human looks at the code.
12. Deployment and verification#
- Script the deployment with Foundry scripts, not manual transactions. Parameters live in version-controlled config files and are reviewed like code.
- Deploy to a testnet first with the exact same script and parameters, then run a dry run on a mainnet fork.
- Verify the source code on the block explorer immediately after deployment, with the same compiler version and optimizer settings.
- Double-check decimals: an allocation of 1,000,000 tokens with 18 decimals is
1_000_000e18, not1_000_000. - Transfer ownership to the multisig in the same script, and confirm the deployer key holds no remaining roles.
13. Operational checks#
- Reconcile regularly: the sum of schedule allocations minus releases should match the committed amount and the contract balance.
- Monitor events and alert on unexpected revocations, role changes or pauses.
- Keep a public or investor-facing overview of schedules, so questions can be answered from on-chain data.
- Rehearse the key procedures: multisig signer rotation, what happens if a beneficiary loses access to their wallet, and how a pause would be communicated.
Final note#
A checklist and an automated review catch many issues early, but they are not a substitute for an independent security audit. Before a vesting contract holds real value, have it reviewed by people who did not write it.
If you want to see how we approach this in practice, take a look at our token suite showcase, which includes a vesting contract with tests, or read more about our blockchain services. Our team is led by a tech lead with 20+ years of experience, and we are happy to review your tokenomics or vesting design — just get in touch.