vestoken.gno
7.23 Kb · 246 lines
1// Package vestoken is a fungible token where new supply is never handed out
2// instantly — it ports the idea behind Solidity's VestingWallet / linear
3// token-streaming contracts (e.g. Sablier) into a single gno.land realm.
4//
5// MintVested opens a linear vesting grant instead of crediting a balance
6// directly. The beneficiary calls Claim over time to pull whatever has
7// unlocked so far into their liquid, transferable balance. State is
8// package-level and persistent; state-mutating functions are crossing
9// functions (`cur realm`) per the gno 0.9 interrealm convention.
10package vestoken
11
12import (
13 "chain/runtime"
14 "errors"
15 "sort"
16 "strconv"
17 "strings"
18)
19
20const (
21 name = "VestToken"
22 symbol = "VEST"
23)
24
25// grant is one linear vesting schedule: `total` unlocks evenly from `start`
26// to `start+duration` (measured in block height), and `claimed` tracks how
27// much of the unlocked amount the beneficiary has already pulled.
28type grant struct {
29 total uint64
30 claimed uint64
31 start int64
32 duration int64
33}
34
35var (
36 // balances holds liquid, already-claimed tokens — transferable like a
37 // 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 uint64
43)
44
45var (
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)
56
57// caller returns the immediate caller's address for a crossing function,
58// enforcing the gno 0.9 authentication primitive: cur must be the live
59// 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}
70
71// MintVested opens a new linear vesting grant for `to`: `amount` tokens
72// 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 demo
76 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 += amount
92 grants[to] = &grant{
93 total: amount,
94 start: runtime.ChainHeight(),
95 duration: durationBlocks,
96 }
97}
98
99// Claim pulls whatever has unlocked so far from the caller's grant into
100// 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 }
107
108 unlocked := vestedAmount(g, runtime.ChainHeight())
109 claimable := unlocked - g.claimed
110 if claimable == 0 {
111 return 0
112 }
113
114 g.claimed = unlocked
115 balances[who] += claimable
116 if g.claimed >= g.total {
117 delete(grants, who)
118 }
119 return claimable
120}
121
122// vestedAmount computes how much of g has unlocked by the given height.
123func vestedAmount(g *grant, height int64) uint64 {
124 elapsed := height - g.start
125 if elapsed <= 0 {
126 return 0
127 }
128 if elapsed >= g.duration {
129 return g.total
130 }
131 return g.total * uint64(elapsed) / uint64(g.duration)
132}
133
134// Transfer moves `amount` of the caller's liquid (already-claimed) balance
135// 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 return
143 }
144 if balances[from] < amount {
145 panic(errInsufficient)
146 }
147 balances[from] -= amount
148 balances[to] += amount
149}
150
151// BalanceOf returns the liquid (claimed, transferable) balance of holder.
152func BalanceOf(holder address) uint64 {
153 return balances[holder]
154}
155
156// 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, 0
162 }
163 unlocked := vestedAmount(g, runtime.ChainHeight())
164 return g.total, g.claimed, unlocked - g.claimed
165}
166
167// TotalSupply returns every token ever minted, vesting or liquid.
168func TotalSupply() uint64 { return totalSupply }
169
170// Name returns the token name.
171func Name() string { return name }
172
173// Symbol returns the token symbol.
174func Symbol() string { return symbol }
175
176// Render produces the gnoweb Markdown view: metadata, active vesting
177// grants, and liquid balances — both tables sorted by address for
178// deterministic output. Render is NOT a crossing function.
179func Render(path string) string {
180 var b strings.Builder
181
182 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")
189
190 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 }
207
208 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 }
220
221 return b.String()
222}
223
224func grantAddrs() []string {
225 keys := make([]string, 0, len(grants))
226 for addr := range grants {
227 keys = append(keys, addr.String())
228 }
229 return keys
230}
231
232func balanceAddrs() []string {
233 keys := make([]string, 0, len(balances))
234 for addr, bal := range balances {
235 if bal == 0 {
236 continue
237 }
238 keys = append(keys, addr.String())
239 }
240 return keys
241}
242
243func sortedAddrKeys(keys []string) []string {
244 sort.Strings(keys)
245 return keys
246}