diff --git a/examples/gno.land/r/gov/dao/v3/impl/govdao.gno b/examples/gno.land/r/gov/dao/v3/impl/govdao.gno index 94a40d675c1..c8fd87a430f 100644 --- a/examples/gno.land/r/gov/dao/v3/impl/govdao.gno +++ b/examples/gno.land/r/gov/dao/v3/impl/govdao.gno @@ -2,6 +2,7 @@ package impl import ( "chain" + "chain/banker" "chain/runtime" "errors" @@ -13,6 +14,9 @@ import ( var ErrMemberNotFound = errors.New("member not found") +// ProposalFeeAmount is the fee required for non-members to create proposals (in ugnot) +var ProposalFeeAmount int64 = 1_000_000 // 1 GNOT + type GovDAO struct { pss ProposalsStatuses render *render @@ -64,11 +68,18 @@ func (g *GovDAO) PreCreateProposal(r dao.ProposalRequest) (address, error) { runtime.CurrentRealm(), runtime.PreviousRealm())) } - // Verify that the one creating the proposal is a member. caller := runtime.OriginCaller() mem, _ := getMembers(cross).GetMember(caller) + + // If the caller is not a member, they must pay a fee if mem == nil { - return caller, errors.New("only members can create new proposals") + sent := banker.OriginSend() + if len(sent) == 0 || sent.AmountOf("ugnot") < ProposalFeeAmount { + return caller, errors.New(ufmt.Sprintf( + "non-members must send %dugnot to create a proposal", + ProposalFeeAmount, + )) + } } return caller, nil diff --git a/examples/gno.land/r/gov/dao/v3/impl/govdao_test.gno b/examples/gno.land/r/gov/dao/v3/impl/govdao_test.gno index b1552abe9e9..132abbd23d9 100644 --- a/examples/gno.land/r/gov/dao/v3/impl/govdao_test.gno +++ b/examples/gno.land/r/gov/dao/v3/impl/govdao_test.gno @@ -5,7 +5,10 @@ import ( "strings" "testing" + "chain" + "gno.land/p/nt/testutils" + "gno.land/p/nt/uassert" "gno.land/p/nt/urequire" "gno.land/r/gov/dao" "gno.land/r/gov/dao/v3/memberstore" @@ -87,7 +90,7 @@ func TestCreateProposalAndVote(cur realm, t *testing.T) { dao.MustCreateProposal(cross, NewAddMemberRequest(cur, nm1, memberstore.T3, portfolio)) }) - urequire.AbortsWithMessage(t, "proposer is not a member", func(cur realm) { + uassert.AbortsContains(t, "proposal creation must be done directly by a user or through the r/gov/dao proxy", func(cur realm) { dao.MustCreateProposal(cross, NewAddMemberRequest(cur, nm1, memberstore.T2, portfolio)) }) @@ -216,9 +219,8 @@ func TestUpgradeDaoImplementation(t *testing.T) { testing.SetOriginCaller(noMember) testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/v3/impl")) - urequire.PanicsWithMessage(t, "proposer is not a member", func() { - NewUpgradeDaoImplRequest(govDAO, "gno.land/r/gov/dao/v4/impl", "Something happened and we have to fix it.") - }) + testing.SetOriginSend(chain.Coins{chain.Coin{Denom: "ugnot", Amount: ProposalFeeAmount}}) // 1 GNOT + NewUpgradeDaoImplRequest(govDAO, "gno.land/r/gov/dao/v4/impl", "Something happened and we have to fix it.") testing.SetOriginCaller(m1) testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/v3/impl")) @@ -275,6 +277,38 @@ func TestUpgradeDaoImplementation(t *testing.T) { urequire.Equal(t, true, contains(dao.Render("7"), "YES PERCENT: 68.42105263157895%")) } +func TestNonMemberProposalFee(cur realm, t *testing.T) { + loadMembers() + + portfolio := "# This is my portfolio:\n\n- THINGS" + + testing.SetOriginCaller(noMember) + + // Test: Non-member without fee should fail with fee message + _, err := govDAO.PreCreateProposal(NewAddMemberRequest(cur, noMember, memberstore.T2, portfolio)) + urequire.Error(t, err) + urequire.ErrorContains(t, err, "non-members must send 1000000ugnot to create a proposal") + + // Test: Non-member with insufficient fee should fail + testing.SetOriginSend(chain.Coins{chain.Coin{Denom: "ugnot", Amount: 500000}}) // 0.5 GNOT - insufficient + _, err = govDAO.PreCreateProposal(NewAddMemberRequest(cur, noMember, memberstore.T2, portfolio)) + urequire.Error(t, err) + urequire.ErrorContains(t, err, "non-members must send 1000000ugnot to create a proposal") + + // Test: Non-member with correct fee should succeed + testing.SetOriginSend(chain.Coins{chain.Coin{Denom: "ugnot", Amount: ProposalFeeAmount}}) // 1 GNOT + _, err = govDAO.PreCreateProposal(NewAddMemberRequest(cur, noMember, memberstore.T2, portfolio)) + urequire.NoError(t, err) + + // Reset OrigSend + testing.SetOriginSend(chain.Coins{}) + + // Test: Members don't need to pay a fee + testing.SetOriginCaller(m1) + _, err = govDAO.PreCreateProposal(NewAddMemberRequest(cur, m1, memberstore.T2, portfolio)) + urequire.NoError(t, err) +} + func contains(s, substr string) bool { return strings.Index(s, substr) >= 0 } diff --git a/examples/gno.land/r/gov/dao/v3/impl/prop_requests.gno b/examples/gno.land/r/gov/dao/v3/impl/prop_requests.gno index c3436a5a9a1..b385e445663 100644 --- a/examples/gno.land/r/gov/dao/v3/impl/prop_requests.gno +++ b/examples/gno.land/r/gov/dao/v3/impl/prop_requests.gno @@ -1,8 +1,6 @@ package impl import ( - "chain/runtime" - "gno.land/p/aeddi/panictoerr" "gno.land/p/moul/md" trs_pkg "gno.land/p/nt/treasury" @@ -14,11 +12,6 @@ import ( ) func NewChangeLawRequest(_ realm, newLaw *Law) dao.ProposalRequest { - member, _ := memberstore.Get().GetMember(runtime.OriginCaller()) - if member == nil { - panic("proposer is not a member") - } - cb := func(_ realm) error { law = newLaw return nil @@ -30,11 +23,6 @@ func NewChangeLawRequest(_ realm, newLaw *Law) dao.ProposalRequest { } func NewUpgradeDaoImplRequest(newDao dao.DAO, realmPkg, reason string) dao.ProposalRequest { - member, _ := memberstore.Get().GetMember(runtime.OriginCaller()) - if member == nil { - panic("proposer is not a member") - } - cb := func(_ realm) error { // dao.UpdateImpl() must be cross-called from v3/impl but // what calls this cb function is r/gov/dao. @@ -66,17 +54,7 @@ func NewAddMemberRequest(_ realm, addr address, tier string, portfolio string) d panic("A portfolio for the proposed member is required") } - member, _ := memberstore.Get().GetMember(runtime.OriginCaller()) - if member == nil { - panic("proposer is not a member") - } - - if member.InvitationPoints <= 0 { - panic("proposer does not have enough invitation points for inviting new people to the board") - } - cb := func(_ realm) error { - member.RemoveInvitationPoint() err := memberstore.Get().SetMember(tier, addr, memberByTier(tier)) return err @@ -209,6 +187,43 @@ func NewTreasuryGRC20TokensUpdate(newTokenKeys []string) dao.ProposalRequest { ) } +// NewProposalFeeAmountRequest creates a proposal request to update the proposal fee amount +// required for non-members to create proposals. +func NewProposalFeeAmountRequest(newAmount int64, reason string) dao.ProposalRequest { + if newAmount < 0 { + panic("proposal fee amount must be non-negative") + } + + if reason == "" { + panic("proposal fee amount change requires a reason") + } + + cb := func(_ realm) error { + ProposalFeeAmount = newAmount + return nil + } + + e := dao.NewSimpleExecutor( + cb, + ufmt.Sprintf( + "The proposal fee amount for non-members will be changed to %d ugnot.\n\nReason: %s", + newAmount, + reason, + ), + ) + + return dao.NewProposalRequest( + "Proposal Fee Amount Change", + ufmt.Sprintf( + "This proposal is looking to change the proposal fee amount for non-members from %d ugnot to %d ugnot.\n\nReason: %s", + ProposalFeeAmount, + newAmount, + reason, + ), + e, + ) +} + func memberByTier(tier string) *memberstore.Member { switch tier { case memberstore.T1: diff --git a/examples/gno.land/r/gov/dao/v3/impl/render.gno b/examples/gno.land/r/gov/dao/v3/impl/render.gno index 3bcf0ef67b8..7c54a994fe3 100644 --- a/examples/gno.land/r/gov/dao/v3/impl/render.gno +++ b/examples/gno.land/r/gov/dao/v3/impl/render.gno @@ -93,7 +93,7 @@ func (ren *render) renderProposalPage(sPid string, d *GovDAO) string { ps := d.pss.GetStatus(dao.ProposalID(pid)) out := ufmt.Sprintf("## Prop #%v - %v\n", pid, p.Title()) - out += "Author: " + tryResolveAddr(p.Author()) + "\n\n" + out += ufmt.Sprintf("Author: %s %s\n\n", tryResolveAddr(p.Author()), renderMembershipBadge(p.Author(), d)) out += p.Description() out += "\n\n" @@ -122,7 +122,7 @@ func (ren *render) renderProposalListItem(sPid string, d *GovDAO) string { ps := d.pss.GetStatus(dao.ProposalID(pid)) out := ufmt.Sprintf("### [Prop #%v - %v](%v:%v)\n", pid, p.Title(), ren.relativeRealmPath, pid) - out += ufmt.Sprintf("Author: %s\n\n", tryResolveAddr(p.Author())) + out += ufmt.Sprintf("Author: %s %s\n\n", tryResolveAddr(p.Author()), renderMembershipBadge(p.Author(), d)) out += "Status: " + getPropStatus(ps) out += "\n\n" @@ -189,3 +189,11 @@ func tryResolveAddr(addr address) string { } return userData.RenderLink("") } + +func renderMembershipBadge(addr address, d *GovDAO) string { + mem, _ := getMembers(cross).GetMember(addr) + if mem == nil { + return "`[non-member]`" + } + return "" +} diff --git a/examples/gno.land/r/gov/dao/v3/impl/render_test.gno b/examples/gno.land/r/gov/dao/v3/impl/render_test.gno new file mode 100644 index 00000000000..a4adf9796b8 --- /dev/null +++ b/examples/gno.land/r/gov/dao/v3/impl/render_test.gno @@ -0,0 +1,21 @@ +package impl + +import ( + "testing" + + "gno.land/p/nt/urequire" +) + +// TestRenderMembershipBadge verifies that the non-member badge is displayed correctly +func TestRenderMembershipBadge(t *testing.T) { + loadMembers() + dao := NewGovDAO() + + // Test member (should not show badge) + badge := renderMembershipBadge(m1, dao) + urequire.Equal(t, "", badge, "Members should not have a badge") + + // Test non-member (should show badge) + badge = renderMembershipBadge(noMember, dao) + urequire.Equal(t, "`[non-member]`", badge, "Non-members should have a badge") +} diff --git a/gno.land/pkg/integration/testdata/govdao_proposal_only_members_proposals.txtar b/gno.land/pkg/integration/testdata/govdao_proposal_nonmember.txtar similarity index 54% rename from gno.land/pkg/integration/testdata/govdao_proposal_only_members_proposals.txtar rename to gno.land/pkg/integration/testdata/govdao_proposal_nonmember.txtar index 2c78f59f1b0..63e2c6a6862 100644 --- a/gno.land/pkg/integration/testdata/govdao_proposal_only_members_proposals.txtar +++ b/gno.land/pkg/integration/testdata/govdao_proposal_nonmember.txtar @@ -1,42 +1,58 @@ adduserfrom member 'success myself purchase tray reject demise scene little legend someone lunar hope media goat regular test area smart save flee surround attack rapid smoke' stdout 'g1c0j899h88nwyvnzvh5jagpq6fkkyuj76nld6t0' -adduserfrom newmember 'smooth crawl poverty trumpet glare useful curtain annual pluck lunar example merge ready forum better verb rescue rule mechanic dynamic drift bench release weekend' +adduserfrom nonmember 'smooth crawl poverty trumpet glare useful curtain annual pluck lunar example merge ready forum better verb rescue rule mechanic dynamic drift bench release weekend' stdout 'g1rfznvu6qfa0sc76cplk5wpqexvefqccjunady0' loadpkg gno.land/r/gov/dao loadpkg gno.land/r/gov/dao/v3/impl - -# load specific govDAO implementation and needed users for your integration test loadpkg gno.land/r/gov/dao/v3/loader $WORK/loader gnoland start -# call gov/dao render to check everything is working as expected and the loader worked -gnokey query vm/qrender --data 'gno.land/r/gov/dao:' +# Case 1: Non-member WITHOUT fee (should fail) +gnokey maketx run -gas-fee 1000000ugnot -gas-wanted 100000000 -broadcast -chainid=tendermint_test nonmember $WORK/proposer/create_proposal.gno +stdout 'non-members must send 1000000ugnot to create a proposal' + +# Case 2: Non-member WITH fee (should succeed) +gnokey maketx run -send "1000000ugnot" -gas-fee 1000000ugnot -gas-wanted 100000000 -broadcast -chainid=tendermint_test nonmember $WORK/proposer/create_proposal_must.gno +stdout OK! -# try to add the proposal using a non-member -gnokey maketx run -gas-fee 1000000ugnot -gas-wanted 100000000 -broadcast -chainid=tendermint_test newmember $WORK/proposer/create_proposal.gno -stdout 'only members can create new proposals' +# Verify proposal was created +gnokey query vm/qrender --data 'gno.land/r/gov/dao:0' +stdout 'New T2 Member Proposal' -- proposer/create_proposal.gno -- package main import ( "gno.land/r/gov/dao" + "gno.land/r/gov/dao/v3/impl" ) func main() { - // Try to create a proposal as a non-member - // Create a simple proposal request with nil executor - // The error should come from PreCreateProposal before executor is validated - pr := dao.NewProposalRequest("Test Proposal", "This is a test proposal", nil) - _, err := dao.CreateProposal(cross, pr) + addr := address("g1rfznvu6qfa0sc76cplk5wpqexvefqccjunady0") + preq := impl.NewAddMemberRequest(cross, addr, "T2", "My portfolio") + _, err := dao.CreateProposal(cross, preq) if err != nil { println(err.Error()) } } +-- proposer/create_proposal_must.gno -- +package main + +import ( + "gno.land/r/gov/dao" + "gno.land/r/gov/dao/v3/impl" +) + +func main() { + addr := address("g1rfznvu6qfa0sc76cplk5wpqexvefqccjunady0") + preq := impl.NewAddMemberRequest(cross, addr, "T2", "My portfolio") + dao.MustCreateProposal(cross, preq) +} + -- loader/load_govdao.gno -- package load_govdao @@ -50,9 +66,7 @@ func init() { memberstore.Get().SetTier(memberstore.T1) memberstore.Get().SetTier(memberstore.T2) memberstore.Get().SetTier(memberstore.T3) - - memberstore.Get().SetMember(memberstore.T1, address("g1c0j899h88nwyvnzvh5jagpq6fkkyuj76nld6t0"), &memberstore.Member{InvitationPoints: 3}) // member address - + memberstore.Get().SetMember(memberstore.T1, address("g1c0j899h88nwyvnzvh5jagpq6fkkyuj76nld6t0"), &memberstore.Member{InvitationPoints: 3}) dao.UpdateImpl(cross, dao.UpdateRequest{ DAO: impl.GetInstance(), AllowedDAOs: []string{"gno.land/r/gov/dao/v3/impl"}, diff --git a/gno.land/pkg/integration/testdata/govdao_proposal_set_fee_amount.txtar b/gno.land/pkg/integration/testdata/govdao_proposal_set_fee_amount.txtar new file mode 100644 index 00000000000..addfe1c65da --- /dev/null +++ b/gno.land/pkg/integration/testdata/govdao_proposal_set_fee_amount.txtar @@ -0,0 +1,62 @@ +adduserfrom member 'success myself purchase tray reject demise scene little legend someone lunar hope media goat regular test area smart save flee surround attack rapid smoke' +stdout 'g1c0j899h88nwyvnzvh5jagpq6fkkyuj76nld6t0' + +loadpkg gno.land/r/gov/dao +loadpkg gno.land/r/gov/dao/v3/impl +loadpkg gno.land/r/sys/users +loadpkg gno.land/r/gnoland/users/v1 +loadpkg gno.land/r/gov/dao/v3/loader $WORK/loader + +gnoland start + +# Register member with a namespace (required for voting) +gnokey maketx call -send "1000000ugnot" -pkgpath gno.land/r/gnoland/users/v1 -func Register -args "mem123" -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test member +stdout 'OK!' + +# Create proposal to change fee from 1000000 to 2000000 ugnot +gnokey maketx run -gas-fee 1000000ugnot -gas-wanted 100000000 -broadcast -chainid=tendermint_test member $WORK/proposer/create_fee_proposal.gno +stdout OK! + +# Vote and execute +gnokey maketx call -pkgpath gno.land/r/gov/dao -func MustVoteOnProposalSimple -gas-fee 1000000ugnot -gas-wanted 10000000 -args 0 -args YES -broadcast -chainid=tendermint_test member +stdout OK! + +gnokey maketx call -pkgpath gno.land/r/gov/dao -func ExecuteProposal -gas-fee 1000000ugnot -gas-wanted 10000000 -args 0 -broadcast -chainid=tendermint_test member +stdout OK! + +# Verify new fee is enforced +gnokey query vm/qeval --data 'gno.land/r/gov/dao/v3/impl.ProposalFeeAmount' +stdout '2000000 int64' + +-- proposer/create_fee_proposal.gno -- +package main + +import ( + "gno.land/r/gov/dao" + "gno.land/r/gov/dao/v3/impl" +) + +func main() { + preq := impl.NewProposalFeeAmountRequest(2000000, "Increasing fee to reduce spam") + dao.MustCreateProposal(cross, preq) +} + +-- loader/load_govdao.gno -- +package load_govdao + +import ( + "gno.land/r/gov/dao" + "gno.land/r/gov/dao/v3/impl" + "gno.land/r/gov/dao/v3/memberstore" +) + +func init() { + memberstore.Get().SetTier(memberstore.T1) + memberstore.Get().SetTier(memberstore.T2) + memberstore.Get().SetTier(memberstore.T3) + memberstore.Get().SetMember(memberstore.T1, address("g1c0j899h88nwyvnzvh5jagpq6fkkyuj76nld6t0"), &memberstore.Member{InvitationPoints: 3}) + dao.UpdateImpl(cross, dao.UpdateRequest{ + DAO: impl.GetInstance(), + AllowedDAOs: []string{"gno.land/r/gov/dao/v3/impl"}, + }) +} diff --git a/gno.land/pkg/integration/testdata/maketx_call_send.txtar b/gno.land/pkg/integration/testdata/maketx_call_send.txtar new file mode 100644 index 00000000000..a9a50296c59 --- /dev/null +++ b/gno.land/pkg/integration/testdata/maketx_call_send.txtar @@ -0,0 +1,36 @@ +# load the package +loadpkg gno.land/r/foo/call_realm $WORK/realm + +# start a new node +gnoland start + +## user balance before realm send +gnokey query auth/accounts/$test1_user_addr +stdout '"coins": "9999998810000ugnot"' + +## realm balance before realm send +gnokey query auth/accounts/g1x4ykzcqksj2hc5qpvr8kd9zaffkd82rvmzqup7 +stdout '"coins": ""' + +# call to realm with -send +gnokey maketx call -send 42ugnot -pkgpath gno.land/r/foo/call_realm -func GimmeMoney -gas-fee 1000000ugnot -gas-wanted 3000000 -broadcast -chainid=tendermint_test test1 +stdout '("send: 42ugnot")' + +## user balance after realm send +# reduced by -gas-fee AND -send +gnokey query auth/accounts/$test1_user_addr +stdout '"coins": "9999997809958ugnot"' + +## realm balance after realm send +gnokey query auth/accounts/g1x4ykzcqksj2hc5qpvr8kd9zaffkd82rvmzqup7 +stdout '"coins": "42ugnot"' + + +-- realm/realm.gno -- +package call_realm + +import "chain/banker" + +func GimmeMoney(cur realm) string { + return "send: " + banker.OriginSend().String() +} diff --git a/gno.land/pkg/integration/testdata/run.txtar b/gno.land/pkg/integration/testdata/maketx_run.txtar similarity index 100% rename from gno.land/pkg/integration/testdata/run.txtar rename to gno.land/pkg/integration/testdata/maketx_run.txtar diff --git a/gno.land/pkg/integration/testdata/maketx_run_send.txtar b/gno.land/pkg/integration/testdata/maketx_run_send.txtar new file mode 100644 index 00000000000..a91997d95e8 --- /dev/null +++ b/gno.land/pkg/integration/testdata/maketx_run_send.txtar @@ -0,0 +1,25 @@ +## start a new node +gnoland start + +## user balance before realm send +gnokey query auth/accounts/$test1_user_addr +stdout '"coins": "10000000000000ugnot"' + +## run script/script.gno with send flag +gnokey maketx run -send 42ugnot -gas-fee 1000000ugnot -gas-wanted 20000000 -broadcast -chainid=tendermint_test test1 $WORK/script/script.gno +stdout 'send: 42ugnot' + +## user balance after realm send +# only reduced by -gas-fee, -send does not affect balance since the ephemeral +# realm shares the same address as the user. +gnokey query auth/accounts/$test1_user_addr +stdout '"coins": "9999999000000ugnot"' + +-- script/script.gno -- +package main + +import "chain/banker" + +func main() { + println("send:", banker.OriginSend()) +} diff --git a/gno.land/pkg/keyscli/run.go b/gno.land/pkg/keyscli/run.go index 913df5a450f..aa8c2e6d10e 100644 --- a/gno.land/pkg/keyscli/run.go +++ b/gno.land/pkg/keyscli/run.go @@ -20,6 +20,7 @@ import ( type MakeRunCfg struct { RootCfg *client.MakeTxCfg + Send string MaxDeposit string } @@ -42,6 +43,12 @@ func NewMakeRunCmd(rootCfg *client.MakeTxCfg, cmdio commands.IO) *commands.Comma } func (c *MakeRunCfg) RegisterFlags(fs *flag.FlagSet) { + fs.StringVar( + &c.Send, + "send", + "", + "send amount", + ) fs.StringVar( &c.MaxDeposit, "max-deposit", @@ -75,7 +82,13 @@ func execMakeRun(cfg *MakeRunCfg, args []string, cmdio commands.IO) error { } caller := info.GetAddress() - // Parase deposit amount + // Parse send amount. + send, err := std.ParseCoins(cfg.Send) + if err != nil { + return errors.Wrap(err, "parsing send coins") + } + + // Parse deposit amount deposit, err := std.ParseCoins(cfg.MaxDeposit) if err != nil { return errors.Wrap(err, "parsing storage deposit coins") @@ -133,6 +146,7 @@ func execMakeRun(cfg *MakeRunCfg, args []string, cmdio commands.IO) error { msg := vm.MsgRun{ Caller: caller, Package: memPkg, + Send: send, MaxDeposit: deposit, } tx := std.Tx{