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