render.gno
11.56 Kb · 364 lines
1package vesting
2
3import (
4 "strconv"
5 "strings"
6
7 "gno.land/p/nt/ufmt/v0"
8
9 "gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/kit/ui/v0"
10 "gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/md/v0"
11 "gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/realmpath/v0"
12 "gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/vesting/v0"
13)
14
15// Render serves three shapes:
16//
17// /r/moul/vesting/v0 the seeded schedules
18// /r/moul/vesting/v0:g1... one address, its declared schedule
19// /r/moul/vesting/v0:g1...?o=&s=&e=&d= one address, a schedule you supply
20//
21// The query form stores nothing. It exists so an address that has not declared
22// anything can still get a real answer: paste the schedule out of
23// `gnokey query auth/accounts/<addr>` and the balance is still read from chain.
24func Render(path string) string {
25 req := realmpath.Parse(path)
26 target := strings.TrimSpace(req.PathPart(0))
27
28 if target == "" {
29 curPage = 1
30 if p, err := strconv.Atoi(req.Query.Get("page")); err == nil && p > 1 {
31 curPage = p
32 }
33 return renderIndex()
34 }
35 if !isAddr(target) {
36 return header() + ui.Empty(md.InlineCode(target)+" is not a gno.land address.") +
37 "\n" + lookupForm()
38 }
39 addr := address(target)
40
41 // An ad-hoc schedule in the query beats a declared one, and is labelled as
42 // coming from the caller.
43 if raw := req.Query.Get("o"); raw != "" {
44 sch, err := scheduleFromQuery(req)
45 if err != "" {
46 return header() + ui.Empty(err) + "\n" + lookupForm()
47 }
48 return header() + renderAccount(addr, sch, sourceQuery, "") + "\n" + lookupForm()
49 }
50
51 if e, ok := lookup(addr); ok {
52 src := sourceDeclared
53 if e.seeded {
54 src = sourceSeeded
55 }
56 return header() + renderAccount(addr, e.schedule, src, e.label) + "\n" + lookupForm()
57 }
58 return header() + renderUnknown(addr) + "\n" + lookupForm()
59}
60
61// Where a schedule came from. This is the distinction the whole page turns on,
62// so it is a value and not a boolean buried in a branch.
63type source int
64
65const (
66 sourceSeeded source = iota // a source constant in this realm
67 sourceDeclared // written by the address itself
68 sourceQuery // typed into the URL by whoever is looking
69 sourceNone // there is none
70)
71
72func (s source) label() string {
73 switch s {
74 case sourceSeeded:
75 return "seeded in this realm's source"
76 case sourceDeclared:
77 return "declared by the address itself"
78 case sourceQuery:
79 return "supplied in the URL by you"
80 }
81 return "unknown"
82}
83
84func header() string {
85 return "# Vesting\n\n" +
86 "How much of an account's balance can actually move right now.\n\n"
87}
88
89// pageSize bounds the declared table. vm/qrender runs on a gas meter and a
90// large return value fails with OutOfGasError{Descriptor:"CPUCycles"} rather
91// than answering slowly, so an unbounded index is a page that eventually stops
92// rendering for everyone. Anyone may Declare, so "eventually" is somebody
93// else's choice unless it is capped here.
94const pageSize = 25
95
96func renderIndex() string {
97 var b strings.Builder
98 b.WriteString(header())
99
100 // Seeded rows always render in full: there are as many as this realm's
101 // source says and no caller can add one.
102 t := ui.NewTable("account", "balance", "spendable", "locked", "vested")
103 seedIndex.Iterate("", "", func(key string, v any) bool {
104 t.Row(rowFor(address(key), v.(*entry))...)
105 return false
106 })
107 b.WriteString(t.String())
108
109 if n := declared.Size(); n > 0 {
110 b.WriteString("\n## Declared\n\n")
111 b.WriteString(ufmt.Sprintf("%d address(es) have put their own schedule on file.\n\n", n))
112
113 d := ui.NewTable("account", "balance", "spendable", "locked", "vested")
114 declared.IterateByOffset(pageOffset(curPage)*pageSize, pageSize, func(key string, v any) bool {
115 d.Row(rowFor(address(key), v.(*entry))...)
116 return false
117 })
118 b.WriteString(d.String())
119 b.WriteString("\n")
120 b.WriteString(pager(n))
121 }
122
123 b.WriteString("\n")
124 b.WriteString(lookupForm())
125 b.WriteString("\n## What is real here, and what is not\n\n")
126 b.WriteString("| figure | where it comes from |\n|---|---|\n")
127 b.WriteString("| balance | read from the chain every render, `banker.GetCoins` |\n")
128 b.WriteString("| the clock | read from the chain every render, the block time |\n")
129 b.WriteString("| the schedule | **supplied**, because no realm can read one |\n\n")
130 b.WriteString("A realm's whole view of an account is its TOTAL balance, locked coins ")
131 b.WriteString("included. `std.VestingSchedule` lives in the auth store and no native ")
132 b.WriteString("reaches it, so the curve has to be told to this realm rather than found.\n\n")
133 b.WriteString("That is safe to cache because a schedule can never change: it is set ")
134 b.WriteString("once, at genesis, and no message type creates or modifies one. A ")
135 b.WriteString("schedule declared correctly today is correct forever.\n")
136 return b.String()
137}
138
139// curPage is the page renderIndex is currently building, set by Render before
140// it delegates. A parameter would be cleaner; Render's signature is fixed by
141// the VM, and threading it through every helper for one value is worse.
142var curPage = 1
143
144func pageOffset(page int) int {
145 if page < 1 {
146 return 0
147 }
148 return page - 1
149}
150
151// rowFor builds one table row. Shared so the seeded and declared tables cannot
152// drift apart in what they show.
153func rowFor(addr address, e *entry) []string {
154 bal := balanceOf(addr)
155 n := now()
156 name := ui.AddrText(addr)
157 if e.label != "" {
158 name = ui.Inline(e.label) + " " + ui.Addr(addr)
159 }
160 return []string{
161 name,
162 gnot(bal),
163 gnot(e.schedule.Spendable(bal, n)),
164 gnot(lockedCapped(e.schedule, bal, n)),
165 permille(e.schedule.PermilleVested(n)),
166 }
167}
168
169// pager renders the page links, and nothing at all when everything fits.
170func pager(total int) string {
171 pages := (total + pageSize - 1) / pageSize
172 if pages <= 1 {
173 return ""
174 }
175 var b strings.Builder
176 b.WriteString(ufmt.Sprintf("Page %d of %d.", curPage, pages))
177 if curPage > 1 {
178 b.WriteString(ufmt.Sprintf(" [previous](%s?page=%d)", Link, curPage-1))
179 }
180 if curPage < pages {
181 b.WriteString(ufmt.Sprintf(" [next](%s?page=%d)", Link, curPage+1))
182 }
183 b.WriteString("\n")
184 return b.String()
185}
186
187func renderAccount(addr address, s vesting.Schedule, src source, label string) string {
188 var b strings.Builder
189 n := now()
190 bal := balanceOf(addr)
191
192 b.WriteString("## ")
193 if label != "" {
194 b.WriteString(ui.Inline(label))
195 b.WriteString(" ")
196 }
197 b.WriteString(ui.AddrText(addr))
198 b.WriteString("\n\n")
199
200 if s.IsZero() {
201 b.WriteString("No vesting schedule, so the whole balance moves.\n\n")
202 b.WriteString("| | |\n|---|---|\n")
203 b.WriteString("| balance | **" + gnot(bal) + "** |\n")
204 b.WriteString("| spendable | **" + gnot(bal) + "** |\n\n")
205 b.WriteString(sourceNote(src))
206 return b.String()
207 }
208
209 locked := lockedCapped(s, bal, n)
210 t := ui.NewTable("", "")
211 t.Row("balance", "**"+gnot(bal)+"**")
212 t.Row("spendable now", "**"+gnot(s.Spendable(bal, n))+"**")
213 t.Row("locked now", gnot(locked))
214 t.Row("granted", gnot(s.Original))
215 t.Row("vested", gnot(s.Vested(n))+" ("+permille(s.PermilleVested(n))+")")
216 t.Row("still to vest", gnot(s.Locked(n)))
217 t.Row("curve", s.Type.String())
218 t.Row("term", tstamp(s.Start)+" to "+tstamp(s.End))
219 t.Row("finishes in", duration(s.RemainingSeconds(n)))
220 b.WriteString(t.String())
221 b.WriteString("\n")
222
223 if s.Locked(n) > bal {
224 b.WriteString("\n> This account holds less than its schedule still locks, so ")
225 b.WriteString("nothing moves at all. That happens when coins were spent while ")
226 b.WriteString("they were free and the grant has since outrun the balance.\n")
227 }
228
229 b.WriteString("\n")
230 b.WriteString(sourceNote(src))
231 return b.String()
232}
233
234func renderUnknown(addr address) string {
235 var b strings.Builder
236 bal := balanceOf(addr)
237 b.WriteString("## ")
238 b.WriteString(ui.AddrText(addr))
239 b.WriteString("\n\nNo schedule on file for this address.\n\n")
240 b.WriteString("| | |\n|---|---|\n")
241 b.WriteString("| balance | **" + gnot(bal) + "** |\n")
242 b.WriteString("| spendable | unknown |\n\n")
243 b.WriteString("The balance above is real. How much of it moves is not knowable from ")
244 b.WriteString("inside a realm. Read the schedule off the chain:\n\n")
245 b.WriteString("```\ngnokey query auth/accounts/" + addr.String() +
246 " -remote https://rpc.gno.land:443\n```\n\n")
247 b.WriteString("then either append it to this URL as `?o=<original_vesting>&s=<start_time>")
248 b.WriteString("&e=<end_time>` (add `&d=1` for a `delayed` schedule), or, if this address ")
249 b.WriteString("is yours, put it on file so the link works for everyone:\n\n")
250 b.WriteString(ui.Action("Declare this schedule", "Declare",
251 "original", "", "start", "", "end", "", "delayed", "false"))
252 b.WriteString("\n")
253 return b.String()
254}
255
256func sourceNote(src source) string {
257 s := "Balance and clock are read from the chain. The schedule is " + src.label() + "."
258 if src == sourceQuery {
259 s += " Nothing was stored."
260 }
261 if src == sourceDeclared {
262 s += " Only that address can write or remove it, so it can misdescribe nobody else."
263 }
264 return "> " + s + "\n"
265}
266
267func lookupForm() string {
268 return "## Check another address\n\n" +
269 "Append it to the path: `" + Link + ":g1…`\n\n" +
270 "| | |\n|---|---|\n" +
271 "| put your own schedule on file | " +
272 ui.Action("Declare", "Declare", "original", "", "start", "", "end", "", "delayed", "false") +
273 " |\n| take it off again | " + ui.Action("Forget", "Forget") + " |\n"
274}
275
276// scheduleFromQuery reads a one-off schedule out of the URL. It returns a
277// human-readable reason rather than an error value, because every caller turns
278// it straight into page text.
279func scheduleFromQuery(req *realmpath.Request) (vesting.Schedule, string) {
280 o, err := strconv.ParseInt(req.Query.Get("o"), 10, 64)
281 if err != nil {
282 return vesting.Schedule{}, "`o` (the original vesting amount, in ugnot) is not a number."
283 }
284 s, err := strconv.ParseInt(orZero(req.Query.Get("s")), 10, 64)
285 if err != nil {
286 return vesting.Schedule{}, "`s` (the start time, unix seconds) is not a number."
287 }
288 e, err := strconv.ParseInt(orZero(req.Query.Get("e")), 10, 64)
289 if err != nil {
290 return vesting.Schedule{}, "`e` (the end time, unix seconds) is not a number."
291 }
292 typ := vesting.Continuous
293 if d := req.Query.Get("d"); d == "1" || d == "true" {
294 typ = vesting.Delayed
295 }
296 sch, verr := vesting.New(o, s, e, typ)
297 if verr != nil {
298 return vesting.Schedule{}, "That is not a valid schedule: " + verr.Error() + "."
299 }
300 return sch, ""
301}
302
303func orZero(s string) string {
304 if s == "" {
305 return "0"
306 }
307 return s
308}
309
310// lockedCapped is what the account cannot move, which is never more than it
311// holds. The schedule alone can say otherwise once coins have been spent.
312func lockedCapped(s vesting.Schedule, balance, n int64) int64 {
313 l := s.Locked(n)
314 if l > balance {
315 return balance
316 }
317 if l < 0 {
318 return 0
319 }
320 return l
321}
322
323// tstamp renders a unix second as a plain UTC date, which is all a two-year
324// term needs. gno has no time formatting worth the dependency here.
325func tstamp(sec int64) string {
326 y, m, d := civil(sec / 86400)
327 return ufmtDate(y, m, d)
328}
329
330// civil converts a day count since 1970-01-01 to a calendar date, by Howard
331// Hinnant's civil_from_days. Integer only, no time package, no locale.
332func civil(z int64) (year int64, month int64, day int64) {
333 z += 719468
334 era := z / 146097
335 if z < 0 {
336 era = (z - 146096) / 146097
337 }
338 doe := z - era*146097
339 yoe := (doe - doe/1460 + doe/36524 - doe/146096) / 365
340 y := yoe + era*400
341 doy := doe - (365*yoe + yoe/4 - yoe/100)
342 mp := (5*doy + 2) / 153
343 d := doy - (153*mp+2)/5 + 1
344 m := mp + 3
345 if mp >= 10 {
346 m = mp - 9
347 }
348 if m <= 2 {
349 y++
350 }
351 return y, m, d
352}
353
354func ufmtDate(y, m, d int64) string {
355 return strconv.FormatInt(y, 10) + "-" + pad2(m) + "-" + pad2(d)
356}
357
358func pad2(n int64) string {
359 s := strconv.FormatInt(n, 10)
360 if len(s) < 2 {
361 return "0" + s
362 }
363 return s
364}