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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}