Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

vesting.gno

19.64 Kb · 547 lines
  1// Package vesting holds grants of GNOT, BUBBLE or any registered GRC20 and pays each out on its own schedule:
  2// a share at the start, the rest by the day over a term, to one beneficiary, for as long as the grant's owner
  3// does not revoke it. Anyone may open a grant for anyone; nothing here is a setting but each grant's owner.
  4//
  5// Every grant has its OWN address — the realm's sub-realm `grant-<id>` (chain.DerivePkgSubAddr) — and that is how
  6// it is funded: send the tokens there (a bank send for GNOT, Transfer for a GRC20, a holders' vote that mints
  7// BUBBLE to it). The realm moves tokens out of a grant's address and never between grants; what sits at the
  8// realm's main address belongs to nobody and stays there, so send only to a grant's address.
  9//
 10// The schedule. Nothing before start, or before start + cliff; at start the up-front share is vested; each full
 11// day after start vests an equal share of the rest, until day Days, when all of Total has vested (Days 0: all of
 12// it at start). Claim pays the beneficiary what has vested and not yet been paid, as far as the grant's balance
 13// goes; anyone may call it, since the money has one destination. Revoke, by the owner, freezes the schedule where
 14// it stands and returns everything above what has vested to the owner; what had vested stays the beneficiary's to
 15// claim. A revoke before the up-front share has arrived (what was already paid counts as arrived) VOIDS the grant:
 16// nothing vests, and whatever lands later is the owner's to sweep. Sweep, by the owner, returns what the schedule can never owe (a gift,
 17// a double funding) without ending anything. A grant opened with no owner is irrevocable, and its excess is the
 18// beneficiary's to sweep.
 19//
 20// Votes. BUBBLE held by a grant is self-delegated, inside the token's ledger, to the grant's own address, and this
 21// realm never delegates it or votes with it; the beneficiary's weight grows as they claim.
 22//
 23// Immutable, not private (gnomod.toml).
 24package vesting
 25
 26import (
 27	"chain"
 28	"chain/banker"
 29	"strconv"
 30	"strings"
 31	"time"
 32
 33	"gno.land/p/nt/avl/v0"
 34	"gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubble"
 35	"gno.land/r/nt/grc20reg/v0"
 36)
 37
 38const (
 39	pkgPath = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/vesting" // must match gnomod.toml
 40	day     = int64(86400)
 41
 42	// GNOT and BUBBLE are the two tokens named directly; every other token is a registry key
 43	// (gno.land/r/nt/grc20reg/v0) such as gno.land/r/g1leu8d2…95wr/wbubble.BUBBLE.
 44	GNOT   = "ugnot"
 45	BUBBLE = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubble"
 46
 47	MaxDays = int64(36500) // a term, and a cliff, of at most a hundred years: the arithmetic below counts on it
 48	maxNote = 120
 49	listMax = 50 // grants the front page lists
 50)
 51
 52type kind int
 53
 54const (
 55	kindNative kind = iota
 56	kindBubble
 57	kindRegistry
 58)
 59
 60// Grant is one schedule. The fields are read through Get; only this realm writes them.
 61type Grant struct {
 62	ID          int64
 63	Token       string  // GNOT, BUBBLE or a registry key
 64	Creator     address // who opened it (named on the page: an owner is named, not implied to have opened it)
 65	Owner       address // may revoke and sweep; "" for an irrevocable grant
 66	OwnerNext   address // named by the owner, taken by the named: the two-step hand-off
 67	Beneficiary address
 68	Total       int64 // base units
 69	Upfront     int64 // vested at start
 70	Start       int64 // unix time the schedule counts from
 71	CliffDays   int64 // nothing is claimable before Start + CliffDays
 72	Days        int64 // the rest vests by the day over this many days after Start
 73	Claimed     int64 // paid to the beneficiary
 74	Returned    int64 // sent back to the owner
 75	RevokedAt   int64 // 0 while the grant runs
 76	Voided      bool  // revoked before anything arrived or was paid: nothing vests
 77	Note        string
 78}
 79
 80var (
 81	grants = avl.NewTree() // key(id) → *Grant
 82	last   int64
 83)
 84
 85// init refuses a deploy at a path other than pkgPath: Address() would then name sub-addresses nothing controls.
 86func init(cur realm) {
 87	if cur.Address() != chain.PackageAddress(pkgPath) {
 88		panic("vesting: deployed at a path other than pkgPath")
 89	}
 90}
 91
 92// ---------------------------------------------------------------- reading --
 93
 94func Count() int64 { return last }
 95
 96// Get is a copy of the grant.
 97func Get(id int64) Grant { return *get(id) }
 98
 99// Address is where a grant is funded: the realm's sub-realm for it. Only for a grant that exists.
100func Address(id int64) address {
101	get(id)
102	return chain.DerivePkgSubAddr(pkgPath, subpath(id))
103}
104
105// Balance is what the grant's address holds of its token.
106func Balance(id int64) int64 { return balance(get(id)) }
107
108// Vested is what the schedule has released by now — or by the revoke, if there was one. Never above Total.
109func Vested(id int64) int64 { return vested(get(id)) }
110
111// Claimable is what Claim would pay now: the vested part not yet paid, as far as the balance goes.
112func Claimable(id int64) int64 { return claimable(get(id)) }
113
114func subpath(id int64) string { return "grant-" + strconv.FormatInt(id, 10) }
115
116func key(id int64) string {
117	s := strconv.FormatInt(id, 10)
118	for len(s) < 12 {
119		s = "0" + s
120	}
121	return s
122}
123
124func get(id int64) *Grant {
125	if id <= 0 || id > last {
126		panic("vesting: no grant " + strconv.FormatInt(id, 10))
127	}
128	return grants.Get(key(id)).(*Grant)
129}
130
131func balance(g *Grant) int64 {
132	addr := chain.DerivePkgSubAddr(pkgPath, subpath(g.ID))
133	switch kindOf(g.Token) {
134	case kindNative:
135		return banker.NewReadonlyBanker().GetCoin(addr, GNOT) // one denom read: dust in other denoms cannot bloat it
136	case kindBubble:
137		return bubble.BalanceOf(addr)
138	}
139	return grc20reg.MustGet(g.Token).BalanceOf(addr)
140}
141
142func vested(g *Grant) int64 {
143	if g.Voided {
144		return 0
145	}
146	t := time.Now().Unix()
147	if g.RevokedAt != 0 {
148		t = g.RevokedAt
149	}
150	return vestedAt(g, t)
151}
152
153// vestedAt is the schedule itself. The split of rest*d/Days into a quotient and a remainder part keeps every
154// intermediate inside int64 for any Total and a term under MaxDays, and equals the exact floor.
155func vestedAt(g *Grant, t int64) int64 {
156	if t < g.Start || t < g.Start+g.CliffDays*day {
157		return 0
158	}
159	d := (t - g.Start) / day
160	if g.Days == 0 || d >= g.Days {
161		return g.Total
162	}
163	rest := g.Total - g.Upfront
164	return g.Upfront + (rest/g.Days)*d + (rest%g.Days)*d/g.Days
165}
166
167func claimable(g *Grant) int64 {
168	due := vested(g) - g.Claimed
169	if bal := balance(g); due > bal {
170		due = bal
171	}
172	if due < 0 {
173		return 0
174	}
175	return due
176}
177
178func kindOf(token string) kind {
179	switch token {
180	case GNOT:
181		return kindNative
182	case BUBBLE:
183		return kindBubble
184	}
185	return kindRegistry
186}
187
188// canon names the token as the realm stores it: GNOT and BUBBLE by their constants, anything else by its registry
189// key, which must resolve.
190func canon(token string) string {
191	switch strings.ToLower(token) {
192	case "gnot", "ugnot":
193		return GNOT
194	case "bubble", BUBBLE:
195		return BUBBLE
196	}
197	if grc20reg.Get(token) == nil {
198		panic("vesting: unknown token " + token + ": say gnot, bubble, or a key of gno.land/r/nt/grc20reg/v0")
199	}
200	return token
201}
202
203// ---------------------------------------------------------------- writing --
204
205func caller(cur realm) address {
206	if !cur.IsCurrent() {
207		panic("vesting: spoofed realm")
208	}
209	return cur.Previous().Address()
210}
211
212func mustBeOwner(cur realm, g *Grant) {
213	if g.Owner == "" {
214		panic("vesting: this grant has no owner; it cannot be revoked or handed on")
215	}
216	if caller(cur) != g.Owner {
217		panic("vesting: only the grant's owner")
218	}
219}
220
221// Create opens a grant and returns its id. owner may revoke it ("" makes it irrevocable); the tokens are sent to
222// Address(id) afterwards, by anyone. token: "gnot", "bubble" or a registry key. start 0 is now; a past start is
223// allowed, a future one up to a hundred years out. days 0 vests everything at start. note is a label of up to 120
224// plain characters (no markup: the pages render it).
225func Create(cur realm, owner, beneficiary address, token string, total, upfront, start, cliffDays, days int64, note string) int64 {
226	creator := caller(cur)
227	if !beneficiary.IsValid() {
228		panic("vesting: the beneficiary is not an address")
229	}
230	if owner != "" && !owner.IsValid() {
231		panic("vesting: the owner is not an address")
232	}
233	if owner == beneficiary {
234		panic("vesting: the owner cannot be the beneficiary")
235	}
236	if total <= 0 {
237		panic("vesting: the total must be positive")
238	}
239	if upfront < 0 || upfront > total {
240		panic("vesting: the up-front share is between 0 and the total")
241	}
242	if days < 0 || days > MaxDays || cliffDays < 0 || cliffDays > MaxDays {
243		panic("vesting: days and cliffDays are between 0 and " + strconv.FormatInt(MaxDays, 10))
244	}
245	now := time.Now().Unix()
246	if start < 0 || start > now+MaxDays*day {
247		panic("vesting: start is a unix time within a hundred years, or 0 for now")
248	}
249	if start == 0 {
250		start = now
251	}
252	if next := chain.DerivePkgSubAddr(pkgPath, subpath(last+1)); beneficiary == next || owner == next {
253		panic("vesting: a grant cannot pay its own address")
254	}
255	if len(note) > maxNote {
256		panic("vesting: the note is at most " + strconv.Itoa(maxNote) + " characters")
257	}
258	for i := 0; i < len(note); i++ {
259		if note[i] < 0x20 || note[i] == 0x7f || strings.IndexByte("[]()<>`*_#|\\", note[i]) >= 0 {
260			panic("vesting: the note is plain text without markup")
261		}
262	}
263	token = canon(token)
264	last++
265	g := &Grant{ID: last, Token: token, Creator: creator, Owner: owner, Beneficiary: beneficiary, Total: total, Upfront: upfront, Start: start, CliffDays: cliffDays, Days: days, Note: note}
266	grants.Set(key(last), g)
267	chain.Emit("Grant", "id", strconv.FormatInt(last, 10), "token", token, "to", beneficiary.String(), "owner", owner.String(), "by", creator.String(), "total", strconv.FormatInt(total, 10), "address", Address(last).String())
268	return last
269}
270
271// Claim pays the beneficiary everything claimable and returns the amount. Anyone may call it.
272func Claim(cur realm, id int64) int64 {
273	caller(cur)
274	g := get(id)
275	n := claimable(g)
276	if n <= 0 {
277		if vested(g) > g.Claimed {
278			panic("vesting: nothing here to pay yet; the grant's address holds less than is due")
279		}
280		panic("vesting: nothing to claim")
281	}
282	g.Claimed += n
283	send(cur, g, g.Beneficiary, n)
284	chain.Emit("Claim", "id", strconv.FormatInt(id, 10), "to", g.Beneficiary.String(), "amount", strconv.FormatInt(n, 10))
285	return n
286}
287
288// Revoke ends the schedule where it stands (the first time) and returns to the owner everything the grant holds
289// beyond what has vested and is still the beneficiary's to claim. Before the up-front share has arrived, counting
290// what was already paid out as arrived, it voids the grant instead: nothing vests, ever — so a unit of dust sent
291// ahead of the owner's revoke, claimed or not, cannot turn a void into a freeze that keeps the up-front share. (A
292// grant with no up-front share measures against what the schedule had released by the revoke.) Returns what went
293// back; 0 when nothing did.
294func Revoke(cur realm, id int64) int64 {
295	g := get(id)
296	mustBeOwner(cur, g)
297	if g.RevokedAt == 0 {
298		g.RevokedAt = time.Now().Unix()
299		floor := g.Upfront
300		if floor == 0 {
301			floor = vestedAt(g, g.RevokedAt)
302		}
303		if floor == 0 {
304			floor = 1
305		}
306		g.Voided = g.Claimed+balance(g) < floor
307		chain.Emit("Revoked", "id", strconv.FormatInt(id, 10), "at", strconv.FormatInt(g.RevokedAt, 10), "voided", strconv.FormatBool(g.Voided))
308	}
309	return sweep(cur, g)
310}
311
312// Sweep returns to the owner what the schedule can never owe the beneficiary — above the unpaid rest of the grant
313// while it runs, above what is still claimable once revoked — without ending anything. Owner only; on a grant with
314// no owner the excess is the beneficiary's, and anyone may send it there.
315func Sweep(cur realm, id int64) int64 {
316	g := get(id)
317	if g.Owner == "" {
318		caller(cur)
319		return sweepTo(cur, g, g.Beneficiary)
320	}
321	mustBeOwner(cur, g)
322	return sweep(cur, g)
323}
324
325func sweep(cur realm, g *Grant) int64 { return sweepTo(cur, g, g.Owner) }
326
327func sweepTo(cur realm, g *Grant, to address) int64 {
328	owed := g.Total - g.Claimed
329	if g.RevokedAt != 0 {
330		owed = claimable(g)
331	}
332	back := balance(g) - owed
333	if back <= 0 {
334		return 0
335	}
336	g.Returned += back
337	send(cur, g, to, back)
338	chain.Emit("Returned", "id", strconv.FormatInt(g.ID, 10), "to", to.String(), "amount", strconv.FormatInt(back, 10))
339	return back
340}
341
342// send moves n of the grant's token out of the grant's own address: the sub-realm is the sender, so one grant can
343// never pay from another's balance.
344func send(cur realm, g *Grant, to address, n int64) {
345	sub := cur.Sub(subpath(g.ID))
346	switch kindOf(g.Token) {
347	case kindNative:
348		banker.NewBanker(banker.BankerTypeRealmSend, sub).SendCoins(sub.Address(), to, chain.Coins{{GNOT, n}})
349	case kindBubble:
350		bubble.Transfer(cross(sub), to, n)
351	default:
352		tok := grc20reg.MustGet(g.Token)
353		if err := tok.RealmTeller(0, sub).Transfer(0, sub, to, n); err != nil {
354			panic("vesting: " + err.Error())
355		}
356	}
357}
358
359// SetOwner names the next owner; nothing moves until they AcceptOwner. Naming "" withdraws the offer.
360func SetOwner(cur realm, id int64, to address) {
361	g := get(id)
362	mustBeOwner(cur, g)
363	if to != "" && !to.IsValid() {
364		panic("vesting: not an address")
365	}
366	if to == g.Beneficiary {
367		panic("vesting: the owner cannot be the beneficiary")
368	}
369	g.OwnerNext = to
370	chain.Emit("OwnerNext", "id", strconv.FormatInt(id, 10), "to", to.String())
371}
372
373// AcceptOwner takes the owner role of a grant, by the one named.
374func AcceptOwner(cur realm, id int64) {
375	g := get(id)
376	who := caller(cur)
377	if g.OwnerNext == "" || who != g.OwnerNext {
378		panic("vesting: not the named next owner")
379	}
380	g.Owner, g.OwnerNext = who, ""
381	chain.Emit("Owner", "id", strconv.FormatInt(id, 10), "to", who.String())
382}
383
384// ----------------------------------------------------------------- pages --
385
386// Render: "" the newest grants; "<id>" one grant; "json/<id>" its figures in base units; "by/<address>" the grants
387// an address owns or receives.
388func Render(path string) string {
389	parts := strings.Split(path, "/")
390	switch {
391	case path == "":
392		return home()
393	case len(parts) == 1:
394		id, err := strconv.ParseInt(parts[0], 10, 64)
395		if err != nil || id <= 0 || id > last {
396			return "404"
397		}
398		return page(get(id))
399	case len(parts) == 2 && parts[0] == "json":
400		id, err := strconv.ParseInt(parts[1], 10, 64)
401		if err != nil || id <= 0 || id > last {
402			return "404"
403		}
404		return json(get(id))
405	case len(parts) == 2 && parts[0] == "by":
406		return by(address(parts[1]))
407	}
408	return "404"
409}
410
411func home() string {
412	var b strings.Builder
413	b.WriteString("# Vesting grants\n\n")
414	b.WriteString(strconv.FormatInt(last, 10) + " grants. Anyone opens one with Create (owner, beneficiary, token, total, up-front share, start, cliff days, days, a note) and funds it by sending the token to the grant's own address; Claim pays the beneficiary what has vested; the owner alone can revoke. Tokens: GNOT, BUBBLE, or any key of the GRC20 registry.\n\n")
415	n := 0
416	grants.ReverseIterate("", "", func(_ string, v any) bool {
417		g := v.(*Grant)
418		b.WriteString("- [#" + strconv.FormatInt(g.ID, 10) + "](" + pkgPath + ":" + strconv.FormatInt(g.ID, 10) + ") " + amount(g, g.Total) + " to " + g.Beneficiary.String() + " · " + state(g))
419		if g.Note != "" {
420			b.WriteString(" · " + g.Note)
421		}
422		b.WriteString("\n")
423		n++
424		return n >= listMax
425	})
426	return b.String()
427}
428
429func page(g *Grant) string {
430	var b strings.Builder
431	b.WriteString("# Grant #" + strconv.FormatInt(g.ID, 10))
432	if g.Note != "" {
433		b.WriteString(": " + g.Note)
434	}
435	b.WriteString("\n\n")
436	b.WriteString("- to: " + g.Beneficiary.String() + "\n")
437	b.WriteString("- grant: " + amount(g, g.Total) + " — " + amount(g, g.Upfront) + " at the start")
438	if g.Days > 0 {
439		b.WriteString(", the rest by the day over " + strconv.FormatInt(g.Days, 10) + " days")
440	}
441	b.WriteString(" from " + date(g.Start))
442	if g.CliffDays > 0 {
443		b.WriteString("; nothing before " + date(g.Start+g.CliffDays*day) + " (a cliff of " + strconv.FormatInt(g.CliffDays, 10) + " days)")
444	}
445	b.WriteString("\n")
446	b.WriteString("- vested: " + amount(g, vested(g)) + " · paid: " + amount(g, g.Claimed) + " · claimable now: " + amount(g, claimable(g)) + " · held at the grant's address: " + amount(g, balance(g)) + "\n")
447	b.WriteString("- state: " + state(g))
448	switch {
449	case g.Voided:
450		b.WriteString(" — revoked on " + date(g.RevokedAt) + " before anything arrived; nothing vests; " + amount(g, g.Returned) + " went back to the owner")
451	case g.RevokedAt != 0:
452		b.WriteString(" — revoked on " + date(g.RevokedAt) + "; the schedule stopped there; " + amount(g, g.Returned) + " went back to the owner")
453	case g.Owner == "":
454		b.WriteString(" — irrevocable: nobody can end it; anything beyond the grant is the beneficiary's (Sweep)")
455	default:
456		b.WriteString(" — the owner can revoke; what has vested by then stays the beneficiary's")
457	}
458	b.WriteString("\n")
459	if g.Owner != "" {
460		b.WriteString("- owner: " + g.Owner.String())
461		if g.OwnerNext != "" {
462			b.WriteString(" (hand-off offered to " + g.OwnerNext.String() + ")")
463		}
464		b.WriteString("\n")
465	}
466	b.WriteString("- opened by: " + g.Creator.String() + "\n")
467	b.WriteString("- token: " + g.Token + "\n")
468	b.WriteString("- the grant's address, to fund it: " + chain.DerivePkgSubAddr(pkgPath, subpath(g.ID)).String() + "\n\n")
469	b.WriteString("Claim pays the beneficiary what has vested and not been paid; anyone may call it. A token held here carries no vote until it is claimed.\n")
470	return b.String()
471}
472
473func by(who address) string {
474	if !who.IsValid() { // the path is anyone's text: only an address is echoed onto the page
475		return "404"
476	}
477	var b strings.Builder
478	b.WriteString("# Grants of " + who.String() + "\n\n")
479	n := 0
480	grants.ReverseIterate("", "", func(_ string, v any) bool {
481		g := v.(*Grant)
482		if g.Owner != who && g.Beneficiary != who {
483			return false
484		}
485		role := "receives"
486		if g.Owner == who {
487			role = "owns"
488		}
489		b.WriteString("- " + role + " [#" + strconv.FormatInt(g.ID, 10) + "](" + pkgPath + ":" + strconv.FormatInt(g.ID, 10) + ") " + amount(g, g.Total) + " · " + state(g) + "\n")
490		n++
491		return n >= listMax
492	})
493	if n == 0 {
494		b.WriteString("none\n")
495	}
496	return b.String()
497}
498
499func json(g *Grant) string {
500	return `{"id":` + strconv.FormatInt(g.ID, 10) + `,"token":"` + g.Token + `","beneficiary":"` + g.Beneficiary.String() + `","owner":"` + g.Owner.String() + `","ownerNext":"` + g.OwnerNext.String() + `","creator":"` + g.Creator.String() +
501		`","address":"` + chain.DerivePkgSubAddr(pkgPath, subpath(g.ID)).String() + `","total":` + strconv.FormatInt(g.Total, 10) + `,"upfront":` + strconv.FormatInt(g.Upfront, 10) +
502		`,"start":` + strconv.FormatInt(g.Start, 10) + `,"cliffDays":` + strconv.FormatInt(g.CliffDays, 10) + `,"days":` + strconv.FormatInt(g.Days, 10) +
503		`,"vested":` + strconv.FormatInt(vested(g), 10) + `,"claimed":` + strconv.FormatInt(g.Claimed, 10) + `,"claimable":` + strconv.FormatInt(claimable(g), 10) + `,"balance":` + strconv.FormatInt(balance(g), 10) +
504		`,"revokedAt":` + strconv.FormatInt(g.RevokedAt, 10) + `,"voided":` + strconv.FormatBool(g.Voided) + `,"returned":` + strconv.FormatInt(g.Returned, 10) + `,"note":"` + strings.ReplaceAll(g.Note, `"`, `\"`) + `"}`
505}
506
507func state(g *Grant) string {
508	switch {
509	case g.Voided:
510		return "voided"
511	case g.RevokedAt != 0:
512		return "revoked"
513	case g.Claimed >= g.Total:
514		return "paid in full"
515	case time.Now().Unix() < g.Start:
516		return "not started"
517	}
518	return "running"
519}
520
521func date(unix int64) string { return time.Unix(unix, 0).UTC().Format("2006-01-02") }
522
523// amount prints base units in the token's own unit, trailing zeros dropped.
524func amount(g *Grant, n int64) string {
525	decimals, symbol := 6, "GNOT"
526	switch kindOf(g.Token) {
527	case kindBubble:
528		symbol = "BUBBLE"
529	case kindRegistry:
530		tok := grc20reg.MustGet(g.Token)
531		decimals, symbol = tok.GetDecimals(), tok.GetSymbol()
532	}
533	one := int64(1)
534	for i := 0; i < decimals; i++ {
535		one *= 10
536	}
537	whole, frac := n/one, n%one
538	s := strconv.FormatInt(whole, 10)
539	if frac != 0 {
540		f := strconv.FormatInt(frac, 10)
541		for len(f) < decimals {
542			f = "0" + f
543		}
544		s += "." + strings.TrimRight(f, "0")
545	}
546	return s + " " + symbol
547}