1package cliffvesting23import (4 "testing"56 "gno.land/p/nt/uassert/v0"7)89func 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 s13}1415func 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)2021 _, 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)2526 _, err = New(100, 0, 200, 100)27 uassert.ErrorIs(t, err, ErrCliffAfter)2829 _, err = New(100, 50, 10, 100)30 uassert.ErrorIs(t, err, ErrCliffBefore)3132 _, err = New(100, 0, 100, 100)33 uassert.NoError(t, err, "a cliff exactly at the end is allowed")34}3536func 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}4344// TestNothingBeforeCliff is the defining behaviour.45func TestNothingBeforeCliff(t *testing.T) {46 s := mustNew(t, 1200, 0, 300, 1200)4748 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}5253// TestCliffReleasesElapsedPortionAtOnce pins the step, not a ramp.54func TestCliffReleasesElapsedPortionAtOnce(t *testing.T) {55 s := mustNew(t, 1200, 0, 300, 1200)5657 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}6162func TestLinearAfterCliff(t *testing.T) {63 s := mustNew(t, 1200, 0, 300, 1200)6465 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}6970// 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 evenly7374 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}8081// TestSmallTotalLongDuration is the rounding bug this package exists to avoid:82// with total < duration, dividing before multiplying truncates the rate to 083// and nothing ever vests.84func TestSmallTotalLongDuration(t *testing.T) {85 s := mustNew(t, 7, 0, 0, 1000)8687 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}9293func TestVestedIsMonotonic(t *testing.T) {94 s := mustNew(t, 997, 10, 40, 310)9596 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 = v102 }103}104105func 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}112113func TestClaimable(t *testing.T) {114 s := mustNew(t, 1000, 0, 0, 100)115116 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}121122// TestClaimableNeverNegative guards the case where bookkeeping says more was123// 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}128129func 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}137138// TestNonZeroStart checks the schedule is relative to Start, not to zero.139func TestNonZeroStart(t *testing.T) {140 s := mustNew(t, 400, 1000, 1100, 1400)141142 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}148149func 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}157158// 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}165166// 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, and170// the wrap is silent: the figures below came back NEGATIVE, which every caller171// 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 = 1789225200176 end int64 = 1852383600177 term = end - start // 63,158,400 seconds178 )179180 for _, tc := range []struct {181 name string182 total int64183 }{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)191192 // Halfway through the term is exactly half the grant, and the naive193 // int64 product has long since wrapped by this size.194 uassert.Equal(t, tc.total/2, s.Vested(start+term/2), tc.name)195196 // 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 = got204 }205 uassert.Equal(t, tc.total, prev, tc.name)206207 // 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 to209 // (total-1)/2 and the percentage floors again, so two roundings in the210 // 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}217Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.