Vesting Schedule
Problem
I need to grant tokens to team members or investors that unlock linearly over time, with an optional cliff before any tokens become claimable.
Solution
contract VestingVault {
struct VestingInfo {
total: amount
released: amount
start: timestamp
cliff: u64
duration: u64
}
field owner: address = deployer
field token: address
field schedules: mapping<address, VestingInfo>
event ScheduleCreated(
beneficiary: address indexed,
total: amount,
start: timestamp,
cliff: u64,
duration: u64
)
event Released(beneficiary: address indexed, amount: amount)
event Revoked(beneficiary: address indexed, unvested: amount)
error NoSchedule()
error NothingToRelease()
error InvalidDuration()
error AlreadyExists()
action grant(
beneficiary: address,
total: amount,
start: timestamp,
cliff: u64,
duration: u64
) only owner {
given duration > 0 or revert_with InvalidDuration()
given schedules[beneficiary].total == 0 or revert_with AlreadyExists()
schedules[beneficiary] = VestingInfo {
total: total,
released: 0,
start: start,
cliff: cliff,
duration: duration
}
emit ScheduleCreated(beneficiary, total, start, cliff, duration)
}
action vested_amount(who: address) -> amount {
let s = schedules[who]
given s.total > 0 or revert_with NoSchedule()
if now < s.start + s.cliff {
return 0
}
if now >= s.start + s.duration {
return s.total
}
return s.total * (now - s.start) / s.duration
}
action release() {
let s = schedules[caller]
given s.total > 0 or revert_with NoSchedule()
let vested = vested_amount(caller)
let claimable = vested - s.released
given claimable > 0 or revert_with NothingToRelease()
schedules[caller].released = s.released + claimable
transfer(token, caller, claimable)
emit Released(caller, claimable)
}
}
Explanation
VestingInfostores everything per-beneficiary in a single struct slot-packed by the compiler- Linear unlock is
total * (now - start) / durationafter the cliff passes - Before
start + cliffthe function short-circuits to0 - After
start + durationit returns the full total (caps the formula) releasedtracks what has already been claimed so the beneficiary canrelease()incrementally
Under the hood, the compiler generates a single SSTORE per grant thanks to struct-packing and a single SSTORE on release (updating only released).
Gas Estimate
| Operation | Gas |
|---|---|
grant | ~90,000 |
release (partial) | ~55,000 |
release (final) | ~60,000 |
vested_amount (view) | ~4,500 |
Common Pitfalls
- Cliff confusion:
cliffis a duration (seconds relative tostart), not an absolute timestamp. Document this clearly in the UI. - Re-granting: The
AlreadyExistsguard prevents accidentally overwriting a schedule and zeroingreleased. Use a separatetop_upaction if you need additive grants. - Revocation not implemented: If the beneficiary leaves early, you need an explicit revoke path — see Variations.
- Zero
duration: Would cause a division-by-zero atvested_amount. TheInvalidDurationguard blocks it atgranttime. - Token pulled from contract balance: The vault must be funded separately before grants can be released. Consider a
fundaction that also emits an event for accounting.
Variations
Revocable vesting
action revoke(beneficiary: address) only owner {
let s = schedules[beneficiary]
let vested = vested_amount(beneficiary)
let unvested = s.total - vested
schedules[beneficiary].total = vested // freeze at vested
transfer(token, owner, unvested)
emit Revoked(beneficiary, unvested)
}
Multi-tranche grant
Replace the single VestingInfo with mapping<address, VestingInfo[]> and iterate in vested_amount.