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

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}