package impl import ( "fmt" "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/urequire/v0" "gno.land/r/gov/dao" "gno.land/r/gov/dao/memberstore/v0" ) // cur is a zero-value realm used as a placeholder when forwarding to // uassert/urequire dispatch helpers that gained an `rlm realm` param. // These tests pass `func()` callbacks (no crossing inside the callback), // so rlm is ignored — a nil realm here is safe. var cur realm func init(cur realm) { loadMembers(cur) dao.UpdateImpl(cross(cur), dao.NewUpdateRequest(govDAO, []string{"gno.land/r/gov/dao/impl/v0"})) } var ( m1 = testutils.TestAddress("m1") m11 = testutils.TestAddress("m1.1") m111 = testutils.TestAddress("m1.1.1") m1111 = testutils.TestAddress("m1.1.1.1") m2 = testutils.TestAddress("m2") m3 = testutils.TestAddress("m3") m4 = testutils.TestAddress("m4") m5 = testutils.TestAddress("m5") m6 = testutils.TestAddress("m6") noMember = testutils.TestAddress("nm1") ) func loadMembers(cur realm) { // This is needed because state is saved between unit tests, // and we want to avoid having real members used on tests mstore := memberstore.Get(0, cur) mstore.DeleteAll() mstore.SetTier(memberstore.T1) mstore.SetTier(memberstore.T2) mstore.SetTier(memberstore.T3) mstore.SetMember(memberstore.T1, m1, memberByTier(memberstore.T1)) mstore.SetMember(memberstore.T1, m11, memberByTier(memberstore.T1)) mstore.SetMember(memberstore.T1, m111, memberByTier(memberstore.T1)) mstore.SetMember(memberstore.T1, m1111, memberByTier(memberstore.T1)) mstore.SetMember(memberstore.T2, m2, memberByTier(memberstore.T2)) mstore.SetMember(memberstore.T2, m3, memberByTier(memberstore.T2)) mstore.SetMember(memberstore.T3, m4, memberByTier(memberstore.T3)) mstore.SetMember(memberstore.T3, m5, memberByTier(memberstore.T3)) mstore.SetMember(memberstore.T3, m6, memberByTier(memberstore.T3)) } func TestCreateProposalAndVote(cur realm, t *testing.T) { loadMembers(cur) portfolio := "# This is my portfolio:\n\n- THINGS" testing.SetOriginCaller(noMember) testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/impl/v0")) nm1 := testutils.TestAddress("nm1") 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) { dao.MustCreateProposal(cross(cur), NewAddMemberRequest(cur, nm1, memberstore.T3, portfolio)) }) urequire.AbortsWithMessage(t, cur, "proposer is not a member", func(cur realm) { dao.MustCreateProposal(cross(cur), NewAddMemberRequest(cur, nm1, memberstore.T2, portfolio)) }) testing.SetOriginCaller(m1) testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/impl/v0")) proposalRequest := NewAddMemberRequest(cur, nm1, memberstore.T2, portfolio) testing.SetOriginCaller(m1) testing.SetRealm(testing.NewUserRealm(m1)) pid := dao.MustCreateProposal(cross(cur), proposalRequest) urequire.Equal(t, int(pid), 0) // m1 votes yes because that member is interested on it dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, dao.ProposalID(0))) testing.SetOriginCaller(m11) dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.NoVote, dao.ProposalID(0))) testing.SetOriginCaller(m2) dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.NoVote, dao.ProposalID(0))) testing.SetOriginCaller(m3) dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.NoVote, dao.ProposalID(0))) testing.SetOriginCaller(m4) urequire.AbortsWithMessage(t, cur, "member on specified tier is not allowed to vote on this proposal", func() { dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.NoVote, dao.ProposalID(0))) }) testing.SetOriginCaller(m111) // Same effect as: // dao.MustVoteOnProposal(dao.VoteRequest{ // Option: dao.NoVote, // ProposalID: dao.ProposalID(0), // }) dao.MustVoteOnProposalSimple(cross(cur), 0, "NO") urequire.Equal(t, true, strings.Contains(dao.Render(cross(cur), ""), "Prop #0 - New T2 Member Proposal")) // urequire.Equal(t, true, strings.Contains(dao.Render(cross(cur), ""), "Author: "+m1.String())) urequire.AbortsWithMessage(t, cur, "proposal didn't reach supermajority yet: 66.66", func() { dao.ExecuteProposal(cross(cur), dao.ProposalID(0)) }) testing.SetOriginCaller(m1111) dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.NoVote, dao.ProposalID(0))) accepted := dao.ExecuteProposal(cross(cur), dao.ProposalID(0)) urequire.Equal(t, false, accepted) urequire.Equal(t, true, contains(dao.Render(cross(cur), "0"), "**PROPOSAL HAS BEEN DENIED**")) urequire.Equal(t, true, contains(dao.Render(cross(cur), "0"), "NO PERCENT: 81.25%")) } func TestExecutorCreationRealm(cur realm, t *testing.T) { loadMembers(cur) // Test that executor creation realm is captured correctly testing.SetOriginCaller(m1) testing.SetRealm(testing.NewCodeRealm("gno.land/r/template/contract")) // Create executor in the template contract realm executor := dao.NewSimpleExecutor(0, cur, func(realm) error { return nil }, "Test executor from template") proposalRequest := dao.NewProposalRequest( "Test Proposal", "This proposal tests executor creation realm tracking", executor, ) // Create proposal from user realm (user can call DAO directly) testing.SetRealm(testing.NewUserRealm(m1)) pid := dao.MustCreateProposal(cross(cur), proposalRequest) // Get the proposal prop := dao.MustGetProposal(pid) // Verify the author is m1 urequire.Equal(t, m1, prop.Author()) // Verify the executor creation realm is captured correctly urequire.Equal(t, "gno.land/r/template/contract", prop.ExecutorCreationRealm()) // Check that it's displayed in the individual proposal render output individualRendered := dao.Render(cross(cur), pid.String()) // Rendered as a code span: CreationRealm() is interface-dispatched and so // third-party controlled, and InlineCode neutralizes it while reading // naturally for a realm path. urequire.Equal(t, true, contains(individualRendered, "Executor created in: `gno.land/r/template/contract`")) urequire.Equal(t, true, contains(individualRendered, "Test executor from template")) // Also verify the main content is there urequire.Equal(t, true, contains(individualRendered, "Test Proposal")) urequire.Equal(t, true, contains(individualRendered, "This proposal tests executor creation realm tracking")) } func TestProposalPagination(cur realm, t *testing.T) { loadMembers(cur) portfolio := "### This is my portfolio:\n\n- THINGS" testing.SetOriginCaller(m1) testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/impl/v0")) nm1 := testutils.TestAddress("nm1") var pid dao.ProposalID proposalRequest := NewAddMemberRequest(cur, nm1, memberstore.T2, portfolio) testing.SetOriginCaller(m1) testing.SetRealm(testing.NewUserRealm(m1)) pid = dao.MustCreateProposal(cross(cur), proposalRequest) // TODO: tests keep the same vm state: https://github.com/gnolang/gno/issues/1982 urequire.Equal(t, 2, int(pid)) testing.SetRealm(testing.NewUserRealm(m1)) pid = dao.MustCreateProposal(cross(cur), proposalRequest) urequire.Equal(t, 3, int(pid)) testing.SetRealm(testing.NewUserRealm(m1)) pid = dao.MustCreateProposal(cross(cur), proposalRequest) urequire.Equal(t, 4, int(pid)) testing.SetRealm(testing.NewUserRealm(m1)) pid = dao.MustCreateProposal(cross(cur), proposalRequest) urequire.Equal(t, 5, int(pid)) testing.SetRealm(testing.NewUserRealm(m1)) pid = dao.MustCreateProposal(cross(cur), proposalRequest) urequire.Equal(t, 6, int(pid)) testing.SetRealm(testing.NewUserRealm(m1)) pid = dao.MustCreateProposal(cross(cur), proposalRequest) urequire.Equal(t, 7, int(pid)) fmt.Println(dao.Render(cross(cur), "")) urequire.Equal(t, true, contains(dao.Render(cross(cur), ""), "### [Prop #7 - New T2 Member Proposal](/r/gov/dao:7)")) urequire.Equal(t, true, contains(dao.Render(cross(cur), ""), "### [Prop #6 - New T2 Member Proposal](/r/gov/dao:6)")) urequire.Equal(t, true, contains(dao.Render(cross(cur), ""), "### [Prop #5 - New T2 Member Proposal](/r/gov/dao:5)")) urequire.Equal(t, true, contains(dao.Render(cross(cur), ""), "### [Prop #4 - New T2 Member Proposal](/r/gov/dao:4)")) urequire.Equal(t, true, contains(dao.Render(cross(cur), ""), "### [Prop #3 - New T2 Member Proposal](/r/gov/dao:3)")) urequire.Equal(t, true, contains(dao.Render(cross(cur), "?page=2"), "### [Prop #2 - New T2 Member Proposal](/r/gov/dao:2)")) urequire.Equal(t, true, contains(dao.Render(cross(cur), "?page=2"), "### [Prop #1 - Test Proposal](/r/gov/dao:1)")) urequire.Equal(t, true, contains(dao.Render(cross(cur), "?page=2"), "### [Prop #0 - New T2 Member Proposal](/r/gov/dao:0)")) } func TestUpgradeDaoImplementation(cur realm, t *testing.T) { loadMembers(cur) testing.SetOriginCaller(noMember) testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/impl/v0")) urequire.PanicsWithMessage(t, cur, "proposer is not a member", func() { NewUpgradeDaoImplRequest(cur, govDAO, "gno.land/r/gov/dao/v1/impl", "Something happened and we have to fix it.") }) testing.SetOriginCaller(m1) testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/impl/v0")) preq := NewUpgradeDaoImplRequest(cur, govDAO, "gno.land/r/gov/dao/v1/impl", "Something happened and we have to fix it.") testing.SetOriginCaller(m1) testing.SetRealm(testing.NewUserRealm(m1)) pid := dao.MustCreateProposal(cross(cur), preq) urequire.Equal(t, int(pid), 8) // m1 votes yes because that member is interested on it dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, dao.ProposalID(pid))) testing.SetOriginCaller(m11) dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, dao.ProposalID(pid))) testing.SetOriginCaller(m2) dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, dao.ProposalID(pid))) testing.SetOriginCaller(m3) dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, dao.ProposalID(pid))) testing.SetOriginCaller(m111) // Same effect as: // dao.MustVoteOnProposal(dao.VoteRequest{ // Option: dao.YesVote, // ProposalID: dao.ProposalID(pid), // }) dao.MustVoteOnProposalSimple(cross(cur), int64(pid), "YES") urequire.Equal(t, true, contains(dao.Render(cross(cur), "8"), "**Proposal is open for votes**")) urequire.Equal(t, true, contains(dao.Render(cross(cur), "8"), "68.42105263157895%")) urequire.Equal(t, true, contains(dao.Render(cross(cur), "8"), "0%")) accepted := dao.ExecuteProposal(cross(cur), dao.ProposalID(pid)) urequire.Equal(t, true, accepted) urequire.Equal(t, true, contains(dao.Render(cross(cur), "8"), "**PROPOSAL HAS BEEN ACCEPTED**")) urequire.Equal(t, true, contains(dao.Render(cross(cur), "8"), "YES PERCENT: 68.42105263157895%")) } func TestAbstainVote(cur realm, t *testing.T) { loadMembers(cur) portfolio := "# This is my portfolio:\n\n- THINGS" testing.SetOriginCaller(m1) testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/impl/v0")) nm1 := testutils.TestAddress("nm1") proposalRequest := NewAddMemberRequest(cur, nm1, memberstore.T2, portfolio) testing.SetOriginCaller(m1) testing.SetRealm(testing.NewUserRealm(m1)) pid := dao.MustCreateProposal(cross(cur), proposalRequest) // m1 votes abstain dao.MustVoteOnProposalSimple(cross(cur), int64(pid), "ABSTAIN") // Other members vote YES to reach supermajority testing.SetOriginCaller(m11) dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, pid)) testing.SetOriginCaller(m111) dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, pid)) testing.SetOriginCaller(m1111) dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, pid)) testing.SetOriginCaller(m2) dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, pid)) testing.SetOriginCaller(m3) dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, pid)) // Verify render shows correct percentages urequire.Equal(t, true, contains(dao.Render(cross(cur), pid.String()), "YES PERCENT: 81.25%")) urequire.Equal(t, true, contains(dao.Render(cross(cur), pid.String()), "NO PERCENT: 0%")) urequire.Equal(t, true, contains(dao.Render(cross(cur), pid.String()), "ABSTAIN PERCENT: 18.75%")) // Supermajority reached despite one abstain voter accepted := dao.ExecuteProposal(cross(cur), pid) urequire.Equal(t, true, accepted) urequire.Equal(t, true, contains(dao.Render(cross(cur), pid.String()), "**PROPOSAL HAS BEEN ACCEPTED**")) // Abstain voter appears in vote list urequire.Equal(t, true, contains(dao.Render(cross(cur), fmt.Sprintf("%v/votes", int64(pid))), "ABSTAIN")) } func contains(s, substr string) bool { return strings.Index(s, substr) >= 0 } // TestAddMemberKeepsInvitationPointWhenAddFails covers the executor's ordering. // // The executor spends the proposer's invitation point and adds the member. If // the add fails, a returned executor error does NOT revert: ExecuteProposal // marks the proposal denied and ExecuteOrRejectProposal still commits the // transaction. So with the old order the point was gone and the member was not // added, permanently. // // The failure is reachable: SetMember refuses an address that is already a // member, and AddMember enrols T3 members directly, so proposing an existing // member to another tier is an ordinary mistake rather than a contrived one. // // Uses ExecuteOrRejectProposal deliberately. ExecuteProposal panics on an // executor error, and a panic aborts the transaction, which would revert the // spend and hide the bug. func TestAddMemberKeepsInvitationPointWhenAddFails(cur realm, t *testing.T) { loadMembers(cur) // m4 is already a T3 member, so adding it to T2 must fail in SetMember. testing.SetOriginCaller(m1) testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/impl/v0")) preq := NewAddMemberRequest(cur, m4, memberstore.T2, "# Portfolio\n\n- things") // memberstore.Get requires an allowed-DAO caller, so read under the impl // realm rather than whatever the previous step left in place. testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/impl/v0")) before, tier := memberstore.Get(0, cur).GetMember(m1) urequire.Equal(t, memberstore.T1, tier) pointsBefore := before.InvitationPoints urequire.Equal(t, true, pointsBefore > 0, "premise: the proposer must have a point to lose") testing.SetOriginCaller(m1) testing.SetRealm(testing.NewUserRealm(m1)) pid := dao.MustCreateProposal(cross(cur), preq) // A T2 addition is voted on by T1 and T2. Every one of them votes yes, so // the proposal certainly passes and the executor certainly runs -- if it did // not, this test would prove nothing. for _, voter := range []address{m1, m11, m111, m1111, m2, m3} { testing.SetOriginCaller(voter) dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, dao.ProposalID(pid))) } accepted := dao.ExecuteOrRejectProposal(cross(cur), dao.ProposalID(pid)) urequire.Equal(t, false, accepted, "the add must fail, since m4 is already a member") // Proves the executor ran and returned an error, rather than the vote // failing to reach a threshold -- only GovDAO.ExecuteProposal writes this. rendered := dao.Render(cross(cur), fmt.Sprintf("%d", int(pid))) urequire.Equal(t, true, contains(rendered, "execution failed"), "the proposal must have been denied by the executor, not by the vote") testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/impl/v0")) after, _ := memberstore.Get(0, cur).GetMember(m1) urequire.Equal(t, pointsBefore, after.InvitationPoints, "a failed add must not spend the proposer's invitation point") } // TestPromoteToTierMissingFromStoreKeepsTheMember covers the remove-then-add // hazard in the promotion executor. // // The executor removes the member and re-adds them at the new tier. SetMember // returns an error rather than panicking when the store has no bucket for that // tier, and a returned executor error does not revert -- the proposal is marked // rejected and the transaction still commits -- so the member would be removed // and not re-added. // // The two tier registries are separate. GetTier reads the global definitions, // which always hold T1/T2/T3; SetMember checks whether this store has a bucket, // and DeleteAll empties the buckets while leaving the definitions intact. So a // destination can pass the first check and fail the second, which is what this // sets up. func TestPromoteToTierMissingFromStoreKeepsTheMember(cur realm, t *testing.T) { loadMembers(cur) // Drop the T3 bucket while the global definition of T3 survives. mstore := memberstore.Get(0, cur) mstore.DeleteAll() mstore.SetTier(memberstore.T1) mstore.SetTier(memberstore.T2) mstore.SetMember(memberstore.T1, m1, memberByTier(memberstore.T1)) mstore.SetMember(memberstore.T1, m11, memberByTier(memberstore.T1)) mstore.SetMember(memberstore.T1, m111, memberByTier(memberstore.T1)) mstore.SetMember(memberstore.T1, m1111, memberByTier(memberstore.T1)) mstore.SetMember(memberstore.T2, m2, memberByTier(memberstore.T2)) mstore.SetMember(memberstore.T2, m3, memberByTier(memberstore.T2)) // Premise: T3 is defined globally but absent from this store. _, defined := memberstore.GetTier(memberstore.T3) urequire.Equal(t, true, defined, "premise: T3 must still be a defined tier") urequire.Equal(t, false, mstore.Has(memberstore.T3), "premise: this store must have no T3 bucket, or the test proves nothing") testing.SetOriginCaller(m1) testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/impl/v0")) preq := NewPromoteMemberRequest(cur, m1, memberstore.T1, memberstore.T3) testing.SetOriginCaller(m1) testing.SetRealm(testing.NewUserRealm(m1)) pid := dao.MustCreateProposal(cross(cur), preq) for _, voter := range []address{m1, m11, m111, m1111, m2, m3} { testing.SetOriginCaller(voter) dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, dao.ProposalID(pid))) } // The executor refuses before removing anything, and returns rather than // panicking. That rejects the proposal and closes it, and since nothing was // written, committing the rejection loses nothing. // // So the assertion below is about the chain, not the harness: the member // survives because no write happened, not because a panic reverted one. // Under the earlier panic-after-removal shape it held only because gno test // does not revert an abort, which proved nothing about production. accepted := dao.ExecuteOrRejectProposal(cross(cur), dao.ProposalID(pid)) urequire.Equal(t, false, accepted, "the promotion must be rejected") testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/impl/v0")) after, tier := memberstore.Get(0, cur).GetMember(m1) urequire.Equal(t, true, after != nil, "the member must survive a refused promotion") urequire.Equal(t, memberstore.T1, tier, "and must still be on their original tier") loadMembers(cur) }