Skip to content

Commit

Permalink
feat: adding home realm for r/n2p5/home and supporting packages (gnol…
Browse files Browse the repository at this point in the history
…ang#3183)

# TLDR

There's no place like [home](https://gno.land/r/n2p5/home). This PR is
for `gno` code in [/r/n2p5/home](https://gno.land/r/n2p5/home) and its
supporting packages and realms. This is a more extensive PR than I'd
hoped, but it is because I ended up structuring out two new little
packages [chonk]() and [group](), as well as a [config]() pattern that
works for my home realm, but is reusable as well.

If you like this, vote for me on [Leon's Hall of
Fame](https://gno.land/r/leon/hof)

# Summary of changes

## [/p/n2p5/chonk](https://gno.land/p/n2p5/chonk/)
`chonk` is a linked-list based string chunker. This allows for
arbitrarily large strings to be stored on-chain across multiple
transactions. The original idea for this was to be able to support large
`Render(path string) string` calls. You can think of this as supporting
a "static site generator" pattern, where Markdown can be broken up into
chunks and then rendered as one large payload. It is used directly in
`/r/n2p5/home`.

## [/p/n2p5/mgroup](https://gno.land/p/n2p5/mgroup/)
`mgroup` (Managed Group) is a bit like `authorizable,` but the goals are
a bit different. I wanted a "managed group" with a single `ownable`
owner, but I wanted an arbitrary list of "backup owners," which are
accounts that could "claim" ownership if the owner account became
unavailable. I also wanted to be able to manage the group members
themselves. Another difference here is has the ability to return
information about the group such as
- a list of all backup owners (there is a method for the complete list,
but also a method for an offset and max count iterator for large groups)
- a list of all members (there is a method for the complete list, but
also a method for an offset and max count iterator for large groups)

## [/r/n2p5/config](https://gno.land/r/n2p5/config)
Inspired by the config work done by @moul and @leohhhn for their home
realms, I decided to take a crack at it as well. This config allows me
to use `mgroup` to manage the config auth. This allows me to power
cross-realm auth using this config realm, and it powers the auth for my
home realm.

## [/r/n2p5/home](https://gno.land/r/n2p5/home)
Bringing this all together, the home realm uses `chonk` and
`/r/n2p5/config` to Render the Markdown stored in the chonk data store.
Because you might submit data over multiple transactions, I've added the
ability to "preview" and "promote" render data, you can see this in the
two variations on the URL:
- https://gno.land/r/n2p5/home
- https://gno.land/r/n2p5/home:preview


<details><summary>Contributors' checklist...</summary>

- [X] Added new tests, or not needed, or not feasible
- [X] Provided an example (e.g. screenshot) to aid review or the PR is
self-explanatory
- [X] Updated the official documentation or not needed
- [X] No breaking changes were made, or a `BREAKING CHANGE: xxx` message
was included in the description
- [X] Added references to related issues and PRs
- [X] Provided any useful hints for running manual tests
</details>
  • Loading branch information
n2p5 authored and r3v4s committed Dec 10, 2024
1 parent e1840e8 commit b7107a3
Show file tree
Hide file tree
Showing 10 changed files with 956 additions and 0 deletions.
84 changes: 84 additions & 0 deletions examples/gno.land/p/n2p5/chonk/chonk.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Package chonk provides a simple way to store arbitrarily large strings
// in a linked list across transactions for efficient storage and retrieval.
// A Chonk support three operations: Add, Flush, and Scanner.
// - Add appends a string to the Chonk.
// - Flush clears the Chonk.
// - Scanner is used to iterate over the chunks in the Chonk.
package chonk

// Chonk is a linked list string storage and
// retrieval system for fine bois.
type Chonk struct {
first *chunk
last *chunk
}

// chunk is a linked list node for Chonk
type chunk struct {
text string
next *chunk
}

// New creates a reference to a new Chonk
func New() *Chonk {
return &Chonk{}
}

// Add appends a string to the Chonk. If the Chonk is empty,
// the string will be the first and last chunk. Otherwise,
// the string will be appended to the end of the Chonk.
func (c *Chonk) Add(text string) {
next := &chunk{text: text}
if c.first == nil {
c.first = next
c.last = next
return
}
c.last.next = next
c.last = next
}

// Flush clears the Chonk by setting the first and last
// chunks to nil. This will allow the garbage collector to
// free the memory used by the Chonk.
func (c *Chonk) Flush() {
c.first = nil
c.last = nil
}

// Scanner returns a new Scanner for the Chonk. The Scanner
// is used to iterate over the chunks in the Chonk.
func (c *Chonk) Scanner() *Scanner {
return &Scanner{
next: c.first,
}
}

// Scanner is a simple string scanner for Chonk. It is used
// to iterate over the chunks in a Chonk from first to last.
type Scanner struct {
current *chunk
next *chunk
}

// Scan advances the scanner to the next chunk. It returns
// true if there is a next chunk, and false if there is not.
func (s *Scanner) Scan() bool {
if s.next != nil {
s.current = s.next
s.next = s.next.next
return true
}
return false
}

// Text returns the current chunk. It is only valid to call
// this method after a call to Scan returns true. Expected usage:
//
// scanner := chonk.Scanner()
// for scanner.Scan() {
// fmt.Println(scanner.Text())
// }
func (s *Scanner) Text() string {
return s.current.text
}
54 changes: 54 additions & 0 deletions examples/gno.land/p/n2p5/chonk/chonk_test.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package chonk

import (
"testing"
)

func TestChonk(t *testing.T) {
t.Parallel()
c := New()
testTable := []struct {
name string
chunks []string
}{
{
name: "empty",
chunks: []string{},
},
{
name: "single chunk",
chunks: []string{"a"},
},
{
name: "multiple chunks",
chunks: []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"},
},
{
name: "multiline chunks",
chunks: []string{"1a\nb\nc\n\n", "d\ne\nf", "g\nh\ni", "j\nk\nl\n\n\n\n"},
},
{
name: "empty",
chunks: []string{},
},
}
testChonk := func(t *testing.T, c *Chonk, chunks []string) {
for _, chunk := range chunks {
c.Add(chunk)
}
scanner := c.Scanner()
i := 0
for scanner.Scan() {
if scanner.Text() != chunks[i] {
t.Errorf("expected %s, got %s", chunks[i], scanner.Text())
}
i++
}
}
for _, test := range testTable {
t.Run(test.name, func(t *testing.T) {
testChonk(t, c, test.chunks)
c.Flush()
})
}
}
1 change: 1 addition & 0 deletions examples/gno.land/p/n2p5/chonk/gno.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module gno.land/p/n2p5/chonk
7 changes: 7 additions & 0 deletions examples/gno.land/p/n2p5/mgroup/gno.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module gno.land/p/n2p5/mgroup

require (
gno.land/p/demo/avl v0.0.0-latest
gno.land/p/demo/ownable v0.0.0-latest
gno.land/p/demo/testutils v0.0.0-latest
)
184 changes: 184 additions & 0 deletions examples/gno.land/p/n2p5/mgroup/mgroup.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// Package mgroup is a simple managed group managing ownership and membership
// for authorization in gno realms. The ManagedGroup struct is used to manage
// the owner, backup owners, and members of a group. The owner is the primary
// owner of the group and can add and remove backup owners and members. Backup
// owners can claim ownership of the group. This is meant to provide backup
// accounts for the owner in case the owner account is lost or compromised.
// Members are used to authorize actions across realms.
package mgroup

import (
"errors"
"std"

"gno.land/p/demo/avl"
"gno.land/p/demo/ownable"
)

var (
ErrCannotRemoveOwner = errors.New("mgroup: cannot remove owner")
ErrNotBackupOwner = errors.New("mgroup: not a backup owner")
ErrNotMember = errors.New("mgroup: not a member")
ErrInvalidAddress = errors.New("mgroup: address is invalid")
)

type ManagedGroup struct {
owner *ownable.Ownable
backupOwners *avl.Tree
members *avl.Tree
}

// New creates a new ManagedGroup with the owner set to the provided address.
// The owner is automatically added as a backup owner and member of the group.
func New(ownerAddress std.Address) *ManagedGroup {
g := &ManagedGroup{
owner: ownable.NewWithAddress(ownerAddress),
backupOwners: avl.NewTree(),
members: avl.NewTree(),
}
g.AddBackupOwner(ownerAddress)
g.AddMember(ownerAddress)
return g
}

// AddBackupOwner adds a backup owner to the group by std.Address.
// If the caller is not the owner, an error is returned.
func (g *ManagedGroup) AddBackupOwner(addr std.Address) error {
if err := g.owner.CallerIsOwner(); err != nil {
return err
}
if !addr.IsValid() {
return ErrInvalidAddress
}
g.backupOwners.Set(addr.String(), struct{}{})
return nil
}

// RemoveBackupOwner removes a backup owner from the group by std.Address.
// The owner cannot be removed. If the caller is not the owner, an error is returned.
func (g *ManagedGroup) RemoveBackupOwner(addr std.Address) error {
if err := g.owner.CallerIsOwner(); err != nil {
return err
}
if !addr.IsValid() {
return ErrInvalidAddress
}
if addr == g.Owner() {
return ErrCannotRemoveOwner
}
g.backupOwners.Remove(addr.String())
return nil
}

// ClaimOwnership allows a backup owner to claim ownership of the group.
// If the caller is not a backup owner, an error is returned.
// The caller is automatically added as a member of the group.
func (g *ManagedGroup) ClaimOwnership() error {
caller := std.PrevRealm().Addr()
// already owner, skip
if caller == g.Owner() {
return nil
}
if !g.IsBackupOwner(caller) {
return ErrNotMember
}
g.owner = ownable.NewWithAddress(caller)
g.AddMember(caller)
return nil
}

// AddMember adds a member to the group by std.Address.
// If the caller is not the owner, an error is returned.
func (g *ManagedGroup) AddMember(addr std.Address) error {
if err := g.owner.CallerIsOwner(); err != nil {
return err
}
if !addr.IsValid() {
return ErrInvalidAddress
}
g.members.Set(addr.String(), struct{}{})
return nil
}

// RemoveMember removes a member from the group by std.Address.
// The owner cannot be removed. If the caller is not the owner,
// an error is returned.
func (g *ManagedGroup) RemoveMember(addr std.Address) error {
if err := g.owner.CallerIsOwner(); err != nil {
return err
}
if !addr.IsValid() {
return ErrInvalidAddress
}
if addr == g.Owner() {
return ErrCannotRemoveOwner
}
g.members.Remove(addr.String())
return nil
}

// MemberCount returns the number of members in the group.
func (g *ManagedGroup) MemberCount() int {
return g.members.Size()
}

// BackupOwnerCount returns the number of backup owners in the group.
func (g *ManagedGroup) BackupOwnerCount() int {
return g.backupOwners.Size()
}

// IsMember checks if an address is a member of the group.
func (g *ManagedGroup) IsMember(addr std.Address) bool {
return g.members.Has(addr.String())
}

// IsBackupOwner checks if an address is a backup owner in the group.
func (g *ManagedGroup) IsBackupOwner(addr std.Address) bool {
return g.backupOwners.Has(addr.String())
}

// Owner returns the owner of the group.
func (g *ManagedGroup) Owner() std.Address {
return g.owner.Owner()
}

// BackupOwners returns a slice of all backup owners in the group, using the underlying
// avl.Tree to iterate over the backup owners. If you have a large group, you may
// want to use BackupOwnersWithOffset to iterate over backup owners in chunks.
func (g *ManagedGroup) BackupOwners() []string {
return g.BackupOwnersWithOffset(0, g.BackupOwnerCount())
}

// Members returns a slice of all members in the group, using the underlying
// avl.Tree to iterate over the members. If you have a large group, you may
// want to use MembersWithOffset to iterate over members in chunks.
func (g *ManagedGroup) Members() []string {
return g.MembersWithOffset(0, g.MemberCount())
}

// BackupOwnersWithOffset returns a slice of backup owners in the group, using the underlying
// avl.Tree to iterate over the backup owners. The offset and count parameters allow you
// to iterate over backup owners in chunks to support patterns such as pagination.
func (g *ManagedGroup) BackupOwnersWithOffset(offset, count int) []string {
return sliceWithOffset(g.backupOwners, offset, count)
}

// MembersWithOffset returns a slice of members in the group, using the underlying
// avl.Tree to iterate over the members. The offset and count parameters allow you
// to iterate over members in chunks to support patterns such as pagination.
func (g *ManagedGroup) MembersWithOffset(offset, count int) []string {
return sliceWithOffset(g.members, offset, count)
}

// sliceWithOffset is a helper function to iterate over an avl.Tree with an offset and count.
func sliceWithOffset(t *avl.Tree, offset, count int) []string {
var result []string
t.IterateByOffset(offset, count, func(k string, _ interface{}) bool {
if k == "" {
return true
}
result = append(result, k)
return false
})
return result
}
Loading

0 comments on commit b7107a3

Please sign in to comment.