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