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}