Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions examples/gno.land/r/gov/dao/v3/impl/govdao.gno
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package impl

import (
"chain"
"chain/banker"
"chain/runtime"
"errors"

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
42 changes: 38 additions & 4 deletions examples/gno.land/r/gov/dao/v3/impl/govdao_test.gno
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
})

Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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
}
59 changes: 37 additions & 22 deletions examples/gno.land/r/gov/dao/v3/impl/prop_requests.gno
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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")
}
Comment on lines -74 to -76
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for me this line should never have been there, invitations point should be only for the T3 AddMember function but since it was here it makes me wondering if we should keep it in case the proposal is coming from a member ?

Copy link
Contributor Author

@Davphla Davphla Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, in the end this is just a helper function to create a ProposalRequest object, any non-member could bypass this condition by creating their own ProposalRequest object.
But I don't think we should keep it as it is not protective, or we should rethink how invitationPoint work in GovDAO.


cb := func(_ realm) error {
member.RemoveInvitationPoint()
err := memberstore.Get().SetMember(tier, addr, memberByTier(tier))

return err
Expand Down Expand Up @@ -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")
}
Comment on lines +192 to +199
Copy link
Member

@MikaelVallenet MikaelVallenet Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think we should add a txtar test flow of changing the proposal fee else the PR looks good to me i will approve once this done

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feat: afbb935
The PR depends on #4868


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:
Expand Down
12 changes: 10 additions & 2 deletions examples/gno.land/r/gov/dao/v3/impl/render.gno
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 ""
}
21 changes: 21 additions & 0 deletions examples/gno.land/r/gov/dao/v3/impl/render_test.gno
Original file line number Diff line number Diff line change
@@ -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")
}
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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"},
Expand Down
Loading
Loading