Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}