1// Package cliffvesting computes cliff-then-linear vesting schedules as a PURE2// calculator — no state, no balances, no transfers.3//4// The shape is the standard employee/token grant: nothing vests until the5// cliff, the cliff releases the whole elapsed portion at once, and the rest6// accrues linearly until the end of the term. Keeping it pure is deliberate:7// the arithmetic is the part that is easy to get subtly wrong and easy to test,8// while custody belongs to the realm holding the coins.9//10// Everything is integer arithmetic. Vested is computed as11// total*elapsed/duration with the multiplication FIRST, so the usual rounding12// bug — dividing before multiplying and truncating the rate to zero — cannot13// happen. Rounding is always DOWN, which means the beneficiary never receives14// more than they have earned and the final instalment collects the remainder;15// at t >= end the result is exactly total, never total-1.16//17// Times are int64 so the caller can use block heights or unix seconds. The unit18// only has to be consistent.19//20// A live demo of this package is at21// [r/moul/x/daily/cliffvestingdemo](/r/moul/x/daily/cliffvestingdemo/v0).22package cliffvesting2324import "errors"2526var (27 ErrBadTotal = errors.New("cliffvesting: total must be positive")28 ErrBadDuration = errors.New("cliffvesting: duration must be positive")29 ErrCliffAfter = errors.New("cliffvesting: cliff must not fall after the end")30 ErrCliffBefore = errors.New("cliffvesting: cliff must not fall before the start")31)3233// Schedule is a cliff-then-linear vesting plan. Construct with New so the34// invariants are checked once.35type Schedule struct {36 Total int64 // total amount to vest37 Start int64 // vesting begins38 Cliff int64 // nothing is claimable before this39 End int64 // fully vested at or after this40}4142// New validates and returns a Schedule. cliff must lie within [start, end].43// Passing cliff == start means "no cliff".44func New(total, start, cliff, end int64) (Schedule, error) {45 s := Schedule{Total: total, Start: start, Cliff: cliff, End: end}46 if total <= 0 {47 return Schedule{}, ErrBadTotal48 }49 if end <= start {50 return Schedule{}, ErrBadDuration51 }52 if cliff > end {53 return Schedule{}, ErrCliffAfter54 }55 if cliff < start {56 return Schedule{}, ErrCliffBefore57 }58 return s, nil59}6061// NewLinear is New with no cliff.62func NewLinear(total, start, end int64) (Schedule, error) {63 return New(total, start, start, end)64}6566// Duration returns the length of the vesting term.67func (s Schedule) Duration() int64 { return s.End - s.Start }6869// HasCliff reports whether the schedule has a non-trivial cliff.70func (s Schedule) HasCliff() bool { return s.Cliff > s.Start }7172// Vested returns how much has vested at time t. Zero before the cliff, exactly73// Total at or after End, and floor(total*elapsed/duration) in between.74func (s Schedule) Vested(t int64) int64 {75 if t < s.Cliff || t < s.Start {76 return 077 }78 if t >= s.End {79 return s.Total80 }81 elapsed := t - s.Start82 // Multiply BEFORE dividing: the reverse truncates the per-tick rate to83 // zero whenever total < duration, which is the classic vesting bug.84 return s.Total * elapsed / s.Duration()85}8687// Unvested returns the remainder still locked at time t.88func (s Schedule) Unvested(t int64) int64 { return s.Total - s.Vested(t) }8990// Claimable returns what can be withdrawn at time t given how much has already91// been claimed. Never negative, even if claimed somehow exceeds vested.92func (s Schedule) Claimable(t, claimed int64) int64 {93 v := s.Vested(t) - claimed94 if v < 0 {95 return 096 }97 return v98}99100// IsFullyVested reports whether the term has completed at time t.101func (s Schedule) IsFullyVested(t int64) bool { return t >= s.End }102103// PercentVested returns the vested share at t as an integer percentage,104// rounded down. Integer-only: no floats reach consensus state.105func (s Schedule) PercentVested(t int64) int64 {106 return s.Vested(t) * 100 / s.Total107}108109// CliffAmount returns the lump sum released the instant the cliff is reached.110func (s Schedule) CliffAmount() int64 { return s.Vested(s.Cliff) }111Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.