render.gno
13.77 Kb · 362 lines
1package grants
2
3import (
4 "strconv"
5 "strings"
6
7 "gno.land/p/moul/kit/ui/v0"
8 "gno.land/p/nt/markdown/sanitize/v0"
9)
10
11// Renderer turns a Program into the three markdown pages a grant program
12// needs: the board, one request, and the ledger. Everything that differs
13// between one program and the next is a field, so a realm's whole Render is
14// building this literal and calling it.
15//
16// Build it INSIDE Render rather than storing it in a realm global: Note is a
17// func, and Balance has to be read fresh on every call anyway.
18//
19// # Escaping
20//
21// Everything a caller typed (titles, bodies, reasons, proofs, notes) is
22// escaped here, once, through p/moul/kit/ui and p/nt/markdown/sanitize. A
23// realm does not repeat it and must not pre-escape: escaping twice shows the
24// backslashes. The realm's OWN fields below (Title, Intro, Notes, Footer) are
25// chrome, are written by whoever deployed the realm, and are emitted as-is so
26// they can carry markdown.
27type Renderer struct {
28 Program *Program
29 Title string // page heading
30 Intro string // markdown under the heading, already wrapped
31 Notes []string // extra paragraphs on the board page: house rules, scope
32 Path string // package path, for the gnokey examples
33 Link string // gnoweb route prefix, e.g. "/r/moul/grant/v0"
34 Treasury address // where the money sits
35 Balance int64 // what it holds right now
36 Footer string // closing paragraph on the board page
37
38 // Note returns an extra callout for one request page, or "". Use it for
39 // anything the library cannot know, such as a seeded request whose
40 // applicant address has no key behind it.
41 Note func(*Request) string
42}
43
44// Render serves the board at "", one request at "request/<id>", and the money
45// trail at "ledger".
46func (rr Renderer) Render(path string) string {
47 path = strings.TrimSpace(path)
48 switch {
49 case path == "" || path == "/":
50 return rr.index()
51 case path == "ledger":
52 return rr.ledger()
53 case strings.HasPrefix(path, "request/"):
54 return rr.request(strings.TrimPrefix(path, "request/"))
55 }
56 return "# " + rr.Title + "\n\nNo page `" + path + "`. Try [the board](" + rr.Link +
57 ") or [the ledger](" + rr.Link + ":ledger).\n"
58}
59
60func (rr Renderer) index() string {
61 p := rr.Program
62 var b strings.Builder
63 b.WriteString("# " + rr.Title + "\n\n")
64 if rr.Intro != "" {
65 b.WriteString(rr.Intro + "\n\n")
66 }
67 for _, n := range rr.Notes {
68 b.WriteString(n + "\n\n")
69 }
70
71 b.WriteString("## Treasury\n\n")
72 b.WriteString("| Field | Value |\n|---|---|\n")
73 b.WriteString("| Address | " + ui.AddrFull(rr.Treasury) + " |\n")
74 b.WriteString("| Balance | " + rr.amount(rr.Balance) + " |\n")
75 b.WriteString("| Promised | " + rr.amount(p.Committed()) + " |\n")
76 b.WriteString("| Unpromised | " + rr.amount(p.Available(rr.Balance)) + " |\n")
77 b.WriteString("| Donated | " + rr.amount(p.Raised()) + " |\n")
78 b.WriteString("| Paid out | " + rr.amount(p.Disbursed()) + " |\n\n")
79 if short := -p.Available(rr.Balance); short > 0 {
80 b.WriteString("The board has promised " + rr.amount(short) + " more than it holds. Approving a\n")
81 b.WriteString("grant does not escrow anything, so a tranche can come due against an empty\n")
82 b.WriteString("treasury; a review that would release one is refused until the money is there.\n")
83 b.WriteString("Top it up with `Fund`, or by sending coins straight to the address above.\n\n")
84 }
85
86 b.WriteString("## Board\n\n")
87 b.WriteString("A decision needs a majority of the members eligible to cast a ballot on it,\n")
88 b.WriteString("recomputed every time: the party a decision is about never votes on it, so an\n")
89 b.WriteString("applicant who sits on the board shrinks the room rather than packing it.\n\n")
90 b.WriteString("| Member | Joined at height |\n|---|---|\n")
91 for _, m := range p.Board.Members() {
92 b.WriteString("| " + ui.AddrFull(m) + " | " + i64(p.Board.JoinedAt(m)) + " |\n")
93 }
94 b.WriteString("\n")
95
96 b.WriteString("## Requests\n\n")
97 all := p.Board.List()
98 if len(all) == 0 {
99 b.WriteString("Nothing has been asked of this board yet. `Apply` is open to anyone.\n\n")
100 } else {
101 b.WriteString("| # | Kind | Title | For | Asking | Status | Ballots |\n|---|---|---|---|---|---|---|\n")
102 for _, r := range all {
103 yes, no := p.Board.Standing(r)
104 b.WriteString("| [" + strconv.Itoa(r.ID) + "](" + rr.Link + ":request/" + strconv.Itoa(r.ID) + ")" +
105 " | " + r.Kind.String() +
106 " | " + ui.Cell(r.Title) +
107 " | " + rr.who(r) +
108 " | " + rr.ask(r) +
109 " | " + r.Status.String() +
110 " | " + strconv.Itoa(yes) + " for, " + strconv.Itoa(no) + " against" +
111 " |\n")
112 }
113 b.WriteString("\n")
114 }
115
116 b.WriteString("## Where the money went\n\n")
117 b.WriteString("[The ledger](" + rr.Link + ":ledger) lists every donation in and every tranche\n")
118 b.WriteString("out, with the height, the payee and the milestone it paid for.\n\n")
119
120 b.WriteString("## Calling it\n\n")
121 b.WriteString("```sh\n")
122 b.WriteString("# put money in\n")
123 b.WriteString("gnokey maketx call -pkgpath " + rr.Path + " -func Fund -send 5000000" + p.Denom + " ...\n")
124 b.WriteString("# ask for some\n")
125 b.WriteString("gnokey maketx call -pkgpath " + rr.Path + " -func Apply \\\n")
126 b.WriteString(" -args 'Port the thing' -args 'why it matters' -args 'design:100,ship:400' ...\n")
127 b.WriteString("# ask on behalf of someone who cannot pay the gas to ask\n")
128 b.WriteString("gnokey maketx call -pkgpath " + rr.Path + " -func ApplyFor \\\n")
129 b.WriteString(" -args g1... -args 'their account is empty' \\\n")
130 b.WriteString(" -args 'Port the thing' -args 'why it matters' -args 'design:100,ship:400' ...\n")
131 b.WriteString("# decide (members only)\n")
132 b.WriteString("gnokey maketx call -pkgpath " + rr.Path + " -func Vote -args 1 -args true -args 'reason' ...\n")
133 b.WriteString("# show your work, then get paid for it\n")
134 b.WriteString("gnokey maketx call -pkgpath " + rr.Path + " -func SubmitProof \\\n")
135 b.WriteString(" -args 1 -args 0 -args url -args 'https://...' -args 'what it is' ...\n")
136 b.WriteString("gnokey maketx call -pkgpath " + rr.Path + " -func Review -args 1 -args 0 -args true -args 'looks done' ...\n")
137 b.WriteString("```\n")
138 if rr.Footer != "" {
139 b.WriteString("\n" + rr.Footer + "\n")
140 }
141 return b.String()
142}
143
144func (rr Renderer) request(raw string) string {
145 id, err := strconv.Atoi(raw)
146 if err != nil {
147 return "# " + rr.Title + "\n\n`" + raw + "` is not a request number.\n"
148 }
149 r := rr.Program.Board.Get(id)
150 if r == nil {
151 return "# " + rr.Title + "\n\nThere is no request " + raw + ".\n"
152 }
153 board := rr.Program.Board
154
155 var b strings.Builder
156 b.WriteString("# Request " + strconv.Itoa(r.ID) + ": " + ui.Inline(r.Title) + "\n\n")
157 b.WriteString("| Field | Value |\n|---|---|\n")
158 b.WriteString("| Kind | " + r.Kind.String() + " |\n")
159 b.WriteString("| Applicant | " + ui.AddrFull(r.Applicant) + " |\n")
160 if r.OnBehalf() {
161 b.WriteString("| Beneficiary | " + ui.AddrFull(r.Beneficiary) + " |\n")
162 }
163 if r.Kind == KindMember {
164 b.WriteString("| Subject | " + ui.AddrFull(r.Subject) + " |\n")
165 b.WriteString("| Effect | " + addRemove(r.Add) + " |\n")
166 }
167 b.WriteString("| Status | " + r.Status.String() + " |\n")
168 b.WriteString("| Filed at height | " + i64(r.CreatedAt) + " |\n")
169 if r.DecidedAt != 0 {
170 b.WriteString("| Decided at height | " + i64(r.DecidedAt) + " |\n")
171 }
172 if r.Kind == KindGrant {
173 b.WriteString("| Asking | " + rr.amount(r.Total()) + " |\n")
174 b.WriteString("| Paid so far | " + rr.amount(r.Paid()) + " |\n")
175 b.WriteString("| Still owed | " + rr.amount(r.Outstanding()) + " |\n")
176 }
177 b.WriteString("\n")
178
179 if r.OnBehalf() {
180 b.WriteString("## Filed for someone else\n\n")
181 b.WriteString("The applicant is not the payee: every tranche of this request pays\n")
182 b.WriteString(ui.AddrFull(r.Beneficiary) + ". Both addresses are barred from voting on it, and\n")
183 b.WriteString("either may submit a proof. The reason given:\n\n")
184 b.WriteString("> " + ui.Inline(r.Reason) + "\n\n")
185 b.WriteString("Nothing checks that reason. It is a claim by the applicant, and the point of\n")
186 b.WriteString("printing it here is that a member can check it before voting.\n\n")
187 }
188
189 if rr.Note != nil {
190 if note := rr.Note(r); note != "" {
191 b.WriteString(note + "\n\n")
192 }
193 }
194
195 if r.Body != "" {
196 b.WriteString("## The ask\n\n" + block(r.Body) + "\n\n")
197 }
198
199 b.WriteString("## Ballots\n\n")
200 yes, no := board.Standing(r)
201 recorded, against := r.Tally()
202 b.WriteString("Needs " + strconv.Itoa(board.Majority(r)) + " of " + strconv.Itoa(board.Eligible(r)) +
203 " eligible members. Standing: " + strconv.Itoa(yes) + " for, " + strconv.Itoa(no) + " against.\n")
204 if recorded != yes || against != no {
205 b.WriteString("On the record: " + strconv.Itoa(recorded) + " for, " + strconv.Itoa(against) +
206 " against. The difference is ballots cast by people who have since left the board;\n")
207 b.WriteString("they stay on the record and stop counting.\n")
208 }
209 b.WriteString("\n")
210 if len(r.Votes) == 0 {
211 b.WriteString("Nobody has voted yet.\n\n")
212 } else {
213 b.WriteString(rr.ballots(r.Votes, "for", "against"))
214 }
215
216 if r.Kind != KindGrant {
217 return b.String()
218 }
219
220 b.WriteString("## Milestones\n\n")
221 b.WriteString("Earned in order. The applicant submits a proof, the board reviews that proof,\n")
222 b.WriteString("and the verdict that carries also moves the coins.\n\n")
223 for i, m := range r.Milestones {
224 b.WriteString("### " + strconv.Itoa(i+1) + ". " + ui.Inline(m.Title) + ": " + rr.amount(m.Amount) + "\n\n")
225 b.WriteString(milestoneState(r, i, m) + "\n\n")
226 for j, a := range m.Attempts {
227 b.WriteString("**Proof " + strconv.Itoa(j+1) + "** (" + a.Proof.Kind + ", height " +
228 i64(a.Proof.Height) + ", " + a.Outcome.String() + ")\n\n")
229 b.WriteString(fence(a.Proof.Ref) + "\n\n")
230 if a.Proof.Note != "" {
231 b.WriteString(block(a.Proof.Note) + "\n\n")
232 }
233 if len(a.Reviews) == 0 {
234 b.WriteString("No review yet.\n\n")
235 continue
236 }
237 b.WriteString(rr.ballots(a.Reviews, "accept", "refuse"))
238 }
239 }
240 return b.String()
241}
242
243func (rr Renderer) ledger() string {
244 p := rr.Program
245 var b strings.Builder
246 b.WriteString("# " + rr.Title + ": the ledger\n\n")
247 b.WriteString("Every coin in and every coin out, in the order it happened. Coins sent\n")
248 b.WriteString("straight to " + ui.AddrFull(rr.Treasury) + " land in the treasury\n")
249 b.WriteString("without appearing here, which is why `Fund` exists: it is the same transfer\n")
250 b.WriteString("with a name attached.\n\n")
251
252 b.WriteString("## In\n\n")
253 if len(p.Donations) == 0 {
254 b.WriteString("Nothing donated through `Fund` yet.\n\n")
255 } else {
256 b.WriteString("| Height | From | Amount |\n|---|---|---|\n")
257 for _, d := range p.Donations {
258 b.WriteString("| " + i64(d.Height) + " | " + ui.AddrFull(d.From) + " | " + rr.amount(d.Amount) + " |\n")
259 }
260 b.WriteString("\nTotal in: " + rr.amount(p.Raised()) + "\n\n")
261 }
262
263 b.WriteString("## Out\n\n")
264 if len(p.Payments) == 0 {
265 b.WriteString("No tranche has been released yet.\n\n")
266 } else {
267 b.WriteString("| Height | Request | Milestone | To | Amount |\n|---|---|---|---|---|\n")
268 for _, pay := range p.Payments {
269 b.WriteString("| " + i64(pay.Height) +
270 " | [" + strconv.Itoa(pay.Request) + "](" + rr.Link + ":request/" + strconv.Itoa(pay.Request) + ")" +
271 " | " + strconv.Itoa(pay.Milestone+1) +
272 " | " + ui.AddrFull(pay.To) +
273 " | " + rr.amount(pay.Amount) + " |\n")
274 }
275 b.WriteString("\nTotal out: " + rr.amount(p.Disbursed()) + "\n\n")
276 }
277
278 b.WriteString("Balance now: " + rr.amount(rr.Balance) + ". [Back to the board](" + rr.Link + ").\n")
279 return b.String()
280}
281
282// ballots renders one decision's ballots, marking anyone who has since left
283// the board: their vote stays on the record and stops counting.
284func (rr Renderer) ballots(bs []Ballot, forLabel, againstLabel string) string {
285 var b strings.Builder
286 b.WriteString("| Member | Vote | Height | Reason |\n|---|---|---|---|\n")
287 for _, v := range bs {
288 who := ui.AddrFull(v.Voter)
289 if !rr.Program.Board.IsMember(v.Voter) {
290 who += " (left the board)"
291 }
292 vote := againstLabel
293 if v.Approve {
294 vote = forLabel
295 }
296 b.WriteString("| " + who + " | " + vote + " | " + i64(v.Height) + " | " + orDash(ui.Cell(v.Reason)) + " |\n")
297 }
298 b.WriteString("\n")
299 return b.String()
300}
301
302func (rr Renderer) amount(n int64) string { return strconv.FormatInt(n, 10) + " " + rr.Program.Denom }
303
304// who is the board index's "For" column: who this request would pay, marked
305// when that is not the address that filed it.
306func (rr Renderer) who(r *Request) string {
307 if r.Kind == KindMember {
308 return "-"
309 }
310 if r.OnBehalf() {
311 return ui.Addr(r.Beneficiary) + " (filed by " + ui.AddrText(r.Applicant) + ")"
312 }
313 return ui.Addr(r.Applicant)
314}
315
316func (rr Renderer) ask(r *Request) string {
317 if r.Kind == KindMember {
318 return "nothing"
319 }
320 return rr.amount(r.Total())
321}
322
323func milestoneState(r *Request, i int, m *Milestone) string {
324 switch {
325 case m.Released:
326 return "Released at height " + i64(m.ReleasedAt) + ", paid to " + ui.AddrFull(r.Payee()) + "."
327 case r.Status != Approved:
328 return "Locked: the request is " + r.Status.String() + "."
329 case m.Current() != nil:
330 return "A proof is under review."
331 case i == r.Next():
332 return "Waiting on a proof from the applicant."
333 }
334 return "Locked until milestone " + strconv.Itoa(r.Next()+1) + " is released."
335}
336
337func addRemove(add bool) string {
338 if add {
339 return "add to the board"
340 }
341 return "remove from the board"
342}
343
344// block and fence escape a multi-line, user-written string for its slot and
345// then trim the sanitizer's own trailing newlines.
346//
347// The trim is not cosmetic. gno collapses two consecutive blank lines in an
348// Example's output the way Go does, so any page that emits one can never be
349// pinned by an ExampleRender, and the only test that proves a whole page is
350// the one that stops working.
351func block(s string) string { return strings.TrimSpace(sanitize.Block(s)) }
352
353func fence(s string) string { return strings.TrimSpace(sanitize.CodeBlock(s)) }
354
355func i64(n int64) string { return strconv.FormatInt(n, 10) }
356
357func orDash(s string) string {
358 if s == "" {
359 return "-"
360 }
361 return s
362}