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

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}