render.gno
6.38 Kb · 187 lines
1package faucet
2
3import (
4 "strconv"
5 "strings"
6
7 "gno.land/p/nt/markdown/sanitize/v0"
8)
9
10// Rows is how many requests each table on the board shows. The rest are
11// counted, not rendered: the per-request page is always reachable by id.
12const Rows = 20
13
14// Render shows the float, the open asks and everything already decided.
15//
16// Two paths: "" is the whole board, "req/<id>" is one request.
17//
18// Every string on this page that a caller typed goes through sanitize before
19// it is concatenated. A reason is arbitrary text from an arbitrary account and
20// Render output is markdown that gnoweb parses, so an unescaped one can plant
21// a link, an image beacon or gnoweb chrome on a page the realm is signing for.
22// This realm's path is permanent, so that bug could only ever be fixed at a
23// new path.
24func Render(path string) string {
25 path = strings.TrimPrefix(path, "/")
26 if id, ok := reqPath(path); ok {
27 return renderOne(id)
28 }
29 return renderBoard()
30}
31
32func renderBoard() string {
33 var b strings.Builder
34
35 b.WriteString("# GNOT faucet\n\n")
36 b.WriteString(intro)
37 b.WriteString("\n\n")
38
39 b.WriteString("| | |\n| --- | ---: |\n")
40 b.WriteString("| Float available | **" + gnot(Balance()) + " GNOT** |\n")
41 b.WriteString("| Paid out | " + gnot(totalSent) + " GNOT over " +
42 strconv.FormatInt(sentCount, 10) + " request(s) |\n")
43 b.WriteString("| Open requests | " + strconv.FormatInt(pendingN, 10) + " |\n")
44 b.WriteString("| Refused | " + strconv.FormatInt(deniedCount, 10) + " |\n")
45 b.WriteString("| Cap per request | " + gnot(maxPerRequest) + " GNOT |\n")
46 b.WriteString("| Float address | `" + Address().String() + "` |\n\n")
47
48 b.WriteString("## Open\n\n")
49 b.WriteString(table(true, pendingN))
50 b.WriteString("\n## Decided\n\n")
51 b.WriteString(table(false, sentCount+deniedCount))
52
53 b.WriteString("\n## Approvers\n\n")
54 approvers.Iterate("", "", func(k string, _ any) bool {
55 b.WriteString("- `" + k + "`\n")
56 return false
57 })
58
59 b.WriteString("\n" + howto + "\n")
60 return b.String()
61}
62
63// table lists at most Rows requests: the open ones oldest first, because a
64// queue is worked from the front, and the decided ones newest first, because
65// nobody scrolls to the bottom of a ledger.
66//
67// It takes the total from the realm's counters rather than counting rows, so
68// it can STOP iterating once the page is full. A Render that walks every
69// request ever filed is a page that grows without bound and a gas cost that
70// grows with it, and this realm's path is permanent, so that would only ever
71// be fixable at a new path.
72func table(open bool, total int64) string {
73 if total == 0 {
74 return "_Nothing here yet._\n"
75 }
76 var rows strings.Builder
77 shown := int64(0)
78 visit := func(_ string, v any) bool {
79 r := v.(*request)
80 if (r.Status == StatusPending) != open {
81 return false
82 }
83 rows.WriteString("| [" + strconv.FormatInt(r.ID, 10) + "](" + Link + ":req/" +
84 strconv.FormatInt(r.ID, 10) + ") | `" + r.To.String() + "` | " +
85 gnot(r.Amount) + " | " + sanitize.TableCell(excerpt(r.Reason, 60)) + " | " +
86 r.Status + " |\n")
87 shown++
88 return shown >= Rows // a true return stops the walk
89 }
90 if open {
91 requests.Iterate("", "", visit)
92 } else {
93 requests.ReverseIterate("", "", visit)
94 }
95
96 out := "| # | To | GNOT | Why | State |\n| ---: | --- | ---: | --- | --- |\n" + rows.String()
97 if rest := total - shown; rest > 0 {
98 out += "\n_" + strconv.FormatInt(rest, 10) + " more not shown._\n"
99 }
100 return out
101}
102
103func renderOne(id int64) string {
104 r := find(id)
105 if r == nil {
106 return "# Request " + strconv.FormatInt(id, 10) + "\n\n_No such request._\n"
107 }
108 var b strings.Builder
109 b.WriteString("# Request " + strconv.FormatInt(r.ID, 10) + "\n\n")
110 b.WriteString("| | |\n| --- | --- |\n")
111 b.WriteString("| To | `" + r.To.String() + "` |\n")
112 b.WriteString("| Amount | " + gnot(r.Amount) + " GNOT |\n")
113 b.WriteString("| Filed by | `" + r.By.String() + "` |\n")
114 b.WriteString("| Filed at height | " + strconv.FormatInt(r.Asked, 10) + " |\n")
115 b.WriteString("| State | " + r.Status + " |\n")
116 if r.Status != StatusPending {
117 b.WriteString("| Decided by | `" + r.Judge.String() + "` |\n")
118 b.WriteString("| Decided at height | " + strconv.FormatInt(r.Decided, 10) + " |\n")
119 }
120 b.WriteString("\n**Why:** " + sanitize.InlineText(r.Reason) + "\n")
121 if r.Note != "" {
122 b.WriteString("\n**Refused because:** " + sanitize.InlineText(r.Note) + "\n")
123 }
124 b.WriteString("\n[Back to the faucet](" + Link + ")\n")
125 return b.String()
126}
127
128// reqPath parses "req/<id>".
129func reqPath(path string) (int64, bool) {
130 rest, ok := strings.CutPrefix(path, "req/")
131 if !ok {
132 return 0, false
133 }
134 id, err := strconv.ParseInt(rest, 10, 64)
135 if err != nil || id <= 0 {
136 return 0, false
137 }
138 return id, true
139}
140
141// excerpt cuts to width RUNES, never bytes: slicing a multi-byte character in
142// half puts invalid UTF-8 on the page, and one emoji in a reason is enough.
143// It cuts before escaping, because escaping inserts backslashes and cutting an
144// already-escaped string can strand a lone one that escapes the chrome after
145// it.
146func excerpt(s string, width int) string {
147 r := []rune(s)
148 if len(r) <= width+1 {
149 return s
150 }
151 return string(r[:width]) + "…"
152}
153
154// gnot renders ugnot as GNOT with the trailing zeros of the fraction trimmed,
155// so 100000000 reads as 100 and 1500000 as 1.5.
156func gnot(ugnot int64) string {
157 neg := ""
158 if ugnot < 0 {
159 neg, ugnot = "-", -ugnot
160 }
161 whole := strconv.FormatInt(ugnot/1_000_000, 10)
162 frac := strconv.FormatInt(ugnot%1_000_000, 10)
163 if frac == "0" {
164 return neg + whole
165 }
166 for len(frac) < 6 {
167 frac = "0" + frac
168 }
169 return neg + whole + "." + strings.TrimRight(frac, "0")
170}
171
172const (
173 intro = "Somebody you trust has no GNOT and cannot use the public faucet. File a\n" +
174 "request on their behalf, an approver releases it, and both halves stay on\n" +
175 "this page with a reason attached. The faucet only ever spends what has been\n" +
176 "sent to its own address."
177
178 howto = "## How\n\n" +
179 "`Request(to, amount, reason)` is open to anyone and moves no money.\n" +
180 "`Approve(id, to, amount)` pays it, and only an approver can call it.\n\n" +
181 "Send both in **one transaction**: a tm2 transaction stops at the first\n" +
182 "message that fails and writes none of their state, so the ask and its\n" +
183 "answer land together or not at all. Read `NextID` to address the approval,\n" +
184 "and pass the recipient and the amount into it as well: if another request\n" +
185 "takes that id first, the mismatch fails the transaction instead of paying\n" +
186 "the wrong account."
187)