1// Package vestoken is a fungible token where new supply is never handed out2// instantly — it ports the idea behind Solidity's VestingWallet / linear3// token-streaming contracts (e.g. Sablier) into a single gno.land realm.4//5// MintVested opens a linear vesting grant instead of crediting a balance6// directly. The beneficiary calls Claim over time to pull whatever has7// unlocked so far into their liquid, transferable balance. State is8// package-level and persistent; state-mutating functions are crossing9// functions (`cur realm`) per the gno 0.9 interrealm convention.10package vestoken1112import (13 "chain/runtime"14 "errors"15 "sort"16 "strconv"17 "strings"18)1920const (21 name = "VestToken"22 symbol = "VEST"23)2425// grant is one linear vesting schedule: `total` unlocks evenly from `start`26// to `start+duration` (measured in block height), and `claimed` tracks how27// much of the unlocked amount the beneficiary has already pulled.28type grant struct {29 total uint6430 claimed uint6431 start int6432 duration int6433}3435var (36 // balances holds liquid, already-claimed tokens — transferable like a37 // normal ERC-20 balance.38 balances = map[address]uint64{}39 // grants holds at most one active vesting schedule per beneficiary.40 grants = map[address]*grant{}41 // totalSupply is every token ever minted (vesting or liquid).42 totalSupply uint6443)4445var (46 errZeroAddress = errors.New("vestoken: zero address")47 errZeroAmount = errors.New("vestoken: amount must be positive")48 errZeroDuration = errors.New("vestoken: duration must be positive")49 errGrantActive = errors.New("vestoken: beneficiary already has an unclaimed grant")50 errNoGrant = errors.New("vestoken: no active grant for this account")51 errInsufficient = errors.New("vestoken: insufficient balance")52 errOverflow = errors.New("vestoken: supply overflow")53 errSpoofedRealm = errors.New("vestoken: spoofed realm")54 errNotUser = errors.New("vestoken: caller is not a user account")55)5657// caller returns the immediate caller's address for a crossing function,58// enforcing the gno 0.9 authentication primitive: cur must be the live59// crossing frame before its Previous() is trusted.60func caller(cur realm) address {61 if !cur.IsCurrent() {62 panic(errSpoofedRealm)63 }64 prev := cur.Previous()65 if !prev.IsUser() {66 panic(errNotUser)67 }68 return prev.Address()69}7071// MintVested opens a new linear vesting grant for `to`: `amount` tokens72// unlock block-by-block over the next `durationBlocks` blocks, starting now.73// A beneficiary may only hold one active grant at a time. Crossing function.74func MintVested(cur realm, to address, amount uint64, durationBlocks int64) {75 _ = caller(cur) // authenticate the crossing frame; anyone may open a grant in this demo76 if !to.IsValid() {77 panic(errZeroAddress)78 }79 if amount == 0 {80 panic(errZeroAmount)81 }82 if durationBlocks <= 0 {83 panic(errZeroDuration)84 }85 if g, ok := grants[to]; ok && g.claimed < g.total {86 panic(errGrantActive)87 }88 if totalSupply+amount < totalSupply {89 panic(errOverflow)90 }91 totalSupply += amount92 grants[to] = &grant{93 total: amount,94 start: runtime.ChainHeight(),95 duration: durationBlocks,96 }97}9899// Claim pulls whatever has unlocked so far from the caller's grant into100// their liquid balance, and returns how much was claimed. Crossing function.101func Claim(cur realm) uint64 {102 who := caller(cur)103 g, ok := grants[who]104 if !ok {105 panic(errNoGrant)106 }107108 unlocked := vestedAmount(g, runtime.ChainHeight())109 claimable := unlocked - g.claimed110 if claimable == 0 {111 return 0112 }113114 g.claimed = unlocked115 balances[who] += claimable116 if g.claimed >= g.total {117 delete(grants, who)118 }119 return claimable120}121122// vestedAmount computes how much of g has unlocked by the given height.123func vestedAmount(g *grant, height int64) uint64 {124 elapsed := height - g.start125 if elapsed <= 0 {126 return 0127 }128 if elapsed >= g.duration {129 return g.total130 }131 return g.total * uint64(elapsed) / uint64(g.duration)132}133134// Transfer moves `amount` of the caller's liquid (already-claimed) balance135// to `to`. Crossing function.136func Transfer(cur realm, to address, amount uint64) {137 from := caller(cur)138 if !to.IsValid() {139 panic(errZeroAddress)140 }141 if amount == 0 {142 return143 }144 if balances[from] < amount {145 panic(errInsufficient)146 }147 balances[from] -= amount148 balances[to] += amount149}150151// BalanceOf returns the liquid (claimed, transferable) balance of holder.152func BalanceOf(holder address) uint64 {153 return balances[holder]154}155156// GrantOf returns the beneficiary's active grant, if any: total amount,157// amount claimed so far, and the amount currently claimable.158func GrantOf(beneficiary address) (total uint64, claimed uint64, claimable uint64) {159 g, ok := grants[beneficiary]160 if !ok {161 return 0, 0, 0162 }163 unlocked := vestedAmount(g, runtime.ChainHeight())164 return g.total, g.claimed, unlocked - g.claimed165}166167// TotalSupply returns every token ever minted, vesting or liquid.168func TotalSupply() uint64 { return totalSupply }169170// Name returns the token name.171func Name() string { return name }172173// Symbol returns the token symbol.174func Symbol() string { return symbol }175176// Render produces the gnoweb Markdown view: metadata, active vesting177// grants, and liquid balances — both tables sorted by address for178// deterministic output. Render is NOT a crossing function.179func Render(path string) string {180 var b strings.Builder181182 b.WriteString("# " + name + " (" + symbol + ")\n\n")183 b.WriteString("A linearly-vesting ERC-20-style token on gno.land — ")184 b.WriteString("new supply unlocks block-by-block instead of landing instantly.\n\n")185 b.WriteString("- **Name:** " + name + "\n")186 b.WriteString("- **Symbol:** " + symbol + "\n")187 b.WriteString("- **Total supply:** " + strconv.FormatUint(totalSupply, 10) + "\n")188 b.WriteString("- **Current height:** " + strconv.FormatInt(runtime.ChainHeight(), 10) + "\n\n")189190 b.WriteString("## Active grants\n\n")191 grantKeys := sortedAddrKeys(grantAddrs())192 if len(grantKeys) == 0 {193 b.WriteString("_No active grants — call `MintVested` to open one._\n\n")194 } else {195 b.WriteString("| Beneficiary | Total | Claimed | Claimable now |\n")196 b.WriteString("| --- | ---: | ---: | ---: |\n")197 for _, k := range grantKeys {198 g := grants[address(k)]199 unlocked := vestedAmount(g, runtime.ChainHeight())200 b.WriteString("| `" + k + "` | " +201 strconv.FormatUint(g.total, 10) + " | " +202 strconv.FormatUint(g.claimed, 10) + " | " +203 strconv.FormatUint(unlocked-g.claimed, 10) + " |\n")204 }205 b.WriteString("\n")206 }207208 b.WriteString("## Liquid balances\n\n")209 balKeys := sortedAddrKeys(balanceAddrs())210 if len(balKeys) == 0 {211 b.WriteString("_No claimed balances yet — call `Claim` once a grant has unlocked._\n")212 return b.String()213 }214 b.WriteString("| Address | Amount |\n")215 b.WriteString("| --- | ---: |\n")216 for _, k := range balKeys {217 b.WriteString("| `" + k + "` | " +218 strconv.FormatUint(balances[address(k)], 10) + " |\n")219 }220221 return b.String()222}223224func grantAddrs() []string {225 keys := make([]string, 0, len(grants))226 for addr := range grants {227 keys = append(keys, addr.String())228 }229 return keys230}231232func balanceAddrs() []string {233 keys := make([]string, 0, len(balances))234 for addr, bal := range balances {235 if bal == 0 {236 continue237 }238 keys = append(keys, addr.String())239 }240 return keys241}242243func sortedAddrKeys(keys []string) []string {244 sort.Strings(keys)245 return keys246}247BalanceOf(holder string) uint64
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}) uint64
GrantOf(beneficiary string) (total uint64, claimed uint64, claimable uint64)
MintVested(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}, to string, amount uint64, durationBlocks int64)
Name() string
Render(path string) string
Symbol() string
TotalSupply() uint64
Transfer(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}, to string, amount uint64)
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.