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

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}