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