Kind Pure package
Name v0
Namespace moul / grants
Exported functions n/a — not supported for pure packages by the node (vm/qfuncs)
Module gno.land/p/moul/grants/v0
gno 0.9 render_test.gno gno
⧉
1 package grants 2 3 import ( 4 "strings" 5 "testing" 6 7 "gno.land/p/nt/testutils/v0" 8 not supported for pure packages by the node (vm/qfuncs)
Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.
"gno.land/p/nt/uassert/v0"
9 "gno.land/p/nt/urequire/v0"
10 )
11
12 var (
13 erin = testutils.TestAddress( "erin" ) // applicant, mid-delivery
14 frank = testutils.TestAddress( "frank" ) // donor
15 grace = testutils.TestAddress( "grace" ) // beneficiary of a request she did not file
16 )
17
18 // demo drives a program through every state the renderer has to show: a
19 // membership change that executed, a donation, a grant carried over one
20 // dissent, a milestone released and paid, a proof the board refused, a second
21 // attempt still under review, and a request turned down flat.
22 //
23 // It uses the library's own API only, so if a rule changes under it the
24 // pinned pages below move and the diff says exactly what changed.
25 func demo() *Program {
26 p := NewProgram( "ugnot" , alice, bob, carol)
27 p.Fund(frank, 3000 , 100 )
28
29 m, _ := p.Board.SubmitMemberChange(alice, dave, true ,
30 "Reviewed six of the last eight applications from the outside. Give them the seat." , 100 )
31 p.Board.Vote(alice, m.ID, true , "Proposed it." , 101 )
32 p.Board.Vote(bob, m.ID, true , "No objection." , 102 )
33
34 g, _ := p.Apply(erin, "Port the p/nt/avl benchmarks to gno" ,
35 "Three weeks. A benchmark harness, a report on the tree rebalancing costs, " +
36 "and a PR against the monorepo." ,
37 "harness merged:1200,report published:800" , 110 )
38 p.Board.Vote(alice, g.ID, true , "Cheap for what it tells us." , 111 )
39 p.Board.Vote(bob, g.ID, false , "The second milestone is vague." , 112 )
40 p.Board.Vote(carol, g.ID, true , "Vague but bounded. Worth it." , 113 )
41 p.Board.Vote(dave, g.ID, true , "Agreed with carol." , 114 )
42
43 p.Board.SubmitProof(erin, g.ID, 0 , Proof{
44 Kind: "url" , Ref: "https://github.com/gnolang/gno/pull/9999" ,
45 Note: "Harness merged, 14 benchmarks, runs in CI." , Height: 120 ,
46 })
47 p.Review(alice, g.ID, 0 , true , "Merged, I reviewed it." , 121 , 3000 )
48 p.Review(carol, g.ID, 0 , true , "Confirmed." , 122 , 3000 )
49 p.Review(dave, g.ID, 0 , true , "Confirmed." , 123 , 3000 )
50
51 p.Board.SubmitProof(erin, g.ID, 1 , Proof{Kind: "text" , Ref: "it is basically done" , Height: 130 })
52 p.Review(alice, g.ID, 1 , false , "That is not a report." , 131 , 1800 )
53 p.Review(bob, g.ID, 1 , false , "Agreed, no numbers." , 132 , 1800 )
54 p.Review(carol, g.ID, 1 , false , "Same." , 133 , 1800 )
55
56 p.Board.SubmitProof(erin, g.ID, 1 , Proof{
57 Kind: "url" , Ref: "https://example.com/avl-rebalancing-costs" ,
58 Note: "Rewritten with the measurements." , Height: 140 ,
59 })
60 p.Review(alice, g.ID, 1 , true , "Much better." , 141 , 1800 )
61
62 o, _ := p.ApplyFor(erin, grace, "Her account is empty, so she cannot pay the gas to file this herself." ,
63 "Translate the onboarding guide to Portuguese" ,
64 "One pass over the eight pages, reviewed by a second speaker." ,
65 "translation merged:600" , 145 )
66 p.Board.Vote(alice, o.ID, true , "Cheap, and we have no pt-BR page at all." , 146 )
67 p.Board.Vote(bob, o.ID, true , "Fine." , 147 )
68 p.Board.Vote(carol, o.ID, true , "Fine." , 148 )
69
70 n, _ := p.Apply(mallory, "Rewrite gnoweb in a different framework" ,
71 "Six months, one person, no migration plan." , "the rewrite:250000" , 150 )
72 p.Board.Vote(alice, n.ID, false , "No migration plan and no second maintainer." , 151 )
73 p.Board.Vote(bob, n.ID, false , "Out of scope for this board." , 152 )
74 p.Board.Vote(carol, n.ID, false , "Same." , 153 )
75 return p
76 }
77
78 // view wraps demo in a Renderer configured the way a realm would. Balance is
79 // 3000 donated minus the 1200 tranche already paid.
80 func view() Renderer {
81 return Renderer{
82 Program: demo(),
83 Title: "Example grant board" ,
84 Intro: "Anyone may ask this board for money." ,
85 Path: "gno.land/r/example/board/v0" ,
86 Link: "/r/example/board/v0" ,
87 Treasury: testutils.TestAddress( "treasury" ),
88 Balance: 1800 ,
89 Footer: "Built on [p/moul/grants/v0](/p/moul/grants/v0)." ,
90 }
91 }
92
93 func TestDemoReachedEveryStateTheseExamplesPin(t *testing.T) {
94 p := demo()
95 uassert.Equal(t, 4 , p.Board.MemberCount(), "the membership change executed" )
96 uassert.Equal(t, 4 , p.Board.Size())
97 uassert.Equal(t, "completed" , p.Board.Get( 1 ).Status.String())
98 uassert.Equal(t, "approved" , p.Board.Get( 2 ).Status.String())
99 uassert.Equal(t, "approved" , p.Board.Get( 3 ).Status.String())
100 uassert.Equal(t, "rejected" , p.Board.Get( 4 ).Status.String())
101
102 o := p.Board.Get( 3 )
103 uassert.True(t, o.OnBehalf(), "request 3 is filed by erin for grace" )
104 uassert.Equal(t, grace.String(), o.Payee().String())
105
106 g := p.Board.Get( 2 )
107 uassert.True(t, g.Milestones[ 0 ].Released, "one tranche paid" )
108 uassert.Equal(t, int64 ( 1200 ), p.Disbursed())
109 uassert.Equal(t, int64 ( 1400 ), p.Committed(), "800 still owed on 2, 600 on 3" )
110 uassert.Equal(t, int64 ( 3000 ), p.Raised())
111 uassert.Equal(t, 1 , len(p.Payments))
112 uassert.Equal(t, 2 , len(g.Milestones[ 1 ].Attempts), "one refused, one in flight" )
113 uassert.Equal(t, "refused" , g.Milestones[ 1 ].Attempts[ 0 ].Outcome.String())
114 uassert.Equal(t, "under review" , g.Milestones[ 1 ].Attempts[ 1 ].Outcome.String())
115 }
116
117 // Deliberately over an EMPTY program: the routing is what is under test, and
118 // rendering the full demo four times costs seconds of VM time for nothing.
119 func TestRenderUnknownPaths(t *testing.T) {
120 v := Renderer{Program: NewProgram( "ugnot" , alice), Title: "Example grant board" , Link: "/r/x/v0" }
121 uassert.Equal(t, "# Example grant board\n\nThere is no request 99.\n" , v.Render( "request/99" ))
122 uassert.Equal(t, "# Example grant board\n\n`abc` is not a request number.\n" , v.Render( "request/abc" ))
123 uassert.True(t, len(v.Render( "nope" )) > 0 )
124 uassert.Equal(t, v.Render( "" ), v.Render( "/" ), "the root is the root either way" )
125 }
126
127 // ExampleRendererIndex pins the board page.
128 func ExampleRendererIndex() {
129 print(view().Render( "" ))
130 // Output:
131 // # Example grant board
132 //
133 // Anyone may ask this board for money.
134 //
135 // ## Treasury
136 //
137 // | Field | Value |
138 // |---|---|
139 // | Address | `g1w3ex2ctnw4e8jh6lta047h6lta047h6llhugks` |
140 // | Balance | 1800 ugnot |
141 // | Promised | 1400 ugnot |
142 // | Unpromised | 400 ugnot |
143 // | Donated | 3000 ugnot |
144 // | Paid out | 1200 ugnot |
145 //
146 // ## Board
147 //
148 // A decision needs a majority of the members eligible to cast a ballot on it,
149 // recomputed every time: the party a decision is about never votes on it, so an
150 // applicant who sits on the board shrinks the room rather than packing it.
151 //
152 // | Member | Joined at height |
153 // |---|---|
154 // | `g1v3shve2lta047h6lta047h6lta047h6lel7d9l` | 102 |
155 // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | 0 |
156 // | `g1vdshymmvta047h6lta047h6lta047h6l2asz94` | 0 |
157 // | `g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu` | 0 |
158 //
159 // ## Requests
160 //
161 // | # | Kind | Title | For | Asking | Status | Ballots |
162 // |---|---|---|---|---|---|---|
163 // | [1](/r/example/board/v0:request/1) | membership | Add g1v3shve2lta047h6lta047h6lta047h6lel7d9l | - | nothing | completed | 2 for, 0 against |
164 // | [2](/r/example/board/v0:request/2) | grant | Port the p/nt/avl benchmarks to gno | `g1v4exjm…sj8d` | 2000 ugnot | approved | 3 for, 1 against |
165 // | [3](/r/example/board/v0:request/3) | grant | Translate the onboarding guide to Portuguese | `g1vaexzc…qsw9` (filed by g1v4exjm…sj8d) | 600 ugnot | approved | 3 for, 0 against |
166 // | [4](/r/example/board/v0:request/4) | grant | Rewrite gnoweb in a different framework | `g1d4skcm…cwc9` | 250000 ugnot | rejected | 0 for, 3 against |
167 //
168 // ## Where the money went
169 //
170 // [The ledger](/r/example/board/v0:ledger) lists every donation in and every tranche
171 // out, with the height, the payee and the milestone it paid for.
172 //
173 // ## Calling it
174 //
175 // ```sh
176 // # put money in
177 // gnokey maketx call -pkgpath gno.land/r/example/board/v0 -func Fund -send 5000000ugnot ...
178 // # ask for some
179 // gnokey maketx call -pkgpath gno.land/r/example/board/v0 -func Apply \
180 // -args 'Port the thing' -args 'why it matters' -args 'design:100,ship:400' ...
181 // # ask on behalf of someone who cannot pay the gas to ask
182 // gnokey maketx call -pkgpath gno.land/r/example/board/v0 -func ApplyFor \
183 // -args g1... -args 'their account is empty' \
184 // -args 'Port the thing' -args 'why it matters' -args 'design:100,ship:400' ...
185 // # decide (members only)
186 // gnokey maketx call -pkgpath gno.land/r/example/board/v0 -func Vote -args 1 -args true -args 'reason' ...
187 // # show your work, then get paid for it
188 // gnokey maketx call -pkgpath gno.land/r/example/board/v0 -func SubmitProof \
189 // -args 1 -args 0 -args url -args 'https://...' -args 'what it is' ...
190 // gnokey maketx call -pkgpath gno.land/r/example/board/v0 -func Review -args 1 -args 0 -args true -args 'looks done' ...
191 // ```
192 //
193 // Built on [p/moul/grants/v0](/p/moul/grants/v0).
194 }
195
196 // ExampleRendererRequest pins the richest page: a grant mid-delivery with one
197 // tranche paid, a refused proof kept on the record, and a second attempt
198 // still being reviewed.
199 func ExampleRendererRequest() {
200 print(view().Render( "request/2" ))
201 // Output:
202 // # Request 2: Port the p/nt/avl benchmarks to gno
203 //
204 // | Field | Value |
205 // |---|---|
206 // | Kind | grant |
207 // | Applicant | `g1v4exjmjlta047h6lta047h6lta047h6lv8sj8d` |
208 // | Status | approved |
209 // | Filed at height | 110 |
210 // | Decided at height | 114 |
211 // | Asking | 2000 ugnot |
212 // | Paid so far | 1200 ugnot |
213 // | Still owed | 800 ugnot |
214 //
215 // ## The ask
216 //
217 //
218 //
219 // Three weeks. A benchmark harness, a report on the tree rebalancing costs, and a PR against the monorepo.
220 //
221 //
222 //
223 // ## Ballots
224 //
225 // Needs 3 of 4 eligible members. Standing: 3 for, 1 against.
226 //
227 // | Member | Vote | Height | Reason |
228 // |---|---|---|---|
229 // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | for | 111 | Cheap for what it tells us\. |
230 // | `g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu` | against | 112 | The second milestone is vague\. |
231 // | `g1vdshymmvta047h6lta047h6lta047h6l2asz94` | for | 113 | Vague but bounded\. Worth it\. |
232 // | `g1v3shve2lta047h6lta047h6lta047h6lel7d9l` | for | 114 | Agreed with carol\. |
233 //
234 // ## Milestones
235 //
236 // Earned in order. The applicant submits a proof, the board reviews that proof,
237 // and the verdict that carries also moves the coins.
238 //
239 // ### 1. harness merged: 1200 ugnot
240 //
241 // Released at height 123, paid to `g1v4exjmjlta047h6lta047h6lta047h6lv8sj8d`.
242 //
243 // **Proof 1** (url, height 120, accepted)
244 //
245 // ```
246 // https://github.com/gnolang/gno/pull/9999
247 // ```
248 //
249 //
250 //
251 // Harness merged, 14 benchmarks, runs in CI.
252 //
253 //
254 //
255 // | Member | Vote | Height | Reason |
256 // |---|---|---|---|
257 // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | accept | 121 | Merged, I reviewed it\. |
258 // | `g1vdshymmvta047h6lta047h6lta047h6l2asz94` | accept | 122 | Confirmed\. |
259 // | `g1v3shve2lta047h6lta047h6lta047h6lel7d9l` | accept | 123 | Confirmed\. |
260 //
261 // ### 2. report published: 800 ugnot
262 //
263 // A proof is under review.
264 //
265 // **Proof 1** (text, height 130, refused)
266 //
267 // ```
268 // it is basically done
269 // ```
270 //
271 // | Member | Vote | Height | Reason |
272 // |---|---|---|---|
273 // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | refuse | 131 | That is not a report\. |
274 // | `g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu` | refuse | 132 | Agreed, no numbers\. |
275 // | `g1vdshymmvta047h6lta047h6lta047h6l2asz94` | refuse | 133 | Same\. |
276 //
277 // **Proof 2** (url, height 140, under review)
278 //
279 // ```
280 // https://example.com/avl-rebalancing-costs
281 // ```
282 //
283 //
284 //
285 // Rewritten with the measurements.
286 //
287 //
288 //
289 // | Member | Vote | Height | Reason |
290 // |---|---|---|---|
291 // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | accept | 141 | Much better\. |
292 }
293
294 // ExampleRendererMembership pins a membership request, which asks for no
295 // money and therefore renders no milestones.
296 func ExampleRendererMembership() {
297 print(view().Render( "request/1" ))
298 // Output:
299 // # Request 1: Add g1v3shve2lta047h6lta047h6lta047h6lel7d9l
300 //
301 // | Field | Value |
302 // |---|---|
303 // | Kind | membership |
304 // | Applicant | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` |
305 // | Subject | `g1v3shve2lta047h6lta047h6lta047h6lel7d9l` |
306 // | Effect | add to the board |
307 // | Status | completed |
308 // | Filed at height | 100 |
309 // | Decided at height | 102 |
310 //
311 // ## The ask
312 //
313 //
314 //
315 // Reviewed six of the last eight applications from the outside. Give them the seat.
316 //
317 //
318 //
319 // ## Ballots
320 //
321 // Needs 2 of 3 eligible members. Standing: 2 for, 0 against.
322 //
323 // | Member | Vote | Height | Reason |
324 // |---|---|---|---|
325 // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | for | 101 | Proposed it\. |
326 // | `g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu` | for | 102 | No objection\. |
327 }
328
329 // ExampleRendererLedger pins the money trail.
330 func ExampleRendererLedger() {
331 print(view().Render( "ledger" ))
332 // Output:
333 // # Example grant board: the ledger
334 //
335 // Every coin in and every coin out, in the order it happened. Coins sent
336 // straight to `g1w3ex2ctnw4e8jh6lta047h6lta047h6llhugks` land in the treasury
337 // without appearing here, which is why `Fund` exists: it is the same transfer
338 // with a name attached.
339 //
340 // ## In
341 //
342 // | Height | From | Amount |
343 // |---|---|---|
344 // | 100 | `g1veexzmntta047h6lta047h6lta047h6lthxxu5` | 3000 ugnot |
345 //
346 // Total in: 3000 ugnot
347 //
348 // ## Out
349 //
350 // | Height | Request | Milestone | To | Amount |
351 // |---|---|---|---|---|
352 // | 123 | [2](/r/example/board/v0:request/2) | 1 | `g1v4exjmjlta047h6lta047h6lta047h6lv8sj8d` | 1200 ugnot |
353 //
354 // Total out: 1200 ugnot
355 //
356 // Balance now: 1800 ugnot. [Back to the board](/r/example/board/v0).
357 }
358
359 // ExampleRendererOnBehalf pins a request one address filed for another: the
360 // beneficiary row, the callout carrying the applicant's reason, and the "For"
361 // column that says on the board page who the money would reach.
362 func ExampleRendererOnBehalf() {
363 print(view().Render( "request/3" ))
364 // Output:
365 // # Request 3: Translate the onboarding guide to Portuguese
366 //
367 // | Field | Value |
368 // |---|---|
369 // | Kind | grant |
370 // | Applicant | `g1v4exjmjlta047h6lta047h6lta047h6lv8sj8d` |
371 // | Beneficiary | `g1vaexzcm9ta047h6lta047h6lta047h6lr5qsw9` |
372 // | Status | approved |
373 // | Filed at height | 145 |
374 // | Decided at height | 148 |
375 // | Asking | 600 ugnot |
376 // | Paid so far | 0 ugnot |
377 // | Still owed | 600 ugnot |
378 //
379 // ## Filed for someone else
380 //
381 // The applicant is not the payee: every tranche of this request pays
382 // `g1vaexzcm9ta047h6lta047h6lta047h6lr5qsw9`. Both addresses are barred from voting on it, and
383 // either may submit a proof. The reason given:
384 //
385 // > Her account is empty, so she cannot pay the gas to file this herself\.
386 //
387 // Nothing checks that reason. It is a claim by the applicant, and the point of
388 // printing it here is that a member can check it before voting.
389 //
390 // ## The ask
391 //
392 // One pass over the eight pages, reviewed by a second speaker.
393 //
394 // ## Ballots
395 //
396 // Needs 3 of 4 eligible members. Standing: 3 for, 0 against.
397 //
398 // | Member | Vote | Height | Reason |
399 // |---|---|---|---|
400 // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | for | 146 | Cheap, and we have no pt\-BR page at all\. |
401 // | `g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu` | for | 147 | Fine\. |
402 // | `g1vdshymmvta047h6lta047h6lta047h6l2asz94` | for | 148 | Fine\. |
403 //
404 // ## Milestones
405 //
406 // Earned in order. The applicant submits a proof, the board reviews that proof,
407 // and the verdict that carries also moves the coins.
408 //
409 // ### 1. translation merged: 600 ugnot
410 //
411 // Waiting on a proof from the applicant.
412 }
413
414 // injected is every dangerous markdown sequence a caller can type, in one
415 // string, so a slot that forgets to escape shows up as a live link or a
416 // gnoweb tag in the assertions below rather than on a deployed page.
417 const injected = "[click](https://evil.example)  " +
418 "<gno-columns> | fake | cell | ```\nsecond line\n"
419
420 // injectedShort is the same attack inside the 100-character limit a title and
421 // a milestone name are held to.
422 const injectedShort = "[click](https://evil.example) <gno-columns>|x|\nnext"
423
424 // TestEveryCallerSuppliedStringIsEscaped drives one board through every slot
425 // a caller controls and asserts that none of them can reshape the page.
426 //
427 // The bar is not the same in every slot, on purpose. A one-line slot (a
428 // title, a milestone name, the reason on a ballot, the reason for filing on
429 // someone else's behalf) is chrome the page lays out, so nothing a caller
430 // typed may survive as markup there. A prose slot (the body of an
431 // application, the note on a proof) goes through sanitize.Block, which
432 // PRESERVES inline links and emphasis by design: a grant application whose
433 // link to the merged PR renders as literal text is a worse page, and the
434 // sanitizer's own contract is that prose formats. What Block still kills is
435 // everything structural, so the paragraph cannot leave its paragraph.
436 //
437 // Assertions are on the dangerous SEQUENCE, never on the exact escaped bytes:
438 // those belong to the sanitizer, they change when it changes, and a test that
439 // pins them fails for the wrong reason.
440 func TestEveryCallerSuppliedStringIsEscaped(t *testing.T) {
441 p := NewProgram( "ugnot" , alice, bob)
442 r, err := p.ApplyFor(erin, grace, injected, injectedShort, injected, injectedShort+ ":100" , 10 )
443 urequire.NoError(t, err)
444 p.Board.Vote(alice, r.ID, true , injected, 11 )
445 p.Board.Vote(bob, r.ID, true , injected, 12 )
446 urequire.NoError(t, p.Board.SubmitProof(erin, r.ID, 0 , Proof{
447 Kind: "url" , Ref: injected, Note: injected, Height: 13 ,
448 }))
449 p.Board.Review(alice, r.ID, 0 , false , injected, 14 )
450
451 v := Renderer{Program: p, Title: "Board" , Path: "gno.land/r/x/v0" , Link: "/r/x/v0" , Balance: 0 }
452
453 // Every structural line of every page: a table row, a heading, a
454 // blockquote. Nothing a caller typed may render as markup in one.
455 for _, page := range [] string { "" , "request/1" , "ledger" } {
456 for _, line := range strings.Split(v.Render(page), "\n" ) {
457 if !strings.HasPrefix(line, "|" ) && !strings.HasPrefix(line, "#" ) &&
458 !strings.HasPrefix(line, ">" ) {
459 continue
460 }
461 for _, dead := range [] string { "](http" , "![" , "<gno-" } {
462 uassert.False(t, strings.Contains(line, dead),
463 "page " +page+ " line still carries " +dead+ ": " +line)
464 }
465 }
466 }
467
468 // A table row keeps exactly its own cells: an unescaped pipe would open a
469 // column and an unescaped newline would end the row early, and both
470 // reshape the table silently rather than failing.
471 for _, line := range strings.Split(v.Render( "request/1" ), "\n" ) {
472 if !strings.HasPrefix(line, "| `" +alice.String()+ "`" ) {
473 continue
474 }
475 uassert.Equal(t, 5 , strings.Count(line, "|" )-strings.Count(line, "\\|" ),
476 "the ballot row kept its four cells: " +line)
477 }
478
479 // An escaper that ate the content would pass every assertion above.
480 out := v.Render( "request/1" )
481 uassert.True(t, strings.Contains(out, "click" ), "the readable text survived" )
482 uassert.True(t, strings.Contains(out, "second line" ), "the second line survived" )
483
484 // The structural attack is dead in the prose slots too, even though the
485 // link in them is deliberately alive.
486 body := out[strings.Index(out, "## The ask" ):strings.Index(out, "## Ballots" )]
487 uassert.False(t, strings.Contains(body, "\n<gno-" ), "no gnoweb tag at a line start" )
488 uassert.False(t, strings.Contains(body, "\n| " ), "prose cannot open a table" )
489 uassert.False(t, strings.Contains(body, "\n#" ), "prose cannot forge a heading" )
490 }
491