Transaction

811E094C4BA863…C417702B3AC3

Block 303,377 · index 0 · indexed

Summary

Hash
811E094C4BA863493A06A0B82688844781FEA9D62A1641F9153BC417702B3AC3
Block
303,377
Size
33437 bytes
Gas used
41,509,149 / 109,625,800
Fee
328877ugnot
Status
success

Messages

#1AddPackagegno.land/p/moul/vesting/v09 arguments
Attached funds
7000000ugnot

Arguments · 9

  1. #1vesting
  2. #2README.md
  3. #3# `p/moul/vesting` The vesting curve **the gno.land chain itself enforces**, as a pure calculator. This is not a vesting scheme of its own. It is a faithful reimplementation of tm2's `std.VestingSchedule`, so a realm can answer "how much of this balance can actually move right now" with the same arithmetic the ante handler uses. Divergence here would be worse than useless, so every rule is copied from `tm2/pkg/std/vesting.go` rather than designed. ```go s, err := vesting.New(106560000000, 1789225200, 1852383600, vesting.Continuous) s.Vested(now) // how much has unlocked s.Locked(now) // what the chain still refuses to move s.Spendable(balance, now) // what can actually leave the account s.PermilleVested(now) // tenths of a percent, for display ``` ## The rules, all of them from tm2 | | | |---|---| | `Continuous` | vests linearly between `Start` and `End` | | `Delayed` | a cliff: nothing before `End`, everything at or after it, and `Start` is ignored | | rounding | **down**, always | | a zero `Original` | means no schedule, which locks nothing | | times | unix seconds, never compared against the chain's clock | Rounding down is the direction that matters. Reporting one ugnot more than the chain will move turns a page into a lie somebody acts on. `Spendable` caps the locked part at the balance: an account that spent while its coins were free can owe the schedule more than it now holds, and the honest answer there is that nothing moves, not a negative number. ## Why this is not `p/moul/x/daily/cliffvesting` That package is the employee-grant shape (start, **cliff**, end) for amounts a person is granted. This one is the chain's shape, for amounts a chain holds, and the difference is not only the curve: `cliffvesting` v0 multiplied in plain `int64`. Over the real mainnet term of 63,158,400 seconds that wraps for any grant above **146,036 GNOT**, silently: a 318,720,000 GNOT grant returned `-6,264,395,224`, a negative vested amount that every caller would have treated as real. tm2 reaches for `math/big` at exactly this point. gno has no `math/big`, so `Vested` goes through a 128-bit intermediate via `math/bits`. `cliffvesting` v1 now does the same. The largest schedule this has to survive is the whole genesis allocation, 1,332,999,998 GNOT, and the tests take it there. ## What this package cannot do Find out an address's schedule. **Realm code cannot read one.** The VM's whole view of an account is `banker.GetCoins`, which returns the total balance with the locked part included, and no native exposes `std.VestingSchedule`. The schedule has to come from the caller. [`r/moul/vesting`](/r/moul/vesting/v0) is what that constraint looks like in a page. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4gnomod.toml
  5. #5module = "gno.land/p/moul/vesting/v0" gno = "0.9"
  6. #6vesting.gno
  7. #7// Package vesting computes a gno.land account's vesting curve, as a PURE // calculator: no state, no balances, no transfers. // // It is not a vesting scheme of its own. It is a faithful reimplementation of // the one the CHAIN enforces, tm2's std.VestingSchedule, so that a realm can // answer "how much of this balance can actually move right now" with the same // arithmetic the ante handler uses. Divergence here is worse than useless, so // every rule below is copied from tm2/pkg/std/vesting.go rather than designed: // // - Times are unix seconds, and nothing compares them against the chain's // clock. A schedule already over when the chain starts is valid and vests // everything at once. // - [Continuous] vests linearly between Start and End. [Delayed] is a cliff: // nothing before End, everything at or after it, and Start is ignored. // - Rounding is DOWN, always, so the account never counts as spendable a // ugnot the chain would refuse to move. // - A zero Original is "no schedule", which locks nothing. // // # Why this is not p/moul/x/daily/cliffvesting // // That package is the employee-grant shape (start, cliff, end) and multiplies // in plain int64, which silently wraps for the amounts a chain actually holds: // over the 63,158,400 second mainnet term, any grant above 146,036 GNOT // overflows, and a 318,720,000 GNOT grant reports a NEGATIVE vested amount. // tm2 reaches for math/big at exactly this point. gno has no math/big, so // [Schedule.Vested] goes through a 128-bit intermediate instead. // // # What this package cannot do // // Find out an address's schedule. Realm code cannot read one: the VM's whole // view of an account is banker.GetCoins, which returns the TOTAL balance with // the locked part included, and no native exposes std.VestingSchedule. The // schedule has to come from the caller. See gno.land/r/moul/vesting for what // that means for a page that wants to show real numbers. package vesting import ( "errors" "math/bits" ) // Type selects the curve. type Type uint8 const ( // Continuous vests linearly from Start to End. The tm2 default. Continuous Type = iota // Delayed is a cliff: nothing vests until End, then all of it. Delayed ) func (t Type) String() string { if t == Delayed { return "delayed" } return "continuous" } var ( ErrNegativeOriginal = errors.New("vesting: original amount cannot be negative") ErrEndNotPositive = errors.New("vesting: end time must be positive") ErrNegativeStart = errors.New("vesting: start time cannot be negative") ErrStartAfterEnd = errors.New("vesting: start time must be before end time") ) // Schedule is one account's vesting plan, as the chain stores it. type Schedule struct { Original int64 // the granted amount, in the smallest unit Start int64 // unix seconds; ignored by Delayed End int64 // unix seconds Type Type } // New validates a schedule, applying tm2's own rules in tm2's own order. // // The Start >= 0 check is not cosmetic and is the reason Vested can stay in // int64 for its subtractions: with 0 <= Start < End, neither End-Start nor // now-Start can overflow. tm2 rejects a negative start for exactly this. func New(original, start, end int64, typ Type) (Schedule, error) { s := Schedule{Original: original, Start: start, End: end, Type: typ} if original < 0 { return Schedule{}, ErrNegativeOriginal } if s.IsZero() { return s, nil // no schedule; the other fields do not matter } if end <= 0 { return Schedule{}, ErrEndNotPositive } if typ != Delayed { if start < 0 { return Schedule{}, ErrNegativeStart } if start >= end { return Schedule{}, ErrStartAfterEnd } } return s, nil } // IsZero reports whether there is no schedule at all, which locks nothing. func (s Schedule) IsZero() bool { return s.Original == 0 } // Vested returns how much of Original has vested at unix time now. func (s Schedule) Vested(now int64) int64 { if s.IsZero() { return 0 } if now >= s.End { return s.Original } if s.Type == Delayed { return 0 // a cliff vests nothing until End } if now <= s.Start { return 0 } return mulDiv(s.Original, now-s.Start, s.End-s.Start) } // Locked returns the part of Original that has not vested at unix time now. // This is what the chain refuses to let leave the account. func (s Schedule) Locked(now int64) int64 { return s.Original - s.Vested(now) } // Spendable returns how much of balance can actually move at unix time now. // // balance is the account's TOTAL, which is what banker.GetCoins reports. The // locked part is capped at the balance: an account that has already spent down // to less than it still owes to the schedule has nothing spendable, not a // negative amount. tm2 reaches the same answer by subtracting locked coins // from the balance and refusing the transfer if the result does not cover it. func (s Schedule) Spendable(balance, now int64) int64 { if balance <= 0 { return 0 } locked := s.Locked(now) if locked >= balance { return 0 } return balance - locked } // PermilleVested returns the vested share at now in tenths of a percent, // rounded down, so a page can show one decimal without a float. Integer only: // no float ever reaches consensus state. func (s Schedule) PermilleVested(now int64) int64 { if s.IsZero() { return 1000 // nothing to vest is fully vested } return mulDiv(s.Vested(now), 1000, s.Original) } // RemainingSeconds returns how long until the schedule completes, zero once it // has. Reported rather than formatted: the caller owns how a duration reads. func (s Schedule) RemainingSeconds(now int64) int64 { if s.IsZero() || now >= s.End { return 0 } return s.End - now } // mulDiv computes floor(a*b/den) through a 128-bit intermediate, so a product // that leaves int64 does not wrap. // // Every caller here passes 0 <= b <= den with den > 0, which is what makes the // bits.Div64 precondition hold: the quotient is then at most a, so it fits in // 64 bits, which is exactly the hi < den that Div64 requires and panics // without. Keep that invariant at the call site, not by checking it here. func mulDiv(a, b, den int64) int64 { if a == 0 || b == 0 { return 0 } hi, lo := bits.Mul64(uint64(a), uint64(b)) q, _ := bits.Div64(hi, lo, uint64(den)) return int64(q) }
  8. #8vesting_test.gno
  9. #9package vesting import ( "testing" "gno.land/p/nt/uassert/v0" ) // The real mainnet §132 term: 2026-09-12T15:00:00Z to 2028-09-12T15:00:00Z. const ( mainnetStart int64 = 1789225200 mainnetEnd int64 = 1852383600 mainnetTerm = mainnetEnd - mainnetStart // 63,158,400 seconds ) func TestNewValidates(t *testing.T) { for _, tc := range []struct { name string original, start, end int64 typ Type wantErr error }{ {"a real mainnet schedule", 106560000000, mainnetStart, mainnetEnd, Continuous, nil}, {"no schedule at all", 0, 0, 0, Continuous, nil}, {"zero original ignores the rest", 0, 99, 1, Continuous, nil}, {"a cliff needs no start", 100, 0, mainnetEnd, Delayed, nil}, {"negative original", -1, 0, 10, Continuous, ErrNegativeOriginal}, {"end not positive", 100, 0, 0, Continuous, ErrEndNotPositive}, {"negative start", 100, -1, 10, Continuous, ErrNegativeStart}, {"start equal to end", 100, 10, 10, Continuous, ErrStartAfterEnd}, {"start after end", 100, 11, 10, Continuous, ErrStartAfterEnd}, } { _, err := New(tc.original, tc.start, tc.end, tc.typ) if tc.wantErr == nil { uassert.NoError(t, err, tc.name) continue } uassert.ErrorIs(t, err, tc.wantErr, tc.name) } } // TestVestedCurve pins the continuous curve at its boundaries and midpoint. func TestVestedCurve(t *testing.T) { s, err := New(1000, 100, 200, Continuous) uassert.NoError(t, err) for _, tc := range []struct { now, want int64 }{ {0, 0}, // long before {100, 0}, // at the start {125, 250}, // a quarter in {150, 500}, // halfway {199, 990}, // one tick short {200, 1000}, // exactly at the end {1 << 40, 1000}, } { uassert.Equal(t, tc.want, s.Vested(tc.now)) uassert.Equal(t, 1000-tc.want, s.Locked(tc.now)) } } // TestDelayedIsACliff: Start is ignored and nothing vests until End. func TestDelayedIsACliff(t *testing.T) { s, err := New(1000, 0, 200, Delayed) uassert.NoError(t, err) uassert.Equal(t, int64(0), s.Vested(0)) uassert.Equal(t, int64(0), s.Vested(199)) uassert.Equal(t, int64(1000), s.Vested(200)) uassert.Equal(t, int64(1000), s.Vested(1000)) uassert.Equal(t, int64(1000), s.Locked(199)) uassert.Equal(t, int64(0), s.Locked(200)) } func TestZeroScheduleLocksNothing(t *testing.T) { var s Schedule uassert.True(t, s.IsZero()) uassert.Equal(t, int64(0), s.Vested(12345)) uassert.Equal(t, int64(0), s.Locked(12345)) uassert.Equal(t, int64(999), s.Spendable(999, 12345)) uassert.Equal(t, int64(1000), s.PermilleVested(12345)) } // TestRoundsDown is the direction that matters: reporting one ugnot more than // the chain will move turns a page into a lie a user acts on. func TestRoundsDown(t *testing.T) { s, err := New(10, 0, 3, Continuous) // 10/3 per second, never exact uassert.NoError(t, err) uassert.Equal(t, int64(3), s.Vested(1)) // 3.33 -> 3 uassert.Equal(t, int64(6), s.Vested(2)) // 6.66 -> 6 uassert.Equal(t, int64(10), s.Vested(3)) } // TestNoOverflowAtChainScale is the whole reason this package exists rather // than reusing p/moul/x/daily/cliffvesting, which computes total*elapsed in // plain int64. Over the mainnet term that wraps above 146,036 GNOT: the // largest genesis grant is 318,720,000 GNOT, and the naive form returns a // NEGATIVE vested amount for it. func TestNoOverflowAtChainScale(t *testing.T) { const largestGrant int64 = 318720000000000 // 318,720,000 GNOT in ugnot s, err := New(largestGrant, mainnetStart, mainnetEnd, Continuous) uassert.NoError(t, err) // Exactly halfway through the term: the answer is half the grant, and the // naive int64 product would have wrapped long before here. half := s.Vested(mainnetStart + mainnetTerm/2) uassert.Equal(t, largestGrant/2, half) uassert.True(t, half > 0, "a wrapped product reports a negative amount") // Every point on the curve stays inside [0, Original], monotonically. prev := int64(0) for i := int64(0); i <= 16; i++ { got := s.Vested(mainnetStart + mainnetTerm*i/16) uassert.True(t, got >= prev, "vested went backwards") uassert.True(t, got >= 0 && got <= largestGrant, "vested left its bounds") prev = got } uassert.Equal(t, largestGrant, prev) } // TestTotalSupplyGrantDoesNotOverflow takes the ceiling to the whole supply, // which is the largest schedule the chain could ever hold. func TestTotalSupplyGrantDoesNotOverflow(t *testing.T) { const supply int64 = 1332999998328067 // total genesis allocation, ugnot s, err := New(supply, mainnetStart, mainnetEnd, Continuous) uassert.NoError(t, err) uassert.Equal(t, supply/2, s.Vested(mainnetStart+mainnetTerm/2)) uassert.Equal(t, supply, s.Vested(mainnetEnd)) } // TestSpendable is the number the page exists to show. func TestSpendable(t *testing.T) { s, err := New(1000, 100, 200, Continuous) uassert.NoError(t, err) // Balance above the grant: the surplus is spendable from the start. uassert.Equal(t, int64(200), s.Spendable(1200, 100)) // 1200 - 1000 locked uassert.Equal(t, int64(700), s.Spendable(1200, 150)) // 1200 - 500 locked uassert.Equal(t, int64(1200), s.Spendable(1200, 200)) // Spent down below what is still locked: nothing moves, and the answer is // zero rather than a negative number. uassert.Equal(t, int64(0), s.Spendable(400, 150)) uassert.Equal(t, int64(0), s.Spendable(500, 150)) // exactly locked uassert.Equal(t, int64(1), s.Spendable(501, 150)) uassert.Equal(t, int64(0), s.Spendable(0, 150)) uassert.Equal(t, int64(0), s.Spendable(-5, 150)) } // TestMoulsOwnAccount walks the real genesis row through the real schedule, // as a regression on the arithmetic the page will print. // // Genesis row, from the allocation sheet that built mainnet: // // g1manfred47kzduec920z88wfr64ylksmdcedlf5=111000000000ugnot; // vesting=106560000000ugnot,1789225200,1852383600 func TestMoulsOwnAccount(t *testing.T) { const ( genesisBalance int64 = 111000000000 grant int64 = 106560000000 ) s, err := New(grant, mainnetStart, mainnetEnd, Continuous) uassert.NoError(t, err) // At genesis: the 4% that never vested is immediately spendable. uassert.Equal(t, int64(0), s.Vested(mainnetStart)) uassert.Equal(t, genesisBalance-grant, s.Spendable(genesisBalance, mainnetStart)) uassert.Equal(t, int64(4440000000), s.Spendable(genesisBalance, mainnetStart)) // Halfway: half the grant has vested. uassert.Equal(t, grant/2, s.Vested(mainnetStart+mainnetTerm/2)) // At the end: everything moves, whatever the balance is by then. uassert.Equal(t, int64(0), s.Locked(mainnetEnd)) uassert.Equal(t, genesisBalance, s.Spendable(genesisBalance, mainnetEnd)) } func TestPermilleVested(t *testing.T) { s, err := New(1000, 100, 200, Continuous) uassert.NoError(t, err) uassert.Equal(t, int64(0), s.PermilleVested(100)) uassert.Equal(t, int64(250), s.PermilleVested(125)) uassert.Equal(t, int64(1000), s.PermilleVested(200)) // Rounds down, like everything else here. s2, err := New(3, 0, 300, Continuous) uassert.NoError(t, err) uassert.Equal(t, int64(333), s2.PermilleVested(100)) // 1/3 of 3 = 1 -> 333 } func TestRemainingSeconds(t *testing.T) { s, err := New(1000, 100, 200, Continuous) uassert.NoError(t, err) uassert.Equal(t, int64(100), s.RemainingSeconds(100)) uassert.Equal(t, int64(1), s.RemainingSeconds(199)) uassert.Equal(t, int64(0), s.RemainingSeconds(200)) uassert.Equal(t, int64(0), s.RemainingSeconds(10000)) var zero Schedule uassert.Equal(t, int64(0), zero.RemainingSeconds(0)) } func TestTypeString(t *testing.T) { uassert.Equal(t, "continuous", Continuous.String()) uassert.Equal(t, "delayed", Delayed.String()) }
Attached funds
7000000ugnot

Arguments · 9

  1. #1cliffvesting
  2. #2README.md
  3. #3# `gno.land/p/moul/x/daily/cliffvesting/v0` **Cliff-then-linear vesting calculator** — `New`, `NewLinear`, `Vested`, `Unvested`, `Claimable`, `PercentVested`, `CliffAmount`, `IsFullyVested`, `Duration`, `HasCliff`. ```go import "gno.land/p/moul/x/daily/cliffvesting/v0" s, _ := cliffvesting.New(1200, 0, 3, 12) // total, start, cliff, end s.Vested(2) // 0 — before the cliff s.Vested(3) // 300 — the cliff releases the elapsed portion at once s.Vested(12) // 1200 s.Claimable(6, 300) // 300 — vested minus already claimed ``` **Pure.** No state, no balances, no transfers. The arithmetic is the part that is easy to get subtly wrong and easy to test; custody belongs to the realm holding the coins. The cliff is a **step, not a ramp**: nothing vests before it, then the whole elapsed portion unlocks at once, and the rest accrues linearly. **All integer arithmetic** — no float ever reaches consensus state: - `total*elapsed/duration`, multiplying **first**. The reverse computes a per-tick rate that truncates to zero whenever `total < duration`, so nothing would ever vest. That is the classic vesting bug; it has its own test (7 tokens over 1000 ticks). - Rounding is **down**, so a beneficiary is never paid more than they earned, and the **final instalment collects the remainder**: 1000 over 3 periods is `333 + 333 + 334`, and `Vested(end)` is exactly `total`, never `total-1`. - `Claimable` never returns negative, even if the caller's bookkeeping says more was claimed than has vested. Times are `int64`, so the caller may use block heights or unix seconds — the unit only has to be consistent. **Live demo:** [`r/moul/x/daily/cliffvestingdemo`](https://github.com/moul/gno-contracts/tree/main/r/moul/x/daily/cliffvestingdemo) · render it at [`/r/moul/x/daily/cliffvestingdemo/v0`](https://gno.land/r/moul/x/daily/cliffvestingdemo/v0). <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4cliffvesting.gno
  5. #5// Package cliffvesting computes cliff-then-linear vesting schedules as a PURE // calculator: no state, no balances, no transfers. // // The shape is the standard employee/token grant: nothing vests until the // cliff, the cliff releases the whole elapsed portion at once, and the rest // accrues linearly until the end of the term. Keeping it pure is deliberate: // the arithmetic is the part that is easy to get subtly wrong and easy to test, // while custody belongs to the realm holding the coins. // // Everything is integer arithmetic. Vested is computed as // total*elapsed/duration with the multiplication FIRST, so the usual rounding // bug, dividing before multiplying and truncating the rate to zero, cannot // happen. Rounding is always DOWN, which means the beneficiary never receives // more than they have earned and the final instalment collects the remainder; // at t >= end the result is exactly total, never total-1. // // The multiplication goes through a 128-bit intermediate. v0 did it in plain // int64, which silently WRAPPED once total*elapsed passed 2^63: over a // two-year term in seconds that is any grant above ~146,036 whole coins, and // a large one reported a NEGATIVE vested amount rather than failing. See // [Schedule.Vested]. // // Times are int64 so the caller can use block heights or unix seconds. The unit // only has to be consistent. // // A live demo of this package is at // [r/moul/x/daily/cliffvestingdemo](/r/moul/x/daily/cliffvestingdemo/v0). package cliffvesting import ( "errors" "math/bits" ) var ( ErrBadTotal = errors.New("cliffvesting: total must be positive") ErrBadDuration = errors.New("cliffvesting: duration must be positive") ErrCliffAfter = errors.New("cliffvesting: cliff must not fall after the end") ErrCliffBefore = errors.New("cliffvesting: cliff must not fall before the start") ) // Schedule is a cliff-then-linear vesting plan. Construct with New so the // invariants are checked once. type Schedule struct { Total int64 // total amount to vest Start int64 // vesting begins Cliff int64 // nothing is claimable before this End int64 // fully vested at or after this } // New validates and returns a Schedule. cliff must lie within [start, end]. // Passing cliff == start means "no cliff". func New(total, start, cliff, end int64) (Schedule, error) { s := Schedule{Total: total, Start: start, Cliff: cliff, End: end} if total <= 0 { return Schedule{}, ErrBadTotal } if end <= start { return Schedule{}, ErrBadDuration } if cliff > end { return Schedule{}, ErrCliffAfter } if cliff < start { return Schedule{}, ErrCliffBefore } return s, nil } // NewLinear is New with no cliff. func NewLinear(total, start, end int64) (Schedule, error) { return New(total, start, start, end) } // Duration returns the length of the vesting term. func (s Schedule) Duration() int64 { return s.End - s.Start } // HasCliff reports whether the schedule has a non-trivial cliff. func (s Schedule) HasCliff() bool { return s.Cliff > s.Start } // Vested returns how much has vested at time t. Zero before the cliff, exactly // Total at or after End, and floor(total*elapsed/duration) in between. // // The product goes through [mulDiv] rather than int64. v0 computed // `s.Total * elapsed / s.Duration()` directly, which is correct only while the // product fits: with the term in seconds, a 318,720,000-coin grant over two // years returned -6,264,395,224. Nothing signalled it, because a wrapped // int64 is still a valid int64. func (s Schedule) Vested(t int64) int64 { if t < s.Cliff || t < s.Start { return 0 } if t >= s.End { return s.Total } // Multiply BEFORE dividing: the reverse truncates the per-tick rate to // zero whenever total < duration, which is the classic vesting bug. return mulDiv(s.Total, t-s.Start, s.Duration()) } // Unvested returns the remainder still locked at time t. func (s Schedule) Unvested(t int64) int64 { return s.Total - s.Vested(t) } // Claimable returns what can be withdrawn at time t given how much has already // been claimed. Never negative, even if claimed somehow exceeds vested. func (s Schedule) Claimable(t, claimed int64) int64 { v := s.Vested(t) - claimed if v < 0 { return 0 } return v } // IsFullyVested reports whether the term has completed at time t. func (s Schedule) IsFullyVested(t int64) bool { return t >= s.End } // PercentVested returns the vested share at t as an integer percentage, // rounded down. Integer-only: no floats reach consensus state. func (s Schedule) PercentVested(t int64) int64 { return mulDiv(s.Vested(t), 100, s.Total) } // CliffAmount returns the lump sum released the instant the cliff is reached. func (s Schedule) CliffAmount() int64 { return s.Vested(s.Cliff) } // mulDiv computes floor(a*b/den) through a 128-bit intermediate, so a product // that leaves int64 does not wrap. // // Every caller here passes 0 <= b <= den with den > 0 and a >= 0, which is // what makes the bits.Div64 precondition hold: the quotient is then at most a, // so it fits in 64 bits, which is exactly the hi < den that Div64 requires and // panics without. New enforces Total > 0 and End > Start; Vested only reaches // this line with Start < t < End. func mulDiv(a, b, den int64) int64 { if a == 0 || b == 0 { return 0 } hi, lo := bits.Mul64(uint64(a), uint64(b)) q, _ := bits.Div64(hi, lo, uint64(den)) return int64(q) }
  6. #6cliffvesting_test.gno
  7. #7package cliffvesting import ( "testing" "gno.land/p/nt/uassert/v0" ) func mustNew(t *testing.T, total, start, cliff, end int64) Schedule { s, err := New(total, start, cliff, end) uassert.NoError(t, err) return s } func TestNewValidation(t *testing.T) { _, err := New(0, 0, 0, 100) uassert.ErrorIs(t, err, ErrBadTotal) _, err = New(-5, 0, 0, 100) uassert.ErrorIs(t, err, ErrBadTotal) _, err = New(100, 50, 50, 50) uassert.ErrorIs(t, err, ErrBadDuration, "end must be after start") _, err = New(100, 50, 50, 10) uassert.ErrorIs(t, err, ErrBadDuration) _, err = New(100, 0, 200, 100) uassert.ErrorIs(t, err, ErrCliffAfter) _, err = New(100, 50, 10, 100) uassert.ErrorIs(t, err, ErrCliffBefore) _, err = New(100, 0, 100, 100) uassert.NoError(t, err, "a cliff exactly at the end is allowed") } func TestNewLinearHasNoCliff(t *testing.T) { s, err := NewLinear(1000, 0, 100) uassert.NoError(t, err) uassert.False(t, s.HasCliff()) uassert.Equal(t, int64(0), s.CliffAmount()) uassert.Equal(t, int64(500), s.Vested(50), "linear from the start") } // TestNothingBeforeCliff is the defining behaviour. func TestNothingBeforeCliff(t *testing.T) { s := mustNew(t, 1200, 0, 300, 1200) uassert.Equal(t, int64(0), s.Vested(0)) uassert.Equal(t, int64(0), s.Vested(299), "one tick before the cliff, still nothing") uassert.Equal(t, int64(1200), s.Unvested(299)) } // TestCliffReleasesElapsedPortionAtOnce pins the step, not a ramp. func TestCliffReleasesElapsedPortionAtOnce(t *testing.T) { s := mustNew(t, 1200, 0, 300, 1200) uassert.Equal(t, int64(300), s.Vested(300), "25% of the term elapsed, released in one step") uassert.Equal(t, int64(300), s.CliffAmount()) uassert.Equal(t, int64(25), s.PercentVested(300)) } func TestLinearAfterCliff(t *testing.T) { s := mustNew(t, 1200, 0, 300, 1200) uassert.Equal(t, int64(600), s.Vested(600)) uassert.Equal(t, int64(900), s.Vested(900)) uassert.Equal(t, int64(1199), s.Vested(1199)) } // TestExactlyTotalAtEnd is what rounding down must never break. func TestExactlyTotalAtEnd(t *testing.T) { s := mustNew(t, 1000, 0, 0, 3) // 1000/3 does not divide evenly uassert.Equal(t, int64(333), s.Vested(1)) uassert.Equal(t, int64(666), s.Vested(2)) uassert.Equal(t, int64(1000), s.Vested(3), "the last instalment collects the remainder") uassert.Equal(t, int64(0), s.Unvested(3)) uassert.Equal(t, int64(1000), s.Vested(99999), "still exactly total long after the end") } // TestSmallTotalLongDuration is the rounding bug this package exists to avoid: // with total < duration, dividing before multiplying truncates the rate to 0 // and nothing ever vests. func TestSmallTotalLongDuration(t *testing.T) { s := mustNew(t, 7, 0, 0, 1000) uassert.Equal(t, int64(0), s.Vested(100), "7*100/1000 = 0.7, floors to 0") uassert.Equal(t, int64(1), s.Vested(150), "7*150/1000 = 1.05, floors to 1") uassert.Equal(t, int64(3), s.Vested(500), "half the term, half of 7, floored") uassert.Equal(t, int64(7), s.Vested(1000), "and still exactly total at the end") } func TestVestedIsMonotonic(t *testing.T) { s := mustNew(t, 997, 10, 40, 310) prev := int64(-1) for tick := int64(0); tick <= 320; tick++ { v := s.Vested(tick) uassert.True(t, v >= prev, "vested must never decrease") uassert.True(t, v <= s.Total, "vested must never exceed total") prev = v } } func TestUnvestedComplementsVested(t *testing.T) { s := mustNew(t, 500, 0, 100, 400) for _, tick := range []int64{0, 99, 100, 250, 399, 400, 500} { uassert.Equal(t, s.Total, s.Vested(tick)+s.Unvested(tick), "vested + unvested must always equal total") } } func TestClaimable(t *testing.T) { s := mustNew(t, 1000, 0, 0, 100) uassert.Equal(t, int64(500), s.Claimable(50, 0)) uassert.Equal(t, int64(300), s.Claimable(50, 200), "already-claimed is deducted") uassert.Equal(t, int64(0), s.Claimable(50, 500), "nothing left right now") uassert.Equal(t, int64(500), s.Claimable(100, 500), "the rest at the end") } // TestClaimableNeverNegative guards the case where bookkeeping says more was // claimed than has vested — the caller gets 0, not a negative payout. func TestClaimableNeverNegative(t *testing.T) { s := mustNew(t, 1000, 0, 0, 100) uassert.Equal(t, int64(0), s.Claimable(10, 999)) } func TestPercentVested(t *testing.T) { s := mustNew(t, 1000, 0, 0, 100) uassert.Equal(t, int64(0), s.PercentVested(0)) uassert.Equal(t, int64(50), s.PercentVested(50)) uassert.Equal(t, int64(99), s.PercentVested(99)) uassert.Equal(t, int64(100), s.PercentVested(100)) uassert.Equal(t, int64(100), s.PercentVested(1000)) } // TestNonZeroStart checks the schedule is relative to Start, not to zero. func TestNonZeroStart(t *testing.T) { s := mustNew(t, 400, 1000, 1100, 1400) uassert.Equal(t, int64(0), s.Vested(999), "before the start") uassert.Equal(t, int64(0), s.Vested(1099), "before the cliff") uassert.Equal(t, int64(100), s.Vested(1100), "cliff: a quarter elapsed") uassert.Equal(t, int64(200), s.Vested(1200)) uassert.Equal(t, int64(400), s.Vested(1400)) } func TestDurationAndFlags(t *testing.T) { s := mustNew(t, 100, 10, 20, 110) uassert.Equal(t, int64(100), s.Duration()) uassert.True(t, s.HasCliff()) uassert.False(t, s.IsFullyVested(109)) uassert.True(t, s.IsFullyVested(110)) uassert.True(t, s.IsFullyVested(999)) } // TestCliffAtEndIsAllOrNothing covers the degenerate schedule. func TestCliffAtEndIsAllOrNothing(t *testing.T) { s := mustNew(t, 100, 0, 100, 100) uassert.Equal(t, int64(0), s.Vested(99)) uassert.Equal(t, int64(100), s.Vested(100)) uassert.Equal(t, int64(100), s.CliffAmount()) } // TestVestedDoesNotOverflow pins the defect that forced v1. // // v0 computed total*elapsed in plain int64. Over a term measured in seconds, // the product passes 2^63 for any grant above roughly 146,036 whole coins, and // the wrap is silent: the figures below came back NEGATIVE, which every caller // then treated as a real vested amount. func TestVestedDoesNotOverflow(t *testing.T) { // The real gno.land mainnet vesting term, 2026-09-12 to 2028-09-12. const ( start int64 = 1789225200 end int64 = 1852383600 term = end - start // 63,158,400 seconds ) for _, tc := range []struct { name string total int64 }{ {"just under the v0 ceiling", 146035000000}, {"just over the v0 ceiling", 146037000000}, {"the largest genesis grant", 318720000000000}, {"the whole genesis allocation", 1332999998328067}, } { s, err := NewLinear(tc.total, start, end) uassert.NoError(t, err, tc.name) // Halfway through the term is exactly half the grant, and the naive // int64 product has long since wrapped by this size. uassert.Equal(t, tc.total/2, s.Vested(start+term/2), tc.name) // The curve stays inside its bounds and never goes backwards. prev := int64(0) for i := int64(0); i <= 8; i++ { got := s.Vested(start + term*i/8) uassert.True(t, got >= prev, tc.name+": vested went backwards") uassert.True(t, got >= 0, tc.name+": vested went negative") uassert.True(t, got <= tc.total, tc.name+": vested exceeded the total") prev = got } uassert.Equal(t, tc.total, prev, tc.name) // PercentVested multiplies by 100 on top, so it has its own ceiling. // It is 49 and not 50 at halfway for an ODD total: Vested floors to // (total-1)/2 and the percentage floors again, so two roundings in the // same direction land just under. That is the documented behaviour, // not the overflow, so the assertion allows both. half := s.PercentVested(start + term/2) uassert.True(t, half == 49 || half == 50, tc.name+": halfway percent left 49..50") uassert.Equal(t, int64(100), s.PercentVested(end), tc.name) } }
  8. #8gnomod.toml
  9. #9module = "gno.land/p/moul/x/daily/cliffvesting/v1" gno = "0.9"

Result log

msg:0,success:true,log:,events:[]
msg:1,success:true,log:,events:[]

← Back to block 303,377