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