Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

render.gno

13.79 Kb · 362 lines
  1package grants
  2
  3import (
  4	"strconv"
  5	"strings"
  6
  7	"gno.land/p/nym-moultest123/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/nym-moultest123/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}