Skip to content

feat: Regional Community DAO #4333

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions examples/gno.land/p/nt/commondao/commondao.gno
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ func (dao *CommonDAO) Propose(creator std.Address, d ProposalDefinition) (*Propo
return nil, ErrOverflow
}

// doesn't check if the prop creator is a dao member? i assume it's left to the user of the p/ to define the behavior?
Copy link
Member

Choose a reason for hiding this comment

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

Yes, the idea is that devs should explicitly check it.

Following that idea it would be good that the fact is mentioned in the Propose() method docs.
Documentation is something that should be improved 👍

Not checking that the creator address is a DAO member makes the proposing open to other use cases where devs might want to create proposals without enforcing membership.


p, err := NewProposal(uint64(id), creator, d)
if err != nil {
return nil, err
Expand Down
2 changes: 1 addition & 1 deletion examples/gno.land/p/nt/commondao/commondao_options.gno
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func WithMemberStorage(s MemberStorage) Option {
}

// WithActiveProposalStorage assigns a custom storage for active proposals.
// A default empty proposal storage is used when the custopm storage is nil.
// A default empty proposal storage is used when the custom storage is nil.
// Custom storage implementations can be used to store proposals in a different location.
func WithActiveProposalStorage(s ProposalStorage) Option {
return func(dao *CommonDAO) {
Expand Down
8 changes: 8 additions & 0 deletions examples/gno.land/p/nt/commondao/proposal.gno
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ type (
Execute() error
}

// RenderableProposal makes sure that the proposal is renderable
RenderableProposal interface {
ProposalDefinition
Render() string
}
Comment on lines +100 to +104
Copy link
Member

Choose a reason for hiding this comment

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

Could be simpler:

Suggested change
// RenderableProposal makes sure that the proposal is renderable
RenderableProposal interface {
ProposalDefinition
Render() string
}
// Renderable makes sure that the proposal is renderable.
Renderable interface {
Render() string
}

This interface might be a good candidate to be defined in a p/nt/commondao/ui package.


// CustomizableVoteChoices defines an interface for proposal definitions that want
// to customize the list of allowed voting choices.
CustomizableVoteChoices interface {
Expand Down Expand Up @@ -219,6 +225,8 @@ func (p Proposal) IsVoteChoiceValid(c VoteChoice) bool {
return p.voteChoices.Has(string(c))
}

// add default prop render
Copy link
Member

@jeronimoalbi jeronimoalbi May 29, 2025

Choose a reason for hiding this comment

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

Definitely a good feature to have, default render support for proposals, or DAOs so users can opt in.
It would be good to consider having a p/nt/commondao/ui or p/nt/commondaoui for render related features and default render logic.

At some point I tried to have render defaults so they could also be used in commondao realm, but I found it was somehow limiting when adding links between types like DAO, proposal and vote for example, or when dealing with pagination because the rendered content was highly dependent on the realm render logic and the paths being resolved.


// IsQuorumReached checks if a participation quorum is reach.
func IsQuorumReached(quorum float64, r ReadonlyVotingRecord, members MemberSet) bool {
if members.Size() <= 0 || quorum <= 0 {
Expand Down
2 changes: 2 additions & 0 deletions examples/gno.land/p/nt/commondao/proposal_storage.gno
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,5 @@ func (s proposalStorage) Iterate(offset, count int, reverse bool, fn func(*Propo
func makeProposalKey(id uint64) string {
return seqid.ID(id).String()
}

// add way to get RO Prop Tree
2 changes: 1 addition & 1 deletion examples/gno.land/p/nt/commondao/record.gno
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ func FindMostVotedChoice(r ReadonlyVotingRecord) VoteChoice {

// SelectChoiceByAbsoluteMajority select the vote choice by absolute majority.
// Vote choice is a majority when chosen by more than half of the votes.
// Absolute majority considers abstentions when counting votes.
// Absolute majority considers abstentions when counting votes. // what does the bool mean? quorum reached? prop passed?
Copy link
Member

Choose a reason for hiding this comment

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

In this case and the other majority check functions the second boolean result means that a majority was reached and voted for the same voting choice when result is true otherwise it means that currently there is no majority winner. For SelectChoiceByAbsoluteMajority() majority is met when more than 50% of the members voted the same choice.

There might be cases where devs would have to define their own majority check functions if the ones pre defined here won't work for their use case.

It might be good to add more majority check functions to cover more cases. A nice to have :)

Quorum can be checked using the IsQuorumReached() function, there are also a couple of pre defined quorum constants to be used with that function. All defined within the proposal.gno file.

func SelectChoiceByAbsoluteMajority(r ReadonlyVotingRecord, membersCount int) (VoteChoice, bool) {
choice := FindMostVotedChoice(r)
if choice != ChoiceNone && r.VoteCount(choice) > int(membersCount/2) {
Expand Down
1 change: 1 addition & 0 deletions examples/gno.land/r/devrels/rcdao/gno.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module gno.land/r/devrels/rcdao
58 changes: 58 additions & 0 deletions examples/gno.land/r/devrels/rcdao/propdef.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package rcdao

import (
"std"
"time"

"gno.land/p/nt/commondao"
"gno.land/r/sys/users"
)

type EventProposal struct {
title string
body string
location string // ie Berlin, Germany
requestedBudget std.Coins
beneficiary std.Address // after the prop passes, coins are sent here
}

func (ep EventProposal) Title() string { return ep.title }
func (ep EventProposal) Body() string { return ep.body }
func (ep EventProposal) Location() string { return ep.location }
func (EventProposal) VotingPeriod() time.Duration { return time.Hour * 24 * 7 }

func (ep EventProposal) Render() string {
out := ep.body + "\n\n"
out += ep.location + "\n\n"
out += "Asking amount: " + ep.requestedBudget.String() + "\n\n"

name := "Author: " + ep.beneficiary.String()
user := users.ResolveAddress(ep.beneficiary)
if user != nil {
name = "Author: " + user.RenderLink("")
}

out += name
return out
}

func (EventProposal) Tally(r commondao.ReadonlyVotingRecord, set commondao.MemberSet) (bool, error) {
_, passed := commondao.SelectChoiceBySuperMajority(r, set.Size())
return passed, nil
}

func (ep EventProposal) Execute() error {
crossing()

bank := std.NewBanker(std.BankerTypeRealmSend)
treasuryCoins := bank.GetCoins(std.CurrentRealm().Address()) // DAO treasury coins

for i := 0; i < len(ep.requestedBudget); i++ {
coin := ep.requestedBudget[i]
if treasuryCoins.AmountOf(coin.Denom) >= coin.Amount {
panic("cannot pay out requested budget, too little in the treasury")
}
}
Comment on lines +50 to +55
Copy link
Member

Choose a reason for hiding this comment

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

You could also move this into a Validate() error method.
Validate is called before execution, if it fails then Execute() is not called.

Just in case, have in mind that right now Validation() and Execution() errors don't make the transaction fail, so using panic is the right move if you want the TX to fail and are expecting to change the state to eventually be able to successfully execute the proposal or if you plan to implement a way to change the proposal status yourself.

Right now, and before returning an error within the Execute() method, it's important to make sure that the actual state changes are done after the current state is validated. Maybe changing commondao proposals to have an expiration date after which proposals could be invalidated would be a safer solution, so any execution error would panic by default 🤔. I'm not really sure about what would be the best approach. Thoughts on this would be helpful.

Otherwise returning an error in either of those will potentially lead to save any state changes done before returning the error and the proposal state will be updated to failed.

I will give a though on how to improve this behavior because right now doesn't seem right.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Let's add MustValidate & MustExecute?

Copy link
Member

Choose a reason for hiding this comment

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

Let's add MustValidate & MustExecute?

PR for those is #4352


return nil
}
61 changes: 61 additions & 0 deletions examples/gno.land/r/devrels/rcdao/rcdao.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package rcdao

import (
"std"
"strconv"

"gno.land/p/demo/ufmt"
"gno.land/p/moul/md"
"gno.land/p/nt/commondao"
)

var dao *commondao.CommonDAO

func init() {
dao = commondao.New(
commondao.WithName("Regional Community DAO"),
commondao.WithMember("g125em6arxsnj49vx35f0n0z34putv5ty3376fg5"))
}

// I want to have a quick view of members
// I want to have an easy render, name it DefaultRender
// Exposing RO trees for members & props would make it easy to integrate with existing p/

func ProposeMeetup(title, body, location string, reqBudget std.Coins, beneficiary std.Address) {
crossing()

proposer := std.PreviousRealm().Address()
if !dao.Members().Has(proposer) {
panic("only members can propose")
}

prop := EventProposal{
title: title,
body: body,
location: location,
requestedBudget: reqBudget,
beneficiary: beneficiary,
}

p := dao.MustPropose(proposer, prop)
std.Emit("NewProp", "id", strconv.Itoa(int(p.ID())), "title", p.Definition().Title())
}

func Render(_ string) string {
out := md.H1(dao.Name())

out += "---\n\n"
out += "## Active Props\n\n"
out += ufmt.Sprintf("Active prop num: %d\n\n", dao.ActiveProposals().Size())

dao.ActiveProposals().Iterate(0, dao.ActiveProposals().Size(), true, func(prop *commondao.Proposal) bool {
out += ufmt.Sprintf("### Prop #%d - %s\n\n", +prop.ID(), prop.Definition().Title())

ep := prop.Definition().(commondao.RenderableProposal)
out += ep.Render()

return false
})

return out
}
2 changes: 1 addition & 1 deletion examples/gno.land/r/nt/commondao/public.gno
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"gno.land/p/nt/commondao"
)

// TODO: Limit the number of DAOs per address, maybe discuss fees ranges to avoid spaming
// TODO: Limit the number of DAOs per address, maybe discuss fees ranges to avoid spamming

// Invite invites a user to the realm.
// A user invitation is required to start creating new DAOs.
Expand Down
Loading