Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

cliffvesting.gno

5.28 Kb · 141 lines
  1// Package cliffvesting computes cliff-then-linear vesting schedules as a PURE
  2// calculator: no state, no balances, no transfers.
  3//
  4// The shape is the standard employee/token grant: nothing vests until the
  5// cliff, the cliff releases the whole elapsed portion at once, and the rest
  6// 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 as
 11// total*elapsed/duration with the multiplication FIRST, so the usual rounding
 12// bug, dividing before multiplying and truncating the rate to zero, cannot
 13// happen. Rounding is always DOWN, which means the beneficiary never receives
 14// 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 plain
 18// int64, which silently WRAPPED once total*elapsed passed 2^63: over a
 19// two-year term in seconds that is any grant above ~146,036 whole coins, and
 20// a large one reported a NEGATIVE vested amount rather than failing. See
 21// [Schedule.Vested].
 22//
 23// Times are int64 so the caller can use block heights or unix seconds. The unit
 24// only has to be consistent.
 25//
 26// A live demo of this package is at
 27// [r/moul/x/daily/cliffvestingdemo](/r/moul/x/daily/cliffvestingdemo/v0).
 28package cliffvesting
 29
 30import (
 31	"errors"
 32	"math/bits"
 33)
 34
 35var (
 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)
 41
 42// Schedule is a cliff-then-linear vesting plan. Construct with New so the
 43// invariants are checked once.
 44type Schedule struct {
 45	Total int64 // total amount to vest
 46	Start int64 // vesting begins
 47	Cliff int64 // nothing is claimable before this
 48	End   int64 // fully vested at or after this
 49}
 50
 51// 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{}, ErrBadTotal
 57	}
 58	if end <= start {
 59		return Schedule{}, ErrBadDuration
 60	}
 61	if cliff > end {
 62		return Schedule{}, ErrCliffAfter
 63	}
 64	if cliff < start {
 65		return Schedule{}, ErrCliffBefore
 66	}
 67	return s, nil
 68}
 69
 70// NewLinear is New with no cliff.
 71func NewLinear(total, start, end int64) (Schedule, error) {
 72	return New(total, start, start, end)
 73}
 74
 75// Duration returns the length of the vesting term.
 76func (s Schedule) Duration() int64 { return s.End - s.Start }
 77
 78// HasCliff reports whether the schedule has a non-trivial cliff.
 79func (s Schedule) HasCliff() bool { return s.Cliff > s.Start }
 80
 81// Vested returns how much has vested at time t. Zero before the cliff, exactly
 82// Total at or after End, and floor(total*elapsed/duration) in between.
 83//
 84// The product goes through [mulDiv] rather than int64. v0 computed
 85// `s.Total * elapsed / s.Duration()` directly, which is correct only while the
 86// product fits: with the term in seconds, a 318,720,000-coin grant over two
 87// years returned -6,264,395,224. Nothing signalled it, because a wrapped
 88// int64 is still a valid int64.
 89func (s Schedule) Vested(t int64) int64 {
 90	if t < s.Cliff || t < s.Start {
 91		return 0
 92	}
 93	if t >= s.End {
 94		return s.Total
 95	}
 96	// Multiply BEFORE dividing: the reverse truncates the per-tick rate to
 97	// zero whenever total < duration, which is the classic vesting bug.
 98	return mulDiv(s.Total, t-s.Start, s.Duration())
 99}
100
101// Unvested returns the remainder still locked at time t.
102func (s Schedule) Unvested(t int64) int64 { return s.Total - s.Vested(t) }
103
104// Claimable returns what can be withdrawn at time t given how much has already
105// been claimed. Never negative, even if claimed somehow exceeds vested.
106func (s Schedule) Claimable(t, claimed int64) int64 {
107	v := s.Vested(t) - claimed
108	if v < 0 {
109		return 0
110	}
111	return v
112}
113
114// IsFullyVested reports whether the term has completed at time t.
115func (s Schedule) IsFullyVested(t int64) bool { return t >= s.End }
116
117// 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}
122
123// CliffAmount returns the lump sum released the instant the cliff is reached.
124func (s Schedule) CliffAmount() int64 { return s.Vested(s.Cliff) }
125
126// mulDiv computes floor(a*b/den) through a 128-bit intermediate, so a product
127// that leaves int64 does not wrap.
128//
129// Every caller here passes 0 <= b <= den with den > 0 and a >= 0, which is
130// 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 and
132// panics without. New enforces Total > 0 and End > Start; Vested only reaches
133// this line with Start < t < End.
134func mulDiv(a, b, den int64) int64 {
135	if a == 0 || b == 0 {
136		return 0
137	}
138	hi, lo := bits.Mul64(uint64(a), uint64(b))
139	q, _ := bits.Div64(hi, lo, uint64(den))
140	return int64(q)
141}