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

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}