-
Notifications
You must be signed in to change notification settings - Fork 386
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: adding home realm for r/n2p5/home and supporting packages (#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
Showing
10 changed files
with
956 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
module gno.land/p/n2p5/chonk |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
Oops, something went wrong.