cliffvesting_test.gno
7.48 Kb · 216 lines
1package cliffvesting
2
3import (
4 "testing"
5
6 "gno.land/p/nt/uassert/v0"
7)
8
9func mustNew(t *testing.T, total, start, cliff, end int64) Schedule {
10 s, err := New(total, start, cliff, end)
11 uassert.NoError(t, err)
12 return s
13}
14
15func TestNewValidation(t *testing.T) {
16 _, err := New(0, 0, 0, 100)
17 uassert.ErrorIs(t, err, ErrBadTotal)
18 _, err = New(-5, 0, 0, 100)
19 uassert.ErrorIs(t, err, ErrBadTotal)
20
21 _, err = New(100, 50, 50, 50)
22 uassert.ErrorIs(t, err, ErrBadDuration, "end must be after start")
23 _, err = New(100, 50, 50, 10)
24 uassert.ErrorIs(t, err, ErrBadDuration)
25
26 _, err = New(100, 0, 200, 100)
27 uassert.ErrorIs(t, err, ErrCliffAfter)
28
29 _, err = New(100, 50, 10, 100)
30 uassert.ErrorIs(t, err, ErrCliffBefore)
31
32 _, err = New(100, 0, 100, 100)
33 uassert.NoError(t, err, "a cliff exactly at the end is allowed")
34}
35
36func TestNewLinearHasNoCliff(t *testing.T) {
37 s, err := NewLinear(1000, 0, 100)
38 uassert.NoError(t, err)
39 uassert.False(t, s.HasCliff())
40 uassert.Equal(t, int64(0), s.CliffAmount())
41 uassert.Equal(t, int64(500), s.Vested(50), "linear from the start")
42}
43
44// TestNothingBeforeCliff is the defining behaviour.
45func TestNothingBeforeCliff(t *testing.T) {
46 s := mustNew(t, 1200, 0, 300, 1200)
47
48 uassert.Equal(t, int64(0), s.Vested(0))
49 uassert.Equal(t, int64(0), s.Vested(299), "one tick before the cliff, still nothing")
50 uassert.Equal(t, int64(1200), s.Unvested(299))
51}
52
53// TestCliffReleasesElapsedPortionAtOnce pins the step, not a ramp.
54func TestCliffReleasesElapsedPortionAtOnce(t *testing.T) {
55 s := mustNew(t, 1200, 0, 300, 1200)
56
57 uassert.Equal(t, int64(300), s.Vested(300), "25% of the term elapsed, released in one step")
58 uassert.Equal(t, int64(300), s.CliffAmount())
59 uassert.Equal(t, int64(25), s.PercentVested(300))
60}
61
62func TestLinearAfterCliff(t *testing.T) {
63 s := mustNew(t, 1200, 0, 300, 1200)
64
65 uassert.Equal(t, int64(600), s.Vested(600))
66 uassert.Equal(t, int64(900), s.Vested(900))
67 uassert.Equal(t, int64(1199), s.Vested(1199))
68}
69
70// TestExactlyTotalAtEnd is what rounding down must never break.
71func TestExactlyTotalAtEnd(t *testing.T) {
72 s := mustNew(t, 1000, 0, 0, 3) // 1000/3 does not divide evenly
73
74 uassert.Equal(t, int64(333), s.Vested(1))
75 uassert.Equal(t, int64(666), s.Vested(2))
76 uassert.Equal(t, int64(1000), s.Vested(3), "the last instalment collects the remainder")
77 uassert.Equal(t, int64(0), s.Unvested(3))
78 uassert.Equal(t, int64(1000), s.Vested(99999), "still exactly total long after the end")
79}
80
81// TestSmallTotalLongDuration is the rounding bug this package exists to avoid:
82// with total < duration, dividing before multiplying truncates the rate to 0
83// and nothing ever vests.
84func TestSmallTotalLongDuration(t *testing.T) {
85 s := mustNew(t, 7, 0, 0, 1000)
86
87 uassert.Equal(t, int64(0), s.Vested(100), "7*100/1000 = 0.7, floors to 0")
88 uassert.Equal(t, int64(1), s.Vested(150), "7*150/1000 = 1.05, floors to 1")
89 uassert.Equal(t, int64(3), s.Vested(500), "half the term, half of 7, floored")
90 uassert.Equal(t, int64(7), s.Vested(1000), "and still exactly total at the end")
91}
92
93func TestVestedIsMonotonic(t *testing.T) {
94 s := mustNew(t, 997, 10, 40, 310)
95
96 prev := int64(-1)
97 for tick := int64(0); tick <= 320; tick++ {
98 v := s.Vested(tick)
99 uassert.True(t, v >= prev, "vested must never decrease")
100 uassert.True(t, v <= s.Total, "vested must never exceed total")
101 prev = v
102 }
103}
104
105func TestUnvestedComplementsVested(t *testing.T) {
106 s := mustNew(t, 500, 0, 100, 400)
107 for _, tick := range []int64{0, 99, 100, 250, 399, 400, 500} {
108 uassert.Equal(t, s.Total, s.Vested(tick)+s.Unvested(tick),
109 "vested + unvested must always equal total")
110 }
111}
112
113func TestClaimable(t *testing.T) {
114 s := mustNew(t, 1000, 0, 0, 100)
115
116 uassert.Equal(t, int64(500), s.Claimable(50, 0))
117 uassert.Equal(t, int64(300), s.Claimable(50, 200), "already-claimed is deducted")
118 uassert.Equal(t, int64(0), s.Claimable(50, 500), "nothing left right now")
119 uassert.Equal(t, int64(500), s.Claimable(100, 500), "the rest at the end")
120}
121
122// TestClaimableNeverNegative guards the case where bookkeeping says more was
123// claimed than has vested — the caller gets 0, not a negative payout.
124func TestClaimableNeverNegative(t *testing.T) {
125 s := mustNew(t, 1000, 0, 0, 100)
126 uassert.Equal(t, int64(0), s.Claimable(10, 999))
127}
128
129func TestPercentVested(t *testing.T) {
130 s := mustNew(t, 1000, 0, 0, 100)
131 uassert.Equal(t, int64(0), s.PercentVested(0))
132 uassert.Equal(t, int64(50), s.PercentVested(50))
133 uassert.Equal(t, int64(99), s.PercentVested(99))
134 uassert.Equal(t, int64(100), s.PercentVested(100))
135 uassert.Equal(t, int64(100), s.PercentVested(1000))
136}
137
138// TestNonZeroStart checks the schedule is relative to Start, not to zero.
139func TestNonZeroStart(t *testing.T) {
140 s := mustNew(t, 400, 1000, 1100, 1400)
141
142 uassert.Equal(t, int64(0), s.Vested(999), "before the start")
143 uassert.Equal(t, int64(0), s.Vested(1099), "before the cliff")
144 uassert.Equal(t, int64(100), s.Vested(1100), "cliff: a quarter elapsed")
145 uassert.Equal(t, int64(200), s.Vested(1200))
146 uassert.Equal(t, int64(400), s.Vested(1400))
147}
148
149func TestDurationAndFlags(t *testing.T) {
150 s := mustNew(t, 100, 10, 20, 110)
151 uassert.Equal(t, int64(100), s.Duration())
152 uassert.True(t, s.HasCliff())
153 uassert.False(t, s.IsFullyVested(109))
154 uassert.True(t, s.IsFullyVested(110))
155 uassert.True(t, s.IsFullyVested(999))
156}
157
158// TestCliffAtEndIsAllOrNothing covers the degenerate schedule.
159func TestCliffAtEndIsAllOrNothing(t *testing.T) {
160 s := mustNew(t, 100, 0, 100, 100)
161 uassert.Equal(t, int64(0), s.Vested(99))
162 uassert.Equal(t, int64(100), s.Vested(100))
163 uassert.Equal(t, int64(100), s.CliffAmount())
164}
165
166// TestVestedDoesNotOverflow pins the defect that forced v1.
167//
168// v0 computed total*elapsed in plain int64. Over a term measured in seconds,
169// the product passes 2^63 for any grant above roughly 146,036 whole coins, and
170// the wrap is silent: the figures below came back NEGATIVE, which every caller
171// then treated as a real vested amount.
172func TestVestedDoesNotOverflow(t *testing.T) {
173 // The real gno.land mainnet vesting term, 2026-09-12 to 2028-09-12.
174 const (
175 start int64 = 1789225200
176 end int64 = 1852383600
177 term = end - start // 63,158,400 seconds
178 )
179
180 for _, tc := range []struct {
181 name string
182 total int64
183 }{
184 {"just under the v0 ceiling", 146035000000},
185 {"just over the v0 ceiling", 146037000000},
186 {"the largest genesis grant", 318720000000000},
187 {"the whole genesis allocation", 1332999998328067},
188 } {
189 s, err := NewLinear(tc.total, start, end)
190 uassert.NoError(t, err, tc.name)
191
192 // Halfway through the term is exactly half the grant, and the naive
193 // int64 product has long since wrapped by this size.
194 uassert.Equal(t, tc.total/2, s.Vested(start+term/2), tc.name)
195
196 // The curve stays inside its bounds and never goes backwards.
197 prev := int64(0)
198 for i := int64(0); i <= 8; i++ {
199 got := s.Vested(start + term*i/8)
200 uassert.True(t, got >= prev, tc.name+": vested went backwards")
201 uassert.True(t, got >= 0, tc.name+": vested went negative")
202 uassert.True(t, got <= tc.total, tc.name+": vested exceeded the total")
203 prev = got
204 }
205 uassert.Equal(t, tc.total, prev, tc.name)
206
207 // PercentVested multiplies by 100 on top, so it has its own ceiling.
208 // It is 49 and not 50 at halfway for an ODD total: Vested floors to
209 // (total-1)/2 and the percentage floors again, so two roundings in the
210 // same direction land just under. That is the documented behaviour,
211 // not the overflow, so the assertion allows both.
212 half := s.PercentVested(start + term/2)
213 uassert.True(t, half == 49 || half == 50, tc.name+": halfway percent left 49..50")
214 uassert.Equal(t, int64(100), s.PercentVested(end), tc.name)
215 }
216}