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

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}