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// The multiplication goes through a 128-bit intermediate. v0 did it in plain18// int64, which silently WRAPPED once total*elapsed passed 2^63: over a19// two-year term in seconds that is any grant above ~146,036 whole coins, and20// a large one reported a NEGATIVE vested amount rather than failing. See21// [Schedule.Vested].22//23// Times are int64 so the caller can use block heights or unix seconds. The unit24// only has to be consistent.25//26// A live demo of this package is at27// [r/moul/x/daily/cliffvestingdemo](/r/moul/x/daily/cliffvestingdemo/v0).28package cliffvesting2930import (31 "errors"32 "math/bits"33)3435var (36 ErrBadTotal = errors.New("cliffvesting: total must be positive")37 ErrBadDuration = errors.New("cliffvesting: duration must be positive")38 ErrCliffAfter = errors.New("cliffvesting: cliff must not fall after the end")39 ErrCliffBefore = errors.New("cliffvesting: cliff must not fall before the start")40)4142// Schedule is a cliff-then-linear vesting plan. Construct with New so the43// invariants are checked once.44type Schedule struct {45 Total int64 // total amount to vest46 Start int64 // vesting begins47 Cliff int64 // nothing is claimable before this48 End int64 // fully vested at or after this49}5051// New validates and returns a Schedule. cliff must lie within [start, end].52// Passing cliff == start means "no cliff".53func New(total, start, cliff, end int64) (Schedule, error) {54 s := Schedule{Total: total, Start: start, Cliff: cliff, End: end}55 if total <= 0 {56 return Schedule{}, ErrBadTotal57 }58 if end <= start {59 return Schedule{}, ErrBadDuration60 }61 if cliff > end {62 return Schedule{}, ErrCliffAfter63 }64 if cliff < start {65 return Schedule{}, ErrCliffBefore66 }67 return s, nil68}6970// NewLinear is New with no cliff.71func NewLinear(total, start, end int64) (Schedule, error) {72 return New(total, start, start, end)73}7475// Duration returns the length of the vesting term.76func (s Schedule) Duration() int64 { return s.End - s.Start }7778// HasCliff reports whether the schedule has a non-trivial cliff.79func (s Schedule) HasCliff() bool { return s.Cliff > s.Start }8081// Vested returns how much has vested at time t. Zero before the cliff, exactly82// Total at or after End, and floor(total*elapsed/duration) in between.83//84// The product goes through [mulDiv] rather than int64. v0 computed85// `s.Total * elapsed / s.Duration()` directly, which is correct only while the86// product fits: with the term in seconds, a 318,720,000-coin grant over two87// years returned -6,264,395,224. Nothing signalled it, because a wrapped88// int64 is still a valid int64.89func (s Schedule) Vested(t int64) int64 {90 if t < s.Cliff || t < s.Start {91 return 092 }93 if t >= s.End {94 return s.Total95 }96 // Multiply BEFORE dividing: the reverse truncates the per-tick rate to97 // zero whenever total < duration, which is the classic vesting bug.98 return mulDiv(s.Total, t-s.Start, s.Duration())99}100101// Unvested returns the remainder still locked at time t.102func (s Schedule) Unvested(t int64) int64 { return s.Total - s.Vested(t) }103104// Claimable returns what can be withdrawn at time t given how much has already105// been claimed. Never negative, even if claimed somehow exceeds vested.106func (s Schedule) Claimable(t, claimed int64) int64 {107 v := s.Vested(t) - claimed108 if v < 0 {109 return 0110 }111 return v112}113114// IsFullyVested reports whether the term has completed at time t.115func (s Schedule) IsFullyVested(t int64) bool { return t >= s.End }116117// PercentVested returns the vested share at t as an integer percentage,118// rounded down. Integer-only: no floats reach consensus state.119func (s Schedule) PercentVested(t int64) int64 {120 return mulDiv(s.Vested(t), 100, s.Total)121}122123// CliffAmount returns the lump sum released the instant the cliff is reached.124func (s Schedule) CliffAmount() int64 { return s.Vested(s.Cliff) }125126// mulDiv computes floor(a*b/den) through a 128-bit intermediate, so a product127// that leaves int64 does not wrap.128//129// Every caller here passes 0 <= b <= den with den > 0 and a >= 0, which is130// what makes the bits.Div64 precondition hold: the quotient is then at most a,131// so it fits in 64 bits, which is exactly the hi < den that Div64 requires and132// panics without. New enforces Total > 0 and End > Start; Vested only reaches133// this line with Start < t < End.134func mulDiv(a, b, den int64) int64 {135 if a == 0 || b == 0 {136 return 0137 }138 hi, lo := bits.Mul64(uint64(a), uint64(b))139 q, _ := bits.Div64(hi, lo, uint64(den))140 return int64(q)141}142Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.