diff --git a/examples/gno.land/p/nt/commondao/member_group.gno b/examples/gno.land/p/nt/commondao/member_group.gno new file mode 100644 index 00000000000..c00ce99ef72 --- /dev/null +++ b/examples/gno.land/p/nt/commondao/member_group.gno @@ -0,0 +1,77 @@ +package commondao + +import ( + "errors" + "strings" +) + +// MemberGroup defines an interface for a group of members. +type MemberGroup interface { + // Name returns the name of the group. + Name() string + + // Members returns the members that belong to the group. + Members() MemberStorage + + // SetMeta sets any metadata relevant to the group. + // Metadata can be used to store data which is specific to the group. + // Usually can be used to store parameter values which would be useful + // during proposal voting or tallying to resolve things like voting + // weights or rights for example. + SetMeta(any) + + // GetMeta returns the group metadata. + GetMeta() any +} + +// NewMemberGroup creates a new group of members. +func NewMemberGroup(name string, members MemberStorage) (MemberGroup, error) { + if members == nil { + return nil, errors.New("member storage is required") + } + + name = strings.TrimSpace(name) + if name == "" { + return nil, errors.New("member group name is required") + } + + return &memberGroup{ + name: name, + members: members, + }, nil +} + +// MustNewMemberGroup creates a new group of members or panics on error. +func MustNewMemberGroup(name string, members MemberStorage) MemberGroup { + g, err := NewMemberGroup(name, members) + if err != nil { + panic(err) + } + return g +} + +type memberGroup struct { + name string + members MemberStorage + meta any +} + +// Name returns the name of the group. +func (g memberGroup) Name() string { + return g.name +} + +// Members returns the members that belong to the group. +func (g memberGroup) Members() MemberStorage { + return g.members +} + +// SetMeta sets any metadata relevant to the group. +func (g *memberGroup) SetMeta(meta any) { + g.meta = meta +} + +// GetMeta returns the group metadata. +func (g memberGroup) GetMeta() any { + return g.meta +} diff --git a/examples/gno.land/p/nt/commondao/member_group_test.gno b/examples/gno.land/p/nt/commondao/member_group_test.gno new file mode 100644 index 00000000000..c73263fdf71 --- /dev/null +++ b/examples/gno.land/p/nt/commondao/member_group_test.gno @@ -0,0 +1,37 @@ +package commondao + +import ( + "testing" + + "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" +) + +func TestMemberGroupNew(t *testing.T) { + g, err := NewMemberGroup("", nil) + urequire.ErrorContains(t, err, "member storage is required") + + storage := NewMemberStorage() + g, err = NewMemberGroup("", storage) + urequire.ErrorContains(t, err, "member group name is required") + + name := "Tier 1" + g, err = NewMemberGroup(name, storage) + urequire.NoError(t, err, "expect no error") + uassert.Equal(t, name, g.Name(), "expect group name to match") + uassert.NotNil(t, g.Members(), "expect members to be not nil") + uassert.Nil(t, g.GetMeta(), "expect default group meta to be nil") +} + +func TestMemberGroupMeta(t *testing.T) { + g, err := NewMemberGroup("Test", NewMemberStorage()) + urequire.NoError(t, err, "expect no error") + + g.SetMeta(42) + v := g.GetMeta() + urequire.NotEqual(t, nil, v, "expect metadata to be not nil") + + meta, ok := v.(int) + urequire.True(t, ok, "expect meta type to be int") + uassert.Equal(t, 42, meta, "expect metadata to match") +} diff --git a/examples/gno.land/p/nt/commondao/member_grouping.gno b/examples/gno.land/p/nt/commondao/member_grouping.gno new file mode 100644 index 00000000000..564eb8c5bd6 --- /dev/null +++ b/examples/gno.land/p/nt/commondao/member_grouping.gno @@ -0,0 +1,86 @@ +package commondao + +import ( + "errors" + + "gno.land/p/demo/avl" +) + +// MemberGrouping defines an interface for storing multiple member groups. +// Member grouping can be used by implementations that require grouping users +// by roles or by tiers for example. +type MemberGrouping interface { + // Size returns the number of groups that grouping contains. + Size() int + + // Has checks if a group exists. + Has(name string) bool + + // Add adds an new member group if it doesn't exists. + Add(name string) (MemberGroup, error) + + // Get returns a member group. + Get(name string) (_ MemberGroup, found bool) + + // Delete deletes a member group. + Delete(name string) error + + // IterateByOffset iterates all member groups. + IterateByOffset(offset, count int, fn func(MemberGroup) bool) +} + +// NewMemberGrouping creates a new members grouping. +func NewMemberGrouping() MemberGrouping { + return &memberGrouping{} +} + +type memberGrouping struct { + groups avl.Tree // string(name) -> MemberGroup +} + +// Size returns the number of groups that grouping contains. +func (g memberGrouping) Size() int { + return g.groups.Size() +} + +// Has checks if a group exists. +func (g memberGrouping) Has(name string) bool { + return g.groups.Has(name) +} + +// Add adds an new member group if it doesn't exists. +func (g *memberGrouping) Add(name string) (MemberGroup, error) { + if g.groups.Has(name) { + return nil, errors.New("member group already exists: " + name) + } + + mg, err := NewMemberGroup(name, NewMemberStorage()) + if err != nil { + return nil, err + } + + g.groups.Set(name, mg) + return mg, nil +} + +// Get returns a member group. +func (g memberGrouping) Get(name string) (_ MemberGroup, found bool) { + v, found := g.groups.Get(name) + if !found { + return nil, false + } + return v.(MemberGroup), true +} + +// Delete deletes a member group. +func (g *memberGrouping) Delete(name string) error { + g.groups.Remove(name) + return nil +} + +// IterateByOffset iterates all member groups. +func (g memberGrouping) IterateByOffset(offset, count int, fn func(MemberGroup) bool) { + g.groups.IterateByOffset(offset, count, func(_ string, v any) bool { + return fn(v.(MemberGroup)) + }) +} diff --git a/examples/gno.land/p/nt/commondao/member_grouping_test.gno b/examples/gno.land/p/nt/commondao/member_grouping_test.gno new file mode 100644 index 00000000000..51a114672a3 --- /dev/null +++ b/examples/gno.land/p/nt/commondao/member_grouping_test.gno @@ -0,0 +1,93 @@ +package commondao + +import ( + "testing" + + "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" +) + +func TestMemberGroupingAdd(t *testing.T) { + t.Run("defauls", func(t *testing.T) { + name := "Foo" + g := NewMemberGrouping() + + uassert.False(t, g.Has(name), "expect grouping group not to be found") + uassert.Equal(t, 0, g.Size(), "expect grouping to be empty") + }) + + t.Run("success", func(t *testing.T) { + name := "Foo" + g := NewMemberGrouping() + mg, err := g.Add(name) + + urequire.NoError(t, err, "expect no error") + uassert.True(t, g.Has(name), "expect grouping group to be found") + uassert.Equal(t, 1, g.Size(), "expect grouping to have a single group") + + urequire.True(t, mg != nil, "expected grouping group to be not nil") + uassert.Equal(t, name, mg.Name(), "expect group to have the right name") + }) + + t.Run("duplicated name", func(t *testing.T) { + name := "Foo" + g := NewMemberGrouping() + _, err := g.Add(name) + urequire.NoError(t, err, "expect no error") + + _, err = g.Add(name) + uassert.ErrorContains(t, err, "member group already exists: Foo", "expect duplication error") + }) +} + +func TestMemberGroupingGet(t *testing.T) { + t.Run("success", func(t *testing.T) { + name := "Foo" + g := NewMemberGrouping() + g.Add(name) + + mg, found := g.Get(name) + + urequire.True(t, found, "expect grouping group to be found") + urequire.True(t, mg != nil, "expect grouping group to be not nil") + uassert.Equal(t, name, mg.Name(), "expect group to have the right name") + }) + + t.Run("group not found", func(t *testing.T) { + g := NewMemberGrouping() + + _, found := g.Get("Foo") + + urequire.False(t, found, "expect grouping group to be not found") + }) +} + +func TestMemberGroupingDelete(t *testing.T) { + name := "Foo" + g := NewMemberGrouping() + g.Add(name) + + err := g.Delete(name) + + uassert.NoError(t, err, "expect no error") + urequire.False(t, g.Has(name), "expect grouping group not to be found") +} + +func TestMemberGroupingIterate(t *testing.T) { + groups := []string{"Tier 1", "Tier 2", "Tier 3"} + g := NewMemberGrouping() + for _, name := range groups { + g.Add(name) + } + + var i int + g.IterateByOffset(0, g.Size(), func(mg MemberGroup) bool { + urequire.True(t, mg != nil, "expect member group not to be nil") + urequire.Equal(t, groups[i], mg.Name(), "expect group to be iterated in order") + + i++ + return false + }) + + uassert.Equal(t, len(groups), i, "expect all groups to be iterated") +} diff --git a/examples/gno.land/p/nt/commondao/member_storage.gno b/examples/gno.land/p/nt/commondao/member_storage.gno index 66415af8187..88fd28071b4 100644 --- a/examples/gno.land/p/nt/commondao/member_storage.gno +++ b/examples/gno.land/p/nt/commondao/member_storage.gno @@ -6,8 +6,6 @@ import ( "gno.land/p/moul/addrset" ) -// TODO: Support member groups and metadata - // MemberStorage defines an interface for member storages. type MemberStorage interface { // Size returns the number of members in the storage. @@ -24,12 +22,38 @@ type MemberStorage interface { // Returns true if member was removed, or false if it was not found. Remove(std.Address) bool + // Grouping returns member groups when supported. + // When nil is returned it means that grouping of members is not supported. + // Member groups can be used by implementations that require grouping users + // by roles or by tiers for example. + Grouping() MemberGrouping + // IterateByOffset iterates members starting at the given offset. // The callback can return true to stop iteration. IterateByOffset(offset, count int, fn func(std.Address) bool) } // NewMemberStorage creates a new member storage. +// Function returns a new member storage that doesn't support member groups. +// This type of storage is useful when there is no need to group members. func NewMemberStorage() MemberStorage { - return &addrset.Set{} + return &memberStorage{} +} + +// NewMemberStorageWithGrouping a new member storage with support for member groups. +// Member groups can be used by implementations that require grouping users by roles +// or by tiers for example. +func NewMemberStorageWithGrouping() MemberStorage { + return &memberStorage{grouping: NewMemberGrouping()} +} + +type memberStorage struct { + addrset.Set + + grouping MemberGrouping +} + +// Grouping returns member groups. +func (s memberStorage) Grouping() MemberGrouping { + return s.grouping } diff --git a/examples/gno.land/p/nt/commondao/member_storage_test.gno b/examples/gno.land/p/nt/commondao/member_storage_test.gno new file mode 100644 index 00000000000..b350fa36818 --- /dev/null +++ b/examples/gno.land/p/nt/commondao/member_storage_test.gno @@ -0,0 +1,71 @@ +package commondao + +import ( + "std" + "testing" + + "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" +) + +func TestMemberStorageWithGrouping(t *testing.T) { + // Prepare + tiers := []struct { + Name string + Weight int + Members []std.Address + }{ + { + Name: "Tier 1", + Weight: 3, + Members: []std.Address{ + "g16jpf0puufcpcjkph5nxueec8etpcldz7zwgydq", + "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", + }, + }, + { + Name: "Tier 2", + Weight: 2, + Members: []std.Address{ + "g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj", + }, + }, + } + + storage := NewMemberStorageWithGrouping() + for _, tier := range tiers { + mg, err := storage.Grouping().Add(tier.Name) + urequire.NoError(t, err, "expect no error adding tier") + + mg.SetMeta(tier.Weight) + + for _, addr := range tier.Members { + ok := mg.Members().Add(addr) + urequire.True(t, ok, "expect member to be added") + } + } + + // Assert + for i := 0; i < len(tiers); i++ { + tier := tiers[i] + mg, found := storage.Grouping().Get(tier.Name) + urequire.True(t, found, "expect member group to be found") + + v := mg.GetMeta() + urequire.True(t, v != nil, "expect meta to be not nil") + + weight, ok := v.(int) + urequire.True(t, ok, "expect group metadata to be an integer") + uassert.Equal(t, tier.Weight, weight, "expect group weight to match") + + var i int + mg.Members().IterateByOffset(0, len(tier.Members), func(addr std.Address) bool { + uassert.Equal(t, tier.Members[i], addr, "expect tier member to match") + + i++ + return false + }) + + uassert.Equal(t, len(tier.Members), i, "expect all tier members to be iterated") + } +} diff --git a/examples/gno.land/p/nt/commondao/memberset_test.gno b/examples/gno.land/p/nt/commondao/memberset_test.gno index 69db9d47282..e3985b2bfbf 100644 --- a/examples/gno.land/p/nt/commondao/memberset_test.gno +++ b/examples/gno.land/p/nt/commondao/memberset_test.gno @@ -5,40 +5,39 @@ import ( "testing" "gno.land/p/demo/uassert" - "gno.land/p/moul/addrset" ) func TestMemberSetSize(t *testing.T) { - var set addrset.Set - members := NewMemberSet(&set) + storage := NewMemberStorage() + members := NewMemberSet(storage) uassert.Equal(t, 0, members.Size(), "expect size 0") - set.Add("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") - members = NewMemberSet(&set) + storage.Add("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + members = NewMemberSet(storage) uassert.Equal(t, 1, members.Size(), "expect size 1") - set.Add("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn") - members = NewMemberSet(&set) + storage.Add("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn") + members = NewMemberSet(storage) uassert.Equal(t, 2, members.Size(), "expect size 2") } func TestMemberSetHas(t *testing.T) { - var set addrset.Set - set.Add("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + storage := NewMemberStorage() + storage.Add("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") - members := NewMemberSet(&set) + members := NewMemberSet(storage) uassert.True(t, members.Has("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"), "expect member is found") uassert.False(t, members.Has("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn"), "expect member is not found") } func TestMemberSetIterateByOffset(t *testing.T) { - var set addrset.Set - set.Add("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") - set.Add("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn") - set.Add("g1w4ek2u3jta047h6lta047h6lta047h6l9huexc") + storage := NewMemberStorage() + storage.Add("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + storage.Add("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn") + storage.Add("g1w4ek2u3jta047h6lta047h6lta047h6l9huexc") - NewMemberSet(&set).IterateByOffset(1, 1, func(addr std.Address) bool { + NewMemberSet(storage).IterateByOffset(1, 1, func(addr std.Address) bool { uassert.Equal(t, "g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", addr.String(), "expect address to match") return true }) diff --git a/examples/gno.land/p/nt/commondao/proposal_test.gno b/examples/gno.land/p/nt/commondao/proposal_test.gno index b70631446e7..695f2a15cc9 100644 --- a/examples/gno.land/p/nt/commondao/proposal_test.gno +++ b/examples/gno.land/p/nt/commondao/proposal_test.gno @@ -7,7 +7,6 @@ import ( "gno.land/p/demo/uassert" "gno.land/p/demo/urequire" - "gno.land/p/moul/addrset" ) func TestProposalNew(t *testing.T) { @@ -271,7 +270,7 @@ func TestIsQuorumReached(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - var members addrset.Set + members := NewMemberStorage() for _, m := range tc.members { members.Add(m) } @@ -281,7 +280,7 @@ func TestIsQuorumReached(t *testing.T) { record.AddVote(v) } - success := IsQuorumReached(tc.quorum, record.Readonly(), NewMemberSet(&members)) + success := IsQuorumReached(tc.quorum, record.Readonly(), NewMemberSet(members)) if tc.fail { uassert.False(t, success, "expect quorum to fail")