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 owner3// 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 how6// it is funded: send the tokens there (a bank send for GNOT, Transfer for a GRC20, a holders' vote that mints7// BUBBLE to it). The realm moves tokens out of a grant's address and never between grants; what sits at the8// 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 full11// day after start vests an equal share of the rest, until day Days, when all of Total has vested (Days 0: all of12// it at start). Claim pays the beneficiary what has vested and not yet been paid, as far as the grant's balance13// goes; anyone may call it, since the money has one destination. Revoke, by the owner, freezes the schedule where14// it stands and returns everything above what has vested to the owner; what had vested stays the beneficiary's to15// 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 the18// 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 this21// realm never delegates it or votes with it; the beneficiary's weight grows as they claim.22//23// Immutable, not private (gnomod.toml).24package vesting2526import (27 "chain"28 "chain/banker"29 "strconv"30 "strings"31 "time"3233 "gno.land/p/nt/avl/v0"34 "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubble"35 "gno.land/r/nt/grc20reg/v0"36)3738const (39 pkgPath = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/vesting" // must match gnomod.toml40 day = int64(86400)4142 // GNOT and BUBBLE are the two tokens named directly; every other token is a registry key43 // (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"4647 MaxDays = int64(36500) // a term, and a cliff, of at most a hundred years: the arithmetic below counts on it48 maxNote = 12049 listMax = 50 // grants the front page lists50)5152type kind int5354const (55 kindNative kind = iota56 kindBubble57 kindRegistry58)5960// Grant is one schedule. The fields are read through Get; only this realm writes them.61type Grant struct {62 ID int6463 Token string // GNOT, BUBBLE or a registry key64 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 grant66 OwnerNext address // named by the owner, taken by the named: the two-step hand-off67 Beneficiary address68 Total int64 // base units69 Upfront int64 // vested at start70 Start int64 // unix time the schedule counts from71 CliffDays int64 // nothing is claimable before Start + CliffDays72 Days int64 // the rest vests by the day over this many days after Start73 Claimed int64 // paid to the beneficiary74 Returned int64 // sent back to the owner75 RevokedAt int64 // 0 while the grant runs76 Voided bool // revoked before anything arrived or was paid: nothing vests77 Note string78}7980var (81 grants = avl.NewTree() // key(id) → *Grant82 last int6483)8485// 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}9192// ---------------------------------------------------------------- reading --9394func Count() int64 { return last }9596// Get is a copy of the grant.97func Get(id int64) Grant { return *get(id) }9899// 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}104105// Balance is what the grant's address holds of its token.106func Balance(id int64) int64 { return balance(get(id)) }107108// 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)) }110111// 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)) }113114func subpath(id int64) string { return "grant-" + strconv.FormatInt(id, 10) }115116func key(id int64) string {117 s := strconv.FormatInt(id, 10)118 for len(s) < 12 {119 s = "0" + s120 }121 return s122}123124func 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}130131func 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 it136 case kindBubble:137 return bubble.BalanceOf(addr)138 }139 return grc20reg.MustGet(g.Token).BalanceOf(addr)140}141142func vested(g *Grant) int64 {143 if g.Voided {144 return 0145 }146 t := time.Now().Unix()147 if g.RevokedAt != 0 {148 t = g.RevokedAt149 }150 return vestedAt(g, t)151}152153// vestedAt is the schedule itself. The split of rest*d/Days into a quotient and a remainder part keeps every154// 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 0158 }159 d := (t - g.Start) / day160 if g.Days == 0 || d >= g.Days {161 return g.Total162 }163 rest := g.Total - g.Upfront164 return g.Upfront + (rest/g.Days)*d + (rest%g.Days)*d/g.Days165}166167func claimable(g *Grant) int64 {168 due := vested(g) - g.Claimed169 if bal := balance(g); due > bal {170 due = bal171 }172 if due < 0 {173 return 0174 }175 return due176}177178func kindOf(token string) kind {179 switch token {180 case GNOT:181 return kindNative182 case BUBBLE:183 return kindBubble184 }185 return kindRegistry186}187188// canon names the token as the realm stores it: GNOT and BUBBLE by their constants, anything else by its registry189// key, which must resolve.190func canon(token string) string {191 switch strings.ToLower(token) {192 case "gnot", "ugnot":193 return GNOT194 case "bubble", BUBBLE:195 return BUBBLE196 }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 token201}202203// ---------------------------------------------------------------- writing --204205func caller(cur realm) address {206 if !cur.IsCurrent() {207 panic("vesting: spoofed realm")208 }209 return cur.Previous().Address()210}211212func 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}220221// Create opens a grant and returns its id. owner may revoke it ("" makes it irrevocable); the tokens are sent to222// Address(id) afterwards, by anyone. token: "gnot", "bubble" or a registry key. start 0 is now; a past start is223// allowed, a future one up to a hundred years out. days 0 vests everything at start. note is a label of up to 120224// 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 = now251 }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 last269}270271// 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 += n283 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 n286}287288// Revoke ends the schedule where it stands (the first time) and returns to the owner everything the grant holds289// beyond what has vested and is still the beneficiary's to claim. Before the up-front share has arrived, counting290// what was already paid out as arrived, it voids the grant instead: nothing vests, ever — so a unit of dust sent291// ahead of the owner's revoke, claimed or not, cannot turn a void into a freeze that keeps the up-front share. (A292// grant with no up-front share measures against what the schedule had released by the revoke.) Returns what went293// 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.Upfront300 if floor == 0 {301 floor = vestedAt(g, g.RevokedAt)302 }303 if floor == 0 {304 floor = 1305 }306 g.Voided = g.Claimed+balance(g) < floor307 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}311312// Sweep returns to the owner what the schedule can never owe the beneficiary — above the unpaid rest of the grant313// while it runs, above what is still claimable once revoked — without ending anything. Owner only; on a grant with314// 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}324325func sweep(cur realm, g *Grant) int64 { return sweepTo(cur, g, g.Owner) }326327func sweepTo(cur realm, g *Grant, to address) int64 {328 owed := g.Total - g.Claimed329 if g.RevokedAt != 0 {330 owed = claimable(g)331 }332 back := balance(g) - owed333 if back <= 0 {334 return 0335 }336 g.Returned += back337 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 back340}341342// send moves n of the grant's token out of the grant's own address: the sub-realm is the sender, so one grant can343// 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}358359// 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 = to370 chain.Emit("OwnerNext", "id", strconv.FormatInt(id, 10), "to", to.String())371}372373// 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}383384// ----------------------------------------------------------------- pages --385386// Render: "" the newest grants; "<id>" one grant; "json/<id>" its figures in base units; "by/<address>" the grants387// 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}410411func home() string {412 var b strings.Builder413 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 := 0416 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 >= listMax425 })426 return b.String()427}428429func page(g *Grant) string {430 var b strings.Builder431 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}472473func by(who address) string {474 if !who.IsValid() { // the path is anyone's text: only an address is echoed onto the page475 return "404"476 }477 var b strings.Builder478 b.WriteString("# Grants of " + who.String() + "\n\n")479 n := 0480 grants.ReverseIterate("", "", func(_ string, v any) bool {481 g := v.(*Grant)482 if g.Owner != who && g.Beneficiary != who {483 return false484 }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 >= listMax492 })493 if n == 0 {494 b.WriteString("none\n")495 }496 return b.String()497}498499func 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}506507func 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}520521func date(unix int64) string { return time.Unix(unix, 0).UTC().Format("2006-01-02") }522523// 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 *= 10536 }537 whole, frac := n/one, n%one538 s := strconv.FormatInt(whole, 10)539 if frac != 0 {540 f := strconv.FormatInt(frac, 10)541 for len(f) < decimals {542 f = "0" + f543 }544 s += "." + strings.TrimRight(f, "0")545 }546 return s + " " + symbol547}548AcceptOwner(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id int64)
Address(id int64) string
Balance(id int64) int64
Claim(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id int64) int64
Claimable(id int64) int64
Count() int64
Create(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, owner string, beneficiary string, token string, total int64, upfront int64, start int64, cliffDays int64, days int64, note string) int64
Get(id int64) struct{ID int64; Token string; Creator .uverse.address; Owner .uverse.address; OwnerNext .uverse.address; Beneficiary .uverse.address; Total int64; Upfront int64; Start int64; CliffDays int64; Days int64; Claimed int64; Returned int64; RevokedAt int64; Voided bool; Note string}
Render(path string) string
Revoke(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id int64) int64
SetOwner(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id int64, to string)
Sweep(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id int64) int64
Vested(id int64) int64
Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.
vm/qrender output, sanitized (docs/render-security.md) and displayed in an empty-sandbox iframe — scripts, forms and popups cannot run. Links stay inert in-preview; right-click to open.