diff --git a/examples/gno.land/p/demo/combinederr/combinederr.gno b/examples/gno.land/p/demo/combinederr/combinederr.gno new file mode 100644 index 00000000000..f446c7846bd --- /dev/null +++ b/examples/gno.land/p/demo/combinederr/combinederr.gno @@ -0,0 +1,40 @@ +package combinederr + +import "strings" + +// CombinedError is a combined execution error +type CombinedError struct { + errors []error +} + +// Error returns the combined execution error +func (e *CombinedError) Error() string { + if len(e.errors) == 0 { + return "" + } + + var sb strings.Builder + + for _, err := range e.errors { + sb.WriteString(err.Error() + "; ") + } + + // Remove the last semicolon and space + result := sb.String() + + return result[:len(result)-2] +} + +// Add adds a new error to the execution error +func (e *CombinedError) Add(err error) { + if err == nil { + return + } + + e.errors = append(e.errors, err) +} + +// Size returns a +func (e *CombinedError) Size() int { + return len(e.errors) +} diff --git a/examples/gno.land/p/demo/combinederr/gno.mod b/examples/gno.land/p/demo/combinederr/gno.mod new file mode 100644 index 00000000000..4c99e0ba7ef --- /dev/null +++ b/examples/gno.land/p/demo/combinederr/gno.mod @@ -0,0 +1 @@ +module gno.land/p/demo/combinederr diff --git a/examples/gno.land/p/demo/dao/dao.gno b/examples/gno.land/p/demo/dao/dao.gno new file mode 100644 index 00000000000..f8ea433192f --- /dev/null +++ b/examples/gno.land/p/demo/dao/dao.gno @@ -0,0 +1,33 @@ +package dao + +const ( + ProposalAddedEvent = "ProposalAdded" // emitted when a new proposal has been added + ProposalAcceptedEvent = "ProposalAccepted" // emitted when a proposal has been accepted + ProposalNotAcceptedEvent = "ProposalNotAccepted" // emitted when a proposal has not been accepted + ProposalExecutedEvent = "ProposalExecuted" // emitted when a proposal has been executed + + ProposalEventIDKey = "proposal-id" + ProposalEventAuthorKey = "proposal-author" + ProposalEventExecutionKey = "exec-status" +) + +// ProposalRequest is a single govdao proposal request +// that contains the necessary information to +// log and generate a valid proposal +type ProposalRequest struct { + Description string // the description associated with the proposal + Executor Executor // the proposal executor +} + +// DAO defines the DAO abstraction +type DAO interface { + // PropStore is the DAO proposal storage + PropStore + + // Propose adds a new proposal to the executor-based GOVDAO. + // Returns the generated proposal ID + Propose(request ProposalRequest) (uint64, error) + + // ExecuteProposal executes the proposal with the given ID + ExecuteProposal(id uint64) error +} diff --git a/examples/gno.land/p/demo/dao/doc.gno b/examples/gno.land/p/demo/dao/doc.gno new file mode 100644 index 00000000000..3fb28204013 --- /dev/null +++ b/examples/gno.land/p/demo/dao/doc.gno @@ -0,0 +1,5 @@ +// Package dao houses common DAO building blocks (framework), which can be used or adopted by any +// specific DAO implementation. By design, the DAO should house the proposals it receives, but not the actual +// DAO members or proposal votes. These abstractions should be implemented by a separate entity, to keep the DAO +// agnostic of implementation details such as these (member / vote management). +package dao diff --git a/examples/gno.land/p/demo/dao/events.gno b/examples/gno.land/p/demo/dao/events.gno new file mode 100644 index 00000000000..97bc794e6f3 --- /dev/null +++ b/examples/gno.land/p/demo/dao/events.gno @@ -0,0 +1,56 @@ +package dao + +import ( + "std" + + "gno.land/p/demo/ufmt" +) + +// EmitProposalAdded emits an event signaling that +// a given proposal was added +func EmitProposalAdded(id uint64, proposer std.Address) { + std.Emit( + ProposalAddedEvent, + ProposalEventIDKey, ufmt.Sprintf("%d", id), + ProposalEventAuthorKey, proposer.String(), + ) +} + +// EmitProposalAccepted emits an event signaling that +// a given proposal was accepted +func EmitProposalAccepted(id uint64) { + std.Emit( + ProposalAcceptedEvent, + ProposalEventIDKey, ufmt.Sprintf("%d", id), + ) +} + +// EmitProposalNotAccepted emits an event signaling that +// a given proposal was not accepted +func EmitProposalNotAccepted(id uint64) { + std.Emit( + ProposalNotAcceptedEvent, + ProposalEventIDKey, ufmt.Sprintf("%d", id), + ) +} + +// EmitProposalExecuted emits an event signaling that +// a given proposal was executed, with the given status +func EmitProposalExecuted(id uint64, status ProposalStatus) { + std.Emit( + ProposalExecutedEvent, + ProposalEventIDKey, ufmt.Sprintf("%d", id), + ProposalEventExecutionKey, status.String(), + ) +} + +// EmitVoteAdded emits an event signaling that +// a vote was cast for a given proposal +func EmitVoteAdded(id uint64, voter std.Address, option VoteOption) { + std.Emit( + VoteAddedEvent, + VoteAddedIDKey, ufmt.Sprintf("%d", id), + VoteAddedAuthorKey, voter.String(), + VoteAddedOptionKey, option.String(), + ) +} diff --git a/examples/gno.land/p/demo/dao/executor.gno b/examples/gno.land/p/demo/dao/executor.gno new file mode 100644 index 00000000000..9291c2c53c5 --- /dev/null +++ b/examples/gno.land/p/demo/dao/executor.gno @@ -0,0 +1,9 @@ +package dao + +// Executor represents a minimal closure-oriented proposal design. +// It is intended to be used by a govdao governance proposal (v1, v2, etc) +type Executor interface { + // Execute executes the given proposal, and returns any error encountered + // during the execution + Execute() error +} diff --git a/examples/gno.land/p/demo/dao/gno.mod b/examples/gno.land/p/demo/dao/gno.mod new file mode 100644 index 00000000000..ecbab2f7692 --- /dev/null +++ b/examples/gno.land/p/demo/dao/gno.mod @@ -0,0 +1,3 @@ +module gno.land/p/demo/dao + +require gno.land/p/demo/ufmt v0.0.0-latest diff --git a/examples/gno.land/p/demo/dao/proposals.gno b/examples/gno.land/p/demo/dao/proposals.gno new file mode 100644 index 00000000000..5cad679d006 --- /dev/null +++ b/examples/gno.land/p/demo/dao/proposals.gno @@ -0,0 +1,62 @@ +package dao + +import "std" + +// ProposalStatus is the currently active proposal status, +// changed based on DAO functionality. +// Status transitions: +// +// ACTIVE -> ACCEPTED -> EXECUTION(SUCCEEDED/FAILED) +// +// ACTIVE -> NOT ACCEPTED +type ProposalStatus string + +var ( + Active ProposalStatus = "active" // proposal is still active + Accepted ProposalStatus = "accepted" // proposal gathered quorum + NotAccepted ProposalStatus = "not accepted" // proposal failed to gather quorum + ExecutionSuccessful ProposalStatus = "execution successful" // proposal is executed successfully + ExecutionFailed ProposalStatus = "execution failed" // proposal is failed during execution +) + +func (s ProposalStatus) String() string { + return string(s) +} + +// PropStore defines the proposal storage abstraction +type PropStore interface { + // Proposals returns the given paginated proposals + Proposals(offset, count uint64) []Proposal + + // ProposalByID returns the proposal associated with + // the given ID, if any + ProposalByID(id uint64) (Proposal, error) + + // Size returns the number of proposals in + // the proposal store + Size() int +} + +// Proposal is the single proposal abstraction +type Proposal interface { + // Author returns the author of the proposal + Author() std.Address + + // Description returns the description of the proposal + Description() string + + // Status returns the status of the proposal + Status() ProposalStatus + + // Executor returns the proposal executor + Executor() Executor + + // Stats returns the voting stats of the proposal + Stats() Stats + + // IsExpired returns a flag indicating if the proposal expired + IsExpired() bool + + // Render renders the proposal in a readable format + Render() string +} diff --git a/examples/gno.land/p/demo/dao/vote.gno b/examples/gno.land/p/demo/dao/vote.gno new file mode 100644 index 00000000000..94369f41e1b --- /dev/null +++ b/examples/gno.land/p/demo/dao/vote.gno @@ -0,0 +1,69 @@ +package dao + +// NOTE: +// This voting pods will be removed in a future version of the +// p/demo/dao package. A DAO shouldn't have to comply with or define how the voting mechanism works internally; +// it should be viewed as an entity that makes decisions +// +// The extent of "votes being enforced" in this implementation is just in the context +// of types a DAO can use (import), and in the context of "Stats", where +// there is a notion of "Yay", "Nay" and "Abstain" votes. +const ( + VoteAddedEvent = "VoteAdded" // emitted when a vote was cast for a proposal + + VoteAddedIDKey = "proposal-id" + VoteAddedAuthorKey = "author" + VoteAddedOptionKey = "option" +) + +// VoteOption is the limited voting option for a DAO proposal +type VoteOption string + +const ( + YesVote VoteOption = "YES" // Proposal should be accepted + NoVote VoteOption = "NO" // Proposal should be rejected + AbstainVote VoteOption = "ABSTAIN" // Side is not chosen +) + +func (v VoteOption) String() string { + return string(v) +} + +// Stats encompasses the proposal voting stats +type Stats struct { + YayVotes uint64 + NayVotes uint64 + AbstainVotes uint64 + + TotalVotingPower uint64 +} + +// YayPercent returns the percentage (0-100) of the yay votes +// in relation to the total voting power +func (v Stats) YayPercent() uint64 { + return v.YayVotes * 100 / v.TotalVotingPower +} + +// NayPercent returns the percentage (0-100) of the nay votes +// in relation to the total voting power +func (v Stats) NayPercent() uint64 { + return v.NayVotes * 100 / v.TotalVotingPower +} + +// AbstainPercent returns the percentage (0-100) of the abstain votes +// in relation to the total voting power +func (v Stats) AbstainPercent() uint64 { + return v.AbstainVotes * 100 / v.TotalVotingPower +} + +// MissingVotes returns the summed voting power that has not +// participated in proposal voting yet +func (v Stats) MissingVotes() uint64 { + return v.TotalVotingPower - (v.YayVotes + v.NayVotes + v.AbstainVotes) +} + +// MissingVotesPercent returns the percentage (0-100) of the missing votes +// in relation to the total voting power +func (v Stats) MissingVotesPercent() uint64 { + return v.MissingVotes() * 100 / v.TotalVotingPower +} diff --git a/examples/gno.land/r/gov/dao/gno.mod b/examples/gno.land/p/demo/membstore/gno.mod similarity index 54% rename from examples/gno.land/r/gov/dao/gno.mod rename to examples/gno.land/p/demo/membstore/gno.mod index f3c0bae990e..da22a8dcae4 100644 --- a/examples/gno.land/r/gov/dao/gno.mod +++ b/examples/gno.land/p/demo/membstore/gno.mod @@ -1,8 +1,9 @@ -module gno.land/r/gov/dao +module gno.land/p/demo/membstore require ( + gno.land/p/demo/avl v0.0.0-latest gno.land/p/demo/testutils v0.0.0-latest + gno.land/p/demo/uassert v0.0.0-latest gno.land/p/demo/ufmt v0.0.0-latest gno.land/p/demo/urequire v0.0.0-latest - gno.land/p/gov/proposal v0.0.0-latest ) diff --git a/examples/gno.land/p/demo/membstore/members.gno b/examples/gno.land/p/demo/membstore/members.gno new file mode 100644 index 00000000000..0bbaaaa8b04 --- /dev/null +++ b/examples/gno.land/p/demo/membstore/members.gno @@ -0,0 +1,38 @@ +package membstore + +import ( + "std" +) + +// MemberStore defines the member storage abstraction +type MemberStore interface { + // Members returns all members in the store + Members(offset, count uint64) []Member + + // Size returns the current size of the store + Size() int + + // IsMember returns a flag indicating if the given address + // belongs to a member + IsMember(address std.Address) bool + + // TotalPower returns the total voting power of the member store + TotalPower() uint64 + + // Member returns the requested member + Member(address std.Address) (Member, error) + + // AddMember adds a member to the store + AddMember(member Member) error + + // UpdateMember updates the member in the store. + // If updating a member's voting power to 0, + // the member will be removed + UpdateMember(address std.Address, member Member) error +} + +// Member holds the relevant member information +type Member struct { + Address std.Address // bech32 gno address of the member (unique) + VotingPower uint64 // the voting power of the member +} diff --git a/examples/gno.land/p/demo/membstore/membstore.gno b/examples/gno.land/p/demo/membstore/membstore.gno new file mode 100644 index 00000000000..6e1932978d9 --- /dev/null +++ b/examples/gno.land/p/demo/membstore/membstore.gno @@ -0,0 +1,209 @@ +package membstore + +import ( + "errors" + "std" + + "gno.land/p/demo/avl" + "gno.land/p/demo/ufmt" +) + +var ( + ErrAlreadyMember = errors.New("address is already a member") + ErrMissingMember = errors.New("address is not a member") + ErrInvalidAddressUpdate = errors.New("invalid address update") + ErrNotGovDAO = errors.New("caller not correct govdao instance") +) + +// maxRequestMembers is the maximum number of +// paginated members that can be requested +const maxRequestMembers = 50 + +type Option func(*MembStore) + +// WithInitialMembers initializes the member store +// with an initial member list +func WithInitialMembers(members []Member) Option { + return func(store *MembStore) { + for _, m := range members { + memberAddr := m.Address.String() + + // Check if the member already exists + if store.members.Has(memberAddr) { + panic(ufmt.Errorf("%s, %s", memberAddr, ErrAlreadyMember)) + } + + store.members.Set(memberAddr, m) + store.totalVotingPower += m.VotingPower + } + } +} + +// WithDAOPkgPath initializes the member store +// with a dao package path guard +func WithDAOPkgPath(daoPkgPath string) Option { + return func(store *MembStore) { + store.daoPkgPath = daoPkgPath + } +} + +// MembStore implements the dao.MembStore abstraction +type MembStore struct { + daoPkgPath string // active dao pkg path, if any + members *avl.Tree // std.Address -> Member + totalVotingPower uint64 // cached value for quick lookups +} + +// NewMembStore creates a new member store +func NewMembStore(opts ...Option) *MembStore { + m := &MembStore{ + members: avl.NewTree(), // empty set + daoPkgPath: "", // no dao guard + totalVotingPower: 0, + } + + // Apply the options + for _, opt := range opts { + opt(m) + } + + return m +} + +// AddMember adds member to the member store `m`. +// It fails if the caller is not GovDAO or +// if the member is already present +func (m *MembStore) AddMember(member Member) error { + if !m.isCallerDAORealm() { + return ErrNotGovDAO + } + + // Check if the member exists + if m.IsMember(member.Address) { + return ErrAlreadyMember + } + + // Add the member + m.members.Set(member.Address.String(), member) + + // Update the total voting power + m.totalVotingPower += member.VotingPower + + return nil +} + +// UpdateMember updates the member with the given address. +// Updating fails if the caller is not GovDAO. +func (m *MembStore) UpdateMember(address std.Address, member Member) error { + if !m.isCallerDAORealm() { + return ErrNotGovDAO + } + + // Get the member + oldMember, err := m.Member(address) + if err != nil { + return err + } + + // Check if this is a removal request + if member.VotingPower == 0 { + m.members.Remove(address.String()) + + // Update the total voting power + m.totalVotingPower -= oldMember.VotingPower + + return nil + } + + // Check that the member wouldn't be + // overwriting an existing one + isAddressUpdate := address != member.Address + if isAddressUpdate && m.IsMember(member.Address) { + return ErrInvalidAddressUpdate + } + + // Remove the old member info + // in case the address changed + if address != member.Address { + m.members.Remove(address.String()) + } + + // Save the new member info + m.members.Set(member.Address.String(), member) + + // Update the total voting power + difference := member.VotingPower - oldMember.VotingPower + m.totalVotingPower += difference + + return nil +} + +// IsMember returns a flag indicating if the given +// address belongs to a member of the member store +func (m *MembStore) IsMember(address std.Address) bool { + _, exists := m.members.Get(address.String()) + + return exists +} + +// Member returns the member associated with the given address +func (m *MembStore) Member(address std.Address) (Member, error) { + member, exists := m.members.Get(address.String()) + if !exists { + return Member{}, ErrMissingMember + } + + return member.(Member), nil +} + +// Members returns a paginated list of members from +// the member store. If the store is empty, an empty slice +// is returned instead +func (m *MembStore) Members(offset, count uint64) []Member { + // Calculate the left and right bounds + if count < 1 || offset >= uint64(m.members.Size()) { + return []Member{} + } + + // Limit the maximum number of returned members + if count > maxRequestMembers { + count = maxRequestMembers + } + + // Gather the members + members := make([]Member, 0) + m.members.IterateByOffset( + int(offset), + int(count), + func(_ string, val interface{}) bool { + member := val.(Member) + + // Save the member + members = append(members, member) + + return false + }) + + return members +} + +// Size returns the number of active members in the member store +func (m *MembStore) Size() int { + return m.members.Size() +} + +// TotalPower returns the total voting power +// of the member store +func (m *MembStore) TotalPower() uint64 { + return m.totalVotingPower +} + +// isCallerDAORealm returns a flag indicating if the +// current caller context is the active DAO Realm. +// We need to include a dao guard, even if the +// executor guarantees it, because +// the API of the member store is public and callable +// by anyone who has a reference to the member store instance. +func (m *MembStore) isCallerDAORealm() bool { + return m.daoPkgPath == "" || std.CurrentRealm().PkgPath() == m.daoPkgPath +} diff --git a/examples/gno.land/p/demo/membstore/membstore_test.gno b/examples/gno.land/p/demo/membstore/membstore_test.gno new file mode 100644 index 00000000000..2181adde077 --- /dev/null +++ b/examples/gno.land/p/demo/membstore/membstore_test.gno @@ -0,0 +1,317 @@ +package membstore + +import ( + "testing" + + "std" + + "gno.land/p/demo/testutils" + "gno.land/p/demo/uassert" + "gno.land/p/demo/ufmt" + "gno.land/p/demo/urequire" +) + +// generateMembers generates dummy govdao members +func generateMembers(t *testing.T, count int) []Member { + t.Helper() + + members := make([]Member, 0, count) + + for i := 0; i < count; i++ { + members = append(members, Member{ + Address: testutils.TestAddress(ufmt.Sprintf("member %d", i)), + VotingPower: 10, + }) + } + + return members +} + +func TestMembStore_GetMember(t *testing.T) { + t.Parallel() + + t.Run("member not found", func(t *testing.T) { + t.Parallel() + + // Create an empty store + m := NewMembStore() + + _, err := m.Member(testutils.TestAddress("random")) + uassert.ErrorIs(t, err, ErrMissingMember) + }) + + t.Run("valid member fetched", func(t *testing.T) { + t.Parallel() + + // Create a non-empty store + members := generateMembers(t, 1) + + m := NewMembStore(WithInitialMembers(members)) + + _, err := m.Member(members[0].Address) + uassert.NoError(t, err) + }) +} + +func TestMembStore_GetMembers(t *testing.T) { + t.Parallel() + + t.Run("no members", func(t *testing.T) { + t.Parallel() + + // Create an empty store + m := NewMembStore() + + members := m.Members(0, 10) + uassert.Equal(t, 0, len(members)) + }) + + t.Run("proper pagination", func(t *testing.T) { + t.Parallel() + + var ( + numMembers = maxRequestMembers * 2 + halfRange = numMembers / 2 + + members = generateMembers(t, numMembers) + m = NewMembStore(WithInitialMembers(members)) + + verifyMembersPresent = func(members, fetchedMembers []Member) { + for _, fetchedMember := range fetchedMembers { + for _, member := range members { + if member.Address != fetchedMember.Address { + continue + } + + uassert.Equal(t, member.VotingPower, fetchedMember.VotingPower) + } + } + } + ) + + urequire.Equal(t, numMembers, m.Size()) + + fetchedMembers := m.Members(0, uint64(halfRange)) + urequire.Equal(t, halfRange, len(fetchedMembers)) + + // Verify the members + verifyMembersPresent(members, fetchedMembers) + + // Fetch the other half + fetchedMembers = m.Members(uint64(halfRange), uint64(halfRange)) + urequire.Equal(t, halfRange, len(fetchedMembers)) + + // Verify the members + verifyMembersPresent(members, fetchedMembers) + }) +} + +func TestMembStore_IsMember(t *testing.T) { + t.Parallel() + + t.Run("non-existing member", func(t *testing.T) { + t.Parallel() + + // Create an empty store + m := NewMembStore() + + uassert.False(t, m.IsMember(testutils.TestAddress("random"))) + }) + + t.Run("existing member", func(t *testing.T) { + t.Parallel() + + // Create a non-empty store + members := generateMembers(t, 50) + + m := NewMembStore(WithInitialMembers(members)) + + for _, member := range members { + uassert.True(t, m.IsMember(member.Address)) + } + }) +} + +func TestMembStore_AddMember(t *testing.T) { + t.Parallel() + + t.Run("caller not govdao", func(t *testing.T) { + t.Parallel() + + // Create an empty store + m := NewMembStore(WithDAOPkgPath("gno.land/r/gov/dao")) + + // Attempt to add a member + member := generateMembers(t, 1)[0] + uassert.ErrorIs(t, m.AddMember(member), ErrNotGovDAO) + }) + + t.Run("member already exists", func(t *testing.T) { + t.Parallel() + + var ( + // Execute as the /r/gov/dao caller + daoPkgPath = "gno.land/r/gov/dao" + r = std.NewCodeRealm(daoPkgPath) + ) + + std.TestSetRealm(r) + + // Create a non-empty store + members := generateMembers(t, 1) + m := NewMembStore(WithDAOPkgPath(daoPkgPath), WithInitialMembers(members)) + + // Attempt to add a member + uassert.ErrorIs(t, m.AddMember(members[0]), ErrAlreadyMember) + }) + + t.Run("new member added", func(t *testing.T) { + t.Parallel() + + var ( + // Execute as the /r/gov/dao caller + daoPkgPath = "gno.land/r/gov/dao" + r = std.NewCodeRealm(daoPkgPath) + ) + + std.TestSetRealm(r) + + // Create an empty store + members := generateMembers(t, 1) + m := NewMembStore(WithDAOPkgPath(daoPkgPath)) + + // Attempt to add a member + urequire.NoError(t, m.AddMember(members[0])) + + // Make sure the member is added + uassert.True(t, m.IsMember(members[0].Address)) + }) +} + +func TestMembStore_Size(t *testing.T) { + t.Parallel() + + t.Run("empty govdao", func(t *testing.T) { + t.Parallel() + + // Create an empty store + m := NewMembStore() + + uassert.Equal(t, 0, m.Size()) + }) + + t.Run("non-empty govdao", func(t *testing.T) { + t.Parallel() + + // Create a non-empty store + members := generateMembers(t, 50) + m := NewMembStore(WithInitialMembers(members)) + + uassert.Equal(t, len(members), m.Size()) + }) +} + +func TestMembStore_UpdateMember(t *testing.T) { + t.Parallel() + + t.Run("caller not govdao", func(t *testing.T) { + t.Parallel() + + // Create an empty store + m := NewMembStore(WithDAOPkgPath("gno.land/r/gov/dao")) + + // Attempt to update a member + member := generateMembers(t, 1)[0] + uassert.ErrorIs(t, m.UpdateMember(member.Address, member), ErrNotGovDAO) + }) + + t.Run("non-existing member", func(t *testing.T) { + t.Parallel() + + var ( + // Execute as the /r/gov/dao caller + daoPkgPath = "gno.land/r/gov/dao" + r = std.NewCodeRealm(daoPkgPath) + ) + + std.TestSetRealm(r) + + // Create an empty store + members := generateMembers(t, 1) + m := NewMembStore(WithDAOPkgPath(daoPkgPath)) + + // Attempt to update a member + uassert.ErrorIs(t, m.UpdateMember(members[0].Address, members[0]), ErrMissingMember) + }) + + t.Run("overwrite member attempt", func(t *testing.T) { + t.Parallel() + + var ( + // Execute as the /r/gov/dao caller + daoPkgPath = "gno.land/r/gov/dao" + r = std.NewCodeRealm(daoPkgPath) + ) + + std.TestSetRealm(r) + + // Create a non-empty store + members := generateMembers(t, 2) + m := NewMembStore(WithDAOPkgPath(daoPkgPath), WithInitialMembers(members)) + + // Attempt to update a member + uassert.ErrorIs(t, m.UpdateMember(members[0].Address, members[1]), ErrInvalidAddressUpdate) + }) + + t.Run("successful update", func(t *testing.T) { + t.Parallel() + + var ( + // Execute as the /r/gov/dao caller + daoPkgPath = "gno.land/r/gov/dao" + r = std.NewCodeRealm(daoPkgPath) + ) + + std.TestSetRealm(r) + + // Create a non-empty store + members := generateMembers(t, 1) + m := NewMembStore(WithDAOPkgPath(daoPkgPath), WithInitialMembers(members)) + + oldVotingPower := m.totalVotingPower + urequire.Equal(t, members[0].VotingPower, oldVotingPower) + + votingPower := uint64(300) + members[0].VotingPower = votingPower + + // Attempt to update a member + uassert.NoError(t, m.UpdateMember(members[0].Address, members[0])) + uassert.Equal(t, votingPower, m.Members(0, 10)[0].VotingPower) + urequire.Equal(t, votingPower, m.totalVotingPower) + }) + + t.Run("member removed", func(t *testing.T) { + t.Parallel() + + var ( + // Execute as the /r/gov/dao caller + daoPkgPath = "gno.land/r/gov/dao" + r = std.NewCodeRealm(daoPkgPath) + ) + + std.TestSetRealm(r) + + // Create a non-empty store + members := generateMembers(t, 1) + m := NewMembStore(WithDAOPkgPath(daoPkgPath), WithInitialMembers(members)) + + votingPower := uint64(0) + members[0].VotingPower = votingPower + + // Attempt to update a member + uassert.NoError(t, m.UpdateMember(members[0].Address, members[0])) + + // Make sure the member was removed + uassert.False(t, m.IsMember(members[0].Address)) + }) +} diff --git a/examples/gno.land/p/demo/simpledao/dao.gno b/examples/gno.land/p/demo/simpledao/dao.gno new file mode 100644 index 00000000000..7a20237ec3f --- /dev/null +++ b/examples/gno.land/p/demo/simpledao/dao.gno @@ -0,0 +1,215 @@ +package simpledao + +import ( + "errors" + "std" + + "gno.land/p/demo/avl" + "gno.land/p/demo/dao" + "gno.land/p/demo/membstore" + "gno.land/p/demo/ufmt" +) + +var ( + ErrInvalidExecutor = errors.New("invalid executor provided") + ErrInsufficientProposalFunds = errors.New("insufficient funds for proposal") + ErrInsufficientExecuteFunds = errors.New("insufficient funds for executing proposal") + ErrProposalExecuted = errors.New("proposal already executed") + ErrProposalInactive = errors.New("proposal is inactive") + ErrProposalNotAccepted = errors.New("proposal is not accepted") +) + +var ( + minProposalFeeValue int64 = 100 * 1_000_000 // minimum gnot required for a govdao proposal (100 GNOT) + minExecuteFeeValue int64 = 500 * 1_000_000 // minimum gnot required for a govdao proposal (500 GNOT) + + minProposalFee = std.NewCoin("ugnot", minProposalFeeValue) + minExecuteFee = std.NewCoin("ugnot", minExecuteFeeValue) +) + +// SimpleDAO is a simple DAO implementation +type SimpleDAO struct { + proposals *avl.Tree // seqid.ID -> proposal + membStore membstore.MemberStore +} + +// New creates a new instance of the simpledao DAO +func New(membStore membstore.MemberStore) *SimpleDAO { + return &SimpleDAO{ + proposals: avl.NewTree(), + membStore: membStore, + } +} + +func (s *SimpleDAO) Propose(request dao.ProposalRequest) (uint64, error) { + // Make sure the executor is set + if request.Executor == nil { + return 0, ErrInvalidExecutor + } + + var ( + caller = getDAOCaller() + sentCoins = std.GetOrigSend() // Get the sent coins, if any + canCoverFee = sentCoins.AmountOf("ugnot") >= minProposalFee.Amount + ) + + // Check if the proposal is valid + if !s.membStore.IsMember(caller) && !canCoverFee { + return 0, ErrInsufficientProposalFunds + } + + // Create the wrapped proposal + prop := &proposal{ + author: caller, + description: request.Description, + executor: request.Executor, + status: dao.Active, + tally: newTally(), + getTotalVotingPowerFn: s.membStore.TotalPower, + } + + // Add the proposal + id, err := s.addProposal(prop) + if err != nil { + return 0, ufmt.Errorf("unable to add proposal, %s", err.Error()) + } + + // Emit the proposal added event + dao.EmitProposalAdded(id, caller) + + return id, nil +} + +func (s *SimpleDAO) VoteOnProposal(id uint64, option dao.VoteOption) error { + // Verify the GOVDAO member + caller := getDAOCaller() + + member, err := s.membStore.Member(caller) + if err != nil { + return ufmt.Errorf("unable to get govdao member, %s", err.Error()) + } + + // Check if the proposal exists + propRaw, err := s.ProposalByID(id) + if err != nil { + return ufmt.Errorf("unable to get proposal %d, %s", id, err.Error()) + } + + prop := propRaw.(*proposal) + + // Check the proposal status + if prop.Status() == dao.ExecutionSuccessful || + prop.Status() == dao.ExecutionFailed { + // Proposal was already executed, nothing to vote on anymore. + // + // In fact, the proposal should stop accepting + // votes as soon as a 2/3+ majority is reached + // on either option, but leaving the ability to vote still, + // even if a proposal is accepted, or not accepted, + // leaves room for "principle" vote decisions to be recorded + return ErrProposalInactive + } + + // Cast the vote + if err = prop.tally.castVote(member, option); err != nil { + return ufmt.Errorf("unable to vote on proposal %d, %s", id, err.Error()) + } + + // Emit the vote cast event + dao.EmitVoteAdded(id, caller, option) + + // Check the votes to see if quorum is reached + var ( + totalPower = s.membStore.TotalPower() + majorityPower = (2 * totalPower) / 3 + ) + + acceptProposal := func() { + prop.status = dao.Accepted + + dao.EmitProposalAccepted(id) + } + + declineProposal := func() { + prop.status = dao.NotAccepted + + dao.EmitProposalNotAccepted(id) + } + + switch { + case prop.tally.yays > majorityPower: + // 2/3+ voted YES + acceptProposal() + case prop.tally.nays > majorityPower: + // 2/3+ voted NO + declineProposal() + case prop.tally.abstains > majorityPower: + // 2/3+ voted ABSTAIN + declineProposal() + case prop.tally.yays+prop.tally.nays+prop.tally.abstains >= totalPower: + // Everyone voted, but it's undecided, + // hence the proposal can't go through + declineProposal() + default: + // Quorum not reached + } + + return nil +} + +func (s *SimpleDAO) ExecuteProposal(id uint64) error { + var ( + caller = getDAOCaller() + sentCoins = std.GetOrigSend() // Get the sent coins, if any + canCoverFee = sentCoins.AmountOf("ugnot") >= minExecuteFee.Amount + ) + + // Check if the non-DAO member can cover the execute fee + if !s.membStore.IsMember(caller) && !canCoverFee { + return ErrInsufficientExecuteFunds + } + + // Check if the proposal exists + propRaw, err := s.ProposalByID(id) + if err != nil { + return ufmt.Errorf("unable to get proposal %d, %s", id, err.Error()) + } + + prop := propRaw.(*proposal) + + // Check if the proposal is executed + if prop.Status() == dao.ExecutionSuccessful || + prop.Status() == dao.ExecutionFailed { + // Proposal is already executed + return ErrProposalExecuted + } + + // Check the proposal status + if prop.Status() != dao.Accepted { + // Proposal is not accepted, cannot be executed + return ErrProposalNotAccepted + } + + // Emit an event when the execution finishes + defer dao.EmitProposalExecuted(id, prop.status) + + // Attempt to execute the proposal + if err = prop.executor.Execute(); err != nil { + prop.status = dao.ExecutionFailed + + return ufmt.Errorf("error during proposal %d execution, %s", id, err.Error()) + } + + // Update the proposal status + prop.status = dao.ExecutionSuccessful + + return nil +} + +// getDAOCaller returns the DAO caller. +// XXX: This is not a great way to determine the caller, and it is very unsafe. +// However, the current MsgRun context does not persist escaping the main() scope. +// Until a better solution is developed, this enables proposals to be made through a package deployment + init() +func getDAOCaller() std.Address { + return std.GetOrigCaller() +} diff --git a/examples/gno.land/p/demo/simpledao/dao_test.gno b/examples/gno.land/p/demo/simpledao/dao_test.gno new file mode 100644 index 00000000000..fb32895e72f --- /dev/null +++ b/examples/gno.land/p/demo/simpledao/dao_test.gno @@ -0,0 +1,829 @@ +package simpledao + +import ( + "errors" + "std" + "testing" + + "gno.land/p/demo/dao" + "gno.land/p/demo/membstore" + "gno.land/p/demo/testutils" + "gno.land/p/demo/uassert" + "gno.land/p/demo/ufmt" + "gno.land/p/demo/urequire" +) + +// generateMembers generates dummy govdao members +func generateMembers(t *testing.T, count int) []membstore.Member { + t.Helper() + + members := make([]membstore.Member, 0, count) + + for i := 0; i < count; i++ { + members = append(members, membstore.Member{ + Address: testutils.TestAddress(ufmt.Sprintf("member %d", i)), + VotingPower: 10, + }) + } + + return members +} + +func TestSimpleDAO_Propose(t *testing.T) { + t.Parallel() + + t.Run("invalid executor", func(t *testing.T) { + t.Parallel() + + s := New(nil) + + _, err := s.Propose(dao.ProposalRequest{}) + uassert.ErrorIs( + t, + err, + ErrInvalidExecutor, + ) + }) + + t.Run("caller cannot cover fee", func(t *testing.T) { + t.Parallel() + + var ( + called = false + cb = func() error { + called = true + + return nil + } + ex = &mockExecutor{ + executeFn: cb, + } + + sentCoins = std.NewCoins( + std.NewCoin( + "ugnot", + minProposalFeeValue-1, + ), + ) + + ms = &mockMemberStore{ + isMemberFn: func(_ std.Address) bool { + return false + }, + } + s = New(ms) + ) + + // Set the sent coins to be lower + // than the proposal fee + std.TestSetOrigSend(sentCoins, std.Coins{}) + + _, err := s.Propose(dao.ProposalRequest{ + Executor: ex, + }) + uassert.ErrorIs( + t, + err, + ErrInsufficientProposalFunds, + ) + + uassert.False(t, called) + }) + + t.Run("proposal added", func(t *testing.T) { + t.Parallel() + + var ( + called = false + cb = func() error { + called = true + + return nil + } + + ex = &mockExecutor{ + executeFn: cb, + } + description = "Proposal description" + + proposer = testutils.TestAddress("proposer") + sentCoins = std.NewCoins( + std.NewCoin( + "ugnot", + minProposalFeeValue, // enough to cover + ), + ) + + ms = &mockMemberStore{ + isMemberFn: func(addr std.Address) bool { + return addr == proposer + }, + } + s = New(ms) + ) + + // Set the sent coins to be enough + // to cover the fee + std.TestSetOrigSend(sentCoins, std.Coins{}) + std.TestSetOrigCaller(proposer) + + // Make sure the proposal was added + id, err := s.Propose(dao.ProposalRequest{ + Description: description, + Executor: ex, + }) + uassert.NoError(t, err) + uassert.False(t, called) + + // Make sure the proposal exists + prop, err := s.ProposalByID(id) + uassert.NoError(t, err) + + uassert.Equal(t, proposer.String(), prop.Author().String()) + uassert.Equal(t, description, prop.Description()) + uassert.Equal(t, dao.Active.String(), prop.Status().String()) + + stats := prop.Stats() + + uassert.Equal(t, uint64(0), stats.YayVotes) + uassert.Equal(t, uint64(0), stats.NayVotes) + uassert.Equal(t, uint64(0), stats.AbstainVotes) + uassert.Equal(t, uint64(0), stats.TotalVotingPower) + }) +} + +func TestSimpleDAO_VoteOnProposal(t *testing.T) { + t.Parallel() + + t.Run("not govdao member", func(t *testing.T) { + t.Parallel() + + var ( + voter = testutils.TestAddress("voter") + fetchErr = errors.New("fetch error") + + ms = &mockMemberStore{ + memberFn: func(_ std.Address) (membstore.Member, error) { + return membstore.Member{ + Address: voter, + }, fetchErr + }, + } + s = New(ms) + ) + + std.TestSetOrigCaller(voter) + + // Attempt to vote on the proposal + uassert.ErrorContains( + t, + s.VoteOnProposal(0, dao.YesVote), + fetchErr.Error(), + ) + }) + + t.Run("missing proposal", func(t *testing.T) { + t.Parallel() + + var ( + voter = testutils.TestAddress("voter") + ms = &mockMemberStore{ + memberFn: func(a std.Address) (membstore.Member, error) { + if a != voter { + return membstore.Member{}, errors.New("not found") + } + + return membstore.Member{ + Address: voter, + }, nil + }, + } + + s = New(ms) + ) + + std.TestSetOrigCaller(voter) + + // Attempt to vote on the proposal + uassert.ErrorContains( + t, + s.VoteOnProposal(0, dao.YesVote), + ErrMissingProposal.Error(), + ) + }) + + t.Run("proposal executed", func(t *testing.T) { + t.Parallel() + + var ( + voter = testutils.TestAddress("voter") + + ms = &mockMemberStore{ + memberFn: func(a std.Address) (membstore.Member, error) { + if a != voter { + return membstore.Member{}, errors.New("not found") + } + + return membstore.Member{ + Address: voter, + }, nil + }, + } + s = New(ms) + + prop = &proposal{ + status: dao.ExecutionSuccessful, + } + ) + + std.TestSetOrigCaller(voter) + + // Add an initial proposal + id, err := s.addProposal(prop) + urequire.NoError(t, err) + + // Attempt to vote on the proposal + uassert.ErrorIs( + t, + s.VoteOnProposal(id, dao.YesVote), + ErrProposalInactive, + ) + }) + + t.Run("double vote on proposal", func(t *testing.T) { + t.Parallel() + + var ( + voter = testutils.TestAddress("voter") + member = membstore.Member{ + Address: voter, + VotingPower: 10, + } + + ms = &mockMemberStore{ + memberFn: func(a std.Address) (membstore.Member, error) { + if a != voter { + return membstore.Member{}, errors.New("not found") + } + + return member, nil + }, + } + s = New(ms) + + prop = &proposal{ + status: dao.Active, + executor: &mockExecutor{}, + tally: newTally(), + } + ) + + std.TestSetOrigCaller(voter) + + // Cast the initial vote + urequire.NoError(t, prop.tally.castVote(member, dao.YesVote)) + + // Add an initial proposal + id, err := s.addProposal(prop) + urequire.NoError(t, err) + + // Attempt to vote on the proposal + uassert.ErrorContains( + t, + s.VoteOnProposal(id, dao.YesVote), + ErrAlreadyVoted.Error(), + ) + }) + + t.Run("majority accepted", func(t *testing.T) { + t.Parallel() + + var ( + members = generateMembers(t, 50) + + ms = &mockMemberStore{ + memberFn: func(address std.Address) (membstore.Member, error) { + for _, m := range members { + if m.Address == address { + return m, nil + } + } + + return membstore.Member{}, errors.New("not found") + }, + + totalPowerFn: func() uint64 { + power := uint64(0) + + for _, m := range members { + power += m.VotingPower + } + + return power + }, + } + s = New(ms) + + prop = &proposal{ + status: dao.Active, + executor: &mockExecutor{}, + tally: newTally(), + } + ) + + // Add an initial proposal + id, err := s.addProposal(prop) + urequire.NoError(t, err) + + majorityIndex := (len(members)*2)/3 + 1 // 2/3+ + for _, m := range members[:majorityIndex] { + std.TestSetOrigCaller(m.Address) + + // Attempt to vote on the proposal + urequire.NoError( + t, + s.VoteOnProposal(id, dao.YesVote), + ) + } + + // Make sure the proposal was accepted + uassert.Equal(t, dao.Accepted.String(), prop.status.String()) + }) + + t.Run("majority rejected", func(t *testing.T) { + t.Parallel() + + var ( + members = generateMembers(t, 50) + + ms = &mockMemberStore{ + memberFn: func(address std.Address) (membstore.Member, error) { + for _, m := range members { + if m.Address == address { + return m, nil + } + } + + return membstore.Member{}, errors.New("member not found") + }, + + totalPowerFn: func() uint64 { + power := uint64(0) + + for _, m := range members { + power += m.VotingPower + } + + return power + }, + } + s = New(ms) + + prop = &proposal{ + status: dao.Active, + executor: &mockExecutor{}, + tally: newTally(), + } + ) + + // Add an initial proposal + id, err := s.addProposal(prop) + urequire.NoError(t, err) + + majorityIndex := (len(members)*2)/3 + 1 // 2/3+ + for _, m := range members[:majorityIndex] { + std.TestSetOrigCaller(m.Address) + + // Attempt to vote on the proposal + urequire.NoError( + t, + s.VoteOnProposal(id, dao.NoVote), + ) + } + + // Make sure the proposal was not accepted + uassert.Equal(t, dao.NotAccepted.String(), prop.status.String()) + }) + + t.Run("majority abstained", func(t *testing.T) { + t.Parallel() + + var ( + members = generateMembers(t, 50) + + ms = &mockMemberStore{ + memberFn: func(address std.Address) (membstore.Member, error) { + for _, m := range members { + if m.Address == address { + return m, nil + } + } + + return membstore.Member{}, errors.New("member not found") + }, + + totalPowerFn: func() uint64 { + power := uint64(0) + + for _, m := range members { + power += m.VotingPower + } + + return power + }, + } + s = New(ms) + + prop = &proposal{ + status: dao.Active, + executor: &mockExecutor{}, + tally: newTally(), + } + ) + + // Add an initial proposal + id, err := s.addProposal(prop) + urequire.NoError(t, err) + + majorityIndex := (len(members)*2)/3 + 1 // 2/3+ + for _, m := range members[:majorityIndex] { + std.TestSetOrigCaller(m.Address) + + // Attempt to vote on the proposal + urequire.NoError( + t, + s.VoteOnProposal(id, dao.AbstainVote), + ) + } + + // Make sure the proposal was not accepted + uassert.Equal(t, dao.NotAccepted.String(), prop.status.String()) + }) + + t.Run("everyone voted, undecided", func(t *testing.T) { + t.Parallel() + + var ( + members = generateMembers(t, 50) + + ms = &mockMemberStore{ + memberFn: func(address std.Address) (membstore.Member, error) { + for _, m := range members { + if m.Address == address { + return m, nil + } + } + + return membstore.Member{}, errors.New("member not found") + }, + + totalPowerFn: func() uint64 { + power := uint64(0) + + for _, m := range members { + power += m.VotingPower + } + + return power + }, + } + s = New(ms) + + prop = &proposal{ + status: dao.Active, + executor: &mockExecutor{}, + tally: newTally(), + } + ) + + // Add an initial proposal + id, err := s.addProposal(prop) + urequire.NoError(t, err) + + // The first half votes yes + for _, m := range members[:len(members)/2] { + std.TestSetOrigCaller(m.Address) + + // Attempt to vote on the proposal + urequire.NoError( + t, + s.VoteOnProposal(id, dao.YesVote), + ) + } + + // The other half votes no + for _, m := range members[len(members)/2:] { + std.TestSetOrigCaller(m.Address) + + // Attempt to vote on the proposal + urequire.NoError( + t, + s.VoteOnProposal(id, dao.NoVote), + ) + } + + // Make sure the proposal is not active, + // since everyone voted, and it was undecided + uassert.Equal(t, dao.NotAccepted.String(), prop.status.String()) + }) + + t.Run("proposal undecided", func(t *testing.T) { + t.Parallel() + + var ( + members = generateMembers(t, 50) + + ms = &mockMemberStore{ + memberFn: func(address std.Address) (membstore.Member, error) { + for _, m := range members { + if m.Address == address { + return m, nil + } + } + + return membstore.Member{}, errors.New("member not found") + }, + + totalPowerFn: func() uint64 { + power := uint64(0) + + for _, m := range members { + power += m.VotingPower + } + + return power + }, + } + s = New(ms) + + prop = &proposal{ + status: dao.Active, + executor: &mockExecutor{}, + tally: newTally(), + } + ) + + // Add an initial proposal + id, err := s.addProposal(prop) + urequire.NoError(t, err) + + // The first quarter votes yes + for _, m := range members[:len(members)/4] { + std.TestSetOrigCaller(m.Address) + + // Attempt to vote on the proposal + urequire.NoError( + t, + s.VoteOnProposal(id, dao.YesVote), + ) + } + + // The second quarter votes no + for _, m := range members[len(members)/4 : len(members)/2] { + std.TestSetOrigCaller(m.Address) + + // Attempt to vote on the proposal + urequire.NoError( + t, + s.VoteOnProposal(id, dao.NoVote), + ) + } + + // Make sure the proposal is still active, + // since there wasn't quorum reached on any decision + uassert.Equal(t, dao.Active.String(), prop.status.String()) + }) +} + +func TestSimpleDAO_ExecuteProposal(t *testing.T) { + t.Parallel() + + t.Run("caller cannot cover fee", func(t *testing.T) { + t.Parallel() + + var ( + sentCoins = std.NewCoins( + std.NewCoin( + "ugnot", + minExecuteFeeValue-1, + ), + ) + + ms = &mockMemberStore{ + isMemberFn: func(_ std.Address) bool { + return false + }, + } + s = New(ms) + ) + + // Set the sent coins to be lower + // than the execute fee + std.TestSetOrigSend(sentCoins, std.Coins{}) + + uassert.ErrorIs( + t, + s.ExecuteProposal(0), + ErrInsufficientExecuteFunds, + ) + }) + + t.Run("missing proposal", func(t *testing.T) { + t.Parallel() + + var ( + sentCoins = std.NewCoins( + std.NewCoin( + "ugnot", + minExecuteFeeValue, + ), + ) + + ms = &mockMemberStore{ + isMemberFn: func(_ std.Address) bool { + return true + }, + } + + s = New(ms) + ) + + // Set the sent coins to be enough + // so the execution can take place + std.TestSetOrigSend(sentCoins, std.Coins{}) + + uassert.ErrorContains( + t, + s.ExecuteProposal(0), + ErrMissingProposal.Error(), + ) + }) + + t.Run("proposal not accepted", func(t *testing.T) { + t.Parallel() + + var ( + voter = testutils.TestAddress("voter") + + ms = &mockMemberStore{ + isMemberFn: func(_ std.Address) bool { + return true + }, + } + s = New(ms) + + prop = &proposal{ + status: dao.NotAccepted, + } + ) + + std.TestSetOrigCaller(voter) + + // Add an initial proposal + id, err := s.addProposal(prop) + urequire.NoError(t, err) + + // Attempt to vote on the proposal + uassert.ErrorIs( + t, + s.ExecuteProposal(id), + ErrProposalNotAccepted, + ) + }) + + t.Run("proposal already executed", func(t *testing.T) { + t.Parallel() + + testTable := []struct { + name string + status dao.ProposalStatus + }{ + { + "execution was successful", + dao.ExecutionSuccessful, + }, + { + "execution failed", + dao.ExecutionFailed, + }, + } + + for _, testCase := range testTable { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + var ( + voter = testutils.TestAddress("voter") + + ms = &mockMemberStore{ + isMemberFn: func(_ std.Address) bool { + return true + }, + } + s = New(ms) + + prop = &proposal{ + status: testCase.status, + } + ) + + std.TestSetOrigCaller(voter) + + // Add an initial proposal + id, err := s.addProposal(prop) + urequire.NoError(t, err) + + // Attempt to vote on the proposal + uassert.ErrorIs( + t, + s.ExecuteProposal(id), + ErrProposalExecuted, + ) + }) + } + }) + + t.Run("execution error", func(t *testing.T) { + t.Parallel() + + var ( + voter = testutils.TestAddress("voter") + + ms = &mockMemberStore{ + isMemberFn: func(_ std.Address) bool { + return true + }, + } + + s = New(ms) + + execError = errors.New("exec error") + + mockExecutor = &mockExecutor{ + executeFn: func() error { + return execError + }, + } + + prop = &proposal{ + status: dao.Accepted, + executor: mockExecutor, + } + ) + + std.TestSetOrigCaller(voter) + + // Add an initial proposal + id, err := s.addProposal(prop) + urequire.NoError(t, err) + + // Attempt to vote on the proposal + uassert.ErrorContains( + t, + s.ExecuteProposal(id), + execError.Error(), + ) + + uassert.Equal(t, dao.ExecutionFailed.String(), prop.status.String()) + }) + + t.Run("successful execution", func(t *testing.T) { + t.Parallel() + + var ( + voter = testutils.TestAddress("voter") + + ms = &mockMemberStore{ + isMemberFn: func(_ std.Address) bool { + return true + }, + } + s = New(ms) + + called = false + mockExecutor = &mockExecutor{ + executeFn: func() error { + called = true + + return nil + }, + } + + prop = &proposal{ + status: dao.Accepted, + executor: mockExecutor, + } + ) + + std.TestSetOrigCaller(voter) + + // Add an initial proposal + id, err := s.addProposal(prop) + urequire.NoError(t, err) + + // Attempt to vote on the proposal + uassert.NoError(t, s.ExecuteProposal(id)) + uassert.Equal(t, dao.ExecutionSuccessful.String(), prop.status.String()) + uassert.True(t, called) + }) +} diff --git a/examples/gno.land/p/demo/simpledao/gno.mod b/examples/gno.land/p/demo/simpledao/gno.mod new file mode 100644 index 00000000000..f6f14f379ec --- /dev/null +++ b/examples/gno.land/p/demo/simpledao/gno.mod @@ -0,0 +1,12 @@ +module gno.land/p/demo/simpledao + +require ( + gno.land/p/demo/avl v0.0.0-latest + gno.land/p/demo/dao v0.0.0-latest + gno.land/p/demo/membstore v0.0.0-latest + gno.land/p/demo/seqid v0.0.0-latest + gno.land/p/demo/testutils v0.0.0-latest + gno.land/p/demo/uassert v0.0.0-latest + gno.land/p/demo/ufmt v0.0.0-latest + gno.land/p/demo/urequire v0.0.0-latest +) diff --git a/examples/gno.land/p/demo/simpledao/mock_test.gno b/examples/gno.land/p/demo/simpledao/mock_test.gno new file mode 100644 index 00000000000..0cf12ccff01 --- /dev/null +++ b/examples/gno.land/p/demo/simpledao/mock_test.gno @@ -0,0 +1,97 @@ +package simpledao + +import ( + "std" + + "gno.land/p/demo/membstore" +) + +type executeDelegate func() error + +type mockExecutor struct { + executeFn executeDelegate +} + +func (m *mockExecutor) Execute() error { + if m.executeFn != nil { + return m.executeFn() + } + + return nil +} + +type ( + membersDelegate func(uint64, uint64) []membstore.Member + sizeDelegate func() int + isMemberDelegate func(std.Address) bool + totalPowerDelegate func() uint64 + memberDelegate func(std.Address) (membstore.Member, error) + addMemberDelegate func(membstore.Member) error + updateMemberDelegate func(std.Address, membstore.Member) error +) + +type mockMemberStore struct { + membersFn membersDelegate + sizeFn sizeDelegate + isMemberFn isMemberDelegate + totalPowerFn totalPowerDelegate + memberFn memberDelegate + addMemberFn addMemberDelegate + updateMemberFn updateMemberDelegate +} + +func (m *mockMemberStore) Members(offset, count uint64) []membstore.Member { + if m.membersFn != nil { + return m.membersFn(offset, count) + } + + return nil +} + +func (m *mockMemberStore) Size() int { + if m.sizeFn != nil { + return m.sizeFn() + } + + return 0 +} + +func (m *mockMemberStore) IsMember(address std.Address) bool { + if m.isMemberFn != nil { + return m.isMemberFn(address) + } + + return false +} + +func (m *mockMemberStore) TotalPower() uint64 { + if m.totalPowerFn != nil { + return m.totalPowerFn() + } + + return 0 +} + +func (m *mockMemberStore) Member(address std.Address) (membstore.Member, error) { + if m.memberFn != nil { + return m.memberFn(address) + } + + return membstore.Member{}, nil +} + +func (m *mockMemberStore) AddMember(member membstore.Member) error { + if m.addMemberFn != nil { + return m.addMemberFn(member) + } + + return nil +} + +func (m *mockMemberStore) UpdateMember(address std.Address, member membstore.Member) error { + if m.updateMemberFn != nil { + return m.updateMemberFn(address, member) + } + + return nil +} diff --git a/examples/gno.land/p/demo/simpledao/propstore.gno b/examples/gno.land/p/demo/simpledao/propstore.gno new file mode 100644 index 00000000000..972297ff0ce --- /dev/null +++ b/examples/gno.land/p/demo/simpledao/propstore.gno @@ -0,0 +1,163 @@ +package simpledao + +import ( + "errors" + "std" + + "gno.land/p/demo/dao" + "gno.land/p/demo/seqid" + "gno.land/p/demo/ufmt" +) + +var ErrMissingProposal = errors.New("proposal is missing") + +// maxRequestProposals is the maximum number of +// paginated proposals that can be requested +const maxRequestProposals = 10 + +// proposal is the internal simpledao proposal implementation +type proposal struct { + author std.Address // initiator of the proposal + description string // description of the proposal + + executor dao.Executor // executor for the proposal + status dao.ProposalStatus // status of the proposal + + tally *tally // voting tally + getTotalVotingPowerFn func() uint64 // callback for the total voting power +} + +func (p *proposal) Author() std.Address { + return p.author +} + +func (p *proposal) Description() string { + return p.description +} + +func (p *proposal) Status() dao.ProposalStatus { + return p.status +} + +func (p *proposal) Executor() dao.Executor { + return p.executor +} + +func (p *proposal) Stats() dao.Stats { + // Get the total voting power of the body + totalPower := p.getTotalVotingPowerFn() + + return dao.Stats{ + YayVotes: p.tally.yays, + NayVotes: p.tally.nays, + AbstainVotes: p.tally.abstains, + TotalVotingPower: totalPower, + } +} + +func (p *proposal) IsExpired() bool { + return false // this proposal never expires +} + +func (p *proposal) Render() string { + // Fetch the voting stats + stats := p.Stats() + + output := "" + output += ufmt.Sprintf("Author: %s", p.Author().String()) + output += "\n\n" + output += p.Description() + output += "\n\n" + output += ufmt.Sprintf("Status: %s", p.Status().String()) + output += "\n\n" + output += ufmt.Sprintf( + "Voting stats: YAY %d (%d%%), NAY %d (%d%%), ABSTAIN %d (%d%%), HAVEN'T VOTED %d (%d%%)", + stats.YayVotes, + stats.YayPercent(), + stats.NayVotes, + stats.NayPercent(), + stats.AbstainVotes, + stats.AbstainPercent(), + stats.MissingVotes(), + stats.MissingVotesPercent(), + ) + output += "\n\n" + output += ufmt.Sprintf("Threshold met: %t", stats.YayVotes > (2*stats.TotalVotingPower)/3) + + return output +} + +// addProposal adds a new simpledao proposal to the store +func (s *SimpleDAO) addProposal(proposal *proposal) (uint64, error) { + // See what the next proposal number should be + nextID := uint64(s.proposals.Size()) + + // Save the proposal + s.proposals.Set(getProposalID(nextID), proposal) + + return nextID, nil +} + +func (s *SimpleDAO) Proposals(offset, count uint64) []dao.Proposal { + // Check the requested count + if count < 1 { + return []dao.Proposal{} + } + + // Limit the maximum number of returned proposals + if count > maxRequestProposals { + count = maxRequestProposals + } + + var ( + startIndex = offset + endIndex = startIndex + count + + numProposals = uint64(s.proposals.Size()) + ) + + // Check if the current offset has any proposals + if startIndex >= numProposals { + return []dao.Proposal{} + } + + // Check if the right bound is good + if endIndex > numProposals { + endIndex = numProposals + } + + props := make([]dao.Proposal, 0) + s.proposals.Iterate( + getProposalID(startIndex), + getProposalID(endIndex), + func(_ string, val interface{}) bool { + prop := val.(*proposal) + + // Save the proposal + props = append(props, prop) + + return false + }, + ) + + return props +} + +func (s *SimpleDAO) ProposalByID(id uint64) (dao.Proposal, error) { + prop, exists := s.proposals.Get(getProposalID(id)) + if !exists { + return nil, ErrMissingProposal + } + + return prop.(*proposal), nil +} + +func (s *SimpleDAO) Size() int { + return s.proposals.Size() +} + +// getProposalID generates a sequential proposal ID +// from the given ID number +func getProposalID(id uint64) string { + return seqid.ID(id).String() +} diff --git a/examples/gno.land/p/demo/simpledao/propstore_test.gno b/examples/gno.land/p/demo/simpledao/propstore_test.gno new file mode 100644 index 00000000000..5aa6ba91a1e --- /dev/null +++ b/examples/gno.land/p/demo/simpledao/propstore_test.gno @@ -0,0 +1,256 @@ +package simpledao + +import ( + "testing" + + "gno.land/p/demo/dao" + "gno.land/p/demo/testutils" + "gno.land/p/demo/uassert" + "gno.land/p/demo/ufmt" + "gno.land/p/demo/urequire" +) + +// generateProposals generates dummy proposals +func generateProposals(t *testing.T, count int) []*proposal { + t.Helper() + + var ( + members = generateMembers(t, count) + proposals = make([]*proposal, 0, count) + ) + + for i := 0; i < count; i++ { + proposal := &proposal{ + author: members[i].Address, + description: ufmt.Sprintf("proposal %d", i), + status: dao.Active, + tally: newTally(), + getTotalVotingPowerFn: func() uint64 { + return 0 + }, + executor: nil, + } + + proposals = append(proposals, proposal) + } + + return proposals +} + +func equalProposals(t *testing.T, p1, p2 dao.Proposal) { + t.Helper() + + uassert.Equal( + t, + p1.Author().String(), + p2.Author().String(), + ) + + uassert.Equal( + t, + p1.Description(), + p2.Description(), + ) + + uassert.Equal( + t, + p1.Status().String(), + p2.Status().String(), + ) + + p1Stats := p1.Stats() + p2Stats := p2.Stats() + + uassert.Equal(t, p1Stats.YayVotes, p2Stats.YayVotes) + uassert.Equal(t, p1Stats.NayVotes, p2Stats.NayVotes) + uassert.Equal(t, p1Stats.AbstainVotes, p2Stats.AbstainVotes) + uassert.Equal(t, p1Stats.TotalVotingPower, p2Stats.TotalVotingPower) +} + +func TestProposal_Data(t *testing.T) { + t.Parallel() + + t.Run("author", func(t *testing.T) { + t.Parallel() + + p := &proposal{ + author: testutils.TestAddress("address"), + } + + uassert.Equal(t, p.author, p.Author()) + }) + + t.Run("description", func(t *testing.T) { + t.Parallel() + + p := &proposal{ + description: "example proposal description", + } + + uassert.Equal(t, p.description, p.Description()) + }) + + t.Run("status", func(t *testing.T) { + t.Parallel() + + p := &proposal{ + status: dao.ExecutionSuccessful, + } + + uassert.Equal(t, p.status.String(), p.Status().String()) + }) + + t.Run("executor", func(t *testing.T) { + t.Parallel() + + var ( + numCalled = 0 + cb = func() error { + numCalled++ + + return nil + } + + ex = &mockExecutor{ + executeFn: cb, + } + + p = &proposal{ + executor: ex, + } + ) + + urequire.NoError(t, p.executor.Execute()) + urequire.NoError(t, p.Executor().Execute()) + + uassert.Equal(t, 2, numCalled) + }) + + t.Run("no votes", func(t *testing.T) { + t.Parallel() + + p := &proposal{ + tally: newTally(), + getTotalVotingPowerFn: func() uint64 { + return 0 + }, + } + + stats := p.Stats() + + uassert.Equal(t, uint64(0), stats.YayVotes) + uassert.Equal(t, uint64(0), stats.NayVotes) + uassert.Equal(t, uint64(0), stats.AbstainVotes) + uassert.Equal(t, uint64(0), stats.TotalVotingPower) + }) + + t.Run("existing votes", func(t *testing.T) { + t.Parallel() + + var ( + members = generateMembers(t, 50) + totalPower = uint64(len(members)) * 10 + + p = &proposal{ + tally: newTally(), + getTotalVotingPowerFn: func() uint64 { + return totalPower + }, + } + ) + + for _, m := range members { + urequire.NoError(t, p.tally.castVote(m, dao.YesVote)) + } + + stats := p.Stats() + + uassert.Equal(t, totalPower, stats.YayVotes) + uassert.Equal(t, uint64(0), stats.NayVotes) + uassert.Equal(t, uint64(0), stats.AbstainVotes) + uassert.Equal(t, totalPower, stats.TotalVotingPower) + }) +} + +func TestSimpleDAO_GetProposals(t *testing.T) { + t.Parallel() + + t.Run("no proposals", func(t *testing.T) { + t.Parallel() + + s := New(nil) + + uassert.Equal(t, 0, s.Size()) + proposals := s.Proposals(0, 0) + + uassert.Equal(t, 0, len(proposals)) + }) + + t.Run("proper pagination", func(t *testing.T) { + t.Parallel() + + var ( + numProposals = 20 + halfRange = numProposals / 2 + + s = New(nil) + proposals = generateProposals(t, numProposals) + ) + + // Add initial proposals + for _, proposal := range proposals { + _, err := s.addProposal(proposal) + + urequire.NoError(t, err) + } + + uassert.Equal(t, numProposals, s.Size()) + + fetchedProposals := s.Proposals(0, uint64(halfRange)) + urequire.Equal(t, halfRange, len(fetchedProposals)) + + for index, fetchedProposal := range fetchedProposals { + equalProposals(t, proposals[index], fetchedProposal) + } + + // Fetch the other half + fetchedProposals = s.Proposals(uint64(halfRange), uint64(halfRange)) + urequire.Equal(t, halfRange, len(fetchedProposals)) + + for index, fetchedProposal := range fetchedProposals { + equalProposals(t, proposals[index+halfRange], fetchedProposal) + } + }) +} + +func TestSimpleDAO_GetProposalByID(t *testing.T) { + t.Parallel() + + t.Run("missing proposal", func(t *testing.T) { + t.Parallel() + + s := New(nil) + + _, err := s.ProposalByID(0) + uassert.ErrorIs(t, err, ErrMissingProposal) + }) + + t.Run("proposal found", func(t *testing.T) { + t.Parallel() + + var ( + s = New(nil) + proposal = generateProposals(t, 1)[0] + ) + + // Add the initial proposal + _, err := s.addProposal(proposal) + urequire.NoError(t, err) + + // Fetch the proposal + fetchedProposal, err := s.ProposalByID(0) + urequire.NoError(t, err) + + equalProposals(t, proposal, fetchedProposal) + }) +} diff --git a/examples/gno.land/p/demo/simpledao/votestore.gno b/examples/gno.land/p/demo/simpledao/votestore.gno new file mode 100644 index 00000000000..35a6564a1e3 --- /dev/null +++ b/examples/gno.land/p/demo/simpledao/votestore.gno @@ -0,0 +1,55 @@ +package simpledao + +import ( + "errors" + + "gno.land/p/demo/avl" + "gno.land/p/demo/dao" + "gno.land/p/demo/membstore" +) + +var ErrAlreadyVoted = errors.New("vote already cast") + +// tally is a simple vote tally system +type tally struct { + // tally cache to keep track of active + // yes / no / abstain votes + yays uint64 + nays uint64 + abstains uint64 + + voters *avl.Tree // std.Address -> dao.VoteOption +} + +// newTally creates a new tally system instance +func newTally() *tally { + return &tally{ + voters: avl.NewTree(), + } +} + +// castVote casts a single vote in the name of the given member +func (t *tally) castVote(member membstore.Member, option dao.VoteOption) error { + // Check if the member voted already + address := member.Address.String() + + _, voted := t.voters.Get(address) + if voted { + return ErrAlreadyVoted + } + + // Update the tally + switch option { + case dao.YesVote: + t.yays += member.VotingPower + case dao.AbstainVote: + t.abstains += member.VotingPower + default: + t.nays += member.VotingPower + } + + // Save the voting status + t.voters.Set(address, option) + + return nil +} diff --git a/examples/gno.land/p/gov/executor/callback.gno b/examples/gno.land/p/gov/executor/callback.gno new file mode 100644 index 00000000000..5d46a97cd69 --- /dev/null +++ b/examples/gno.land/p/gov/executor/callback.gno @@ -0,0 +1,39 @@ +package executor + +import ( + "errors" + "std" +) + +var errInvalidCaller = errors.New("invalid executor caller") + +// NewCallbackExecutor creates a new callback executor with the provided callback function +func NewCallbackExecutor(callback func() error, path string) *CallbackExecutor { + return &CallbackExecutor{ + callback: callback, + daoPkgPath: path, + } +} + +// CallbackExecutor is an implementation of the dao.Executor interface, +// based on a specific callback. +// The given callback should verify the validity of the govdao call +type CallbackExecutor struct { + callback func() error // the callback to be executed + daoPkgPath string // the active pkg path of the govdao +} + +// Execute runs the executor's callback function. +func (exec *CallbackExecutor) Execute() error { + // Verify the caller is an adequate Realm + caller := std.CurrentRealm().PkgPath() + if caller != exec.daoPkgPath { + return errInvalidCaller + } + + if exec.callback != nil { + return exec.callback() + } + + return nil +} diff --git a/examples/gno.land/p/gov/executor/context.gno b/examples/gno.land/p/gov/executor/context.gno new file mode 100644 index 00000000000..158e3b1e0be --- /dev/null +++ b/examples/gno.land/p/gov/executor/context.gno @@ -0,0 +1,75 @@ +package executor + +import ( + "errors" + "std" + + "gno.land/p/demo/context" +) + +type propContextKey string + +func (k propContextKey) String() string { return string(k) } + +const ( + statusContextKey = propContextKey("govdao-prop-status") + approvedStatus = "approved" +) + +var errNotApproved = errors.New("not approved by govdao") + +// CtxExecutor is an implementation of the dao.Executor interface, +// based on the given context. +// It utilizes the given context to assert the validity of the govdao call +type CtxExecutor struct { + callbackCtx func(ctx context.Context) error // the callback ctx fn, if any + daoPkgPath string // the active pkg path of the govdao +} + +// NewCtxExecutor creates a new executor with the provided callback function. +func NewCtxExecutor(callback func(ctx context.Context) error, path string) *CtxExecutor { + return &CtxExecutor{ + callbackCtx: callback, + daoPkgPath: path, + } +} + +// Execute runs the executor's callback function +func (exec *CtxExecutor) Execute() error { + // Verify the caller is an adequate Realm + caller := std.CurrentRealm().PkgPath() + if caller != exec.daoPkgPath { + return errInvalidCaller + } + + // Create the context + ctx := context.WithValue( + context.Empty(), + statusContextKey, + approvedStatus, + ) + + return exec.callbackCtx(ctx) +} + +// IsApprovedByGovdaoContext asserts that the govdao approved the context +func IsApprovedByGovdaoContext(ctx context.Context) bool { + v := ctx.Value(statusContextKey) + if v == nil { + return false + } + + vs, ok := v.(string) + + return ok && vs == approvedStatus +} + +// AssertContextApprovedByGovDAO asserts the given context +// was approved by GOVDAO +func AssertContextApprovedByGovDAO(ctx context.Context) { + if IsApprovedByGovdaoContext(ctx) { + return + } + + panic(errNotApproved) +} diff --git a/examples/gno.land/p/gov/proposal/gno.mod b/examples/gno.land/p/gov/executor/gno.mod similarity index 80% rename from examples/gno.land/p/gov/proposal/gno.mod rename to examples/gno.land/p/gov/executor/gno.mod index 3f6ef34a759..99f2ab3610b 100644 --- a/examples/gno.land/p/gov/proposal/gno.mod +++ b/examples/gno.land/p/gov/executor/gno.mod @@ -1,4 +1,4 @@ -module gno.land/p/gov/proposal +module gno.land/p/gov/executor require ( gno.land/p/demo/context v0.0.0-latest diff --git a/examples/gno.land/p/gov/executor/proposal_test.gno b/examples/gno.land/p/gov/executor/proposal_test.gno new file mode 100644 index 00000000000..3a70fc40596 --- /dev/null +++ b/examples/gno.land/p/gov/executor/proposal_test.gno @@ -0,0 +1,180 @@ +package executor + +import ( + "errors" + "std" + "testing" + + "gno.land/p/demo/context" + "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" +) + +func TestExecutor_Callback(t *testing.T) { + t.Parallel() + + t.Run("govdao not caller", func(t *testing.T) { + t.Parallel() + + var ( + called = false + + cb = func() error { + called = true + + return nil + } + ) + + // Create the executor + e := NewCallbackExecutor(cb, "gno.land/r/gov/dao") + + // Execute as not the /r/gov/dao caller + uassert.ErrorIs(t, e.Execute(), errInvalidCaller) + uassert.False(t, called, "expected proposal to not execute") + }) + + t.Run("execution successful", func(t *testing.T) { + t.Parallel() + + var ( + called = false + + cb = func() error { + called = true + + return nil + } + ) + + // Create the executor + daoPkgPath := "gno.land/r/gov/dao" + e := NewCallbackExecutor(cb, daoPkgPath) + + // Execute as the /r/gov/dao caller + r := std.NewCodeRealm(daoPkgPath) + std.TestSetRealm(r) + + uassert.NoError(t, e.Execute()) + uassert.True(t, called, "expected proposal to execute") + }) + + t.Run("execution unsuccessful", func(t *testing.T) { + t.Parallel() + + var ( + called = false + expectedErr = errors.New("unexpected") + + cb = func() error { + called = true + + return expectedErr + } + ) + + // Create the executor + daoPkgPath := "gno.land/r/gov/dao" + e := NewCallbackExecutor(cb, daoPkgPath) + + // Execute as the /r/gov/dao caller + r := std.NewCodeRealm(daoPkgPath) + std.TestSetRealm(r) + + uassert.ErrorIs(t, e.Execute(), expectedErr) + uassert.True(t, called, "expected proposal to execute") + }) +} + +func TestExecutor_Context(t *testing.T) { + t.Parallel() + + t.Run("govdao not caller", func(t *testing.T) { + t.Parallel() + + var ( + called = false + + cb = func(ctx context.Context) error { + if !IsApprovedByGovdaoContext(ctx) { + t.Fatal("not govdao caller") + } + + called = true + + return nil + } + ) + + // Create the executor + e := NewCtxExecutor(cb, "gno.land/r/gov/dao") + + // Execute as not the /r/gov/dao caller + uassert.ErrorIs(t, e.Execute(), errInvalidCaller) + uassert.False(t, called, "expected proposal to not execute") + }) + + t.Run("execution successful", func(t *testing.T) { + t.Parallel() + + var ( + called = false + + cb = func(ctx context.Context) error { + if !IsApprovedByGovdaoContext(ctx) { + t.Fatal("not govdao caller") + } + + called = true + + return nil + } + ) + + // Create the executor + daoPkgPath := "gno.land/r/gov/dao" + e := NewCtxExecutor(cb, daoPkgPath) + + // Execute as the /r/gov/dao caller + r := std.NewCodeRealm(daoPkgPath) + std.TestSetRealm(r) + + urequire.NoError(t, e.Execute()) + uassert.True(t, called, "expected proposal to execute") + }) + + t.Run("execution unsuccessful", func(t *testing.T) { + t.Parallel() + + var ( + called = false + expectedErr = errors.New("unexpected") + + cb = func(ctx context.Context) error { + if !IsApprovedByGovdaoContext(ctx) { + t.Fatal("not govdao caller") + } + + called = true + + return expectedErr + } + ) + + // Create the executor + daoPkgPath := "gno.land/r/gov/dao" + e := NewCtxExecutor(cb, daoPkgPath) + + // Execute as the /r/gov/dao caller + r := std.NewCodeRealm(daoPkgPath) + std.TestSetRealm(r) + + uassert.NotPanics(t, func() { + err := e.Execute() + + uassert.ErrorIs(t, err, expectedErr) + }) + + uassert.True(t, called, "expected proposal to execute") + }) +} diff --git a/examples/gno.land/p/gov/proposal/proposal.gno b/examples/gno.land/p/gov/proposal/proposal.gno deleted file mode 100644 index ca1767228c9..00000000000 --- a/examples/gno.land/p/gov/proposal/proposal.gno +++ /dev/null @@ -1,106 +0,0 @@ -// Package proposal provides a structure for executing proposals. -package proposal - -import ( - "errors" - "std" - - "gno.land/p/demo/context" -) - -var errNotGovDAO = errors.New("only r/gov/dao can be the caller") - -// NewExecutor creates a new executor with the provided callback function. -func NewExecutor(callback func() error) Executor { - return &executorImpl{ - callback: callback, - done: false, - } -} - -// NewCtxExecutor creates a new executor with the provided callback function. -func NewCtxExecutor(callback func(ctx context.Context) error) Executor { - return &executorImpl{ - callbackCtx: callback, - done: false, - } -} - -// executorImpl is an implementation of the Executor interface. -type executorImpl struct { - callback func() error - callbackCtx func(ctx context.Context) error - done bool - success bool -} - -// Execute runs the executor's callback function. -func (exec *executorImpl) Execute() error { - if exec.done { - return ErrAlreadyDone - } - - // Verify the executor is r/gov/dao - assertCalledByGovdao() - - var err error - if exec.callback != nil { - err = exec.callback() - } else if exec.callbackCtx != nil { - ctx := context.WithValue(context.Empty(), statusContextKey, approvedStatus) - err = exec.callbackCtx(ctx) - } - exec.done = true - exec.success = err == nil - - return err -} - -// IsDone returns whether the executor has been executed. -func (exec *executorImpl) IsDone() bool { - return exec.done -} - -// IsSuccessful returns whether the execution was successful. -func (exec *executorImpl) IsSuccessful() bool { - return exec.success -} - -// IsExpired returns whether the execution had expired or not. -// This implementation never expires. -func (exec *executorImpl) IsExpired() bool { - return false -} - -func IsApprovedByGovdaoContext(ctx context.Context) bool { - v := ctx.Value(statusContextKey) - if v == nil { - return false - } - vs, ok := v.(string) - return ok && vs == approvedStatus -} - -func AssertContextApprovedByGovDAO(ctx context.Context) { - if !IsApprovedByGovdaoContext(ctx) { - panic("not approved by govdao") - } -} - -// assertCalledByGovdao asserts that the calling Realm is /r/gov/dao -func assertCalledByGovdao() { - caller := std.CurrentRealm().PkgPath() - - if caller != daoPkgPath { - panic(errNotGovDAO) - } -} - -type propContextKey string - -func (k propContextKey) String() string { return string(k) } - -const ( - statusContextKey = propContextKey("govdao-prop-status") - approvedStatus = "approved" -) diff --git a/examples/gno.land/p/gov/proposal/proposal_test.gno b/examples/gno.land/p/gov/proposal/proposal_test.gno deleted file mode 100644 index 536871e644d..00000000000 --- a/examples/gno.land/p/gov/proposal/proposal_test.gno +++ /dev/null @@ -1,156 +0,0 @@ -package proposal - -import ( - "errors" - "std" - "testing" - - "gno.land/p/demo/uassert" - "gno.land/p/demo/urequire" -) - -func TestExecutor(t *testing.T) { - t.Parallel() - - verifyProposalFailed := func(e Executor) { - uassert.True(t, e.IsDone(), "expected proposal to be done") - uassert.False(t, e.IsSuccessful(), "expected proposal to fail") - } - - verifyProposalSucceeded := func(e Executor) { - uassert.True(t, e.IsDone(), "expected proposal to be done") - uassert.True(t, e.IsSuccessful(), "expected proposal to be successful") - } - - t.Run("govdao not caller", func(t *testing.T) { - t.Parallel() - - var ( - called = false - - cb = func() error { - called = true - - return nil - } - ) - - // Create the executor - e := NewExecutor(cb) - - urequire.False(t, e.IsDone(), "expected status to be NotExecuted") - - // Execute as not the /r/gov/dao caller - uassert.PanicsWithMessage(t, errNotGovDAO.Error(), func() { - _ = e.Execute() - }) - - uassert.False(t, called, "expected proposal to not execute") - }) - - t.Run("execution successful", func(t *testing.T) { - t.Parallel() - - var ( - called = false - - cb = func() error { - called = true - - return nil - } - ) - - // Create the executor - e := NewExecutor(cb) - - urequire.False(t, e.IsDone(), "expected status to be NotExecuted") - - // Execute as the /r/gov/dao caller - r := std.NewCodeRealm(daoPkgPath) - std.TestSetRealm(r) - - uassert.NotPanics(t, func() { - err := e.Execute() - - uassert.NoError(t, err) - }) - - uassert.True(t, called, "expected proposal to execute") - - // Make sure the execution params are correct - verifyProposalSucceeded(e) - }) - - t.Run("execution unsuccessful", func(t *testing.T) { - t.Parallel() - - var ( - called = false - expectedErr = errors.New("unexpected") - - cb = func() error { - called = true - - return expectedErr - } - ) - - // Create the executor - e := NewExecutor(cb) - - // Execute as the /r/gov/dao caller - r := std.NewCodeRealm(daoPkgPath) - std.TestSetRealm(r) - - uassert.NotPanics(t, func() { - err := e.Execute() - - uassert.ErrorIs(t, err, expectedErr) - }) - - uassert.True(t, called, "expected proposal to execute") - - // Make sure the execution params are correct - verifyProposalFailed(e) - }) - - t.Run("proposal already executed", func(t *testing.T) { - t.Parallel() - - var ( - called = false - - cb = func() error { - called = true - - return nil - } - ) - - // Create the executor - e := NewExecutor(cb) - - urequire.False(t, e.IsDone(), "expected status to be NotExecuted") - - // Execute as the /r/gov/dao caller - r := std.NewCodeRealm(daoPkgPath) - std.TestSetRealm(r) - - uassert.NotPanics(t, func() { - uassert.NoError(t, e.Execute()) - }) - - uassert.True(t, called, "expected proposal to execute") - - // Make sure the execution params are correct - verifyProposalSucceeded(e) - - // Attempt to execute the proposal again - uassert.NotPanics(t, func() { - err := e.Execute() - - uassert.ErrorIs(t, err, ErrAlreadyDone) - }) - }) -} diff --git a/examples/gno.land/p/gov/proposal/types.gno b/examples/gno.land/p/gov/proposal/types.gno deleted file mode 100644 index 6cd2da9ccfe..00000000000 --- a/examples/gno.land/p/gov/proposal/types.gno +++ /dev/null @@ -1,37 +0,0 @@ -// Package proposal defines types for proposal execution. -package proposal - -import "errors" - -// Executor represents a minimal closure-oriented proposal design. -// It is intended to be used by a govdao governance proposal (v1, v2, etc). -type Executor interface { - // Execute executes the given proposal, and returns any error encountered - // during the execution - Execute() error - - // IsDone returns a flag indicating if the proposal was executed - IsDone() bool - - // IsSuccessful returns a flag indicating if the proposal was executed - // and is successful - IsSuccessful() bool // IsDone() && !err - - // IsExpired returns whether the execution had expired or not. - IsExpired() bool -} - -// ErrAlreadyDone is the error returned when trying to execute an already -// executed proposal. -var ErrAlreadyDone = errors.New("already executed") - -// Status enum. -type Status string - -const ( - NotExecuted Status = "not_executed" - Succeeded Status = "succeeded" - Failed Status = "failed" -) - -const daoPkgPath = "gno.land/r/gov/dao" // TODO: make sure this is configurable through r/sys/vars diff --git a/examples/gno.land/r/gnoland/blog/admin.gno b/examples/gno.land/r/gnoland/blog/admin.gno index 08b0911cf24..9c94a265fca 100644 --- a/examples/gno.land/r/gnoland/blog/admin.gno +++ b/examples/gno.land/r/gnoland/blog/admin.gno @@ -5,8 +5,8 @@ import ( "strings" "gno.land/p/demo/avl" - "gno.land/p/demo/context" - "gno.land/p/gov/proposal" + "gno.land/p/demo/dao" + "gno.land/r/gov/dao/bridge" ) var ( @@ -41,10 +41,14 @@ func AdminRemoveModerator(addr std.Address) { moderatorList.Set(addr.String(), false) // FIXME: delete instead? } -func DaoAddPost(ctx context.Context, slug, title, body, publicationDate, authors, tags string) { - proposal.AssertContextApprovedByGovDAO(ctx) - caller := std.DerivePkgAddr("gno.land/r/gov/dao") - addPost(caller, slug, title, body, publicationDate, authors, tags) +func NewPostExecutor(slug, title, body, publicationDate, authors, tags string) dao.Executor { + callback := func() error { + addPost(std.PrevRealm().Addr(), slug, title, body, publicationDate, authors, tags) + + return nil + } + + return bridge.GovDAO().NewGovDAOExecutor(callback) } func ModAddPost(slug, title, body, publicationDate, authors, tags string) { diff --git a/examples/gno.land/r/gnoland/blog/gno.mod b/examples/gno.land/r/gnoland/blog/gno.mod index 17c17e0cfa6..8a4c5851b4c 100644 --- a/examples/gno.land/r/gnoland/blog/gno.mod +++ b/examples/gno.land/r/gnoland/blog/gno.mod @@ -3,6 +3,6 @@ module gno.land/r/gnoland/blog require ( gno.land/p/demo/avl v0.0.0-latest gno.land/p/demo/blog v0.0.0-latest - gno.land/p/demo/context v0.0.0-latest - gno.land/p/gov/proposal v0.0.0-latest + gno.land/p/demo/dao v0.0.0-latest + gno.land/r/gov/dao/bridge v0.0.0-latest ) diff --git a/examples/gno.land/r/gnoland/home/home.gno b/examples/gno.land/r/gnoland/home/home.gno index 93f9a68f39a..57570902f5a 100644 --- a/examples/gno.land/r/gnoland/home/home.gno +++ b/examples/gno.land/r/gnoland/home/home.gno @@ -193,7 +193,7 @@ func packageStaffPicks() ui.Element { ui.BulletList{ ui.Link{URL: "r/sys/names"}, ui.Link{URL: "r/sys/rewards"}, - ui.Link{URL: "r/sys/validators"}, + ui.Link{URL: "/r/sys/validators/v2"}, }, }, { ui.H4("[r/demo](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/demo)"), diff --git a/examples/gno.land/r/gnoland/home/home_filetest.gno b/examples/gno.land/r/gnoland/home/home_filetest.gno index 2260dc3a409..89721fd8d08 100644 --- a/examples/gno.land/r/gnoland/home/home_filetest.gno +++ b/examples/gno.land/r/gnoland/home/home_filetest.gno @@ -117,7 +117,7 @@ func main() { // // - [r/sys/names](r/sys/names) // - [r/sys/rewards](r/sys/rewards) -// - [r/sys/validators](r/sys/validators) +// - [/r/sys/validators/v2](/r/sys/validators/v2) // // //