govdao_test.gno
18.44 Kb · 460 lines
1package impl
2
3import (
4 "fmt"
5 "strings"
6 "testing"
7
8 "gno.land/p/nt/testutils/v0"
9 "gno.land/p/nt/urequire/v0"
10 "gno.land/r/gov/dao"
11 "gno.land/r/gov/dao/memberstore/v0"
12)
13
14// cur is a zero-value realm used as a placeholder when forwarding to
15// uassert/urequire dispatch helpers that gained an `rlm realm` param.
16// These tests pass `func()` callbacks (no crossing inside the callback),
17// so rlm is ignored — a nil realm here is safe.
18var cur realm
19
20func init(cur realm) {
21 loadMembers(cur)
22
23 dao.UpdateImpl(cross(cur), dao.NewUpdateRequest(govDAO, []string{"gno.land/r/gov/dao/impl/v0"}))
24}
25
26var (
27 m1 = testutils.TestAddress("m1")
28 m11 = testutils.TestAddress("m1.1")
29 m111 = testutils.TestAddress("m1.1.1")
30 m1111 = testutils.TestAddress("m1.1.1.1")
31 m2 = testutils.TestAddress("m2")
32 m3 = testutils.TestAddress("m3")
33 m4 = testutils.TestAddress("m4")
34 m5 = testutils.TestAddress("m5")
35 m6 = testutils.TestAddress("m6")
36
37 noMember = testutils.TestAddress("nm1")
38)
39
40func loadMembers(cur realm) {
41 // This is needed because state is saved between unit tests,
42 // and we want to avoid having real members used on tests
43 mstore := memberstore.Get(0, cur)
44 mstore.DeleteAll()
45
46 mstore.SetTier(memberstore.T1)
47 mstore.SetTier(memberstore.T2)
48 mstore.SetTier(memberstore.T3)
49
50 mstore.SetMember(memberstore.T1, m1, memberByTier(memberstore.T1))
51 mstore.SetMember(memberstore.T1, m11, memberByTier(memberstore.T1))
52 mstore.SetMember(memberstore.T1, m111, memberByTier(memberstore.T1))
53 mstore.SetMember(memberstore.T1, m1111, memberByTier(memberstore.T1))
54
55 mstore.SetMember(memberstore.T2, m2, memberByTier(memberstore.T2))
56 mstore.SetMember(memberstore.T2, m3, memberByTier(memberstore.T2))
57 mstore.SetMember(memberstore.T3, m4, memberByTier(memberstore.T3))
58 mstore.SetMember(memberstore.T3, m5, memberByTier(memberstore.T3))
59 mstore.SetMember(memberstore.T3, m6, memberByTier(memberstore.T3))
60}
61
62func TestCreateProposalAndVote(cur realm, t *testing.T) {
63 loadMembers(cur)
64
65 portfolio := "# This is my portfolio:\n\n- THINGS"
66
67 testing.SetOriginCaller(noMember)
68 testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/impl/v0"))
69
70 nm1 := testutils.TestAddress("nm1")
71
72 urequire.AbortsWithMessage(t, cur, "Only T1 and T2 members can be added by proposal. To add a T3 member use AddMember function directly.", func(cur realm) {
73 dao.MustCreateProposal(cross(cur), NewAddMemberRequest(cur, nm1, memberstore.T3, portfolio))
74 })
75
76 urequire.AbortsWithMessage(t, cur, "proposer is not a member", func(cur realm) {
77 dao.MustCreateProposal(cross(cur), NewAddMemberRequest(cur, nm1, memberstore.T2, portfolio))
78 })
79
80 testing.SetOriginCaller(m1)
81 testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/impl/v0"))
82
83 proposalRequest := NewAddMemberRequest(cur, nm1, memberstore.T2, portfolio)
84
85 testing.SetOriginCaller(m1)
86 testing.SetRealm(testing.NewUserRealm(m1))
87 pid := dao.MustCreateProposal(cross(cur), proposalRequest)
88 urequire.Equal(t, int(pid), 0)
89
90 // m1 votes yes because that member is interested on it
91 dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, dao.ProposalID(0)))
92
93 testing.SetOriginCaller(m11)
94
95 dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.NoVote, dao.ProposalID(0)))
96
97 testing.SetOriginCaller(m2)
98
99 dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.NoVote, dao.ProposalID(0)))
100
101 testing.SetOriginCaller(m3)
102
103 dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.NoVote, dao.ProposalID(0)))
104
105 testing.SetOriginCaller(m4)
106
107 urequire.AbortsWithMessage(t, cur, "member on specified tier is not allowed to vote on this proposal", func() {
108 dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.NoVote, dao.ProposalID(0)))
109 })
110
111 testing.SetOriginCaller(m111)
112
113 // Same effect as:
114 // dao.MustVoteOnProposal(dao.VoteRequest{
115 // Option: dao.NoVote,
116 // ProposalID: dao.ProposalID(0),
117 // })
118 dao.MustVoteOnProposalSimple(cross(cur), 0, "NO")
119
120 urequire.Equal(t, true, strings.Contains(dao.Render(cross(cur), ""), "Prop #0 - New T2 Member Proposal"))
121 // urequire.Equal(t, true, strings.Contains(dao.Render(cross(cur), ""), "Author: "+m1.String()))
122
123 urequire.AbortsWithMessage(t, cur, "proposal didn't reach supermajority yet: 66.66", func() {
124 dao.ExecuteProposal(cross(cur), dao.ProposalID(0))
125 })
126
127 testing.SetOriginCaller(m1111)
128 dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.NoVote, dao.ProposalID(0)))
129
130 accepted := dao.ExecuteProposal(cross(cur), dao.ProposalID(0))
131 urequire.Equal(t, false, accepted)
132
133 urequire.Equal(t, true, contains(dao.Render(cross(cur), "0"), "**PROPOSAL HAS BEEN DENIED**"))
134 urequire.Equal(t, true, contains(dao.Render(cross(cur), "0"), "NO PERCENT: 81.25%"))
135}
136
137func TestExecutorCreationRealm(cur realm, t *testing.T) {
138 loadMembers(cur)
139
140 // Test that executor creation realm is captured correctly
141 testing.SetOriginCaller(m1)
142 testing.SetRealm(testing.NewCodeRealm("gno.land/r/template/contract"))
143
144 // Create executor in the template contract realm
145 executor := dao.NewSimpleExecutor(0, cur, func(realm) error { return nil }, "Test executor from template")
146
147 proposalRequest := dao.NewProposalRequest(
148 "Test Proposal",
149 "This proposal tests executor creation realm tracking",
150 executor,
151 )
152
153 // Create proposal from user realm (user can call DAO directly)
154 testing.SetRealm(testing.NewUserRealm(m1))
155 pid := dao.MustCreateProposal(cross(cur), proposalRequest)
156
157 // Get the proposal
158 prop := dao.MustGetProposal(pid)
159
160 // Verify the author is m1
161 urequire.Equal(t, m1, prop.Author())
162
163 // Verify the executor creation realm is captured correctly
164 urequire.Equal(t, "gno.land/r/template/contract", prop.ExecutorCreationRealm())
165
166 // Check that it's displayed in the individual proposal render output
167 individualRendered := dao.Render(cross(cur), pid.String())
168 // Rendered as a code span: CreationRealm() is interface-dispatched and so
169 // third-party controlled, and InlineCode neutralizes it while reading
170 // naturally for a realm path.
171 urequire.Equal(t, true, contains(individualRendered, "Executor created in: `gno.land/r/template/contract`"))
172 urequire.Equal(t, true, contains(individualRendered, "Test executor from template"))
173
174 // Also verify the main content is there
175 urequire.Equal(t, true, contains(individualRendered, "Test Proposal"))
176 urequire.Equal(t, true, contains(individualRendered, "This proposal tests executor creation realm tracking"))
177}
178
179func TestProposalPagination(cur realm, t *testing.T) {
180 loadMembers(cur)
181 portfolio := "### This is my portfolio:\n\n- THINGS"
182
183 testing.SetOriginCaller(m1)
184 testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/impl/v0"))
185
186 nm1 := testutils.TestAddress("nm1")
187
188 var pid dao.ProposalID
189
190 proposalRequest := NewAddMemberRequest(cur, nm1, memberstore.T2, portfolio)
191
192 testing.SetOriginCaller(m1)
193 testing.SetRealm(testing.NewUserRealm(m1))
194 pid = dao.MustCreateProposal(cross(cur), proposalRequest)
195
196 // TODO: tests keep the same vm state: https://github.com/gnolang/gno/issues/1982
197 urequire.Equal(t, 2, int(pid))
198
199 testing.SetRealm(testing.NewUserRealm(m1))
200 pid = dao.MustCreateProposal(cross(cur), proposalRequest)
201 urequire.Equal(t, 3, int(pid))
202
203 testing.SetRealm(testing.NewUserRealm(m1))
204 pid = dao.MustCreateProposal(cross(cur), proposalRequest)
205 urequire.Equal(t, 4, int(pid))
206
207 testing.SetRealm(testing.NewUserRealm(m1))
208 pid = dao.MustCreateProposal(cross(cur), proposalRequest)
209 urequire.Equal(t, 5, int(pid))
210
211 testing.SetRealm(testing.NewUserRealm(m1))
212 pid = dao.MustCreateProposal(cross(cur), proposalRequest)
213 urequire.Equal(t, 6, int(pid))
214
215 testing.SetRealm(testing.NewUserRealm(m1))
216 pid = dao.MustCreateProposal(cross(cur), proposalRequest)
217 urequire.Equal(t, 7, int(pid))
218
219 fmt.Println(dao.Render(cross(cur), ""))
220 urequire.Equal(t, true, contains(dao.Render(cross(cur), ""), "### [Prop #7 - New T2 Member Proposal](/r/gov/dao:7)"))
221 urequire.Equal(t, true, contains(dao.Render(cross(cur), ""), "### [Prop #6 - New T2 Member Proposal](/r/gov/dao:6)"))
222 urequire.Equal(t, true, contains(dao.Render(cross(cur), ""), "### [Prop #5 - New T2 Member Proposal](/r/gov/dao:5)"))
223 urequire.Equal(t, true, contains(dao.Render(cross(cur), ""), "### [Prop #4 - New T2 Member Proposal](/r/gov/dao:4)"))
224 urequire.Equal(t, true, contains(dao.Render(cross(cur), ""), "### [Prop #3 - New T2 Member Proposal](/r/gov/dao:3)"))
225
226 urequire.Equal(t, true, contains(dao.Render(cross(cur), "?page=2"), "### [Prop #2 - New T2 Member Proposal](/r/gov/dao:2)"))
227 urequire.Equal(t, true, contains(dao.Render(cross(cur), "?page=2"), "### [Prop #1 - Test Proposal](/r/gov/dao:1)"))
228 urequire.Equal(t, true, contains(dao.Render(cross(cur), "?page=2"), "### [Prop #0 - New T2 Member Proposal](/r/gov/dao:0)"))
229}
230
231func TestUpgradeDaoImplementation(cur realm, t *testing.T) {
232 loadMembers(cur)
233
234 testing.SetOriginCaller(noMember)
235 testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/impl/v0"))
236
237 urequire.PanicsWithMessage(t, cur, "proposer is not a member", func() {
238 NewUpgradeDaoImplRequest(cur, govDAO, "gno.land/r/gov/dao/v1/impl", "Something happened and we have to fix it.")
239 })
240
241 testing.SetOriginCaller(m1)
242 testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/impl/v0"))
243
244 preq := NewUpgradeDaoImplRequest(cur, govDAO, "gno.land/r/gov/dao/v1/impl", "Something happened and we have to fix it.")
245
246 testing.SetOriginCaller(m1)
247 testing.SetRealm(testing.NewUserRealm(m1))
248 pid := dao.MustCreateProposal(cross(cur), preq)
249 urequire.Equal(t, int(pid), 8)
250
251 // m1 votes yes because that member is interested on it
252 dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, dao.ProposalID(pid)))
253
254 testing.SetOriginCaller(m11)
255
256 dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, dao.ProposalID(pid)))
257
258 testing.SetOriginCaller(m2)
259
260 dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, dao.ProposalID(pid)))
261
262 testing.SetOriginCaller(m3)
263
264 dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, dao.ProposalID(pid)))
265
266 testing.SetOriginCaller(m111)
267
268 // Same effect as:
269 // dao.MustVoteOnProposal(dao.VoteRequest{
270 // Option: dao.YesVote,
271 // ProposalID: dao.ProposalID(pid),
272 // })
273 dao.MustVoteOnProposalSimple(cross(cur), int64(pid), "YES")
274
275 urequire.Equal(t, true, contains(dao.Render(cross(cur), "8"), "**Proposal is open for votes**"))
276 urequire.Equal(t, true, contains(dao.Render(cross(cur), "8"), "68.42105263157895%"))
277 urequire.Equal(t, true, contains(dao.Render(cross(cur), "8"), "0%"))
278
279 accepted := dao.ExecuteProposal(cross(cur), dao.ProposalID(pid))
280 urequire.Equal(t, true, accepted)
281 urequire.Equal(t, true, contains(dao.Render(cross(cur), "8"), "**PROPOSAL HAS BEEN ACCEPTED**"))
282 urequire.Equal(t, true, contains(dao.Render(cross(cur), "8"), "YES PERCENT: 68.42105263157895%"))
283}
284
285func TestAbstainVote(cur realm, t *testing.T) {
286 loadMembers(cur)
287
288 portfolio := "# This is my portfolio:\n\n- THINGS"
289
290 testing.SetOriginCaller(m1)
291 testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/impl/v0"))
292
293 nm1 := testutils.TestAddress("nm1")
294 proposalRequest := NewAddMemberRequest(cur, nm1, memberstore.T2, portfolio)
295
296 testing.SetOriginCaller(m1)
297 testing.SetRealm(testing.NewUserRealm(m1))
298 pid := dao.MustCreateProposal(cross(cur), proposalRequest)
299
300 // m1 votes abstain
301 dao.MustVoteOnProposalSimple(cross(cur), int64(pid), "ABSTAIN")
302
303 // Other members vote YES to reach supermajority
304 testing.SetOriginCaller(m11)
305 dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, pid))
306
307 testing.SetOriginCaller(m111)
308 dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, pid))
309
310 testing.SetOriginCaller(m1111)
311 dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, pid))
312
313 testing.SetOriginCaller(m2)
314 dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, pid))
315
316 testing.SetOriginCaller(m3)
317 dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, pid))
318
319 // Verify render shows correct percentages
320 urequire.Equal(t, true, contains(dao.Render(cross(cur), pid.String()), "YES PERCENT: 81.25%"))
321 urequire.Equal(t, true, contains(dao.Render(cross(cur), pid.String()), "NO PERCENT: 0%"))
322 urequire.Equal(t, true, contains(dao.Render(cross(cur), pid.String()), "ABSTAIN PERCENT: 18.75%"))
323
324 // Supermajority reached despite one abstain voter
325 accepted := dao.ExecuteProposal(cross(cur), pid)
326 urequire.Equal(t, true, accepted)
327 urequire.Equal(t, true, contains(dao.Render(cross(cur), pid.String()), "**PROPOSAL HAS BEEN ACCEPTED**"))
328
329 // Abstain voter appears in vote list
330 urequire.Equal(t, true, contains(dao.Render(cross(cur), fmt.Sprintf("%v/votes", int64(pid))), "ABSTAIN"))
331}
332
333func contains(s, substr string) bool {
334 return strings.Index(s, substr) >= 0
335}
336
337// TestAddMemberKeepsInvitationPointWhenAddFails covers the executor's ordering.
338//
339// The executor spends the proposer's invitation point and adds the member. If
340// the add fails, a returned executor error does NOT revert: ExecuteProposal
341// marks the proposal denied and ExecuteOrRejectProposal still commits the
342// transaction. So with the old order the point was gone and the member was not
343// added, permanently.
344//
345// The failure is reachable: SetMember refuses an address that is already a
346// member, and AddMember enrols T3 members directly, so proposing an existing
347// member to another tier is an ordinary mistake rather than a contrived one.
348//
349// Uses ExecuteOrRejectProposal deliberately. ExecuteProposal panics on an
350// executor error, and a panic aborts the transaction, which would revert the
351// spend and hide the bug.
352func TestAddMemberKeepsInvitationPointWhenAddFails(cur realm, t *testing.T) {
353 loadMembers(cur)
354
355 // m4 is already a T3 member, so adding it to T2 must fail in SetMember.
356 testing.SetOriginCaller(m1)
357 testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/impl/v0"))
358 preq := NewAddMemberRequest(cur, m4, memberstore.T2, "# Portfolio\n\n- things")
359
360 // memberstore.Get requires an allowed-DAO caller, so read under the impl
361 // realm rather than whatever the previous step left in place.
362 testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/impl/v0"))
363 before, tier := memberstore.Get(0, cur).GetMember(m1)
364 urequire.Equal(t, memberstore.T1, tier)
365 pointsBefore := before.InvitationPoints
366 urequire.Equal(t, true, pointsBefore > 0, "premise: the proposer must have a point to lose")
367
368 testing.SetOriginCaller(m1)
369 testing.SetRealm(testing.NewUserRealm(m1))
370 pid := dao.MustCreateProposal(cross(cur), preq)
371
372 // A T2 addition is voted on by T1 and T2. Every one of them votes yes, so
373 // the proposal certainly passes and the executor certainly runs -- if it did
374 // not, this test would prove nothing.
375 for _, voter := range []address{m1, m11, m111, m1111, m2, m3} {
376 testing.SetOriginCaller(voter)
377 dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, dao.ProposalID(pid)))
378 }
379
380 accepted := dao.ExecuteOrRejectProposal(cross(cur), dao.ProposalID(pid))
381 urequire.Equal(t, false, accepted, "the add must fail, since m4 is already a member")
382
383 // Proves the executor ran and returned an error, rather than the vote
384 // failing to reach a threshold -- only GovDAO.ExecuteProposal writes this.
385 rendered := dao.Render(cross(cur), fmt.Sprintf("%d", int(pid)))
386 urequire.Equal(t, true, contains(rendered, "execution failed"),
387 "the proposal must have been denied by the executor, not by the vote")
388
389 testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/impl/v0"))
390 after, _ := memberstore.Get(0, cur).GetMember(m1)
391 urequire.Equal(t, pointsBefore, after.InvitationPoints,
392 "a failed add must not spend the proposer's invitation point")
393}
394
395// TestPromoteToTierMissingFromStoreKeepsTheMember covers the remove-then-add
396// hazard in the promotion executor.
397//
398// The executor removes the member and re-adds them at the new tier. SetMember
399// returns an error rather than panicking when the store has no bucket for that
400// tier, and a returned executor error does not revert -- the proposal is marked
401// rejected and the transaction still commits -- so the member would be removed
402// and not re-added.
403//
404// The two tier registries are separate. GetTier reads the global definitions,
405// which always hold T1/T2/T3; SetMember checks whether this store has a bucket,
406// and DeleteAll empties the buckets while leaving the definitions intact. So a
407// destination can pass the first check and fail the second, which is what this
408// sets up.
409func TestPromoteToTierMissingFromStoreKeepsTheMember(cur realm, t *testing.T) {
410 loadMembers(cur)
411
412 // Drop the T3 bucket while the global definition of T3 survives.
413 mstore := memberstore.Get(0, cur)
414 mstore.DeleteAll()
415 mstore.SetTier(memberstore.T1)
416 mstore.SetTier(memberstore.T2)
417 mstore.SetMember(memberstore.T1, m1, memberByTier(memberstore.T1))
418 mstore.SetMember(memberstore.T1, m11, memberByTier(memberstore.T1))
419 mstore.SetMember(memberstore.T1, m111, memberByTier(memberstore.T1))
420 mstore.SetMember(memberstore.T1, m1111, memberByTier(memberstore.T1))
421 mstore.SetMember(memberstore.T2, m2, memberByTier(memberstore.T2))
422 mstore.SetMember(memberstore.T2, m3, memberByTier(memberstore.T2))
423
424 // Premise: T3 is defined globally but absent from this store.
425 _, defined := memberstore.GetTier(memberstore.T3)
426 urequire.Equal(t, true, defined, "premise: T3 must still be a defined tier")
427 urequire.Equal(t, false, mstore.Has(memberstore.T3),
428 "premise: this store must have no T3 bucket, or the test proves nothing")
429
430 testing.SetOriginCaller(m1)
431 testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/impl/v0"))
432 preq := NewPromoteMemberRequest(cur, m1, memberstore.T1, memberstore.T3)
433
434 testing.SetOriginCaller(m1)
435 testing.SetRealm(testing.NewUserRealm(m1))
436 pid := dao.MustCreateProposal(cross(cur), preq)
437
438 for _, voter := range []address{m1, m11, m111, m1111, m2, m3} {
439 testing.SetOriginCaller(voter)
440 dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, dao.ProposalID(pid)))
441 }
442
443 // The executor refuses before removing anything, and returns rather than
444 // panicking. That rejects the proposal and closes it, and since nothing was
445 // written, committing the rejection loses nothing.
446 //
447 // So the assertion below is about the chain, not the harness: the member
448 // survives because no write happened, not because a panic reverted one.
449 // Under the earlier panic-after-removal shape it held only because gno test
450 // does not revert an abort, which proved nothing about production.
451 accepted := dao.ExecuteOrRejectProposal(cross(cur), dao.ProposalID(pid))
452 urequire.Equal(t, false, accepted, "the promotion must be rejected")
453
454 testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/impl/v0"))
455 after, tier := memberstore.Get(0, cur).GetMember(m1)
456 urequire.Equal(t, true, after != nil, "the member must survive a refused promotion")
457 urequire.Equal(t, memberstore.T1, tier, "and must still be on their original tier")
458
459 loadMembers(cur)
460}