cliffvesting.gno
4.00 Kb · 110 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// Times are int64 so the caller can use block heights or unix seconds. The unit
18// only has to be consistent.
19//
20// A live demo of this package is at
21// [r/moul/x/daily/cliffvestingdemo](/r/moul/x/daily/cliffvestingdemo/v0).
22package cliffvesting
23
24import "errors"
25
26var (
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)
32
33// Schedule is a cliff-then-linear vesting plan. Construct with New so the
34// invariants are checked once.
35type Schedule struct {
36 Total int64 // total amount to vest
37 Start int64 // vesting begins
38 Cliff int64 // nothing is claimable before this
39 End int64 // fully vested at or after this
40}
41
42// 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{}, ErrBadTotal
48 }
49 if end <= start {
50 return Schedule{}, ErrBadDuration
51 }
52 if cliff > end {
53 return Schedule{}, ErrCliffAfter
54 }
55 if cliff < start {
56 return Schedule{}, ErrCliffBefore
57 }
58 return s, nil
59}
60
61// NewLinear is New with no cliff.
62func NewLinear(total, start, end int64) (Schedule, error) {
63 return New(total, start, start, end)
64}
65
66// Duration returns the length of the vesting term.
67func (s Schedule) Duration() int64 { return s.End - s.Start }
68
69// HasCliff reports whether the schedule has a non-trivial cliff.
70func (s Schedule) HasCliff() bool { return s.Cliff > s.Start }
71
72// Vested returns how much has vested at time t. Zero before the cliff, exactly
73// 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 0
77 }
78 if t >= s.End {
79 return s.Total
80 }
81 elapsed := t - s.Start
82 // Multiply BEFORE dividing: the reverse truncates the per-tick rate to
83 // zero whenever total < duration, which is the classic vesting bug.
84 return s.Total * elapsed / s.Duration()
85}
86
87// Unvested returns the remainder still locked at time t.
88func (s Schedule) Unvested(t int64) int64 { return s.Total - s.Vested(t) }
89
90// Claimable returns what can be withdrawn at time t given how much has already
91// been claimed. Never negative, even if claimed somehow exceeds vested.
92func (s Schedule) Claimable(t, claimed int64) int64 {
93 v := s.Vested(t) - claimed
94 if v < 0 {
95 return 0
96 }
97 return v
98}
99
100// IsFullyVested reports whether the term has completed at time t.
101func (s Schedule) IsFullyVested(t int64) bool { return t >= s.End }
102
103// 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.Total
107}
108
109// CliffAmount returns the lump sum released the instant the cliff is reached.
110func (s Schedule) CliffAmount() int64 { return s.Vested(s.Cliff) }