Transaction
5568DD127D173A…388319EE8198
Block 303,378 · index 0 · indexed
Summary
- Hash
- 5568DD127D173A480C5987A03F7A3027F2E0E02F68E1D31F7E0A388319EE8198
- Block
- 303,378
- Size
- 42605 bytes
- Gas used
- 54,658,099 / 133,392,400
- Fee
- 400177ugnot
- Status
- success
Messages
- Attached funds
- 14000000ugnot
- Package
- gno.land/r/moul/vesting/v0
Arguments · 13
- #1vesting
- #2README.md
- #3# `r/moul/vesting` How much of a gno.land account's balance can **actually move right now**. ## Read this before trusting a number here A realm cannot read an account's vesting schedule. `banker.GetCoins` returns the **total** balance with the locked part included, and no native exposes `std.VestingSchedule`. So the page reads two things from the chain and is told the third: | figure | source | trust | |---|---|---| | balance | `banker.GetCoins`, every render | always true | | the clock | the block time, every render | always true | | the schedule | supplied, see below | only as good as its source | Every rendered figure says which of the two it rests on. Nothing is presented as verified when it is not. That honesty is the feature: a page that quietly guessed the locked amount would be worse than no page. ## Where a schedule comes from **The URL**, for a one-off that stores nothing: ``` /r/moul/vesting/v0:g1youraddress?o=106560000000&s=1789225200&e=1852383600 ``` `o` original vesting in ugnot, `s` start, `e` end, both unix seconds, `&d=1` for a `delayed` schedule. Read your real one off the chain: ```sh gnokey query auth/accounts/g1youraddress -remote https://rpc.gno.land:443 ``` **Or the registry.** `Declare` writes a schedule for the **caller's own address and no other**. That is the whole trust model: an address can only misdescribe itself, and a wrong entry misleads nobody but its author. `Forget` takes it off again. ## Why a cached schedule is honest rather than stale Because a schedule can never change. `std.SetVesting` has exactly one caller in the monorepo, `gno.land/pkg/gnoland/app.go`, during genesis balance loading, and no message type creates or modifies one. A schedule declared correctly today is correct forever. ## Routes | | | |---|---| | `/r/moul/vesting/v0` | every schedule on file | | `…:g1…` | one address | | `…:g1…?o=&s=&e=` | one address, with a schedule you supply | ## Notes The seeded schedules are source constants, not registry rows. This realm is `private = true`, so a redeploy wipes realm state, and the one schedule the page exists to show should not need a transaction to come back. A seeded address is refused by `Declare` for the same reason: a redeploy would silently revert whatever it set. The arithmetic is [`p/moul/vesting`](/p/moul/vesting/v0), which mirrors what the chain enforces, down to the rounding direction. <!-- 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. **Dependency graph:**  > ⚠️ **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 -->
- #4gnomod.toml
- #5module = "gno.land/r/moul/vesting/v0" gno = "0.9" # Redeployable by its creator: nothing imports this realm and it hands no # object of its own to another one, so `private = true` costs nothing and keeps # the path reusable. Designed for the wipe a redeploy causes: the seeded # schedules are source constants that init() restores, and a declared schedule # is one transaction for its owner to re-submit. private = true
- #6render.gno
- #7package vesting import ( "strconv" "strings" "gno.land/p/moul/kit/ui/v0" "gno.land/p/moul/realmpath/v0" "gno.land/p/moul/vesting/v0" ) // Render serves three shapes: // // /r/moul/vesting/v0 the seeded schedules // /r/moul/vesting/v0:g1... one address, its declared schedule // /r/moul/vesting/v0:g1...?o=&s=&e=&d= one address, a schedule you supply // // The query form stores nothing. It exists so an address that has not declared // anything can still get a real answer: paste the schedule out of // `gnokey query auth/accounts/<addr>` and the balance is still read from chain. func Render(path string) string { req := realmpath.Parse(path) target := strings.TrimSpace(req.PathPart(0)) if target == "" { return renderIndex() } if !isAddr(target) { return header() + ui.Empty("`"+ui.Inline(target)+"` is not a gno.land address.") + "\n" + lookupForm() } addr := address(target) // An ad-hoc schedule in the query beats a declared one, and is labelled as // coming from the caller. if raw := req.Query.Get("o"); raw != "" { sch, err := scheduleFromQuery(req) if err != "" { return header() + ui.Empty(err) + "\n" + lookupForm() } return header() + renderAccount(addr, sch, sourceQuery, "") + "\n" + lookupForm() } if e, ok := lookup(addr); ok { src := sourceDeclared if e.seeded { src = sourceSeeded } return header() + renderAccount(addr, e.schedule, src, e.label) + "\n" + lookupForm() } return header() + renderUnknown(addr) + "\n" + lookupForm() } // Where a schedule came from. This is the distinction the whole page turns on, // so it is a value and not a boolean buried in a branch. type source int const ( sourceSeeded source = iota // a source constant in this realm sourceDeclared // written by the address itself sourceQuery // typed into the URL by whoever is looking sourceNone // there is none ) func (s source) label() string { switch s { case sourceSeeded: return "seeded in this realm's source" case sourceDeclared: return "declared by the address itself" case sourceQuery: return "supplied in the URL by you" } return "unknown" } func header() string { return "# Vesting\n\n" + "How much of an account's balance can actually move right now.\n\n" } func renderIndex() string { var b strings.Builder b.WriteString(header()) t := ui.NewTable("account", "balance", "spendable", "locked", "vested") declared.Iterate("", "", func(key string, v any) bool { e := v.(*entry) addr := address(key) bal := balanceOf(addr) n := now() name := ui.AddrText(addr) if e.label != "" { name = ui.Inline(e.label) + " " + ui.Addr(addr) } t.Row( name, gnot(bal), gnot(e.schedule.Spendable(bal, n)), gnot(lockedCapped(e.schedule, bal, n)), permille(e.schedule.PermilleVested(n)), ) return false }) b.WriteString(t.OrEmpty("No schedules yet.")) b.WriteString("\n") b.WriteString(lookupForm()) b.WriteString("\n## What is real here, and what is not\n\n") b.WriteString("| figure | where it comes from |\n|---|---|\n") b.WriteString("| balance | read from the chain every render, `banker.GetCoins` |\n") b.WriteString("| the clock | read from the chain every render, the block time |\n") b.WriteString("| the schedule | **supplied**, because no realm can read one |\n\n") b.WriteString("A realm's whole view of an account is its TOTAL balance, locked coins ") b.WriteString("included. `std.VestingSchedule` lives in the auth store and no native ") b.WriteString("reaches it, so the curve has to be told to this realm rather than found.\n\n") b.WriteString("That is safe to cache because a schedule can never change: it is set ") b.WriteString("once, at genesis, and no message type creates or modifies one. A ") b.WriteString("schedule declared correctly today is correct forever.\n") return b.String() } func renderAccount(addr address, s vesting.Schedule, src source, label string) string { var b strings.Builder n := now() bal := balanceOf(addr) b.WriteString("## ") if label != "" { b.WriteString(ui.Inline(label)) b.WriteString(" ") } b.WriteString(ui.AddrText(addr)) b.WriteString("\n\n") if s.IsZero() { b.WriteString("No vesting schedule, so the whole balance moves.\n\n") b.WriteString("| | |\n|---|---|\n") b.WriteString("| balance | **" + gnot(bal) + "** |\n") b.WriteString("| spendable | **" + gnot(bal) + "** |\n\n") b.WriteString(sourceNote(src)) return b.String() } locked := lockedCapped(s, bal, n) t := ui.NewTable("", "") t.Row("balance", "**"+gnot(bal)+"**") t.Row("spendable now", "**"+gnot(s.Spendable(bal, n))+"**") t.Row("locked now", gnot(locked)) t.Row("granted", gnot(s.Original)) t.Row("vested", gnot(s.Vested(n))+" ("+permille(s.PermilleVested(n))+")") t.Row("still to vest", gnot(s.Locked(n))) t.Row("curve", s.Type.String()) t.Row("term", tstamp(s.Start)+" to "+tstamp(s.End)) t.Row("finishes in", duration(s.RemainingSeconds(n))) b.WriteString(t.String()) b.WriteString("\n") if s.Locked(n) > bal { b.WriteString("\n> This account holds less than its schedule still locks, so ") b.WriteString("nothing moves at all. That happens when coins were spent while ") b.WriteString("they were free and the grant has since outrun the balance.\n") } b.WriteString("\n") b.WriteString(sourceNote(src)) return b.String() } func renderUnknown(addr address) string { var b strings.Builder bal := balanceOf(addr) b.WriteString("## ") b.WriteString(ui.AddrText(addr)) b.WriteString("\n\nNo schedule on file for this address.\n\n") b.WriteString("| | |\n|---|---|\n") b.WriteString("| balance | **" + gnot(bal) + "** |\n") b.WriteString("| spendable | unknown |\n\n") b.WriteString("The balance above is real. How much of it moves is not knowable from ") b.WriteString("inside a realm. Read the schedule off the chain:\n\n") b.WriteString("```\ngnokey query auth/accounts/" + addr.String() + " -remote https://rpc.gno.land:443\n```\n\n") b.WriteString("then either append it to this URL as `?o=<original_vesting>&s=<start_time>") b.WriteString("&e=<end_time>` (add `&d=1` for a `delayed` schedule), or, if this address ") b.WriteString("is yours, put it on file so the link works for everyone:\n\n") b.WriteString(ui.Action("Declare this schedule", "Declare", "original", "", "start", "", "end", "", "delayed", "false")) b.WriteString("\n") return b.String() } func sourceNote(src source) string { s := "Balance and clock are read from the chain. The schedule is " + src.label() + "." if src == sourceQuery { s += " Nothing was stored." } if src == sourceDeclared { s += " Only that address can write or remove it, so it can misdescribe nobody else." } return "> " + s + "\n" } func lookupForm() string { return "## Check another address\n\n" + "Append it to the path: `" + Link + ":g1…`\n\n" + "| | |\n|---|---|\n" + "| put your own schedule on file | " + ui.Action("Declare", "Declare", "original", "", "start", "", "end", "", "delayed", "false") + " |\n| take it off again | " + ui.Action("Forget", "Forget") + " |\n" } // scheduleFromQuery reads a one-off schedule out of the URL. It returns a // human-readable reason rather than an error value, because every caller turns // it straight into page text. func scheduleFromQuery(req *realmpath.Request) (vesting.Schedule, string) { o, err := strconv.ParseInt(req.Query.Get("o"), 10, 64) if err != nil { return vesting.Schedule{}, "`o` (the original vesting amount, in ugnot) is not a number." } s, err := strconv.ParseInt(orZero(req.Query.Get("s")), 10, 64) if err != nil { return vesting.Schedule{}, "`s` (the start time, unix seconds) is not a number." } e, err := strconv.ParseInt(orZero(req.Query.Get("e")), 10, 64) if err != nil { return vesting.Schedule{}, "`e` (the end time, unix seconds) is not a number." } typ := vesting.Continuous if d := req.Query.Get("d"); d == "1" || d == "true" { typ = vesting.Delayed } sch, verr := vesting.New(o, s, e, typ) if verr != nil { return vesting.Schedule{}, "That is not a valid schedule: " + verr.Error() + "." } return sch, "" } func orZero(s string) string { if s == "" { return "0" } return s } // lockedCapped is what the account cannot move, which is never more than it // holds. The schedule alone can say otherwise once coins have been spent. func lockedCapped(s vesting.Schedule, balance, n int64) int64 { l := s.Locked(n) if l > balance { return balance } if l < 0 { return 0 } return l } // tstamp renders a unix second as a plain UTC date, which is all a two-year // term needs. gno has no time formatting worth the dependency here. func tstamp(sec int64) string { y, m, d := civil(sec / 86400) return ufmtDate(y, m, d) } // civil converts a day count since 1970-01-01 to a calendar date, by Howard // Hinnant's civil_from_days. Integer only, no time package, no locale. func civil(z int64) (year int64, month int64, day int64) { z += 719468 era := z / 146097 if z < 0 { era = (z - 146096) / 146097 } doe := z - era*146097 yoe := (doe - doe/1460 + doe/36524 - doe/146096) / 365 y := yoe + era*400 doy := doe - (365*yoe + yoe/4 - yoe/100) mp := (5*doy + 2) / 153 d := doy - (153*mp+2)/5 + 1 m := mp + 3 if mp >= 10 { m = mp - 9 } if m <= 2 { y++ } return y, m, d } func ufmtDate(y, m, d int64) string { return strconv.FormatInt(y, 10) + "-" + pad2(m) + "-" + pad2(d) } func pad2(n int64) string { s := strconv.FormatInt(n, 10) if len(s) < 2 { return "0" + s } return s }
- #8render_example_test.gno
- #9package vesting // ExampleRender pins the index. // // reset() first, deliberately: an Example runs AFTER every Test in the package // and sees the rows they declared, so without it this output depends on test // order. The block time inside a gno test is fixed at 2009-02-13, before the // real mainnet term starts, so the seeded row renders at 0% vested with the // whole grant locked. That is a legitimate point on the curve and a stable one. func ExampleRender() { reset() print(Render("")) // Output: // # Vesting // // How much of an account's balance can actually move right now. // // | account | balance | spendable | locked | vested | // | --- | --- | --- | --- | --- | // | moul `g1manfre…dlf5` | 0.000000 GNOT | 0.000000 GNOT | 0.000000 GNOT | 0.0% | // // ## Check another address // // Append it to the path: `/r/moul/vesting/v0:g1…` // // | | | // |---|---| // | put your own schedule on file | [Declare](/r/moul/vesting/v0$help&func=Declare&delayed=false&end=&original=&start=) | // | take it off again | [Forget](/r/moul/vesting/v0$help&func=Forget) | // // ## What is real here, and what is not // // | figure | where it comes from | // |---|---| // | balance | read from the chain every render, `banker.GetCoins` | // | the clock | read from the chain every render, the block time | // | the schedule | **supplied**, because no realm can read one | // // A realm's whole view of an account is its TOTAL balance, locked coins included. `std.VestingSchedule` lives in the auth store and no native reaches it, so the curve has to be told to this realm rather than found. // // That is safe to cache because a schedule can never change: it is set once, at genesis, and no message type creates or modifies one. A schedule declared correctly today is correct forever. }
- #10vesting.gno
- #11// Realm vesting answers one question for a gno.land account: of the coins it // holds, how many can actually move right now. // // # Read this before you trust a number here // // A realm CANNOT read an account's vesting schedule. 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. So this realm // reads two things from the chain and is told the third: // // balance read from the chain, banker.GetCoins always true // now read from the chain, block time always true // schedule supplied, see below only as good as its source // // Every rendered figure says which of the two it rests on. Nothing here is // presented as verified when it is not. // // # Where a schedule comes from // // Either the query string, for a one-off calculation that stores nothing, or // the registry. [Declare] writes a schedule for the CALLER'S OWN address and // no other, which is the whole trust model: an address can only misdescribe // itself, and a wrong entry misleads nobody but its author. // // Anyone can read their real schedule in one command and declare it: // // gnokey query auth/accounts/g1youraddress -remote https://rpc.gno.land:443 // // # Why the schedule can be cached at all // // Because it can never change. std.SetVesting has exactly one caller in the // monorepo, gno.land/pkg/gnoland/app.go, during genesis balance loading, and // no message type creates or modifies a schedule. A schedule declared once is // correct forever, which is what makes a registry honest rather than stale. package vesting import ( "strconv" "strings" "time" "chain" "chain/banker" "chain/runtime" "chain/runtime/unsafe" "gno.land/p/nt/avl/v0" "gno.land/p/nt/ufmt/v0" "gno.land/p/moul/vesting/v0" ) const ( // Path is this realm's package path. Path = "gno.land/r/moul/vesting/v0" // Link is Path as a gnoweb route. Link = "/r/moul/vesting/v0" // Denom is the only coin this realm reports on. Denom = "ugnot" ) // seeded are the schedules this realm ships with, restored by init() after // every redeploy. // // They are source constants and not registry rows on purpose: the realm is // private = true, so a redeploy wipes realm state, and the one schedule the // page exists to show should not need a transaction to come back. Each is // copied from the genesis allocation sheet that built mainnet, which is the // public, pinned input named in misc/deployments/mainnet.gno.land/gen-genesis.sh. var seeded = []struct { addr address label string original int64 start int64 end int64 delayed bool }{ { addr: "g1manfred47kzduec920z88wfr64ylksmdcedlf5", label: "moul", original: 106560000000, start: 1789225200, end: 1852383600, }, } // declared maps an address to the schedule that address declared for itself. var declared avl.Tree // entry is one row of the registry. type entry struct { schedule vesting.Schedule label string // non-empty only for a seeded row seeded bool at int64 // block height the row was written at; 0 for a seeded row } func init() { reset() } // reset restores the registry to exactly what the source declares. init() is // its only caller on chain; a test calls it to render a page that does not // depend on what the test before it wrote, since an Example runs after every // Test in the package and sees the state they left. func reset() { declared = avl.Tree{} for _, s := range seeded { typ := vesting.Continuous if s.delayed { typ = vesting.Delayed } sch, err := vesting.New(s.original, s.start, s.end, typ) if err != nil { panic("vesting: seeded schedule for " + s.label + ": " + err.Error()) } declared.Set(s.addr.String(), &entry{schedule: sch, label: s.label, seeded: true}) } } // Declare records the caller's own vesting schedule, replacing any previous // one. It cannot write a row for anybody else, which is what keeps the // registry honest without the realm being able to verify a thing. // // Pass original = 0 to declare that the address has no schedule at all. func Declare(cur realm, original, start, end int64, delayed bool) { typ := vesting.Continuous if delayed { typ = vesting.Delayed } sch, err := vesting.New(original, start, end, typ) if err != nil { panic(err.Error()) } who := unsafe.PreviousRealm().Address() if e, ok := lookup(who); ok && e.seeded { panic("vesting: " + who.String() + " is seeded in the source; edit the realm instead") } declared.Set(who.String(), &entry{schedule: sch, at: chainHeight()}) chain.Emit("declared", "addr", who.String(), "original", strconv.FormatInt(original, 10), "start", strconv.FormatInt(start, 10), "end", strconv.FormatInt(end, 10), "type", typ.String()) } // Forget removes the caller's own declaration. func Forget(cur realm) { who := unsafe.PreviousRealm().Address() e, ok := lookup(who) if !ok { panic("vesting: nothing declared for " + who.String()) } if e.seeded { panic("vesting: " + who.String() + " is seeded in the source; edit the realm instead") } declared.Remove(who.String()) chain.Emit("forgot", "addr", who.String()) } // Count returns how many schedules the registry holds. func Count() int { return declared.Size() } // ScheduleOf returns the declared schedule for addr, and whether there is one. func ScheduleOf(addr address) (original, start, end int64, delayed, ok bool) { e, found := lookup(addr) if !found { return 0, 0, 0, false, false } s := e.schedule return s.Original, s.Start, s.End, s.Type == vesting.Delayed, true } // SpendableOf returns what addr can move right now, and whether the answer // rests on a declared schedule. With ok false the figure is the whole balance, // which is correct only if the address really has no schedule. func SpendableOf(addr address) (spendable int64, ok bool) { bal := balanceOf(addr) e, found := lookup(addr) if !found { return bal, false } return e.schedule.Spendable(bal, now()), true } // LockedOf returns what addr cannot move right now, and whether the answer // rests on a declared schedule. func LockedOf(addr address) (locked int64, ok bool) { e, found := lookup(addr) if !found { return 0, false } bal := balanceOf(addr) l := e.schedule.Locked(now()) if l > bal { l = bal // cannot lock more than is there } return l, true } // lookup reads a row. avl's Get returns ONE value and nil for a miss, unlike // Remove which returns two; that asymmetry is a documented gno-vs-Go trap. func lookup(addr address) (*entry, bool) { v := declared.Get(addr.String()) if v == nil { return nil, false } return v.(*entry), true } // balanceOf reads the address's TOTAL ugnot, locked included. That is what // banker.GetCoins reports and the only balance figure a realm can obtain. // A readonly banker takes no realm, so this works from inside Render. func balanceOf(addr address) int64 { return banker.NewReadonlyBanker().GetCoins(addr).AmountOf(Denom) } // gnot renders a ugnot amount as GNOT with six decimals and no float. func gnot(u int64) string { neg := u < 0 if neg { u = -u } whole, frac := u/1_000_000, u%1_000_000 s := ufmt.Sprintf("%d.%s", whole, pad6(frac)) if neg { s = "-" + s } return s + " GNOT" } // pad6 left-pads to six digits. ufmt has no width flags, so this is by hand. func pad6(n int64) string { s := strconv.FormatInt(n, 10) for len(s) < 6 { s = "0" + s } return s } // permille renders tenths of a percent as a percentage with one decimal. func permille(p int64) string { return ufmt.Sprintf("%d.%d%%", p/10, p%10) } // duration renders a span of seconds as days and hours, which is the only // resolution worth reading on a two-year schedule. func duration(sec int64) string { if sec <= 0 { return "complete" } d, h := sec/86400, (sec%86400)/3600 var b strings.Builder if d > 0 { b.WriteString(strconv.FormatInt(d, 10)) b.WriteString("d ") } b.WriteString(strconv.FormatInt(h, 10)) b.WriteString("h") return b.String() } // isAddr is a cheap shape check, so a typo renders as "not an address" rather // than as an account with a zero balance. func isAddr(s string) bool { if len(s) != 40 || !strings.HasPrefix(s, "g1") { return false } for _, r := range s { if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') { return false } } return true } // now is the chain's own clock, in unix seconds. time.Now() in a realm returns // the BLOCK time, not a wall clock, which is exactly the value the ante // handler compares a vesting schedule against. func now() int64 { return time.Now().Unix() } func chainHeight() int64 { return runtime.ChainHeight() }
- #12vesting_test.gno
- #13package vesting import ( "strings" "testing" "chain/runtime" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/testutils/v0" ) const ( mainnetStart int64 = 1789225200 mainnetEnd int64 = 1852383600 moul = address("g1manfred47kzduec920z88wfr64ylksmdcedlf5") ) // TestSeededSurvivesInit is the property the seed exists for: this realm is // private = true, so a redeploy re-runs init() and wipes everything else. The // one schedule the page is about has to come back without a transaction. func TestSeededSurvivesInit(t *testing.T) { o, s, e, delayed, ok := ScheduleOf(moul) uassert.True(t, ok, "moul's schedule is missing after init") uassert.Equal(t, int64(106560000000), o) uassert.Equal(t, mainnetStart, s) uassert.Equal(t, mainnetEnd, e) uassert.False(t, delayed) } // TestDeclareWritesOnlyYourOwnRow is the entire trust model. A caller cannot // name an address, so it cannot describe one but its own. func TestDeclareWritesOnlyYourOwnRow(cur realm, t *testing.T) { alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") testing.SetRealm(testing.NewUserRealm(alice)) Declare(cross(cur), 500, 100, 200, false) o, s, e, delayed, ok := ScheduleOf(alice) uassert.True(t, ok) uassert.Equal(t, int64(500), o) uassert.Equal(t, int64(100), s) uassert.Equal(t, int64(200), e) uassert.False(t, delayed) // Nothing alice did touched bob. _, _, _, _, ok = ScheduleOf(bob) uassert.False(t, ok, "declaring for alice wrote a row for bob") } func TestDeclareReplacesAndForgetRemoves(cur realm, t *testing.T) { carol := testutils.TestAddress("carol") testing.SetRealm(testing.NewUserRealm(carol)) Declare(cross(cur), 500, 100, 200, false) testing.SetRealm(testing.NewUserRealm(carol)) Declare(cross(cur), 900, 10, 20, true) o, s, e, delayed, ok := ScheduleOf(carol) uassert.True(t, ok) uassert.Equal(t, int64(900), o) uassert.Equal(t, int64(10), s) uassert.Equal(t, int64(20), e) uassert.True(t, delayed, "the delayed flag did not survive") testing.SetRealm(testing.NewUserRealm(carol)) Forget(cross(cur)) _, _, _, _, ok = ScheduleOf(carol) uassert.False(t, ok, "Forget left the row behind") } func TestForgetWithoutADeclarationAborts(cur realm, t *testing.T) { dave := testutils.TestAddress("dave") testing.SetRealm(testing.NewUserRealm(dave)) uassert.AbortsContains(t, cur, "nothing declared", func() { Forget(cross(cur)) }) } // TestSeededRowIsNotWritable: the seeds are source, so a signer who happens to // hold a seeded address still cannot rewrite it from a transaction. Otherwise // a redeploy would silently revert whatever they set. func TestSeededRowIsNotWritable(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(moul)) uassert.AbortsContains(t, cur, "seeded in the source", func() { Declare(cross(cur), 1, 2, 3, false) }) testing.SetRealm(testing.NewUserRealm(moul)) uassert.AbortsContains(t, cur, "seeded in the source", func() { Forget(cross(cur)) }) } func TestDeclareRejectsAnImpossibleSchedule(cur realm, t *testing.T) { erin := testutils.TestAddress("erin") testing.SetRealm(testing.NewUserRealm(erin)) uassert.AbortsContains(t, cur, "start time must be before end time", func() { Declare(cross(cur), 100, 200, 100, false) }) testing.SetRealm(testing.NewUserRealm(erin)) uassert.AbortsContains(t, cur, "cannot be negative", func() { Declare(cross(cur), -1, 0, 100, false) }) } // TestDeclareZeroMeansNoSchedule lets an address say "nothing of mine is // locked", which is a real and useful claim. func TestDeclareZeroMeansNoSchedule(cur realm, t *testing.T) { frank := testutils.TestAddress("frank") testing.SetRealm(testing.NewUserRealm(frank)) Declare(cross(cur), 0, 0, 0, false) o, _, _, _, ok := ScheduleOf(frank) uassert.True(t, ok) uassert.Equal(t, int64(0), o) spend, known := SpendableOf(frank) uassert.True(t, known) uassert.Equal(t, balanceOf(frank), spend) } // TestUnknownAddressReportsItsBalanceAndSaysSo: the balance is real, the // spendable figure is not, and the caller is told which is which. func TestUnknownAddressReportsItsBalanceAndSaysSo(t *testing.T) { stranger := testutils.TestAddress("stranger") spend, ok := SpendableOf(stranger) uassert.False(t, ok, "an unknown address must not claim a known schedule") uassert.Equal(t, balanceOf(stranger), spend) locked, ok := LockedOf(stranger) uassert.False(t, ok) uassert.Equal(t, int64(0), locked) } func TestCountTracksTheRegistry(cur realm, t *testing.T) { before := Count() grace := testutils.TestAddress("grace") testing.SetRealm(testing.NewUserRealm(grace)) Declare(cross(cur), 10, 1, 2, false) uassert.Equal(t, before+1, Count()) testing.SetRealm(testing.NewUserRealm(grace)) Forget(cross(cur)) uassert.Equal(t, before, Count()) } // TestLockedIsCappedByTheBalance: an account that spent while its coins were // free can owe the schedule more than it now holds. The honest answer is that // nothing moves, never a negative spendable. func TestLockedIsCappedByTheBalance(cur realm, t *testing.T) { poor := testutils.TestAddress("poor") testing.SetRealm(testing.NewUserRealm(poor)) Declare(cross(cur), 1000000000000, mainnetStart, mainnetEnd, false) locked, ok := LockedOf(poor) uassert.True(t, ok) uassert.Equal(t, balanceOf(poor), locked, "locked must not exceed the balance") spend, _ := SpendableOf(poor) uassert.Equal(t, int64(0), spend) uassert.True(t, spend >= 0, "spendable went negative") } func TestIsAddr(t *testing.T) { uassert.True(t, isAddr("g1manfred47kzduec920z88wfr64ylksmdcedlf5")) uassert.False(t, isAddr("")) uassert.False(t, isAddr("g1manfred")) uassert.False(t, isAddr("x1manfred47kzduec920z88wfr64ylksmdcedlf5")) uassert.False(t, isAddr("g1MANFRED47kzduec920z88wfr64ylksmdcedlf5")) uassert.False(t, isAddr("g1manfred47kzduec920z88wfr64ylksmdcedlf5x")) } func TestGnotFormatting(t *testing.T) { uassert.Equal(t, "0.000000 GNOT", gnot(0)) uassert.Equal(t, "0.000001 GNOT", gnot(1)) uassert.Equal(t, "1.000000 GNOT", gnot(1000000)) uassert.Equal(t, "106560.000000 GNOT", gnot(106560000000)) uassert.Equal(t, "1.234567 GNOT", gnot(1234567)) uassert.Equal(t, "-1.500000 GNOT", gnot(-1500000)) } func TestPermilleFormatting(t *testing.T) { uassert.Equal(t, "0.0%", permille(0)) uassert.Equal(t, "5.0%", permille(50)) uassert.Equal(t, "49.9%", permille(499)) uassert.Equal(t, "100.0%", permille(1000)) } func TestDurationFormatting(t *testing.T) { uassert.Equal(t, "complete", duration(0)) uassert.Equal(t, "complete", duration(-1)) uassert.Equal(t, "0h", duration(60)) uassert.Equal(t, "1h", duration(3600)) uassert.Equal(t, "1d 0h", duration(86400)) uassert.Equal(t, "2d 3h", duration(2*86400+3*3600)) } // TestCivilDates pins the date arithmetic against dates that are known exactly, // including the two ends of the real mainnet term and a leap day. func TestCivilDates(t *testing.T) { uassert.Equal(t, "1970-01-01", tstamp(0)) uassert.Equal(t, "2000-02-29", tstamp(951782400)) uassert.Equal(t, "2026-09-12", tstamp(mainnetStart)) uassert.Equal(t, "2028-09-12", tstamp(mainnetEnd)) } // TestRenderShapes walks the three routes the page serves and checks each one // names its source, which is the promise the whole realm makes. func TestRenderShapes(t *testing.T) { index := Render("") uassert.True(t, strings.Contains(index, "# Vesting"), "index has no title") uassert.True(t, strings.Contains(index, "no realm can read one"), "index does not say the schedule is supplied") seeded := Render(moul.String()) uassert.True(t, strings.Contains(seeded, "seeded in this realm's source"), "a seeded row does not say where it came from") uassert.True(t, strings.Contains(seeded, "spendable now")) query := Render(moul.String() + "?o=1000000&s=100&e=200") uassert.True(t, strings.Contains(query, "supplied in the URL by you"), "a query schedule does not say it was supplied") uassert.True(t, strings.Contains(query, "Nothing was stored.")) unknown := Render(testutils.TestAddress("nobody").String()) uassert.True(t, strings.Contains(unknown, "No schedule on file")) uassert.True(t, strings.Contains(unknown, "gnokey query auth/accounts/"), "the unknown page does not say how to find the schedule") bad := Render("not-an-address") uassert.True(t, strings.Contains(bad, "is not a gno.land address")) } // TestRenderEscapesWhatTheCallerTyped: the path is attacker-controlled and a // live realm can never be patched, so the escaping is checked before deploy. func TestRenderEscapesWhatTheCallerTyped(t *testing.T) { out := Render("[x](/r/evil)") uassert.False(t, strings.Contains(out, "[x](/r/evil)"), "a markdown link typed into the path reached the page unescaped") } // TestRenderQueryRejectsGarbage keeps a typo readable instead of rendering a // schedule of zeroes as though it were real. func TestRenderQueryRejectsGarbage(t *testing.T) { out := Render(moul.String() + "?o=abc") uassert.True(t, strings.Contains(out, "is not a number")) out = Render(moul.String() + "?o=100&s=200&e=100") uassert.True(t, strings.Contains(out, "not a valid schedule")) } // TestChainHeightIsReadable guards the one call that would make Declare // non-deterministic if it were wrong. func TestChainHeightIsReadable(t *testing.T) { uassert.Equal(t, runtime.ChainHeight(), chainHeight()) }
- Attached funds
- 5000000ugnot
Arguments · 9
- #1cliffvestingdemo
- #2README.md
- #3# `gno.land/r/moul/x/daily/cliffvestingdemo/v0` **Live demo of [`p/moul/x/daily/cliffvesting`](https://github.com/moul/gno-contracts/tree/main/p/moul/x/daily/cliffvesting).** A thin, stateless realm: it holds no state of its own and contains none of the library's logic — `Render` just exercises the package and shows the result. Because it is stateless, the rendered page is identical on every call and on every node. Render it at [`/r/moul/x/daily/cliffvestingdemo/v0`](https://gno.land/r/moul/x/daily/cliffvestingdemo/v0). The library README explains the design and the trade-offs; this realm is the worked example. <!-- 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. **Dependency graph:**  > 🧪 **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 -->
- #4cliffvestingdemo.gno
- #5// Package cliffvestingdemo is a small gnoweb demo of the vesting calculator // provided by the [p/moul/x/daily/cliffvesting](/p/moul/x/daily/cliffvesting/v1) // library: the cliff step, the linear ramp, integer rounding, and the two // arithmetic bugs the library exists to avoid. // // It contains no vesting logic of its own and holds no coins. Stateless, so // Render is deterministic, which is precisely what the library is for. package cliffvestingdemo import ( "strconv" "strings" "gno.land/p/moul/x/daily/cliffvesting/v1" ) // Render renders the demo for gnoweb. func Render(path string) string { var b strings.Builder b.WriteString("# Cliff Vesting\n\n") b.WriteString("A pure vesting calculator, demoing the ") b.WriteString("[`p/moul/x/daily/cliffvesting`](/p/moul/x/daily/cliffvesting/v1) library.\n\n") // 1200 tokens over 12 months, 3-month cliff. s, err := cliffvesting.New(1200, 0, 3, 12) if err != nil { return "error: " + err.Error() } b.WriteString("## A 12-month grant with a 3-month cliff\n\n") b.WriteString("`1200` tokens, `Start = 0`, `Cliff = 3`, `End = 12`.\n\n") b.WriteString("| month | vested | unvested | % |\n|---|---|---|---|\n") for m := int64(0); m <= 12; m++ { note := "" switch m { case 2: note = " ← nothing yet" case 3: note = " ← **cliff**" case 12: note = " ← fully vested" } b.WriteString("| " + strconv.FormatInt(m, 10) + " | " + strconv.FormatInt(s.Vested(m), 10) + " | " + strconv.FormatInt(s.Unvested(m), 10) + " | " + strconv.FormatInt(s.PercentVested(m), 10) + "%" + note + " |\n") } b.WriteString("\nThe cliff is a **step, not a ramp**: at month 3 a quarter of the term ") b.WriteString("has elapsed, so `") b.WriteString(strconv.FormatInt(s.CliffAmount(), 10)) b.WriteString("` tokens unlock at once. After that it accrues linearly.\n\n") b.WriteString("## Claiming\n\n") b.WriteString("The library tracks no balances, so the caller supplies what has already ") b.WriteString("been claimed:\n\n") b.WriteString("| at month | already claimed | claimable |\n|---|---|---|\n") for _, c := range [][2]int64{{6, 0}, {6, 300}, {6, 600}, {12, 600}} { b.WriteString("| " + strconv.FormatInt(c[0], 10) + " | " + strconv.FormatInt(c[1], 10) + " | " + strconv.FormatInt(s.Claimable(c[0], c[1]), 10) + " |\n") } b.WriteString("\n## Integer rounding\n\n") b.WriteString("All arithmetic is integer, no float ever reaches consensus state. ") b.WriteString("Rounding is **down**, so nobody is ever paid more than they earned, ") b.WriteString("and the final instalment collects the remainder.\n\n") odd, _ := cliffvesting.NewLinear(1000, 0, 3) b.WriteString("`1000` over `3` periods:\n\n") b.WriteString("| t | vested |\n|---|---|\n") for m := int64(0); m <= 3; m++ { b.WriteString("| " + strconv.FormatInt(m, 10) + " | " + strconv.FormatInt(odd.Vested(m), 10) + " |\n") } b.WriteString("\n`333 + 333 + 334`, and the end is exactly `1000`, never `999`.\n\n") b.WriteString("## Bug 1: a rate truncated to zero\n\n") tiny, _ := cliffvesting.NewLinear(7, 0, 1000) b.WriteString("When the total is smaller than the duration, computing a per-tick rate ") b.WriteString("first truncates it to zero and **nothing ever vests**. Multiplying ") b.WriteString("before dividing keeps it honest. `7` tokens over `1000` ticks:\n\n") b.WriteString("| t | vested |\n|---|---|\n") for _, m := range []int64{100, 150, 500, 1000} { b.WriteString("| " + strconv.FormatInt(m, 10) + " | " + strconv.FormatInt(tiny.Vested(m), 10) + " |\n") } b.WriteString("\n## Bug 2: a product that leaves int64\n\n") b.WriteString("Multiplying first is only safe if the product fits. Over a term ") b.WriteString("measured in **seconds**, `total * elapsed` passes 2^63 for any grant ") b.WriteString("above about `146,036` whole coins, and a wrapped int64 is still a ") b.WriteString("valid int64: `v0` of this library returned a **negative** vested ") b.WriteString("amount and nothing signalled it. `v1` routes the product through a ") b.WriteString("128-bit intermediate.\n\n") b.WriteString("A real two-year schedule, `1789225200` to `1852383600`, at the ") b.WriteString("halfway mark:\n\n") b.WriteString("| grant | vested at halfway | expected |\n|---|---|---|\n") const ( vStart int64 = 1789225200 vEnd int64 = 1852383600 ) for _, total := range []int64{100000000000, 146037000000, 318720000000000} { big, err := cliffvesting.NewLinear(total, vStart, vEnd) if err != nil { continue } b.WriteString("| " + strconv.FormatInt(total, 10) + " | " + strconv.FormatInt(big.Vested(vStart+(vEnd-vStart)/2), 10) + " | " + strconv.FormatInt(total/2, 10) + " |\n") } return b.String() }
- #6gnomod.toml
- #7module = "gno.land/r/moul/x/daily/cliffvestingdemo/v1" gno = "0.9" private = true
- #8render_example_test.gno
- #9package cliffvestingdemo // ExampleRender pins the realm's Render output as a testable example. func ExampleRender() { print(Render("")) // Output: // # Cliff Vesting // // A pure vesting calculator, demoing the [`p/moul/x/daily/cliffvesting`](/p/moul/x/daily/cliffvesting/v1) library. // // ## A 12-month grant with a 3-month cliff // // `1200` tokens, `Start = 0`, `Cliff = 3`, `End = 12`. // // | month | vested | unvested | % | // |---|---|---|---| // | 0 | 0 | 1200 | 0% | // | 1 | 0 | 1200 | 0% | // | 2 | 0 | 1200 | 0% ← nothing yet | // | 3 | 300 | 900 | 25% ← **cliff** | // | 4 | 400 | 800 | 33% | // | 5 | 500 | 700 | 41% | // | 6 | 600 | 600 | 50% | // | 7 | 700 | 500 | 58% | // | 8 | 800 | 400 | 66% | // | 9 | 900 | 300 | 75% | // | 10 | 1000 | 200 | 83% | // | 11 | 1100 | 100 | 91% | // | 12 | 1200 | 0 | 100% ← fully vested | // // The cliff is a **step, not a ramp**: at month 3 a quarter of the term has elapsed, so `300` tokens unlock at once. After that it accrues linearly. // // ## Claiming // // The library tracks no balances, so the caller supplies what has already been claimed: // // | at month | already claimed | claimable | // |---|---|---| // | 6 | 0 | 600 | // | 6 | 300 | 300 | // | 6 | 600 | 0 | // | 12 | 600 | 600 | // // ## Integer rounding // // All arithmetic is integer, no float ever reaches consensus state. Rounding is **down**, so nobody is ever paid more than they earned, and the final instalment collects the remainder. // // `1000` over `3` periods: // // | t | vested | // |---|---| // | 0 | 0 | // | 1 | 333 | // | 2 | 666 | // | 3 | 1000 | // // `333 + 333 + 334`, and the end is exactly `1000`, never `999`. // // ## Bug 1: a rate truncated to zero // // When the total is smaller than the duration, computing a per-tick rate first truncates it to zero and **nothing ever vests**. Multiplying before dividing keeps it honest. `7` tokens over `1000` ticks: // // | t | vested | // |---|---| // | 100 | 0 | // | 150 | 1 | // | 500 | 3 | // | 1000 | 7 | // // ## Bug 2: a product that leaves int64 // // Multiplying first is only safe if the product fits. Over a term measured in **seconds**, `total * elapsed` passes 2^63 for any grant above about `146,036` whole coins, and a wrapped int64 is still a valid int64: `v0` of this library returned a **negative** vested amount and nothing signalled it. `v1` routes the product through a 128-bit intermediate. // // A real two-year schedule, `1789225200` to `1852383600`, at the halfway mark: // // | grant | vested at halfway | expected | // |---|---|---| // | 100000000000 | 50000000000 | 50000000000 | // | 146037000000 | 73018500000 | 73018500000 | // | 318720000000000 | 159360000000000 | 159360000000000 | }
Result log
msg:0,success:true,log:,events:[] msg:1,success:true,log:,events:[]