Transaction

8B0A009205BB09…D24E8B38F97E

Block 272,406 · index 0 · indexed

Summary

Hash
8B0A009205BB0996163CDC69FA409E8C3034CED3BC60455E6AC6D24E8B38F97E
Block
272,406
Size
675676 bytes
Gas used
754,583,982 / 1,999,088,200
Fee
5997264ugnot
Status
success

Messages

#1AddPackagegno.land/p/moul/kit/store/v09 arguments
Attached funds
11000000ugnot

Arguments · 9

  1. #1store
  2. #2README.md
  3. #3# `gno.land/p/moul/kit/store/v0` The auto-id ordered collection every `r/moul` realm was building by hand: an `avl.Tree`, an int counter, and a private function that zero-pads the counter into a key so the tree iterates in the order a human expects. Second package of the `p/moul/kit/*` layer ([moul/gno-contracts#151](https://github.com/moul/gno-contracts/issues/151)). `kit` composes the existing packages, it does not replace them. ## Why it exists Sixteen realm files in this repo carry that third piece. They do not agree: | Name | Width | Implementation | Realms | |---|---|---|---| | `padID` | 6 | `for len(s) < 6 { s = "0" + s }` | `asciiart` | | `pad` | 12 | `for len(s) < 12 { s = "0" + s }` | `tictactoe` | | `key` | 12 | the same loop, a different name | `blog`, `crowdfund`, `englishauction`, `erc721`, `splitter` | | `idKey` | 12 | the same loop again, a third name | `connect4` | | `idKey` | 12 | `strings.Repeat`, guarded by `len(s) >= width` | `governor`, `todos` | | `idKey` | 12 | `strings.Repeat`, **unguarded** | `timecapsule` | | `seqKey` | 16 | `strings.Repeat`, guarded | `guestbook` | Five names, three widths, three implementations, one job, across 16 files in 12 realms. **Every one of them is a silent ceiling.** The key is a decimal string, and decimal strings do not sort numerically. Below the width the zero-padding hides that; at the width the padding stops and the tree starts ordering `"1000000000000"` before `"999999999999"`, so every list the realm renders is wrong from that entry on. Nothing fails, nothing logs, the order is just quietly false. The unguarded variant fails harder. `strings.Repeat` panics on a negative count, so past its width `timecapsule` stops accepting writes rather than mis-sorting them. `asciiart`'s width of 6 puts its ceiling at one million entries. Both tests live in [`store_test.gno`](./store_test.gno) (`TestOrderSurvivesThePaddingCeiling`, `TestUnguardedPadPanicsPastItsWidth`), asserting the defect rather than describing it. ## The fix is to delete the decision Keys are the big-endian bytes of a [`p/nt/seqid`](/p/nt/seqid/v0) ID: a fixed 8 bytes whose lexicographic order **is** numeric order, for every value a `uint64` can hold. There is no width to pick and no width to outgrow. `seqid` already solved this and was imported by exactly one file in the repo. ```go import "gno.land/p/moul/kit/store/v0" var games = store.Named("game") // or `var games store.Store` func NewGame(cur realm) int64 { return int64(games.Add(&Game{Board: empty, X: caller()})) } func Move(cur realm, gameID int64, cell int) { g := games.MustGet(store.ID(gameID)).(*Game) // panics "game #7 not found" ... } func Render(path string) string { for _, e := range games.PageReverse(1, 20) { // newest first, one page g := e.Value.(*Game) ... e.ID ... } } ``` ## API | | | |---|---| | `New()` | a new empty store; the zero `Store` works too | | `Named("game")` | the same, with a noun for the `MustGet` panic | | `Add(v) ID` | store under the next ID | | `Set(id, v) bool` | write at an explicit ID, reports a replacement | | `Get(id) (any, bool)` | value and presence, so a stored `nil` is not "absent" | | `MustGet(id) any` | or panic `store: no entry #7`, or `game #7 not found` when named | | `Has(id)`, `Remove(id)`, `Len()`, `LastID()` | | | `Each(fn)`, `EachReverse(fn)` | every entry, ascending / descending | | `EachUntil(fn) bool`, `EachReverseUntil(fn) bool` | stop when `fn` returns true | | `Page(page, size) []Entry` | 1-based, ascending | | `PageReverse(page, size) []Entry` | 1-based, newest first | | `Pages(size) int` | for the pager footer | | `ParseID(s) (ID, bool)`, `ID.String()`, `ID.Key()` | the path round trip | ### IDs stay plain integers An `ID` is a `uint64` that renders as a decimal number, so a realm ported to this package keeps showing `#7` exactly as it did. Only the avl key changes, and the key was never user-visible. IDs start at **1**, so the zero `ID` is usable as "absent" and `ParseID("0")` rejects. Removing an entry does **not** free its ID: IDs are a history, not a dense index, and reusing one would silently repoint an old link at a new object. ### Two iteration shapes, on purpose `Each` takes no stop signal. It is the common case, and a callback whose `bool` means "stop" reads identically to one whose `bool` means "continue", so the wrong guess is invisible. When iteration has to end early the name says so: `EachUntil`, where **true means stop**, matching `avl.IterCbFn` exactly. A callback moved between this package and a raw tree keeps its meaning. ### Paging is 1-based and forgiving Page numbers are shown to a reader ("page 1 of 4"), and an off-by-one between the URL and the label is the bug this avoids. A page past the end, or a page or size below 1, returns an empty slice rather than panicking, because the page number usually arrives from a `Render` path and that is user input. Only the requested window is walked, not the whole tree. ## Design rule **The safe, conventional thing must be the shortest thing to type.** `Add` is shorter than `nextID++` plus a padding function, and it cannot be got wrong. That is the only mechanism that stops this helper from being written a seventeenth time. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/p/moul/kit/store/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/p/moul/kit/store/v0/deps.png) > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4gnomod.toml
  5. #5module = "gno.land/p/moul/kit/store/v0" gno = "0.9"
  6. #6store.gno
  7. #7// Package store is the auto-id ordered collection every r/moul realm was // building by hand: an avl.Tree, an int counter, and a private function that // zero-pads the counter into a key so the tree iterates in the order a human // expects. // // Sixteen realm files in this repo carry that third piece. They do not agree: // // padID width 6 for len(s) < 6 { s = "0" + s } asciiart // pad width 12 for len(s) < 12 { s = "0" + s } tictactoe // key width 12 the same loop, a different name blog, crowdfund, // englishauction, // erc721, splitter // idKey width 12 the same loop, a third name connect4 // idKey width 12 strings.Repeat, guarded by len(s) >= 12 governor, todos // idKey width 12 strings.Repeat, UNGUARDED timecapsule // seqKey width 16 strings.Repeat, guarded guestbook // // Every one of them is a silent ceiling. Past the width, the padding stops and // the tree starts ordering "1000000000000" before "999999999999", so the list // a realm renders is simply wrong from that entry on. The unguarded variant is // worse: strings.Repeat panics on a negative count, so the realm stops // accepting writes rather than merely mis-sorting them. asciiart's width of 6 // puts that ceiling at one million entries. // // The ceiling exists because the key is a decimal string, and decimal strings // do not sort numerically. This package removes the ceiling by removing the // decision: keys are the big-endian bytes of a [gno.land/p/nt/seqid/v0] ID, a // fixed 8 bytes whose lexicographic order IS numeric order, for every value a // uint64 can hold. There is no width to pick and no width to outgrow. // // # Usage // // var games store.Store // the zero value is an empty store // // func NewGame(cur realm) int64 { // id := games.Add(&Game{...}) // return int64(id) // } // // func Move(cur realm, gameID int64, cell int) { // g := games.MustGet(store.ID(gameID)).(*Game) // ... // } // // func Render(path string) string { // for _, e := range games.PageReverse(1, 20) { // newest first // g := e.Value.(*Game) // ... e.ID ... // } // } // // # Identifiers // // An ID is a uint64 that renders as a plain decimal number, so a realm ported // to this package keeps showing "#7" exactly as it did before. Only the avl // key changes, and the key was never user-visible. // // IDs start at 1. The zero ID is never assigned, so it is usable as "absent", // and [Store.Get] on it always misses. // // Removing an entry does not free its ID. IDs are a history, not a dense // index: reusing one would silently repoint an old link at a new object. package store import ( "strconv" "gno.land/p/nt/avl/v0" "gno.land/p/nt/seqid/v0" ) // ID identifies one entry. It is a plain integer to the outside world and an // ordered fixed-width key inside the tree. type ID uint64 // String renders the ID in decimal, which is how realms show it and how it // arrives back in a Render path. func (id ID) String() string { return strconv.FormatUint(uint64(id), 10) } // Key is the avl key for the ID: the 8 big-endian bytes of the underlying // seqid, whose byte order is its numeric order. // // Exposed because a realm that keeps a secondary index keyed by the same ID // needs the identical key, and because it is the one place the encoding is // decided. func (id ID) Key() string { return seqid.ID(id).Binary() } // ParseID reads an ID from its decimal form, the shape it has in a Render // path or a function argument. // // It returns false for anything that is not a plain unsigned decimal, which // includes "", "-1", "1.0", a value past uint64, and the zero ID (never // assigned, so accepting it would only produce a lookup that cannot hit). // Leading zeros are accepted: "007" is the ID 7. func ParseID(s string) (ID, bool) { n, err := strconv.ParseUint(s, 10, 64) if err != nil || n == 0 { return 0, false } return ID(n), true } // Entry is one ID/value pair, as returned by the paging methods. type Entry struct { ID ID Value any } // Store is an ordered collection of values under auto-assigned IDs. // // The zero Store is an empty store and is ready to use, so a realm can declare // one as a package-level var without an initializer. [New] exists for the // pointer form. type Store struct { tree avl.Tree last seqid.ID label string } // New returns an empty store. func New() *Store { return &Store{} } // Named is [New] with a noun for what the store holds, used in the panic // [Store.MustGet] raises: store.Named("game") makes it "game #7 not found" // instead of "store: no entry #7". // // It exists so porting a realm to this package does not have to trade its own // error wording for one shared implementation. The realms being replaced here // panic "game not found", "proposal not found", "capsule not found"; the label // keeps that and adds the id they all omitted. func Named(label string) *Store { return &Store{label: label} } // Add stores v under the next ID and returns it. // // It panics if the ID space is exhausted, which is [seqid.ID.Next]'s behaviour // and requires 2^64 insertions to reach. func (s *Store) Add(v any) ID { id := ID(s.last.Next()) s.tree.Set(id.Key(), v) return id } // Set writes v at an existing or explicit ID, and reports whether it replaced // a value. // // Use it to update an entry in place. It does not move the ID counter, so // setting an ID above [Store.LastID] leaves a gap that [Store.Add] will later // walk into and overwrite. Prefer [Store.Add] to create. func (s *Store) Set(id ID, v any) (replaced bool) { return s.tree.Set(id.Key(), v) } // Get returns the value at id, and whether it was there. // // Unlike avl.Tree.Get, a nil value stored at a live ID is distinguishable from // an absent one, because the second result comes from Has rather than from the // value being nil. func (s *Store) Get(id ID) (any, bool) { k := id.Key() if !s.tree.Has(k) { return nil, false } return s.tree.Get(k), true } // MustGet returns the value at id, or panics. // // This is the "get or panic" the realms wrote eighteen times, with one // implementation instead of eighteen. The panic names the id, which none of // them did: "store: no entry #7", or "game #7 not found" for a store built // with [Named]. func (s *Store) MustGet(id ID) any { v, ok := s.Get(id) if !ok { panic(s.missing(id)) } return v } func (s *Store) missing(id ID) string { if s.label == "" { return "store: no entry #" + id.String() } return s.label + " #" + id.String() + " not found" } // Has reports whether id is present. func (s *Store) Has(id ID) bool { return s.tree.Has(id.Key()) } // Remove deletes the entry at id and returns the value it held. // // The ID is not recycled: see the package doc. func (s *Store) Remove(id ID) (any, bool) { return s.tree.Remove(id.Key()) } // Len reports how many entries are present. Removals lower it; [Store.LastID] // does not move. func (s *Store) Len() int { return s.tree.Size() } // LastID is the highest ID ever assigned, or 0 when nothing has been added. // It is the count of insertions, not of live entries. func (s *Store) LastID() ID { return ID(s.last) } // Each visits every entry in ascending ID order. // // It takes no stop signal on purpose: this is the common case, and a callback // whose bool means "stop" reads identically to one whose bool means // "continue". When iteration has to end early, say so in the name and use // [Store.EachUntil]. func (s *Store) Each(fn func(id ID, v any)) { s.tree.Iterate("", "", func(k string, v any) bool { fn(keyID(k), v) return false }) } // EachReverse is [Store.Each] in descending ID order, which is what a // newest-first list wants. func (s *Store) EachReverse(fn func(id ID, v any)) { s.tree.ReverseIterate("", "", func(k string, v any) bool { fn(keyID(k), v) return false }) } // EachUntil visits entries in ascending ID order and stops when fn returns // true. It reports whether it stopped early. // // True means stop, matching avl.IterCbFn exactly, so a callback moved between // this package and a raw tree keeps its meaning. func (s *Store) EachUntil(fn func(id ID, v any) bool) bool { return s.tree.Iterate("", "", func(k string, v any) bool { return fn(keyID(k), v) }) } // EachReverseUntil is [Store.EachUntil] in descending ID order. func (s *Store) EachReverseUntil(fn func(id ID, v any) bool) bool { return s.tree.ReverseIterate("", "", func(k string, v any) bool { return fn(keyID(k), v) }) } // Page returns page number page of size entries, in ascending ID order. // // Pages are 1-based, because they are shown to a reader ("page 1 of 4") and an // off-by-one between the URL and the label is the bug this saves. A page past // the end, or a page or size below 1, returns an empty slice rather than // panicking: the page number usually comes from a Render path, which is user // input. // // It walks only the requested window, not the whole tree. func (s *Store) Page(page, size int) []Entry { return s.slice(page, size, false) } // PageReverse is [Store.Page] in descending ID order: page 1 holds the newest // entries. func (s *Store) PageReverse(page, size int) []Entry { return s.slice(page, size, true) } func (s *Store) slice(page, size int, reverse bool) []Entry { if page < 1 || size < 1 { return nil } offset := (page - 1) * size out := make([]Entry, 0, size) cb := func(k string, v any) bool { out = append(out, Entry{ID: keyID(k), Value: v}) return false } if reverse { s.tree.ReverseIterateByOffset(offset, size, cb) } else { s.tree.IterateByOffset(offset, size, cb) } return out } // Pages reports how many pages of the given size the store holds, which is // what a pager footer needs. A size below 1 gives 0. func (s *Store) Pages(size int) int { if size < 1 { return 0 } n := s.Len() return (n + size - 1) / size } // keyID decodes an avl key back to its ID. The keys in this tree are always // written by ID.Key, so a short or malformed key means the tree was written by // something other than this package. func keyID(k string) ID { if len(k) != 8 { panic("store: foreign key in tree") } var n uint64 for i := 0; i < 8; i++ { n = n<<8 | uint64(k[i]) } return ID(n) }
  8. #8store_test.gno
  9. #9package store import ( "strconv" "strings" "testing" "gno.land/p/nt/uassert/v0" ) func TestZeroValueIsUsable(t *testing.T) { var s Store uassert.Equal(t, 0, s.Len()) uassert.Equal(t, uint64(0), uint64(s.LastID())) uassert.Equal(t, uint64(1), uint64(s.Add("first"))) uassert.Equal(t, 1, s.Len()) } func TestAddAssignsSequentialIDsFromOne(t *testing.T) { s := New() for want := uint64(1); want <= 5; want++ { uassert.Equal(t, want, uint64(s.Add("v"+strconv.FormatUint(want, 10)))) } uassert.Equal(t, 5, s.Len()) uassert.Equal(t, uint64(5), uint64(s.LastID())) } func TestGetHasMustGet(t *testing.T) { s := New() id := s.Add("hello") got, ok := s.Get(id) uassert.True(t, ok, "live id is found") uassert.Equal(t, "hello", got.(string)) uassert.True(t, s.Has(id), "Has agrees with Get") uassert.Equal(t, "hello", s.MustGet(id).(string)) _, ok = s.Get(ID(2)) uassert.False(t, ok, "unassigned id misses") _, ok = s.Get(ID(0)) uassert.False(t, ok, "the zero id always misses") uassert.False(t, s.Has(ID(0)), "the zero id is never present") } // A nil value at a live id must still read as present. avl.Tree.Get alone // cannot tell those apart, which is why Get consults Has. func TestNilValueIsDistinguishableFromAbsent(t *testing.T) { s := New() id := s.Add(nil) v, ok := s.Get(id) uassert.True(t, ok, "a stored nil is present") uassert.Nil(t, v, "and reads back as nil") uassert.Equal(t, 1, s.Len()) } func TestMustGetPanicsNamingTheEntry(cur realm, t *testing.T) { s := New() s.Add("only") uassert.PanicsWithMessage(t, cur, "store: no entry #7", func() { s.MustGet(ID(7)) }) uassert.NotPanics(t, cur, func() { s.MustGet(ID(1)) }) } // A labelled store keeps the realm's own wording, so porting one does not // trade "game not found" for a generic message. func TestNamedStoreUsesItsLabelInThePanic(cur realm, t *testing.T) { s := Named("game") s.Add("only") uassert.PanicsWithMessage(t, cur, "game #7 not found", func() { s.MustGet(ID(7)) }) uassert.NotPanics(t, cur, func() { s.MustGet(ID(1)) }) // The label changes nothing else. uassert.Equal(t, uint64(1), uint64(s.LastID())) uassert.Equal(t, 1, s.Len()) } func TestSetReplacesInPlace(t *testing.T) { s := New() id := s.Add("before") uassert.True(t, s.Set(id, "after"), "Set reports the replacement") uassert.Equal(t, "after", s.MustGet(id).(string)) uassert.Equal(t, 1, s.Len(), "replacing does not grow the store") uassert.Equal(t, uint64(1), uint64(s.LastID()), "replacing does not move the counter") } func TestRemoveDoesNotRecycleIDs(t *testing.T) { s := New() a := s.Add("a") s.Add("b") v, ok := s.Remove(a) uassert.True(t, ok, "removing a live id succeeds") uassert.Equal(t, "a", v.(string)) uassert.Equal(t, 1, s.Len()) _, ok = s.Remove(a) uassert.False(t, ok, "removing twice is a miss, not a panic") // The next Add continues the history rather than refilling the hole. uassert.Equal(t, uint64(3), uint64(s.Add("c"))) } func TestParseID(t *testing.T) { cases := []struct { name string in string want ID ok bool }{ {"plain", "7", 7, true}, {"leading zeros", "007", 7, true}, {"max uint64", "18446744073709551615", ID(18446744073709551615), true}, {"empty", "", 0, false}, {"zero is never assigned", "0", 0, false}, {"negative", "-1", 0, false}, {"signed positive", "+1", 0, false}, {"decimal point", "1.0", 0, false}, {"not a number", "abc", 0, false}, {"trailing space", "7 ", 0, false}, {"overflows uint64", "18446744073709551616", 0, false}, } for _, tc := range cases { got, ok := ParseID(tc.in) uassert.Equal(t, tc.ok, ok, tc.name+": ok") uassert.Equal(t, uint64(tc.want), uint64(got), tc.name+": value") } } // The round trip a realm actually performs: render an id into a path, take it // back off the path, look the entry up. func TestIDRoundTripsThroughAPath(t *testing.T) { s := New() id := s.Add("entry") for i := 0; i < 20; i++ { id = s.Add("entry") } parsed, ok := ParseID(id.String()) uassert.True(t, ok, "the rendered id parses back") uassert.Equal(t, uint64(id), uint64(parsed)) uassert.Equal(t, "entry", s.MustGet(parsed).(string)) } func TestKeyIsFixedWidthAndOrdered(t *testing.T) { uassert.Equal(t, 8, len(ID(1).Key()), "every key is 8 bytes") uassert.Equal(t, 8, len(ID(18446744073709551615).Key()), "including the largest") // Byte order is numeric order, which is the whole point. prev := ID(0).Key() for _, n := range []uint64{1, 9, 10, 99, 100, 999999, 1000000, 999999999999, 1000000000000, 18446744073709551615} { k := ID(n).Key() uassert.True(t, prev < k, "keys ascend at "+strconv.FormatUint(n, 10)) prev = k } } // padWidth is the shape sixteen realm files carry, reproduced so the defect it // causes is asserted rather than described. The widths in the tree are 6 // (asciiart), 12 (most) and 16 (guestbook). func padWidth(n uint64, width int) string { s := strconv.FormatUint(n, 10) if len(s) >= width { return s } return strings.Repeat("0", width-len(s)) + s } // The ceiling: one entry past the chosen width, decimal keys stop sorting // numerically. store's keys do not have a width to outgrow. func TestOrderSurvivesThePaddingCeiling(t *testing.T) { cases := []struct { name string width int lo, hi uint64 }{ {"asciiart, width 6", 6, 999999, 1000000}, {"the width 12 majority", 12, 999999999999, 1000000000000}, {"guestbook, width 16", 16, 9999999999999999, 10000000000000000}, } for _, tc := range cases { // The hand-rolled key inverts: the larger id sorts first. uassert.True(t, padWidth(tc.hi, tc.width) < padWidth(tc.lo, tc.width), tc.name+": the padded key is expected to invert here") // The store key does not. uassert.True(t, ID(tc.lo).Key() < ID(tc.hi).Key(), tc.name+": store keys stay ordered") // And iteration follows. s := New() s.Set(ID(tc.hi), "hi") s.Set(ID(tc.lo), "lo") var order []string s.Each(func(id ID, v any) { order = append(order, v.(string)) }) uassert.Equal(t, "lo,hi", strings.Join(order, ","), tc.name+": iteration is numeric") } } // timecapsule's variant calls strings.Repeat without the len guard, so past // the width it panics instead of mis-sorting: the realm stops accepting // writes. Asserted here because it is the reason this is a correctness fix and // not a tidy-up. func TestUnguardedPadPanicsPastItsWidth(cur realm, t *testing.T) { unguarded := func(n uint64) string { return strings.Repeat("0", 12-len(strconv.FormatUint(n, 10))) + strconv.FormatUint(n, 10) } uassert.NotPanics(t, cur, func() { unguarded(999999999999) }) uassert.PanicsContains(t, cur, "negative", func() { unguarded(1000000000000) }) uassert.NotPanics(t, cur, func() { ID(1000000000000).Key() }) } func TestEachVisitsEverythingInOrder(t *testing.T) { s := New() for i := 1; i <= 5; i++ { s.Add(strconv.Itoa(i)) } var fwd, rev []string s.Each(func(id ID, v any) { fwd = append(fwd, id.String()+"="+v.(string)) }) s.EachReverse(func(id ID, v any) { rev = append(rev, id.String()+"="+v.(string)) }) uassert.Equal(t, "1=1,2=2,3=3,4=4,5=5", strings.Join(fwd, ",")) uassert.Equal(t, "5=5,4=4,3=3,2=2,1=1", strings.Join(rev, ",")) } func TestEachOnAnEmptyStoreIsANoOp(t *testing.T) { s := New() calls := 0 s.Each(func(id ID, v any) { calls++ }) s.EachReverse(func(id ID, v any) { calls++ }) uassert.Equal(t, 0, calls) uassert.Equal(t, 0, len(s.Page(1, 10))) uassert.Equal(t, 0, s.Pages(10)) } // True means stop, the same as avl.IterCbFn, so a callback keeps its meaning // when it moves between this package and a raw tree. func TestEachUntilStopsOnTrue(t *testing.T) { s := New() for i := 1; i <= 5; i++ { s.Add(strconv.Itoa(i)) } var seen []string stopped := s.EachUntil(func(id ID, v any) bool { seen = append(seen, v.(string)) return id == 3 }) uassert.True(t, stopped, "it reports stopping early") uassert.Equal(t, "1,2,3", strings.Join(seen, ",")) seen = nil stopped = s.EachReverseUntil(func(id ID, v any) bool { seen = append(seen, v.(string)) return id == 4 }) uassert.True(t, stopped) uassert.Equal(t, "5,4", strings.Join(seen, ",")) stopped = s.EachUntil(func(id ID, v any) bool { return false }) uassert.False(t, stopped, "running to the end is not stopping early") } func TestPageIsOneBased(t *testing.T) { s := New() for i := 1; i <= 7; i++ { s.Add(strconv.Itoa(i)) } cases := []struct { name string page, size int want string }{ {"first page", 1, 3, "1,2,3"}, {"second page", 2, 3, "4,5,6"}, {"short last page", 3, 3, "7"}, {"past the end", 4, 3, ""}, {"far past the end", 99, 3, ""}, {"whole store in one page", 1, 100, "1,2,3,4,5,6,7"}, {"page 0 is not page 1", 0, 3, ""}, {"negative page", -1, 3, ""}, {"size 0", 1, 0, ""}, {"negative size", 1, -3, ""}, } for _, tc := range cases { var got []string for _, e := range s.Page(tc.page, tc.size) { got = append(got, e.Value.(string)) } uassert.Equal(t, tc.want, strings.Join(got, ","), tc.name) } } func TestPageReverseStartsWithTheNewest(t *testing.T) { s := New() for i := 1; i <= 7; i++ { s.Add(strconv.Itoa(i)) } cases := []struct { name string page, size int want string }{ {"newest first", 1, 3, "7,6,5"}, {"second page", 2, 3, "4,3,2"}, {"short last page", 3, 3, "1"}, {"past the end", 4, 3, ""}, } for _, tc := range cases { var got []string for _, e := range s.PageReverse(tc.page, tc.size) { got = append(got, e.Value.(string)) } uassert.Equal(t, tc.want, strings.Join(got, ","), tc.name) } } func TestPageCarriesTheID(t *testing.T) { s := New() s.Add("a") s.Add("b") p := s.Page(1, 10) uassert.Equal(t, 2, len(p)) uassert.Equal(t, uint64(1), uint64(p[0].ID)) uassert.Equal(t, uint64(2), uint64(p[1].ID)) r := s.PageReverse(1, 10) uassert.Equal(t, uint64(2), uint64(r[0].ID)) uassert.Equal(t, uint64(1), uint64(r[1].ID)) } func TestPages(t *testing.T) { s := New() uassert.Equal(t, 0, s.Pages(10), "an empty store has no pages") for i := 1; i <= 10; i++ { s.Add("v") } uassert.Equal(t, 1, s.Pages(10), "an exact fit is one page") uassert.Equal(t, 2, s.Pages(9), "a remainder adds a page") uassert.Equal(t, 10, s.Pages(1)) uassert.Equal(t, 0, s.Pages(0), "a size below 1 has no pages") uassert.Equal(t, 0, s.Pages(-1)) } // Paging and iteration must stay consistent after entries are removed from the // middle, which is where a dense-index assumption would break. func TestPagingAfterRemovals(t *testing.T) { s := New() for i := 1; i <= 6; i++ { s.Add(strconv.Itoa(i)) } s.Remove(ID(2)) s.Remove(ID(5)) uassert.Equal(t, 4, s.Len()) uassert.Equal(t, uint64(6), uint64(s.LastID()), "the counter does not rewind") var got []string s.Each(func(id ID, v any) { got = append(got, id.String()) }) uassert.Equal(t, "1,3,4,6", strings.Join(got, ",")) got = nil for _, e := range s.Page(2, 2) { got = append(got, e.ID.String()) } uassert.Equal(t, "4,6", strings.Join(got, ","), "page 2 of the survivors") uassert.Equal(t, 2, s.Pages(2)) }
#2AddPackagegno.land/p/moul/mygnoscan/v09 arguments
Attached funds
14000000ugnot

Arguments · 9

  1. #1mygnoscan
  2. #2README.md
  3. #3# `gno.land/p/moul/mygnoscan` Builds links into a [mygnoscan](https://mygnoscan.moul.p2p.team) block explorer, from inside a realm. A realm knows things its reader cannot see: which chain it is on, what its own address is, which block it was last written at. Turning any of those into a link means knowing the explorer's route table, and a route table copied into thirty realms is thirty places to fix when a route moves. This is the one place. ```go s := mygnoscan.Default() s.Realm("gno.land/r/moul/home") // .../realm/r/moul/home?network=mainnet s.Realm("r/moul/home", mygnoscan.TabSource) // ...&tab=source s.RealmFunc("r/moul/config", "Set") // source tab, at one function s.Address(addr) // an account s.Block(runtime.ChainHeight()) // the block we are in s.Tx(hash) // one transaction, base64 hash s.Proposal(7) // one GovDAO proposal s.Page(mygnoscan.PageGas) // a list page s.RealmFooter("gno.land/r/moul/home") // the markdown line for a Render ``` `Default()` is `DefaultBase` (moul's instance) on whichever network answers for the running chain. `New(base)` points somewhere else, `WithNetwork(id)` overrides the network. Nothing here reads chain state beyond `ChainDomain`, `ChainID` and `ChainHeight`, and nothing writes: building a link is free. For a realm that wants the target to be **changeable without a redeploy**, read the base from [`r/moul/config`](../../../r/moul/config) instead of calling `Default()`: `config.Scanner()` returns exactly this type, configured. ## The routes are measured Every path was read out of the explorer's own router (`route()` in its single-page frontend) on 2026-09-22, not inferred from clicking the UI. Two realm tabs that the UI still redirects are deliberately absent because they no longer exist: `?tab=graph` (folded into `deps`) and `?tab=inert` (folded into the default tab, and dropped from the URL). ## Three things that will bite otherwise - **An unknown path renders the home page.** The explorer's router falls through to `home` rather than to a 404, so a misspelled route is not visible as an error: the link works and goes somewhere else. That is why the page names are constants and `Page` is the only door to them. - **Omitting `?network=` is not the same as asking for mainnet.** The explorer then answers for every chain it indexes at once, which looks plausible and is wrong. A `Scanner` on a chain `NetworkFor` does not know emits no network parameter, so `New` under gnodev (chain-id `dev`) produces links to the blend. `WithNetwork` is how you fix that. - **The network ids belong to the instance, not to the chain.** `DefaultBase` names mainnet `mainnet`; the upstream default configuration names the same chain `gnoland1`. `NetworkFor` maps chain-ids to what `DefaultBase` serves, so pointing `New` at another instance usually means setting `WithNetwork` too. ## A path a caller typed cannot break out of the link Everything here can end up inside a markdown link, so a path carrying `)` would close the link early and render the rest as page text. `url.PathEscape` does not help: `(` and `)` are legal URL sub-delims and it leaves both alone. So the two shapes are handled differently, and neither trusts its input: - **Package paths and page names are refused**, not escaped, because escaping their separators would break the route. `Realm`, `RealmFunc`, `RealmFile`, `RealmFooter` and `Page` fall back to a list page for anything outside `[A-Za-z0-9._/-]` or with an empty segment. - **Hashes, token keys and addresses are escaped** with `encodeURIComponent` semantics, which is what the explorer's `decodeURIComponent` round-trips. `address` is a string type and nothing stops a realm casting a caller's input into one, so it gets the same treatment. `Link(text, target)` does **not** escape its text. Pass a constant. A label a caller typed belongs in `ui.Inline` first. `CurrentRealm` and `CurrentRealmFooter` stack-walk (`unsafe.CurrentRealm`), so inside a helper borrowed by another realm they name the **borrower**. That is the right answer for a link and the wrong one for authorisation: never branch on them. A realm calling through a shared config realm should pass its own path explicitly, because the stack seen from in there has the config realm on it. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4gnomod.toml
  5. #5module = "gno.land/p/moul/mygnoscan/v0" gno = "0.9"
  6. #6mygnoscan.gno
  7. #7// Package mygnoscan builds links into a mygnoscan block explorer. // // A realm knows things a reader cannot see from its Render output: which chain // it is on, what its address is, which block it was last written at. Turning // any of those into a link means knowing the explorer's route table, and a // route table hand-written into thirty realms is thirty places to fix when it // moves. This package is the one place. // // # The routes are measured, not guessed // // Every path below was read out of the explorer's own router (its single-page // frontend, `route()`), not inferred from the UI, on 2026-09-22. Two of them // are retired and deliberately absent: `?tab=graph` (folded into `deps`, still // redirected) and `?tab=inert` (folded into the default tab, silently dropped). // // # An unknown path renders the home page // // The explorer's router falls through to `home` rather than to a 404, so a // misspelled route is not visible as an error: the link works, it just goes // somewhere else. That is why the page names here are constants and why Page // is the only door to them. // // # The network is per instance, not per chain // // The explorer takes `?network=<id>`, and the ids are chosen by whoever runs // the instance: moul's names mainnet `mainnet`, while the upstream default // config names the same chain `gnoland1`. NetworkFor maps a chain-id to the // ids DefaultBase serves. Point Scanner at another instance and you very // likely also want WithNetwork. // // Omitting the parameter is not the same as asking for mainnet: the explorer // then blends every chain it indexes, which looks plausible and is wrong. A // Scanner whose network is empty emits no parameter, so New on a chain nobody // indexes produces a link to the blend. Set it explicitly when it matters. // // # Usage // // The whole point is a realm footer that stays right when the chain changes: // // func Render(path string) string { // return body + "\n\n---\n\n" + mygnoscan.Default().CurrentRealmFooter() // } // // and, for a realm that wants to hand out sharper links: // // s := mygnoscan.Default() // s.Address(someAddr) // an account // s.Block(runtime.ChainHeight()) // the block we are at // s.Realm("gno.land/r/moul/home", mygnoscan.TabSource) // s.RealmFunc("gno.land/r/moul/config", "Set") // straight at one function // // Nothing here reads chain state except ChainDomain, ChainID and ChainHeight, // and nothing here writes. Building a link is free. package mygnoscan import ( "chain/runtime" "chain/runtime/unsafe" "net/url" "strconv" "strings" ) // DefaultBase is moul's instance, the one the rest of this repo links to. const DefaultBase = "https://mygnoscan.moul.p2p.team" // The realm-page tabs, as the explorer's own tabNames list has them. An // unknown tab name lands on the default tab rather than erroring, so these // exist to keep a typo out of a deployed realm. const ( TabInfo = "info" // the default; passing it emits no parameter TabDocs = "docs" // exported symbols TabSource = "source" // the .gno files as deployed TabCalls = "calls" // transactions that called this realm TabEvents = "events" // events it emitted TabStorage = "storage" // what it pays to store TabDefi = "defi" // balances and token positions TabDeps = "deps" // imports, dependents and the graph over them ) // The explorer's list pages. Page takes one of these. const ( PageRealms = "realms" PagePackages = "packages" PageContracts = "contracts" PageApps = "apps" PageTxs = "txs" PageBlocks = "blocks" PageAccounts = "accounts" PageDefi = "defi" PageCoins = "coins" PageGRC20 = "grc20" PageValidators = "validators" PageGovDAO = "govdao" PageProposals = "govdao/proposals" PageVoters = "govdao/voters" PageOptions = "govdao/options" PageParams = "params" PageEvents = "events" PageGas = "gas" PageGasRealms = "gas/realms" PageGasUsers = "gas/users" PageGasTxs = "gas/txs" PageStorage = "storage" PageAnalytics = "analytics" PageDashboards = "dashboards" PageSanity = "sanity" PageWatch = "watch" ) // The sub-views of the packages and accounts pages, the only two list pages // that restore a view from the URL. // // Every other list page writes its state into the query string (?by=, ?page=, // ?status=, ?window=, ?section=, ?failed=, ?storage=, ?txs=) and does NOT read // any of it back on load: a link carrying one of those opens the page on its // default view instead, silently. So there are no helpers for them here. // Measured against the explorer's router on 2026-09-22. const ( PackagesAll = "all" // every package PackagesInert = "inert" // the parked queue: submitted, not yet approved AccountsActivity = "activity" // ranked by what they have done AccountsBalances = "balances" // ranked by what they hold ) // Scanner is one explorer instance plus the network its links should open on. // It is a value: copying one is free and there is nothing to close. type Scanner struct { base string network string } // Default returns a Scanner pointed at DefaultBase, on whichever network // answers for the running chain. func Default() Scanner { return New(DefaultBase) } // New returns a Scanner pointed at base, on whichever network answers for the // running chain. A trailing slash on base is dropped so New("…/") and New("…") // build the same links. func New(base string) Scanner { return Scanner{ base: strings.TrimSuffix(base, "/"), network: NetworkFor(runtime.ChainID()), } } // WithNetwork returns a copy of s whose links carry ?network=id. The empty // string removes the parameter, which asks the explorer for every chain at // once rather than for this one. func (s Scanner) WithNetwork(id string) Scanner { s.network = id return s } // Base returns the instance root, without a trailing slash. func (s Scanner) Base() string { return s.base } // Network returns the network id these links carry, empty when they carry none. func (s Scanner) Network() string { return s.network } // NetworkFor maps a chain-id to the network id DefaultBase serves it under, or // "" when that instance does not index the chain (a gnodev, a local test). // // Verified against DefaultBase's /api/networks on 2026-09-22: it serves // mainnet, pearl and staging. sapphire is mapped because the chain exists and // an instance configured for it uses that id; DefaultBase currently answers // for it with the blend, which is the same failure mode as an unknown chain // and is why the caller can override with WithNetwork. func NetworkFor(chainID string) string { switch chainID { case "gnoland-1": return "mainnet" case "pearl-1": return "pearl" case "sapphire-1": return "sapphire" case "staging": return "staging" default: return "" } } // URL is the one place a link is assembled: an absolute link to path on this // instance, carrying query as key/value pairs plus the network. A pair with an // empty key or value is dropped, so an optional parameter needs no branch at // the call site. // // Prefer the typed helpers below; this is the escape hatch for a route added // to the explorer after this package was last touched. func (s Scanner) URL(path string, query ...string) string { if len(query)%2 != 0 { panic("mygnoscan: odd number of query arguments") } if path == "" { path = "/" } else if !strings.HasPrefix(path, "/") { path = "/" + path } v := url.Values{} for i := 0; i < len(query); i += 2 { if query[i] == "" || query[i+1] == "" { continue } v.Set(query[i], query[i+1]) } if s.network != "" { v.Set("network", s.network) } out := s.base + path if len(v) > 0 { out += "?" + v.Encode() } return out } // Home links the explorer's front page on this network. func (s Scanner) Home() string { return s.URL("/") } // Page links one of the Page* list pages. A name that is not shaped like one // falls back to the front page rather than to the explorer's own // unknown-path-renders-home behaviour, which would look identical and mean // something else. func (s Scanner) Page(name string) string { n := strings.Trim(name, "/") if !safePath(n) { return s.Home() } return s.URL("/" + n) } // Packages links the package list, on the PackagesAll or PackagesInert view. // The empty string takes the page's own default, which is PackagesAll. func (s Scanner) Packages(view string) string { return s.URL("/"+PagePackages, "pv", view) } // Accounts links the account list, on the AccountsActivity or AccountsBalances // view. The empty string takes the page's own default, which is // AccountsActivity. func (s Scanner) Accounts(view string) string { return s.URL("/"+PageAccounts, "av", view) } // Realm links a package or realm page, optionally opening one tab. // // pkgPath is accepted either fully qualified ("gno.land/r/moul/home") or bare // ("r/moul/home"): TrimDomain normalises it. Passing TabInfo, or no tab at // all, emits no tab parameter. func (s Scanner) Realm(pkgPath string, tab ...string) string { p := TrimDomain(pkgPath) if p == "" { return s.Home() } return s.URL("/realm/"+p, "tab", oneTab(tab)) } // CurrentRealm links the page of the realm that called into this package. // // It stack-walks (unsafe.CurrentRealm), so it names whichever realm was // current when it ran. For a plain read like this one that is the BORROWER, // which is the wanted answer: a realm asking for "my page" gets its own, and // so does one asking through a shared config realm, because a borrowed call // opens no realm frame. Measured in the test harness on 2026-09-22. // // Inside a CROSSING function it names that function's realm instead. That is // also correct and rarely what a link wants, so pass the path explicitly // there. Never branch on it for authorisation either way. func (s Scanner) CurrentRealm(tab ...string) string { return s.Realm(unsafe.CurrentRealm().PkgPath(), tab...) } // RealmFunc links a realm's source tab scrolled to one exported function. func (s Scanner) RealmFunc(pkgPath, fn string) string { p := TrimDomain(pkgPath) if p == "" || fn == "" { return s.Realm(pkgPath) } return s.URL("/realm/"+p, "tab", TabSource, "fn", fn) } // RealmFile links a realm's source tab scrolled to one line of one file. // A line of 0 or less links the file without an anchor. func (s Scanner) RealmFile(pkgPath, file string, line int) string { p := TrimDomain(pkgPath) if p == "" || file == "" { return s.Realm(pkgPath) } if line <= 0 { return s.URL("/realm/"+p, "tab", TabSource, "file", file) } return s.URL("/realm/"+p, "tab", TabSource, "file", file, "line", strconv.Itoa(line)) } // Address links an account page: its balance, its transactions, what it deployed. // // The address is escaped rather than trusted. `address` is a string type and // nothing stops a realm casting a caller's input into one, so a value reaching // here is not necessarily bech32. func (s Scanner) Address(addr address) string { if addr == "" { return s.Page(PageAccounts) } return s.URL("/address/" + escapeSegment(addr.String())) } // Tx links one transaction. The hash is the base64 form the indexer and gnokey // print, which carries "+", "/" and "="; the whole segment is percent-escaped // so the explorer's decodeURIComponent gives it back byte for byte. func (s Scanner) Tx(hash string) string { if hash == "" { return s.Page(PageTxs) } return s.URL("/tx/" + escapeSegment(hash)) } // Block links one block by height. func (s Scanner) Block(height int64) string { if height <= 0 { return s.Page(PageBlocks) } return s.URL("/block/" + strconv.FormatInt(height, 10)) } // CurrentBlock links the block this call is executing in. func (s Scanner) CurrentBlock() string { return s.Block(runtime.ChainHeight()) } // Validator links one validator by address. func (s Scanner) Validator(addr address) string { if addr == "" { return s.Page(PageValidators) } return s.URL("/validator/" + escapeSegment(addr.String())) } // Proposal links one GovDAO proposal by id. func (s Scanner) Proposal(id int) string { if id < 0 { return s.Page(PageProposals) } return s.URL("/govdao/" + strconv.Itoa(id)) } // Token links one GRC20 asset by its ledger key, which looks like // "gno.land/r/gnoswap/gns.GNS.0000000". That key carries both slashes and // dots, and neither is a route separator, so it is percent-escaped whole. func (s Scanner) Token(key string) string { if key == "" { return s.Page(PageGRC20) } return s.URL("/grc20/" + escapeSegment(key)) } // TrimDomain strips the chain domain from a package path, so both // "gno.land/r/moul/home" and "r/moul/home" become "r/moul/home". A path that // is not shaped like a package path (see safePath) comes back empty, and every // caller here treats empty as "no such target" and falls back to a list page. // // It tries the running chain's domain first, then the literal "gno.land/", // because the explorer's own realm route reassembles the path by prepending // "gno.land/" unconditionally: on a chain whose domain is something else, a // bare path is still what the link must carry. func TrimDomain(pkgPath string) string { p := strings.TrimPrefix(pkgPath, runtime.ChainDomain()+"/") p = strings.TrimPrefix(p, "gno.land/") p = strings.Trim(p, "/") if !safePath(p) { return "" } return p } // escapeSegment percent-encodes everything outside the RFC 3986 unreserved // set, which is exactly what JavaScript's encodeURIComponent does and // therefore exactly what the explorer's decodeURIComponent round-trips. // // url.PathEscape is the obvious choice and is wrong here twice: it leaves '/' // alone in some modes and, more importantly, it never escapes '(' or ')', // which are legal URL sub-delims and which close a markdown link early. Since // every link this package builds may end up inside one, the segment escaper // has to be stricter than the URL spec requires. func escapeSegment(s string) string { const hex = "0123456789ABCDEF" var b strings.Builder for i := 0; i < len(s); i++ { c := s[i] switch { case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_', c == '.', c == '~': b.WriteByte(c) default: b.WriteByte('%') b.WriteByte(hex[c>>4]) b.WriteByte(hex[c&0x0f]) } } return b.String() } // safePath reports whether p is shaped like a package path: [A-Za-z0-9._/-], // and no empty segment. // // This is a rendering guard, not a validity check. Everything built here ends // up inside a markdown link, so a path carrying ')' would close the link early // and the rest would render as page text; a path carrying a space or a newline // breaks it differently. Percent-escaping cannot be the answer because '(' and // ')' are legal sub-delims in a URL path and url.PathEscape leaves them alone, // and escaping the separators would break the route. So the characters are // refused instead. // // It matters because a realm may well pass a path a caller typed (a registry, // a directory, a "link to my realm" form), and this package cannot tell. func safePath(p string) bool { if p == "" { return false } lastSlash := true // a leading slash was already trimmed, so no empty first segment for i := 0; i < len(p); i++ { c := p[i] switch { case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': lastSlash = false case c == '.', c == '-', c == '_': lastSlash = false case c == '/': if lastSlash { return false // "" or "a//b" } lastSlash = true default: return false } } return !lastSlash // no trailing slash left over } // Link renders a markdown link. Neither argument is escaped: everything this // package builds is a path or a constant, never text a caller typed. Passing a // user-supplied label through here would let it break out of the link. func Link(text, target string) string { return "[" + text + "](" + target + ")" } // RealmFooter is the line a realm appends under its Render output: the four // views of itself worth one click, separated by middots. // // Deliberately plain markdown with no leading rule, so the caller decides // where it sits and what separates it from the body. func (s Scanner) RealmFooter(pkgPath string) string { p := TrimDomain(pkgPath) if p == "" { return "" } return Link("explorer", s.Realm(p)) + " · " + Link("source", s.Realm(p, TabSource)) + " · " + Link("calls", s.Realm(p, TabCalls)) + " · " + Link("deps", s.Realm(p, TabDeps)) } // CurrentRealmFooter is RealmFooter for the realm that called in. It carries // the same stack-walking caveat as CurrentRealm. func (s Scanner) CurrentRealmFooter() string { return s.RealmFooter(unsafe.CurrentRealm().PkgPath()) } // oneTab reads the optional tab argument. More than one is a call-site bug and // panics rather than silently using the first, and TabInfo is dropped because // it is the default the explorer lands on anyway. func oneTab(tab []string) string { switch len(tab) { case 0: return "" case 1: if tab[0] == TabInfo { return "" } return tab[0] default: panic("mygnoscan: at most one tab") } }
  8. #8mygnoscan_test.gno
  9. #9package mygnoscan import ( "testing" "gno.land/p/nt/uassert/v0" ) // mainnet is what a Scanner looks like on the chain that matters. Tests build // it explicitly rather than calling Default(), because the test harness runs // with chain-id "dev", which NetworkFor maps to no network at all: pinning // Default()'s output would pin the absence of the parameter and prove nothing // about the case every real link is in. func mainnet() Scanner { return New(DefaultBase).WithNetwork("mainnet") } func TestNetworkFor(t *testing.T) { tests := []struct { chainID string want string }{ {"gnoland-1", "mainnet"}, {"pearl-1", "pearl"}, {"sapphire-1", "sapphire"}, {"staging", "staging"}, {"dev", ""}, // gnodev, and what `gno test` reports {"", ""}, // no chain at all {"gnoland1", ""}, // the upstream default instance's id, not a chain-id {"test3", ""}, // a retired testnet {"mainnet", ""}, // the network id, fed back in by mistake } for _, tt := range tests { uassert.Equal(t, tt.want, NetworkFor(tt.chainID), "NetworkFor("+tt.chainID+")") } } func TestTrimDomain(t *testing.T) { tests := []struct { in string want string }{ {"gno.land/r/moul/home", "r/moul/home"}, {"r/moul/home", "r/moul/home"}, {"/r/moul/home", "r/moul/home"}, {"gno.land/p/moul/mygnoscan/v0", "p/moul/mygnoscan/v0"}, {"gno.land/r/gnoland/blog", "r/gnoland/blog"}, {"", ""}, {"gno.land/", ""}, // A path whose own name starts with the domain is only trimmed once, // so a realm cannot be linked out of its own namespace by its name. {"gno.land/r/moul/gno.land", "r/moul/gno.land"}, // Anything not shaped like a package path comes back empty, so the // caller falls back to a list page instead of emitting it. {"r/moul/home)", ""}, {"r/moul/ho me", ""}, {"r/moul//home", ""}, {"r/moul/home\n", ""}, } for _, tt := range tests { uassert.Equal(t, tt.want, TrimDomain(tt.in), "TrimDomain("+tt.in+")") } } func TestBaseNormalisation(t *testing.T) { uassert.Equal(t, DefaultBase, New(DefaultBase+"/").Base()) uassert.Equal(t, DefaultBase, New(DefaultBase).Base()) uassert.Equal(t, "https://scan.example.com", New("https://scan.example.com/").Base()) } func TestTargets(t *testing.T) { s := mainnet() const b = DefaultBase tests := []struct { name string got string want string }{ {"home", s.Home(), b + "/?network=mainnet"}, {"page", s.Page(PageTxs), b + "/txs?network=mainnet"}, {"page nested", s.Page(PageGasRealms), b + "/gas/realms?network=mainnet"}, {"page slashed", s.Page("/blocks/"), b + "/blocks?network=mainnet"}, {"realm qualified", s.Realm("gno.land/r/moul/home"), b + "/realm/r/moul/home?network=mainnet"}, {"realm bare", s.Realm("r/moul/home"), b + "/realm/r/moul/home?network=mainnet"}, {"realm tab", s.Realm("r/moul/home", TabSource), b + "/realm/r/moul/home?network=mainnet&tab=source"}, // TabInfo is the tab the explorer lands on, so asking for it adds nothing. {"realm tab info", s.Realm("r/moul/home", TabInfo), b + "/realm/r/moul/home?network=mainnet"}, {"realm empty", s.Realm(""), b + "/?network=mainnet"}, {"realm func", s.RealmFunc("r/moul/config", "Set"), b + "/realm/r/moul/config?fn=Set&network=mainnet&tab=source"}, {"realm file", s.RealmFile("r/moul/home", "render.gno", 42), b + "/realm/r/moul/home?file=render.gno&line=42&network=mainnet&tab=source"}, {"realm file no line", s.RealmFile("r/moul/home", "render.gno", 0), b + "/realm/r/moul/home?file=render.gno&network=mainnet&tab=source"}, {"address", s.Address("g1manfred47kzduec920z88wfr64ylksmdcedlf5"), b + "/address/g1manfred47kzduec920z88wfr64ylksmdcedlf5?network=mainnet"}, {"address empty", s.Address(""), b + "/accounts?network=mainnet"}, {"block", s.Block(80618), b + "/block/80618?network=mainnet"}, {"block zero", s.Block(0), b + "/blocks?network=mainnet"}, {"validator", s.Validator("g1manfred47kzduec920z88wfr64ylksmdcedlf5"), b + "/validator/g1manfred47kzduec920z88wfr64ylksmdcedlf5?network=mainnet"}, {"proposal", s.Proposal(7), b + "/govdao/7?network=mainnet"}, {"proposal negative", s.Proposal(-1), b + "/govdao/proposals?network=mainnet"}, {"token empty", s.Token(""), b + "/grc20?network=mainnet"}, } for _, tt := range tests { uassert.Equal(t, tt.want, tt.got, tt.name) } } // TestEscaping covers the two routes whose argument is not URL-safe. Both are // read back with decodeURIComponent by the explorer, so percent-escaping the // whole segment is what round-trips; leaving the slashes alone would split the // segment and land on a different route entirely. func TestEscaping(t *testing.T) { s := mainnet() // A 32-byte hash in the base64 form the indexer and gnokey print, picked // to carry all three of the characters that make base64 awkward in a URL. // Everything outside the unreserved set moves, which is what the // explorer's decodeURIComponent undoes byte for byte. uassert.Equal(t, DefaultBase+"/tx/BppSxd7qRiAPsMHzDx5mys%2BYpeDCMUzxI1qeb%2FEhaYc%3D?network=mainnet", s.Tx("BppSxd7qRiAPsMHzDx5mys+YpeDCMUzxI1qeb/EhaYc=")) uassert.Equal(t, DefaultBase+"/txs?network=mainnet", s.Tx("")) // A GRC20 ledger key carries both slashes and dots. uassert.Equal(t, DefaultBase+"/grc20/gno.land%2Fr%2Fgnoswap%2Fgns.GNS.0000000?network=mainnet", s.Token("gno.land/r/gnoswap/gns.GNS.0000000")) } func TestURLQuery(cur realm, t *testing.T) { s := mainnet() uassert.Equal(t, DefaultBase+"/anything?network=mainnet", s.URL("anything")) uassert.Equal(t, DefaultBase+"/anything?network=mainnet", s.URL("/anything")) uassert.Equal(t, DefaultBase+"/a?k=v&network=mainnet", s.URL("/a", "k", "v")) // An empty key or value is dropped, so an optional parameter needs no // branch at the call site. uassert.Equal(t, DefaultBase+"/a?network=mainnet", s.URL("/a", "k", "")) uassert.Equal(t, DefaultBase+"/a?network=mainnet", s.URL("/a", "", "v")) uassert.PanicsWithMessage(t, cur, "mygnoscan: odd number of query arguments", func() { s.URL("/a", "k") }) } // TestNoNetwork pins what a Scanner emits when it does not know the chain: // nothing. The explorer then answers for every chain it indexes at once, which // is a real answer to a different question, so this is worth being explicit // about rather than discovering from a plausible-looking page. func TestNoNetwork(t *testing.T) { s := New(DefaultBase).WithNetwork("") uassert.Equal(t, "", s.Network()) uassert.Equal(t, DefaultBase+"/realm/r/moul/home", s.Realm("r/moul/home")) uassert.Equal(t, DefaultBase+"/realm/r/moul/home?tab=calls", s.Realm("r/moul/home", TabCalls)) uassert.Equal(t, DefaultBase+"/", s.Home()) } // TestDefaultUnderTest pins Default() as the test harness sees it: chain-id // "dev" is indexed nowhere, so the links carry no network. A realm rendering // under gnodev gets exactly this, which is why a local preview link is not // proof that the mainnet one is right. func TestDefaultUnderTest(t *testing.T) { s := Default() uassert.Equal(t, DefaultBase, s.Base()) uassert.Equal(t, "", s.Network()) uassert.Equal(t, DefaultBase+"/realm/r/moul/home", s.Realm("gno.land/r/moul/home")) } func TestTabArity(cur realm, t *testing.T) { s := mainnet() uassert.PanicsWithMessage(t, cur, "mygnoscan: at most one tab", func() { s.Realm("r/moul/home", TabSource, TabCalls) }) } func TestLink(t *testing.T) { uassert.Equal(t, "[text](https://example.com)", Link("text", "https://example.com")) } func TestRealmFooter(t *testing.T) { s := mainnet() const want = "[explorer](https://mygnoscan.moul.p2p.team/realm/r/moul/home?network=mainnet)" + " · [source](https://mygnoscan.moul.p2p.team/realm/r/moul/home?network=mainnet&tab=source)" + " · [calls](https://mygnoscan.moul.p2p.team/realm/r/moul/home?network=mainnet&tab=calls)" + " · [deps](https://mygnoscan.moul.p2p.team/realm/r/moul/home?network=mainnet&tab=deps)" uassert.Equal(t, want, s.RealmFooter("gno.land/r/moul/home")) uassert.Equal(t, want, s.RealmFooter("r/moul/home")) uassert.Equal(t, "", s.RealmFooter("")) } // TestOtherInstance is the whole reason base and network are separate knobs: // a second instance serving the same chain under a different id. func TestOtherInstance(t *testing.T) { s := New("https://scan.example.com").WithNetwork("gnoland1") uassert.Equal(t, "https://scan.example.com/realm/r/moul/home?network=gnoland1", s.Realm("gno.land/r/moul/home")) } // TestMarkdownBreakout is the reason safePath and escapeSegment exist. A realm // that links a path or a hash a caller typed would otherwise let that caller // close the markdown link early and write page text of their choosing. // // url.PathEscape is not enough on its own: "(" and ")" are legal URL // sub-delims and it leaves both alone. func TestMarkdownBreakout(t *testing.T) { s := mainnet() // A path is refused outright, because escaping its separators would break // the route. The caller gets the front page, not a broken link. uassert.Equal(t, DefaultBase+"/?network=mainnet", s.Realm("r/evil/x)](https://evil.example.com")) uassert.Equal(t, "", s.RealmFooter("r/evil/x)](https://evil.example.com")) // A hash and a token key are opaque, so they are escaped rather than // refused, and the parentheses move. uassert.Equal(t, DefaultBase+"/tx/x%29%5D%28evil?network=mainnet", s.Tx("x)](evil")) uassert.Equal(t, DefaultBase+"/grc20/x%29%5D%28evil?network=mainnet", s.Token("x)](evil")) // An address is a string type; nothing stops a realm casting into it. uassert.Equal(t, DefaultBase+"/address/g1%29%5D%28evil?network=mainnet", s.Address(address("g1)](evil"))) // A page name that is not a page name lands on the front page, rather // than on the explorer's own unknown-path-renders-home behaviour, which // would look identical and mean something else. uassert.Equal(t, DefaultBase+"/?network=mainnet", s.Page("txs)](evil")) uassert.Equal(t, DefaultBase+"/?network=mainnet", s.Page("")) } func TestSafePath(t *testing.T) { tests := []struct { in string want bool }{ {"r/moul/home", true}, {"p/moul/mygnoscan/v0", true}, {"gas/realms", true}, {"a", true}, {"a.b-c_d", true}, {"", false}, {"a//b", false}, {"a/", false}, {"a b", false}, {"a)b", false}, {"a(b", false}, {"a]b", false}, {"a\nb", false}, {"a?b", false}, {"a#b", false}, {"a%b", false}, } for _, tt := range tests { uassert.Equal(t, tt.want, safePath(tt.in), "safePath("+tt.in+")") } } func TestSubViews(t *testing.T) { s := mainnet() uassert.Equal(t, DefaultBase+"/packages?network=mainnet&pv=inert", s.Packages(PackagesInert)) uassert.Equal(t, DefaultBase+"/packages?network=mainnet", s.Packages("")) uassert.Equal(t, DefaultBase+"/accounts?av=balances&network=mainnet", s.Accounts(AccountsBalances)) uassert.Equal(t, DefaultBase+"/accounts?network=mainnet", s.Accounts("")) }
#3AddPackagegno.land/p/moul/pausable/v09 arguments
Attached funds
7000000ugnot

Arguments · 9

  1. #1pausable
  2. #2README.md
  3. #3# `gno.land/p/moul/pausable` The switch a realm checks before it acts: a parsed pause state, the rule for combining two of them, and the asserts that stop a call. It holds no state and knows nothing about where a pause setting is stored. [`r/moul/config`](../../../r/moul/config) is the realm that stores one and wires this to it; this package is the part worth getting right once. ```go st := pausable.MustParse(raw) // a stored value, failing closed st.AllowsRead() // false only when fully paused st.AllowsWrite() // false unless running st.AssertWritable() // aborts, with the reason if there is one st.Notice() // the markdown banner, or "" while running pausable.Strictest(a, b) // combine a global and a per-realm state ``` ## Three levels, because "paused" is usually too blunt Taking a realm fully offline hides the thing people came to read. Most incidents only need the writes stopped: a board keeps rendering, an exchange keeps quoting, nothing new lands while the fix is prepared. | level | value | reads | writes | |---|---|---|---| | Running | `""` or `running` | yes | yes | | ReadOnly | `readonly` | yes | no | | Paused | `paused` | no | no | Each optionally followed by `: <reason>`, which shows up in the banner and in the abort message: `paused: migrating storage, back in an hour`. So a realm guards its mutating functions and leaves `Render` alone: ```go func Post(cur realm, body string) { config.AssertWritable() // aborts while ReadOnly or Paused ... } func Render(path string) string { return config.TopBlock() + body // the banner explains itself } ``` `AssertReadable` exists but most realms should not put it in `Render`. A page that aborts tells a reader nothing; the banner tells them what happened and when to come back. Reach for it only where serving stale data is itself the harm. ## It fails closed, and that is why the writer must validate `MustParse` turns a value it does not recognise into **Paused**, not Running. A pause switch that a typo silently disables is not a pause switch: the failure would be invisible until the incident it was meant to cover. The cost is that a typo takes a realm offline, so `Parse` reports `ok=false` and the storing realm is expected to refuse the write. **Validate on the way in, fail closed on the way out.** `r/moul/config` does exactly that, so in practice the closed path is never reached. The values this refuses are the ones someone would plausibly type: `yes`, `true`, `1`, `on`, `stop`, `read-only`. None of them may read as Running. ## The stricter of two states wins A global pause and a per-realm pause combine with `Strictest`, so a per-realm entry left behind from last month can never re-open a realm during a global halt. The cost is that exempting one realm from a global pause is not expressible. Clear the global and set the others instead. That is the right trade for an emergency brake: the failure mode of the alternative is a stale exemption nobody remembers, discovered during the incident. The reason travels with the level that won, and on a tie the first argument does, so calling `Strictest(global, scoped)` keeps the global explanation when both say the same thing. ## Notice is one markdown block The reason is text a manager typed, and it lands inside a blockquote where a newline would end the quote and let the rest render as page content. `Notice` folds it to a single line. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4gnomod.toml
  5. #5module = "gno.land/p/moul/pausable/v0" gno = "0.9"
  6. #6pausable.gno
  7. #7// Package pausable is the switch a realm checks before it acts: a parsed pause // state, the rule for combining two of them, and the asserts that stop a call. // // # Three levels, because "paused" is usually too blunt // // Taking a realm fully offline hides the thing people came to read. Most // incidents only need the writes stopped: a board keeps rendering, an exchange // keeps quoting, and nothing new lands while the fix is prepared. So the // levels are Running, ReadOnly and Paused, and a realm normally guards its // mutating functions with AssertWritable and leaves Render alone. // // func Post(cur realm, body string) { // config.AssertWritable() // aborts while the realm is ReadOnly or Paused // … // } // // func Render(path string) string { // return config.TopBlock() + body // the banner explains itself // } // // # It fails closed // // MustParse turns a value it does not recognise into Paused, not Running. A // pause switch that a typo silently disables is not a pause switch, and the // failure is loud and one transaction from fixed, where the opposite failure // is silent and discovered during the incident it was meant to cover. // // The cost of that choice is a typo taking a realm offline, so the writer is // expected to validate: Parse reports ok=false and the storing realm refuses // the write. Validate on the way in, fail closed on the way out. // // # The stricter of two states wins // // A global pause and a per-realm pause combine with Strictest, so a stale // per-realm entry can never re-open a realm during a global halt. The cost is // that exempting one realm from a global pause is not expressible; clear the // global and set the others instead. package pausable import "strings" // Level is how much of a realm is still allowed to work. type Level int const ( // Running is the normal state, and what an unset value means. Running Level = iota // ReadOnly still renders and still answers queries; it refuses writes. ReadOnly // Paused refuses everything, reads included. Paused ) // The canonical spelling of each level, which is what a value round-trips to. const ( runningWord = "running" readOnlyWord = "readonly" pausedWord = "paused" ) // reasonSep separates the level from the human explanation in a stored value: // "paused: migrating storage, back in an hour". const reasonSep = ":" func (l Level) String() string { switch l { case Running: return runningWord case ReadOnly: return readOnlyWord case Paused: return pausedWord default: // Not reachable through Parse, and not worth panicking over in a // Render path: an unknown level reads as the strictest one. return pausedWord } } // AllowsRead reports whether rendering and querying are still allowed. func (l Level) AllowsRead() bool { return l != Paused } // AllowsWrite reports whether state-changing calls are still allowed. func (l Level) AllowsWrite() bool { return l == Running } // State is a parsed pause setting: a level and an optional reason to show. // // The zero State is Running with no reason, which is what an unset setting // means, so a realm that never configures anything is never paused. type State struct { Level Level Reason string } // Parse reads a stored value: "", "running", "readonly", "paused", each // optionally followed by ": <reason>". // // ok is false for anything else. A caller that is STORING the value should // refuse on !ok; a caller that is READING one should use MustParse, which // fails closed instead. func Parse(raw string) (State, bool) { raw = strings.TrimSpace(raw) if raw == "" { return State{}, true } word, reason := raw, "" if i := strings.Index(raw, reasonSep); i >= 0 { word = strings.TrimSpace(raw[:i]) reason = strings.TrimSpace(raw[i+1:]) } switch strings.ToLower(word) { case runningWord: return State{Level: Running, Reason: reason}, true case readOnlyWord: return State{Level: ReadOnly, Reason: reason}, true case pausedWord: return State{Level: Paused, Reason: reason}, true default: return State{}, false } } // MustParse is Parse for a reader, failing closed: a value it cannot read // becomes Paused, carrying the raw text as the reason so whoever hits it can // see what is wrong. func MustParse(raw string) State { if s, ok := Parse(raw); ok { return s } return State{Level: Paused, Reason: "unreadable pause setting: " + strings.TrimSpace(raw)} } // String renders a State back into a storable value. It round-trips through // Parse, and the zero State renders empty so an unset setting stays unset. func (s State) String() string { if s.Level == Running && s.Reason == "" { return "" } if s.Reason == "" { return s.Level.String() } return s.Level.String() + reasonSep + " " + s.Reason } // Strictest returns whichever of the two stops more, keeping the reason that // belongs to the level it returns. On a tie the first argument wins, so a // caller passing (global, scoped) keeps the global explanation when both say // the same thing, and the scoped one when it is the stricter. func Strictest(a, b State) State { if b.Level > a.Level { return b } return a } // AllowsRead reports whether rendering and querying are still allowed. func (s State) AllowsRead() bool { return s.Level.AllowsRead() } // AllowsWrite reports whether state-changing calls are still allowed. func (s State) AllowsWrite() bool { return s.Level.AllowsWrite() } // IsPaused reports whether anything at all is being held back. It is true for // ReadOnly as well as Paused, because the question a caller usually means by // "is it paused" is "is it behaving normally". func (s State) IsPaused() bool { return s.Level != Running } // AssertWritable aborts unless writes are allowed. This is the one a realm // puts at the top of every state-changing function. func (s State) AssertWritable() { if !s.AllowsWrite() { panic(s.message("writes are paused")) } } // AssertReadable aborts unless reads are allowed. Most realms do not want this // in Render: a page that aborts tells a reader nothing, while Notice tells // them what happened and when to come back. func (s State) AssertReadable() { if !s.AllowsRead() { panic(s.message("paused")) } } func (s State) message(what string) string { if s.Reason == "" { return what } return what + reasonSep + " " + s.Reason } // Notice is the banner a Render puts above its content, or "" while running. // // It is a markdown blockquote, so it reads as set apart from the page without // needing any style the renderer might not have. func (s State) Notice() string { if s.Level == Running { return "" } head := "**Paused.** This realm is not accepting anything right now." if s.Level == ReadOnly { head = "**Read-only.** This realm is still readable, but not accepting changes." } if s.Reason == "" { return "> " + head } // The reason is text a manager typed, and it lands inside a blockquote // where a newline would end the quote and let the rest render as page // content. Folding it to a single line keeps the banner one block. return "> " + head + " " + oneLine(s.Reason) } // oneLine collapses every run of whitespace, newlines included, into single // spaces. func oneLine(s string) string { var b strings.Builder space := false for i := 0; i < len(s); i++ { c := s[i] if c == ' ' || c == '\t' || c == '\n' || c == '\r' { space = true continue } if space && b.Len() > 0 { b.WriteByte(' ') } space = false b.WriteByte(c) } return b.String() }
  8. #8pausable_test.gno
  9. #9package pausable import ( "testing" "gno.land/p/nt/uassert/v0" ) func TestParse(t *testing.T) { tests := []struct { raw string wantLevel Level wantReason string wantOK bool }{ {"", Running, "", true}, {" ", Running, "", true}, {"running", Running, "", true}, {"readonly", ReadOnly, "", true}, {"paused", Paused, "", true}, {"Paused", Paused, "", true}, // case-insensitive {" paused ", Paused, "", true}, {"paused: migrating storage", Paused, "migrating storage", true}, {"readonly: spaced ", ReadOnly, "spaced", true}, {"running: nothing wrong", Running, "nothing wrong", true}, // A reason containing the separator keeps everything after the first // one, so a URL or a time survives. {"paused: back at 14:00 UTC", Paused, "back at 14:00 UTC", true}, // Not recognised. These are exactly the values that must NOT read as // Running. {"yes", Running, "", false}, {"true", Running, "", false}, {"1", Running, "", false}, {"stop", Running, "", false}, {"read-only", Running, "", false}, } for _, tt := range tests { got, ok := Parse(tt.raw) uassert.Equal(t, tt.wantOK, ok, "Parse("+tt.raw+") ok") if !ok { continue } uassert.Equal(t, int(tt.wantLevel), int(got.Level), "Parse("+tt.raw+") level") uassert.Equal(t, tt.wantReason, got.Reason, "Parse("+tt.raw+") reason") } } // TestMustParseFailsClosed is the whole safety argument of this package. Every // value here is something a person might plausibly type, and every one of them // has to stop the realm rather than quietly leave it running. func TestMustParseFailsClosed(t *testing.T) { for _, raw := range []string{"yes", "true", "1", "on", "stop", "PAUSE", "pasued"} { got := MustParse(raw) uassert.Equal(t, int(Paused), int(got.Level), "MustParse("+raw+") must fail closed") uassert.False(t, got.AllowsWrite(), "MustParse("+raw+") must refuse writes") uassert.True(t, len(got.Reason) > 0, "MustParse("+raw+") must say why") } // And the values it does understand are untouched. uassert.Equal(t, int(Running), int(MustParse("").Level)) uassert.Equal(t, int(ReadOnly), int(MustParse("readonly").Level)) } func TestAllows(t *testing.T) { tests := []struct { level Level read bool write bool isPaused bool }{ {Running, true, true, false}, {ReadOnly, true, false, true}, {Paused, false, false, true}, } for _, tt := range tests { s := State{Level: tt.level} uassert.Equal(t, tt.read, s.AllowsRead(), tt.level.String()+" read") uassert.Equal(t, tt.write, s.AllowsWrite(), tt.level.String()+" write") uassert.Equal(t, tt.isPaused, s.IsPaused(), tt.level.String()+" isPaused") } } func TestStrictest(t *testing.T) { run := State{Level: Running} ro := State{Level: ReadOnly, Reason: "scoped"} paused := State{Level: Paused, Reason: "global"} uassert.Equal(t, int(ReadOnly), int(Strictest(run, ro).Level)) uassert.Equal(t, int(ReadOnly), int(Strictest(ro, run).Level)) uassert.Equal(t, int(Paused), int(Strictest(paused, ro).Level)) uassert.Equal(t, int(Paused), int(Strictest(ro, paused).Level)) // A stale per-realm entry can never re-open a realm during a global halt. // This is the property the whole precedence rule exists for. uassert.Equal(t, int(Paused), int(Strictest(paused, run).Level), "a per-realm 'running' cannot defeat a global pause") // The reason travels with the level that won. uassert.Equal(t, "global", Strictest(paused, ro).Reason) uassert.Equal(t, "scoped", Strictest(run, ro).Reason) // On a tie the first argument wins, so a caller passing (global, scoped) // keeps the global explanation. a := State{Level: Paused, Reason: "first"} b := State{Level: Paused, Reason: "second"} uassert.Equal(t, "first", Strictest(a, b).Reason) } func TestStringRoundTrips(t *testing.T) { for _, raw := range []string{"", "readonly", "paused", "paused: migrating storage"} { s, ok := Parse(raw) uassert.True(t, ok, "Parse("+raw+")") again, ok := Parse(s.String()) uassert.True(t, ok, "Parse(String()) for "+raw) uassert.Equal(t, int(s.Level), int(again.Level), "level round-trip for "+raw) uassert.Equal(t, s.Reason, again.Reason, "reason round-trip for "+raw) } // The zero State renders empty, so an unset setting stays unset rather // than being written back as the word "running". uassert.Equal(t, "", State{}.String()) uassert.Equal(t, "running: why", State{Reason: "why"}.String()) } func TestAsserts(cur realm, t *testing.T) { State{}.AssertWritable() State{}.AssertReadable() State{Level: ReadOnly}.AssertReadable() uassert.PanicsWithMessage(t, cur, "writes are paused", func() { State{Level: ReadOnly}.AssertWritable() }) uassert.PanicsWithMessage(t, cur, "writes are paused: migrating", func() { State{Level: Paused, Reason: "migrating"}.AssertWritable() }) uassert.PanicsWithMessage(t, cur, "paused: migrating", func() { State{Level: Paused, Reason: "migrating"}.AssertReadable() }) } func TestNotice(t *testing.T) { uassert.Equal(t, "", State{}.Notice(), "a running realm shows no banner") uassert.Equal(t, "> **Read-only.** This realm is still readable, but not accepting changes.", State{Level: ReadOnly}.Notice()) uassert.Equal(t, "> **Paused.** This realm is not accepting anything right now. back in an hour", State{Level: Paused, Reason: "back in an hour"}.Notice()) } // TestNoticeStaysOneBlock covers the reason a manager typed. It lands inside a // markdown blockquote, where a newline ends the quote and lets the rest render // as ordinary page content, so it is folded to one line. func TestNoticeStaysOneBlock(t *testing.T) { s := State{Level: Paused, Reason: "line one\n\nline two\n# not a heading"} uassert.Equal(t, "> **Paused.** This realm is not accepting anything right now. line one line two # not a heading", s.Notice()) }
#4AddPackagegno.land/p/moul/x/framelab/v07 arguments
Attached funds
5000000ugnot

Arguments · 7

  1. #1framelab
  2. #2README.md
  3. #3# `gno.land/p/moul/x/framelab/v0` A 30-line probe for one language property: **pure-package code runs in the frame of the realm that imported it**, when that realm forwards its own `cur`. The instance-per-realm pattern (`p/moul/x/pair/v0`) rests entirely on this. If the frame shifted, a shared `p/` could not move an instance's funds and every instance would have to carry its own copy of the logic. `r/moul/x/framelab/probe/v0` is the realm that exercises it, and asserts: - pure-package code reports the **instance realm's** path and address; - the forwarded `rlm.IsCurrent()` is still true, so grc20's spoof check passes; - a `TransferFrom` issued from inside the pure package credits the **instance realm's** own address. It also pins the two rules found while proving it: a pure package cannot declare a crossing function (`func F(cur realm, ...)`), and the realm parameter cannot be named `cur` there. Hence the `(_ int, rlm realm, ...)` shape used throughout. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/p/moul/x/framelab/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/p/moul/x/framelab/v0/deps.png) > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4framelab.gno
  5. #5// Package framelab probes, at runtime, which realm frame pure-package code // executes in when a realm forwards its own `cur` into it. // // The whole instance-per-realm ("factory") pattern rests on the answer: if a // p/ helper acting on `cur` binds to the *importing realm's* identity, then a // 20-line instance realm can hold state and funds while every line of logic // lives in one shared pure package. If instead the frame shifts, the pattern // is dead and each instance has to carry its own copy of the logic. package framelab import "gno.land/p/nt/grc20/v0" // The leading `_ int` is mandatory, not cosmetic: gno rejects a function whose // FIRST parameter is `realm` when it is declared in a pure package ("crossing // function declared in non-realm package"). Same reason grc20's Teller methods // are shaped `Transfer(_ int, rlm realm, ...)`. The name matters too: only the // first realm argument of a real crossing function may be called `cur`. // // Who reports the frame identity as seen from inside pure-package code. func Who(_ int, rlm realm) (pkgPath string, addr address, isCurrent bool) { return rlm.PkgPath(), rlm.Address(), rlm.IsCurrent() } // Pull performs the operation the pattern actually needs: a GRC20 pull done by // pure-package code, under the importing realm's identity. It returns the // address the teller bound to, which is the claim under test. func Pull(_ int, rlm realm, tok *grc20.Token, from address, amount int64) address { self := rlm.Address() err := tok.RealmTeller(0, rlm).TransferFrom(0, rlm, from, self, amount) if err != nil { panic(err) } return self }
  6. #6gnomod.toml
  7. #7module = "gno.land/p/moul/x/framelab/v0" gno = "0.9"
#5AddPackagegno.land/p/moul/x/games/clock/v09 arguments
Attached funds
8000000ugnot

Arguments · 9

  1. #1clock
  2. #2README.md
  3. #3# `gno.land/p/moul/x/games/clock/v0` **A deadline that actions push forward, and that still ends**: `New`, `Bump`, `BumpBy`, `BumpShare`, `Expired`, `Remaining`, `Deadline`, `HardEnd`, `Final`. ```go import "gno.land/p/moul/x/games/clock/v0" c, _ := clock.New(height, 2000, 300, 100000) // now, window, floor, life c.Deadline() // height+2000 c.BumpShare(h, 20) // a write buys a fifth of the gap to the hard end c.Expired(h) // the game is over, and cannot be reopened c.Final() // the deadline has hit the hard end: holding now beats acting ``` Every last-action-wins game has this clock, and written the obvious way it fails in one of two opposite directions. **With a constant extension it never ends.** The pot grows faster than the price of the next action, so the pot-to-price ratio *rises*, there is always a rational next action, and the second-to-last actor is always the mark. `life` is the terminator: an absolute end, fixed when the clock opens, that no bump can pass. **With a decaying extension it ends too suddenly.** A grant that shrinks toward zero makes the last stretch shorter than the time a transaction needs to land, so whoever is holding at that moment wins by being unreachable rather than by paying. `floor` is the anti-snipe: a bump always leaves at least that much on the clock. Four invariants, deliberately in the library and not in the caller: - **An expired clock never restarts.** A late action cannot reopen a settled game, which is the difference between a closed pot and a stolen one. - **The deadline never moves backwards.** A small grant late in the game leaves the existing deadline alone instead of shortening it. - **A bump always leaves at least the floor**, however little there was to share. - **The deadline never passes the hard end**, against any grant, including one that would overflow an int64. **The caller owns the policy, the clock owns the guards.** `BumpBy` takes whatever grant the game computed: a constant, a function of the amount paid, anything. `BumpShare` is the one worth knowing, and it is a share of the distance to the **hard end**, not of the time remaining: `now + (deadline-now)*pct/100` is always *before* the deadline, so the obvious formula extends nothing at all. A share of the gap to the hard end makes each action close a fraction of what is left, so the grants shrink as the game runs, which is the pressure a constant grant never relieves. Times are `int64` and the unit is the caller's, block heights or unix seconds, as long as it is consistent. Nothing here reads the chain, so a realm can test its whole endgame without one. **Live game:** [`r/moul/x/games/lastwords`](https://github.com/moul/gno-contracts/tree/main/r/moul/x/games/lastwords) · render it at [`/r/moul/x/games/lastwords/v0`](https://gno.land/r/moul/x/games/lastwords/v0). <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4clock.gno
  5. #5// Package clock is a deadline that actions push forward, with the two guards a // naive "extend on every action" clock lacks. // // Any last-action-wins game has the same clock: every action buys more time, so // the game ends only when nobody acts. Written naively it has two failure modes, // and both have been observed on chain rather than reasoned about. // // The first is that it never ends. If the grant per action is constant, a // contested pot is extended forever and the prize is always worth one more // action. Life is the terminator: an absolute end, fixed when the clock opens, // that no bump can pass. A game with real money in it needs one. // // The second is the opposite, and it shows up as soon as the grant decays to // relieve the pressure of the first: a grant that is a share of the life left // shrinks toward zero near the end, so the last stretch becomes too short for a // transaction to land in, and whoever is holding when that happens simply wins. // Floor is the anti-snipe: a bump always leaves at least that much on the // clock, however little is left to share. // // Times are int64 and the unit is the caller's: block heights or unix seconds, // as long as it is consistent. Nothing here reads the chain, so a realm can // test its whole endgame without one. // // A game built on this package is at // [r/moul/x/games/lastwords](/r/moul/x/games/lastwords/v0). package clock import "errors" const maxInt64 = int64(9223372036854775807) var ( // ErrBadWindow is returned when the opening grant is not positive. ErrBadWindow = errors.New("clock: window must be positive") // ErrBadFloor is returned when the floor is negative or exceeds the window. ErrBadFloor = errors.New("clock: floor must be between zero and the window") // ErrBadLife is returned when the life is negative, or positive but shorter // than the opening window, which would close the clock before it opened. ErrBadLife = errors.New("clock: life must be zero (unbounded) or at least the window") // ErrBadShare is returned when a share is outside 0..100. ErrBadShare = errors.New("clock: share must be a percentage between 0 and 100") // ErrUnbounded is returned when a share of the remaining life is asked of a // clock that has no hard end to measure against. ErrUnbounded = errors.New("clock: a share of the life needs a bounded clock") // ErrOverflow is returned when the requested times do not fit in an int64. ErrOverflow = errors.New("clock: times overflow int64") ) // Clock is a deadline plus the three bounds that make it terminate: the window // a bump grants, the floor a bump always leaves, and the life it can never pass. // // The zero Clock is not usable; build one with New. type Clock struct { start int64 deadline int64 window int64 floor int64 life int64 // 0 means unbounded } // New opens a clock at now, due now+window. // // window is what a plain Bump grants. floor is the minimum a bump leaves on the // clock, and must not exceed the window (a floor above the window would mean // every bump granting more than the window, which is not a floor). life is the // total lifetime from now, after which the deadline can no longer move; zero // leaves the clock unbounded, which is the shape that never terminates, so pass // it deliberately. func New(now, window, floor, life int64) (*Clock, error) { if window <= 0 { return nil, ErrBadWindow } if floor < 0 || floor > window { return nil, ErrBadFloor } if life < 0 || (life > 0 && life < window) { return nil, ErrBadLife } if window > maxInt64-now || life > maxInt64-now { return nil, ErrOverflow } return &Clock{ start: now, deadline: now + window, window: window, floor: floor, life: life, }, nil } // Start returns when the clock opened. func (c *Clock) Start() int64 { return c.start } // Deadline returns the time the clock currently expires at. func (c *Clock) Deadline() int64 { return c.deadline } // Window returns the grant a plain Bump aims for. func (c *Clock) Window() int64 { return c.window } // Floor returns the minimum a bump leaves on the clock. func (c *Clock) Floor() int64 { return c.floor } // Life returns the configured lifetime, zero when unbounded. func (c *Clock) Life() int64 { return c.life } // HardEnd returns the time no bump can push the deadline past, or zero when the // clock is unbounded. func (c *Clock) HardEnd() int64 { if c.life == 0 { return 0 } return c.start + c.life } // Expired reports whether the clock has run out at now. The deadline itself is // past it: a clock due at 100 is expired at 100, so an action and an expiry can // never both be valid at the same instant. func (c *Clock) Expired(now int64) bool { return now >= c.deadline } // Remaining returns how much time is left at now, never negative. func (c *Clock) Remaining(now int64) int64 { if now >= c.deadline { return 0 } return c.deadline - now } // Elapsed returns how long the clock has been open at now, never negative. func (c *Clock) Elapsed(now int64) int64 { if now <= c.start { return 0 } return now - c.start } // Final reports whether the deadline has reached the hard end, so no further // bump can move it. A game should say so on its page: it is the only moment at // which holding is worth more than acting. func (c *Clock) Final() bool { return c.life > 0 && c.deadline >= c.HardEnd() } // Bump extends the deadline by the window. See BumpBy for the guards. func (c *Clock) Bump(now int64) int64 { return c.BumpBy(now, c.window) } // BumpBy extends the deadline to now+grant and returns the new deadline. // // The caller owns the policy, so grant is whatever it wants: a constant, a share // of what is left, a function of the amount paid. The clock owns the four // invariants that policy keeps getting wrong: // // - An expired clock never restarts. Once it has run out the deadline is // frozen, so a late action cannot reopen a settled game. // - The deadline never moves backwards. A small grant late in the game leaves // the existing deadline alone rather than shortening it. // - A bump always leaves at least the floor on the clock, so a decaying grant // cannot be shaved below the time a transaction needs to land. // - The deadline never passes the hard end. func (c *Clock) BumpBy(now, grant int64) int64 { if c.Expired(now) { return c.deadline } if grant < c.floor { grant = c.floor } want := c.deadline if grant <= maxInt64-now && now+grant > want { want = now + grant } else if grant > maxInt64-now { want = maxInt64 } if end := c.HardEnd(); c.life > 0 && want > end { want = end } c.deadline = want return c.deadline } // BumpShare grants pct percent of the time left until the hard end, and is the // decaying grant a converging game wants. // // A grant that is a share of what is left on the DEADLINE cannot extend // anything: now+(deadline-now)*pct/100 is always before the deadline itself. A // share of the distance to the hard end is the one that works. Each action // closes a fraction of the gap, so the deadline crawls toward the hard end and // the grants shrink as it does, which is exactly the "the pot is worth one more // action" pressure a fixed grant never relieves. The floor is what stops the // tail of that curve from becoming too short to act in. // // It requires a bounded clock: there is no distance to share without one. func (c *Clock) BumpShare(now, pct int64) (int64, error) { if c.life == 0 { return c.deadline, ErrUnbounded } if pct < 0 || pct > 100 { return c.deadline, ErrBadShare } left := c.HardEnd() - now if left < 0 { left = 0 } var grant int64 if left > maxInt64/100 { grant = left / 100 * pct // lossy, but only where exactness is meaningless } else { grant = left * pct / 100 // multiply first, so a small gap still grants } return c.BumpBy(now, grant), nil }
  6. #6clock_test.gno
  7. #7package clock import ( "testing" "gno.land/p/nt/uassert/v0" ) func mustNew(t *testing.T, now, window, floor, life int64) *Clock { t.Helper() c, err := New(now, window, floor, life) uassert.NoError(t, err) return c } func TestNewValidation(t *testing.T) { cases := []struct { name string now, window, floor, life int64 want error }{ {"zero window", 0, 0, 0, 0, ErrBadWindow}, {"negative window", 0, -1, 0, 0, ErrBadWindow}, {"negative floor", 0, 100, -1, 0, ErrBadFloor}, {"floor above window", 0, 100, 101, 0, ErrBadFloor}, {"negative life", 0, 100, 10, -1, ErrBadLife}, {"life shorter than window", 0, 100, 10, 99, ErrBadLife}, {"window overflows", maxInt64 - 5, 100, 0, 0, ErrOverflow}, {"life overflows", maxInt64 - 500, 100, 0, 1000, ErrOverflow}, {"unbounded is legal", 0, 100, 10, 0, nil}, {"life equal to window is legal", 0, 100, 10, 100, nil}, {"floor equal to window is legal", 0, 100, 100, 0, nil}, } for _, tc := range cases { _, err := New(tc.now, tc.window, tc.floor, tc.life) if tc.want == nil { uassert.NoError(t, err, tc.name) continue } uassert.ErrorIs(t, err, tc.want, tc.name) } } func TestOpensDueAtWindow(t *testing.T) { c := mustNew(t, 1000, 100, 10, 500) uassert.Equal(t, int64(1000), c.Start()) uassert.Equal(t, int64(1100), c.Deadline()) uassert.Equal(t, int64(1500), c.HardEnd()) uassert.Equal(t, int64(100), c.Remaining(1000)) uassert.Equal(t, int64(0), c.Elapsed(1000)) uassert.False(t, c.Expired(1099)) uassert.True(t, c.Expired(1100), "the deadline itself is expired") uassert.False(t, c.Final()) } func TestUnboundedHasNoHardEnd(t *testing.T) { c := mustNew(t, 0, 100, 0, 0) uassert.Equal(t, int64(0), c.HardEnd()) uassert.False(t, c.Final(), "an unbounded clock is never final") c.Bump(90) c.Bump(180) uassert.Equal(t, int64(280), c.Deadline(), "nothing caps it") } // TestBumpNeverMovesBackwards is the invariant a caller supplying its own grant // gets wrong first: a late, small grant must not shorten the clock. func TestBumpNeverMovesBackwards(t *testing.T) { c := mustNew(t, 0, 1000, 0, 0) uassert.Equal(t, int64(1000), c.Deadline()) uassert.Equal(t, int64(1000), c.BumpBy(10, 5), "now+5 is before the deadline") uassert.Equal(t, int64(1000), c.BumpBy(900, 0), "a zero grant leaves it alone") uassert.Equal(t, int64(1100), c.BumpBy(900, 200), "a grant past the deadline moves it") } // TestFloorSurvivesATinyGrant is the anti-snipe: whatever the policy grants, a // bump leaves at least the floor, so the last stretch stays reachable. func TestFloorSurvivesATinyGrant(t *testing.T) { c := mustNew(t, 0, 1000, 50, 0) c.BumpBy(990, 1) uassert.Equal(t, int64(1040), c.Deadline(), "1 was raised to the floor of 50") uassert.Equal(t, int64(50), c.Remaining(990)) c2 := mustNew(t, 0, 1000, 50, 0) c2.BumpBy(990, 0) uassert.Equal(t, int64(50), c2.Remaining(990), "even a zero grant leaves the floor") } // TestHardEndCapsEveryBump is the terminator: without it a contested clock is // extended forever and the game has no end. func TestHardEndCapsEveryBump(t *testing.T) { c := mustNew(t, 0, 100, 10, 250) uassert.Equal(t, int64(250), c.HardEnd()) c.BumpBy(90, 100) uassert.Equal(t, int64(190), c.Deadline()) c.BumpBy(180, 100) uassert.Equal(t, int64(250), c.Deadline(), "capped at the hard end, not 280") uassert.True(t, c.Final()) c.BumpBy(240, 1000000) uassert.Equal(t, int64(250), c.Deadline(), "the cap holds against any grant") uassert.Equal(t, int64(10), c.Remaining(240), "and it beats the floor") } // TestExpiredClockNeverRestarts stops a late action from reopening a settled // game, which is the difference between a closed pot and a stolen one. func TestExpiredClockNeverRestarts(t *testing.T) { c := mustNew(t, 0, 100, 10, 0) uassert.True(t, c.Expired(100)) uassert.Equal(t, int64(100), c.BumpBy(100, 500), "exactly at the deadline") uassert.Equal(t, int64(100), c.Bump(5000), "long after") uassert.Equal(t, int64(0), c.Remaining(100)) settled := mustNew(t, 0, 100, 10, 1000) uassert.True(t, settled.Expired(100)) got, err := settled.BumpShare(200, 50) uassert.NoError(t, err) uassert.Equal(t, int64(100), got, "not even a large share reopens it") } func TestBumpShareValidation(t *testing.T) { c := mustNew(t, 0, 100, 0, 500) _, err := c.BumpShare(10, -1) uassert.ErrorIs(t, err, ErrBadShare) _, err = c.BumpShare(10, 101) uassert.ErrorIs(t, err, ErrBadShare) uassert.Equal(t, int64(100), c.Deadline(), "a rejected share changes nothing") unbounded := mustNew(t, 0, 100, 0, 0) _, err = unbounded.BumpShare(10, 50) uassert.ErrorIs(t, err, ErrUnbounded, "no hard end, nothing to take a share of") } // TestBumpShareDecays is why the share grant exists: each action closes a // fraction of the gap to the hard end, so the grants shrink as the game runs // and the pressure to act one more time is relieved instead of compounding. func TestBumpShareDecays(t *testing.T) { c := mustNew(t, 0, 100, 1, 1000) first, err := c.BumpShare(10, 50) uassert.NoError(t, err) uassert.Equal(t, int64(505), first, "half the 990 left to the hard end") second, err := c.BumpShare(500, 50) uassert.NoError(t, err) uassert.Equal(t, int64(750), second, "half of the 500 now left, a smaller grant") third, err := c.BumpShare(700, 50) uassert.NoError(t, err) uassert.Equal(t, int64(850), third, "smaller again") uassert.True(t, third < c.HardEnd(), "it approaches the hard end without reaching it") } // TestBumpShareRoundingStillGrants is the multiply-first lesson: a share of a // gap smaller than the divisor must not truncate to a zero grant. func TestBumpShareRoundingStillGrants(t *testing.T) { // 52 due, 50 left to the hard end: a tenth of that is 5, and dividing // before multiplying would truncate it to a grant of zero. c := mustNew(t, 0, 52, 0, 100) got, err := c.BumpShare(50, 10) uassert.NoError(t, err) uassert.Equal(t, int64(55), got, "a tenth of the 50 left is 5, not 0") } func TestBumpShareRespectsFloorAndCap(t *testing.T) { c := mustNew(t, 0, 100, 20, 150) got, err := c.BumpShare(95, 10) // a tenth of the 55 left is 5, floored to 20 uassert.NoError(t, err) uassert.Equal(t, int64(115), got) got, err = c.BumpShare(110, 100) // would be 150 exactly, and never past it uassert.NoError(t, err) uassert.Equal(t, int64(150), got) uassert.True(t, c.Final()) } // TestHugeGrantDoesNotWrap keeps a bump total at the extremes: a wrapped // deadline would land in the past and settle the game instantly. func TestHugeGrantDoesNotWrap(t *testing.T) { c := mustNew(t, 0, 100, 0, 0) uassert.Equal(t, maxInt64, c.BumpBy(50, maxInt64), "clamped, not wrapped") uassert.True(t, c.Deadline() > 0) big := mustNew(t, maxInt64/2, 1000, 0, maxInt64/4) got, err := big.BumpShare(maxInt64/2+1, 100) uassert.NoError(t, err) uassert.True(t, got > maxInt64/2, "a huge remaining share does not wrap") } func TestElapsedAndRemainingAreNeverNegative(t *testing.T) { c := mustNew(t, 100, 100, 0, 0) uassert.Equal(t, int64(0), c.Elapsed(50), "before the start") uassert.Equal(t, int64(100), c.Remaining(100)) uassert.Equal(t, int64(0), c.Remaining(9999), "long past the deadline") uassert.Equal(t, int64(9899), c.Elapsed(9999)) }
  8. #8gnomod.toml
  9. #9module = "gno.land/p/moul/x/games/clock/v0" gno = "0.9"
#6AddPackagegno.land/p/moul/x/games/prorata/v09 arguments
Attached funds
6000000ugnot

Arguments · 9

  1. #1prorata
  2. #2README.md
  3. #3# `gno.land/p/moul/x/games/prorata/v0` **A weighted split of an integer amount that adds up**: `Split`, `Share`, `Total`. ```go import "gno.land/p/moul/x/games/prorata/v0" prorata.Split(100, []int64{1, 2, 3}) // [17 33 50], and it sums to exactly 100 prorata.Split(10, []int64{1, 1, 1}) // [4 3 3], the dust goes to the lowest index prorata.Share(100, 1, 3) // 33, floored: "what would I get" ``` Every payout in a game is this operation: a pot, a roster, a weight each. Written the obvious way, `amount*weight/total` per claimant, it is wrong in two ways that only show up once there is real money in the contract. **The shares do not add up.** Each one is rounded down, so their sum is short by up to one unit per claimant. That dust either sits in the realm forever or lands on whoever the loop happened to pay last, which is a rule nobody chose. `Split` distributes by **largest remainder**: floor every share, then hand the leftover units out one at a time to the claimants whose truncated fraction was largest, ties going to the lower index. The result sums to **exactly** the amount, and the rule is deterministic, so every node computes the same split and a `Render` of it does not change between calls. **`amount*weight` overflows an int64** long before either factor does, which turns a large payout negative. Nothing here ever computes that product: it splits the amount into whole multiples of the total and what is left over, so `amount*weight/total` becomes `q*weight + r*weight/total` with `r` already smaller than the total. A pot of 10^18 ugnot across billion-unit weights is exact, and what genuinely cannot fit returns `ErrOverflow` rather than wrapping. Two smaller decisions, each with a test: - **A zero weight is paid nothing, including no remainder unit.** Somebody with no claim is not handed dust. - **An empty roster, or one where every weight is zero, is an error**, not an empty split. Silently returning nothing would strand the amount, and only the caller knows where it should go instead. Nothing here reads the chain and nothing holds coins: the caller owns the roster and the custody. **Live game:** [`r/moul/x/games/lastwords`](https://github.com/moul/gno-contracts/tree/main/r/moul/x/games/lastwords) · render it at [`/r/moul/x/games/lastwords/v0`](https://gno.land/r/moul/x/games/lastwords/v0). <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4gnomod.toml
  5. #5module = "gno.land/p/moul/x/games/prorata/v0" gno = "0.9"
  6. #6prorata.gno
  7. #7// Package prorata splits an integer amount across weighted claimants without // leaking or inventing a unit. // // Every payout in a game is this operation: a pot, a roster, and a weight each. // Written the obvious way, `amount*weight/total` per claimant, it is wrong in // two ways that only show up with real money in the contract. The shares are // each rounded down, so their sum is short of the amount by up to one unit per // claimant, and that dust either accumulates in the contract forever or gets // handed to whoever the code happens to pay last. And `amount*weight` overflows // an int64 long before either factor does, which turns a large payout negative. // // Split fixes both. It distributes by largest remainder: floor every share, // then hand the leftover units out one at a time to the claimants whose // truncated fraction was largest, ties going to the lower index. The result // sums to EXACTLY the amount, every time, and the rule is deterministic, so // every node computes the same split and a Render of it does not change between // calls. // // Nothing here reads the chain and nothing holds coins: the caller owns the // roster and the custody. A game built on this package is at // [r/moul/x/games/lastwords](/r/moul/x/games/lastwords/v0). package prorata import ( "errors" "sort" ) const maxInt64 = int64(9223372036854775807) var ( // ErrNegativeAmount is returned when the amount to split is negative. ErrNegativeAmount = errors.New("prorata: amount must not be negative") // ErrNegativeWeight is returned when any weight is negative. ErrNegativeWeight = errors.New("prorata: weights must not be negative") // ErrNoWeight is returned when there is nobody to pay, or every weight is // zero. The caller must decide where the amount goes instead: silently // returning an empty split would strand it. ErrNoWeight = errors.New("prorata: total weight is zero, nothing to split across") // ErrOverflow is returned when the arithmetic does not fit in an int64. ErrOverflow = errors.New("prorata: weights overflow int64") ) // Split divides amount across weights and returns one share per weight, in the // same order. The shares sum to exactly amount. // // A zero weight is allowed and receives nothing, including no remainder unit: // somebody with no claim is not paid dust. func Split(amount int64, weights []int64) ([]int64, error) { if amount < 0 { return nil, ErrNegativeAmount } total, err := Total(weights) if err != nil { return nil, err } if total == 0 { return nil, ErrNoWeight } shares := make([]int64, len(weights)) if amount == 0 { return shares, nil } // Floor each share, and keep the truncated numerator to rank by. rems := make([]entry, 0, len(weights)) assigned := int64(0) for i, w := range weights { if w == 0 { continue } share, rem, err := divide(amount, w, total) if err != nil { return nil, err } shares[i] = share assigned += share rems = append(rems, entry{index: i, rem: rem}) } // Hand the leftover out by largest remainder. There are strictly fewer // leftover units than claimants, so one pass over the ranking is enough. left := amount - assigned if left <= 0 { return shares, nil } sort.Sort(byRemainder(rems)) for i := 0; i < len(rems) && left > 0; i++ { shares[rems[i].index]++ left-- } return shares, nil } // Share returns what one weight is owed out of amount, rounded down. It is the // "what would I get" query, and deliberately does NOT account for the // remainder: use Split when the shares have to add up. func Share(amount, weight, total int64) (int64, error) { if amount < 0 { return 0, ErrNegativeAmount } if weight < 0 || total < 0 { return 0, ErrNegativeWeight } if total == 0 { return 0, ErrNoWeight } if weight > total { weight = total } share, _, err := divide(amount, weight, total) return share, err } // Total sums weights, refusing an int64 overflow rather than wrapping into a // negative total that would make every share nonsense. func Total(weights []int64) (int64, error) { sum := int64(0) for _, w := range weights { if w < 0 { return 0, ErrNegativeWeight } if w > maxInt64-sum { return 0, ErrOverflow } sum += w } return sum, nil } // divide returns amount*weight/total rounded down, plus the remainder of that // division, without ever computing amount*weight. // // It splits the amount into whole multiples of total and what is left over: // amount = q*total + r, so amount*weight/total is exactly q*weight + // r*weight/total. The first term is exact and the second multiplies a value // already smaller than total, which is what keeps the product in range. func divide(amount, weight, total int64) (share, rem int64, err error) { q := amount / total r := amount % total if weight != 0 && q > maxInt64/weight { return 0, 0, ErrOverflow } whole := q * weight if weight != 0 && r > maxInt64/weight { return 0, 0, ErrOverflow } part := r * weight if whole > maxInt64-part/total { return 0, 0, ErrOverflow } return whole + part/total, part % total, nil } // entry is one claimant's truncated fraction, for ranking the leftover. type entry struct { index int rem int64 } // byRemainder ranks by largest truncated remainder, ties by lowest index, so // the split is identical on every node. type byRemainder []entry func (b byRemainder) Len() int { return len(b) } func (b byRemainder) Swap(i, j int) { b[i], b[j] = b[j], b[i] } func (b byRemainder) Less(i, j int) bool { if b[i].rem != b[j].rem { return b[i].rem > b[j].rem } return b[i].index < b[j].index }
  8. #8prorata_test.gno
  9. #9package prorata import ( "testing" "gno.land/p/nt/uassert/v0" ) func sum(xs []int64) int64 { t := int64(0) for _, x := range xs { t += x } return t } func TestSplitTable(t *testing.T) { cases := []struct { name string amount int64 weights []int64 want []int64 }{ {"exact thirds", 99, []int64{1, 1, 1}, []int64{33, 33, 33}}, {"one unit of dust, to the lowest index", 10, []int64{1, 1, 1}, []int64{4, 3, 3}}, {"two units of dust", 11, []int64{1, 1, 1}, []int64{4, 4, 3}}, {"uneven weights", 100, []int64{1, 2, 3}, []int64{17, 33, 50}}, {"a single claimant takes it all", 7, []int64{5}, []int64{7}}, {"zero weight is paid nothing", 10, []int64{0, 1, 1}, []int64{0, 5, 5}}, {"zero weight is not paid dust either", 10, []int64{0, 1, 1, 1}, []int64{0, 4, 3, 3}}, {"nothing to split", 0, []int64{3, 1}, []int64{0, 0}}, {"amount smaller than the roster", 2, []int64{1, 1, 1, 1}, []int64{1, 1, 0, 0}}, {"weight dominates", 1000, []int64{999, 1}, []int64{999, 1}}, } for _, tc := range cases { got, err := Split(tc.amount, tc.weights) uassert.NoError(t, err, tc.name) uassert.Equal(t, len(tc.want), len(got), tc.name) for i := range tc.want { uassert.Equal(t, tc.want[i], got[i], tc.name) } } } // TestSplitAlwaysSumsToTheAmount is the whole point: a split that is short by // dust strands coins in the realm forever, and one that is over cannot be paid. func TestSplitAlwaysSumsToTheAmount(t *testing.T) { weightsets := [][]int64{ {1, 1, 1}, {1, 2, 3, 4, 5, 6, 7}, {7, 0, 1}, {100, 1}, {3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3}, {1}, {999983, 17, 5}, } for _, ws := range weightsets { for amount := int64(0); amount < 60; amount++ { got, err := Split(amount, ws) uassert.NoError(t, err) uassert.Equal(t, amount, sum(got), "shares must sum to the amount") } for _, amount := range []int64{1000, 123457, 999999999} { got, err := Split(amount, ws) uassert.NoError(t, err) uassert.Equal(t, amount, sum(got)) } } } // TestLargestRemainderWins pins the allocation rule, so a payout cannot quietly // change to "whoever the loop paid last". func TestLargestRemainderWins(t *testing.T) { // total 10, amount 4: floors are 0,0,1,1 with remainders 4,8,2,6. got, err := Split(4, []int64{1, 2, 3, 4}) uassert.NoError(t, err) uassert.Equal(t, int64(0), got[0], "smallest remainder, no extra unit") uassert.Equal(t, int64(1), got[1], "largest remainder, gets one") uassert.Equal(t, int64(1), got[2], "floor 1, remainder 2, gets none") uassert.Equal(t, int64(2), got[3], "second largest, gets the other") uassert.Equal(t, int64(4), sum(got)) } func TestSplitValidation(t *testing.T) { _, err := Split(-1, []int64{1}) uassert.ErrorIs(t, err, ErrNegativeAmount) _, err = Split(10, []int64{1, -1}) uassert.ErrorIs(t, err, ErrNegativeWeight) _, err = Split(10, nil) uassert.ErrorIs(t, err, ErrNoWeight, "an empty roster is the caller's problem, not dust") _, err = Split(10, []int64{0, 0}) uassert.ErrorIs(t, err, ErrNoWeight, "every weight zero is the same problem") } // TestNoOverflowOnLargePots is the second failure mode: amount*weight wraps // negative long before either factor is anywhere near the limit. func TestNoOverflowOnLargePots(t *testing.T) { // A pot of 10^18 ugnot split across weights that would overflow if // multiplied together first. pot := int64(1000000000000000000) got, err := Split(pot, []int64{3000000000, 1000000000}) uassert.NoError(t, err) uassert.Equal(t, pot, sum(got)) uassert.Equal(t, int64(750000000000000000), got[0]) uassert.Equal(t, int64(250000000000000000), got[1]) got, err = Split(maxInt64, []int64{1, 1}) uassert.NoError(t, err) uassert.Equal(t, maxInt64, sum(got), "even the largest possible pot") _, err = Total([]int64{maxInt64, 1}) uassert.ErrorIs(t, err, ErrOverflow, "a wrapped total makes every share nonsense") } func TestShare(t *testing.T) { got, err := Share(100, 1, 3) uassert.NoError(t, err) uassert.Equal(t, int64(33), got, "rounded down, the remainder is Split's job") got, err = Share(1000000000000000000, 3000000000, 4000000000) uassert.NoError(t, err) uassert.Equal(t, int64(750000000000000000), got) got, err = Share(100, 7, 3) uassert.NoError(t, err) uassert.Equal(t, int64(100), got, "a weight above the total is clamped, never over-paid") _, err = Share(10, 1, 0) uassert.ErrorIs(t, err, ErrNoWeight) _, err = Share(-1, 1, 2) uassert.ErrorIs(t, err, ErrNegativeAmount) _, err = Share(10, -1, 2) uassert.ErrorIs(t, err, ErrNegativeWeight) } func TestTotal(t *testing.T) { got, err := Total([]int64{1, 2, 3}) uassert.NoError(t, err) uassert.Equal(t, int64(6), got) got, err = Total(nil) uassert.NoError(t, err) uassert.Equal(t, int64(0), got, "empty sums to zero; Split is what refuses it") }
#7AddPackagegno.land/p/moul/x/grc20wrap/v017 arguments
Attached funds
20000000ugnot

Arguments · 17

  1. #1grc20wrap
  2. #2README.md
  3. #3# `gno.land/p/moul/x/grc20wrap/v0` **Build a new GRC20 on top of one you do not control.** `Vault` escrows an existing token and issues its own against it; `Basket` does the same over several at once. A `Policy` decides the exchange rate in both directions and whether the wrapped token may move, which is the whole personality of a wrapper. ```go import ( "gno.land/p/moul/x/grc20wrap/v0" "gno.land/p/nt/grc20/v0" ) // In your realm, over any *grc20.Token you can reach (an import, or a // gno.land/r/nt/grc20reg/v0 lookup): v := grc20wrap.NewVault(under, grc20wrap.Pool{}, "Pooled FOO", "pFOO", 4, 0, cur) // The holder approves YOUR realm's address on the underlying first, through // the underlying realm's own entry point. Then: shares, err := v.Wrap(0, cur, holder, 1000) // escrow 1000, mint shares out, err := v.Unwrap(0, cur, holder, shares) // burn shares, release escrow err = v.Donate(0, cur, patron, 500) // no mint: every share is worth more ``` ## The five policies | policy | wrapped token behaves like | |---|---| | `OneToOne` | a 1:1 custody receipt | | `Ratio{Num, Den}` | the same token re-denominated (`{1000, 1}` adds three decimals) | | `Soulbound{Base}` | a badge: wrap and unwrap freely, never transferable | | `Pool` | a share of the escrow; `Donate` pays every holder at once | | `Fee{Base, WrapBPS, UnwrapBPS}` | a haircut left behind, which over `Pool` pays whoever stays | They compose: `Fee{Base: Pool{}, UnwrapBPS: 100}` is a pool with a 1% exit fee. Writing your own means three methods and a name; embed `OneToOne` and override only what differs. ## Basket: one token backed by two ```go b := grc20wrap.NewBasket( []*grc20.Token{red, blue}, []int64{1, 2}, "Purple", "PURPLE", 4, 0, cur, ) err := b.Fuse(0, cur, holder, 100) // escrow 100 RED + 200 BLUE, mint 100 PURPLE err = b.Defuse(0, cur, holder, 40) // burn 40, hand back 40 RED + 80 BLUE ``` Proportions are fixed, and the meta-token is only ever minted against the real thing, so it cannot drift from its backing or be arbitraged. It has no price oracle and no rebalancing, because both would mean valuing the legs. ## Three things worth knowing before using it **Custody is an allowance, never a privilege.** Escrow sits at the host realm's address and moves through `grc20.RealmTeller`, which grc20 binds eagerly to that address. A wrapper can only take what a holder approved for it, and can only ever spend its own realm's balance. **Errors are returned before anything moves, panics after.** Wrapping is two ledger writes on two different tokens and gno has no rollback short of a panic, so each method validates first and returns an error while the world is still untouched, then panics if a write fails past the point of no return. Aborting the transaction is the only atomicity available. **A policy veto binds users, not realms.** `Vault.Move` is the only path a signing account has, because `MsgCall` cannot build the `realm` argument grc20's tellers require. A realm holding the wrapped token can always call `grc20.RealmTeller` and move its own balance. `Soulbound` means "no user can pass it on", not "it can never move". **Live demo:** [`r/moul/x/grc20wrapdemo`](https://github.com/moul/gno-contracts/tree/main/r/moul/x/grc20wrapdemo/v0) · render it at [`/r/moul/x/grc20wrapdemo/v0`](https://gno.land/r/moul/x/grc20wrapdemo/v0). <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/p/moul/x/grc20wrap/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/p/moul/x/grc20wrap/v0/deps.png) > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4basket.gno
  5. #5package grc20wrap import ( "math/overflow" "gno.land/p/nt/grc20/v0" "gno.land/p/nt/seqid/v0" "gno.land/p/nt/ufmt/v0" ) // leg is one component of a Basket. type leg struct { tok *grc20.Token teller grc20.Teller per int64 // units escrowed per meta unit held int64 // units escrowed so far } // Basket issues ONE meta-token over several underlying GRC20s at fixed // proportions. Minting a meta unit escrows every leg's `per` amount; redeeming // hands all of them back. // // A two-leg basket is the interesting one: it makes a pair tradable, quotable // and transferable as a single object, without a price oracle, an AMM or any // notion of what the legs are worth. The basket never values anything - it only // ever swaps a fixed bundle for a receipt and back, so it cannot be arbitraged // or drained. What it CAN do is let the market price the bundle, which is // exactly how an index token works. // // Proportions are fixed at creation. Rebalancing would mean revaluing the legs, // which needs a price, which is a different contract. type Basket struct { legs []*leg home address tok *grc20.Token led *grc20.PrivateLedger } // NewBasket issues a meta-token over tokens[i], escrowing parts[i] units of // each per meta unit. // // Both slices must be the same length, hold at least two entries, name no token // twice, and every part must be positive. As with NewVault, pass the owning // realm's own cur: escrow lands at that realm's address. func NewBasket(tokens []*grc20.Token, parts []int64, name, symbol string, decimals int, id seqid.ID, rlm realm) *Basket { if len(tokens) < 2 || len(tokens) != len(parts) { panic(ErrBadLegs) } legs := make([]*leg, 0, len(tokens)) for i, t := range tokens { if t == nil { panic(ErrNilToken) } if parts[i] <= 0 { panic(ErrBadLegs) } for _, seen := range legs { if seen.tok.ID() == t.ID() { panic(ErrBadLegs) } } legs = append(legs, &leg{ tok: t, teller: t.RealmTeller(0, rlm), per: parts[i], }) } tok, led := grc20.NewToken(name, symbol, decimals, id, rlm) return &Basket{ legs: legs, home: rlm.Address(), tok: tok, led: led, } } // Token is the meta-token this basket issues. func (b *Basket) Token() *grc20.Token { return b.tok } // Home is the escrow account: the host realm's address. func (b *Basket) Home() address { return b.home } // Legs is the number of components. func (b *Basket) Legs() int { return len(b.legs) } // Leg returns the i-th component: its token, the units escrowed per meta unit, // and the units escrowed so far. func (b *Basket) Leg(i int) (*grc20.Token, int64, int64) { l := b.legs[i] return l.tok, l.per, l.held } // Solvent reports whether every leg's escrow account still covers what the // basket recorded taking in. func (b *Basket) Solvent() bool { for _, l := range b.legs { if l.tok.BalanceOf(b.home) < l.held { return false } } return true } // Fuse escrows per*units of every leg from `from` and mints `units` of the meta // token to it. // // `from` must have approved b.Home() on EVERY leg first. The whole basket is // priced and checked before the first transfer, so a caller short on leg two // gets an error with leg one untouched. func (b *Basket) Fuse(_ int, rlm realm, from address, units int64) error { if units <= 0 { return ErrInvalidAmount } need := make([]int64, len(b.legs)) for i, l := range b.legs { n, ok := overflow.Mul64(l.per, units) if !ok { return ErrOverflow } if l.tok.BalanceOf(from) < n { return ErrShortBalance } if l.tok.Allowance(from, b.home) < n { return ErrShortAllowance } need[i] = n } // Past here every leg was checked, so a failure is an invariant // violation and the transaction must not stand. for i, l := range b.legs { if err := l.teller.TransferFrom(0, rlm, from, b.home, need[i]); err != nil { panic(err) } l.held += need[i] } if err := b.led.Mint(from, units); err != nil { panic(err) } return nil } // Defuse burns `units` of the meta token held by `to` and returns every leg's // share of the escrow to it. func (b *Basket) Defuse(_ int, rlm realm, to address, units int64) error { if units <= 0 { return ErrInvalidAmount } give := make([]int64, len(b.legs)) for i, l := range b.legs { n, ok := overflow.Mul64(l.per, units) if !ok { return ErrOverflow } if n > l.held { return ErrUnbacked } give[i] = n } if err := b.led.Burn(to, units); err != nil { return err } for i, l := range b.legs { l.held -= give[i] if err := l.teller.Transfer(0, rlm, to, give[i]); err != nil { panic(err) } } return nil } // Move transfers meta units between two accounts. func (b *Basket) Move(from, to address, amount int64) error { if amount <= 0 { return ErrInvalidAmount } return b.led.Transfer(from, to, amount) } // Allow sets `spender`'s allowance over `owner`'s meta balance. func (b *Basket) Allow(owner, spender address, amount int64) error { return b.led.Approve(owner, spender, amount) } // MoveFrom spends `spender`'s allowance over `owner`'s meta balance. func (b *Basket) MoveFrom(spender, owner, to address, amount int64) error { if amount <= 0 { return ErrInvalidAmount } return b.led.TransferFrom(owner, spender, to, amount) } // Summary renders the basket as a markdown block. func (b *Basket) Summary() string { s := ufmt.Sprintf("**%s** (%s) - meta-token over %d legs\n\n", b.tok.GetName(), b.tok.GetSymbol(), len(b.legs)) s += ufmt.Sprintf("- meta supply: %d\n", b.tok.TotalSupply()) for _, l := range b.legs { s += ufmt.Sprintf("- leg %s: %d per unit, %d escrowed\n", l.tok.GetSymbol(), l.per, l.held) } if b.Solvent() { s += "- solvent: yes\n" } else { s += "- solvent: **NO**\n" } return s }
  6. #6basket_test.gno
  7. #7package grc20wrap import ( "testing" "gno.land/p/nt/grc20/v0" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) const ( pairPath = "gno.land/r/test/pair" basketPath = "gno.land/r/test/basket" ) // mintPair issues two independent tokens from one foreign realm and funds the // holders with both. func mintPair(cur realm, t *testing.T, red, blue int64, funded ...address) (*grc20.Token, *grc20.PrivateLedger, *grc20.Token, *grc20.PrivateLedger) { t.Helper() testing.SetRealm(testing.NewCodeRealm(pairPath)) rt, rl := grc20.NewToken("Red", "RED", 4, 0, cur) bt, bl := grc20.NewToken("Blue", "BLUE", 4, 1, cur) for _, a := range funded { urequire.NoError(t, rl.Mint(a, red)) urequire.NoError(t, bl.Mint(a, blue)) } return rt, rl, bt, bl } func TestFuseAndDefuse(cur realm, t *testing.T) { red, redLed, blue, blueLed := mintPair(cur, t, 1_000, 1_000, alice) testing.SetRealm(testing.NewCodeRealm(basketPath)) b := NewBasket( []*grc20.Token{red, blue}, []int64{1, 2}, "Purple", "PURPLE", 4, 0, cur, ) approve(cur, t, redLed, alice, b.Home(), 1_000) approve(cur, t, blueLed, alice, b.Home(), 1_000) urequire.NoError(t, b.Fuse(0, cur, alice, 10)) uassert.Equal(t, int64(10), b.Token().BalanceOf(alice)) uassert.Equal(t, int64(990), red.BalanceOf(alice)) uassert.Equal(t, int64(980), blue.BalanceOf(alice)) _, per, held := b.Leg(0) uassert.Equal(t, int64(1), per) uassert.Equal(t, int64(10), held) _, per, held = b.Leg(1) uassert.Equal(t, int64(2), per) uassert.Equal(t, int64(20), held) uassert.True(t, b.Solvent()) urequire.NoError(t, b.Defuse(0, cur, alice, 4)) uassert.Equal(t, int64(6), b.Token().TotalSupply()) uassert.Equal(t, int64(994), red.BalanceOf(alice)) uassert.Equal(t, int64(988), blue.BalanceOf(alice)) // Out entirely: the basket closes empty on both legs. urequire.NoError(t, b.Defuse(0, cur, alice, 6)) uassert.Equal(t, int64(0), b.Token().TotalSupply()) _, _, held = b.Leg(0) uassert.Equal(t, int64(0), held) _, _, held = b.Leg(1) uassert.Equal(t, int64(0), held) uassert.Equal(t, int64(1_000), red.BalanceOf(alice)) uassert.Equal(t, int64(1_000), blue.BalanceOf(alice)) } // The whole basket is priced before the first transfer, so being short on the // SECOND leg leaves the first one untouched. Without the pre-pass this is where // a partially-applied mint would come from. func TestFuseChecksEveryLegBeforeMovingAny(cur realm, t *testing.T) { red, redLed, blue, blueLed := mintPair(cur, t, 1_000, 10, alice) testing.SetRealm(testing.NewCodeRealm(basketPath)) b := NewBasket( []*grc20.Token{red, blue}, []int64{1, 2}, "Purple", "PURPLE", 4, 0, cur, ) approve(cur, t, redLed, alice, b.Home(), 1_000) approve(cur, t, blueLed, alice, b.Home(), 1_000) // 100 units needs 100 RED (fine) and 200 BLUE (alice has 10). uassert.ErrorIs(t, b.Fuse(0, cur, alice, 100), ErrShortBalance) uassert.Equal(t, int64(1_000), red.BalanceOf(alice)) uassert.Equal(t, int64(0), b.Token().TotalSupply()) // Same for a missing allowance on the second leg only. approve(cur, t, blueLed, alice, b.Home(), 0) urequire.NoError(t, blueLed.Mint(alice, 1_000)) uassert.ErrorIs(t, b.Fuse(0, cur, alice, 5), ErrShortAllowance) uassert.Equal(t, int64(1_000), red.BalanceOf(alice)) uassert.Equal(t, int64(0), b.Token().TotalSupply()) } func TestDefuseStopsAtTheBalance(cur realm, t *testing.T) { red, redLed, blue, blueLed := mintPair(cur, t, 1_000, 1_000, alice, bob) testing.SetRealm(testing.NewCodeRealm(basketPath)) b := NewBasket( []*grc20.Token{red, blue}, []int64{1, 1}, "Purple", "PURPLE", 4, 0, cur, ) for _, a := range []address{alice, bob} { approve(cur, t, redLed, a, b.Home(), 1_000) approve(cur, t, blueLed, a, b.Home(), 1_000) } urequire.NoError(t, b.Fuse(0, cur, alice, 50)) uassert.ErrorContains(t, b.Defuse(0, cur, bob, 1), "insufficient balance") uassert.ErrorIs(t, b.Defuse(0, cur, alice, 0), ErrInvalidAmount) _, _, held := b.Leg(0) uassert.Equal(t, int64(50), held) } func TestNewBasketRejectsBadLegs(cur realm, t *testing.T) { red, _, blue, _ := mintPair(cur, t, 1_000, 1_000) testing.SetRealm(testing.NewCodeRealm(basketPath)) // A one-leg basket is a Vault; say so instead of pretending. uassert.PanicsContains(t, cur, "at least two legs", func() { NewBasket([]*grc20.Token{red}, []int64{1}, "X", "X", 4, 0, cur) }) // Mismatched slices. uassert.PanicsContains(t, cur, "at least two legs", func() { NewBasket([]*grc20.Token{red, blue}, []int64{1}, "X", "X", 4, 0, cur) }) // A zero part would let a leg be minted for free. uassert.PanicsContains(t, cur, "at least two legs", func() { NewBasket([]*grc20.Token{red, blue}, []int64{1, 0}, "X", "X", 4, 0, cur) }) // The same token twice is two accountings of one balance. uassert.PanicsContains(t, cur, "at least two legs", func() { NewBasket([]*grc20.Token{red, red}, []int64{1, 1}, "X", "X", 4, 0, cur) }) uassert.PanicsContains(t, cur, "nil underlying token", func() { NewBasket([]*grc20.Token{red, nil}, []int64{1, 1}, "X", "X", 4, 0, cur) }) } func TestBasketMoveAndAllowance(cur realm, t *testing.T) { red, redLed, blue, blueLed := mintPair(cur, t, 1_000, 1_000, alice) testing.SetRealm(testing.NewCodeRealm(basketPath)) b := NewBasket( []*grc20.Token{red, blue}, []int64{1, 1}, "Purple", "PURPLE", 4, 0, cur, ) approve(cur, t, redLed, alice, b.Home(), 1_000) approve(cur, t, blueLed, alice, b.Home(), 1_000) urequire.NoError(t, b.Fuse(0, cur, alice, 100)) urequire.NoError(t, b.Move(alice, bob, 40)) uassert.Equal(t, int64(40), b.Token().BalanceOf(bob)) urequire.NoError(t, b.Allow(alice, bob, 30)) urequire.NoError(t, b.MoveFrom(bob, alice, carl, 30)) uassert.Equal(t, int64(30), b.Token().BalanceOf(carl)) uassert.ErrorIs(t, b.MoveFrom(bob, alice, carl, 0), ErrInvalidAmount) // bob holds meta units he never minted, and can still split them. urequire.NoError(t, b.Defuse(0, cur, bob, 40)) uassert.Equal(t, int64(40), red.BalanceOf(bob)) uassert.Equal(t, int64(40), blue.BalanceOf(bob)) } func TestBasketSummary(cur realm, t *testing.T) { red, redLed, blue, blueLed := mintPair(cur, t, 1_000, 1_000, alice) testing.SetRealm(testing.NewCodeRealm(basketPath)) b := NewBasket( []*grc20.Token{red, blue}, []int64{1, 2}, "Purple", "PURPLE", 4, 0, cur, ) approve(cur, t, redLed, alice, b.Home(), 1_000) approve(cur, t, blueLed, alice, b.Home(), 1_000) urequire.NoError(t, b.Fuse(0, cur, alice, 7)) want := "**Purple** (PURPLE) - meta-token over 2 legs\n\n" + "- meta supply: 7\n" + "- leg RED: 1 per unit, 7 escrowed\n" + "- leg BLUE: 2 per unit, 14 escrowed\n" + "- solvent: yes\n" uassert.Equal(t, want, b.Summary()) }
  8. #8gnomod.toml
  9. #9module = "gno.land/p/moul/x/grc20wrap/v0" gno = "0.9"
  10. #10policy.gno
  11. #11package grc20wrap import "gno.land/p/nt/ufmt/v0" // Policy is a wrapper's personality. It sets the exchange rate in both // directions and can veto a move of the wrapped token. // // Every method receives the vault, so a policy can price against live state - // escrow, supply, custody - instead of a frozen constant. Embed OneToOne to // write a policy that only overrides what it cares about. type Policy interface { // WrapRate returns how many wrapped units `in` underlying units mint. // It is called BEFORE the escrow moves, so v.Held() excludes `in`. WrapRate(v *Vault, in int64) (int64, error) // UnwrapRate returns how many underlying units `in` wrapped units // release. It is called before the burn, so v.Supply() includes `in`. UnwrapRate(v *Vault, in int64) (int64, error) // CanMove vetoes a transfer of the wrapped token between two accounts. CanMove(v *Vault, from, to address, amount int64) error // Name labels the policy in a catalogue. Short, lowercase. Name() string } // OneToOne is the boring wrapper: one underlying unit in, one wrapped unit // out, and no restriction on who may hold the result. Useful on its own as a // pure custody receipt, and as the embedded base of every policy below. type OneToOne struct{} func (OneToOne) WrapRate(v *Vault, in int64) (int64, error) { return in, nil } func (OneToOne) UnwrapRate(v *Vault, in int64) (int64, error) { return in, nil } func (OneToOne) CanMove(v *Vault, from, to address, amount int64) error { return nil } func (OneToOne) Name() string { return "1:1" } // Ratio re-denominates: Num wrapped units per Den underlying units. Both terms // must be positive. // // Ratio{Num: 1000, Den: 1} turns one underlying unit into a thousand wrapped // ones, which is how you give a token three more decimals without touching the // realm that issued it. The reverse direction divides, so a redemption that // rounds to zero is refused (ErrDust) rather than silently burning value: with // Num=1000, unwrapping 999 wrapped units returns nothing and is rejected. type Ratio struct { OneToOne Num int64 Den int64 } func (r Ratio) WrapRate(v *Vault, in int64) (int64, error) { if r.Num <= 0 || r.Den <= 0 { return 0, ErrBadRatio } return mulDiv(in, r.Num, r.Den) } func (r Ratio) UnwrapRate(v *Vault, in int64) (int64, error) { if r.Num <= 0 || r.Den <= 0 { return 0, ErrBadRatio } return mulDiv(in, r.Den, r.Num) } func (r Ratio) Name() string { return ufmt.Sprintf("%d:%d", r.Num, r.Den) } // Soulbound wraps another policy and refuses every transfer of the wrapped // token. Rates are whatever Base says; a nil Base means OneToOne. // // The result is an account-bound receipt: anyone can wrap into it and anyone // can unwrap back out, but the wrapped unit itself never changes hands. That // makes it a membership badge, a proof of deposit, or a vote weight that // cannot be rented - while the underlying stays fully liquid one step away. // // The veto binds signing users only; see the package doc. type Soulbound struct { Base Policy } func (s Soulbound) base() Policy { if s.Base == nil { return OneToOne{} } return s.Base } func (s Soulbound) WrapRate(v *Vault, in int64) (int64, error) { return s.base().WrapRate(v, in) } func (s Soulbound) UnwrapRate(v *Vault, in int64) (int64, error) { return s.base().UnwrapRate(v, in) } func (s Soulbound) CanMove(v *Vault, from, to address, amount int64) error { return ErrSoulbound } func (s Soulbound) Name() string { return "soulbound/" + s.base().Name() } // Pool prices in shares instead of units: the wrapped token is a claim on a // fraction of the escrow rather than on a fixed amount. // // wrap: shares = in * supply / held (1:1 while the pool is empty) // unwrap: units = in * held / supply // // Nothing changes until someone calls Vault.Donate, which adds escrow without // minting shares. Every outstanding share is then worth more, permanently and // for everyone at once. That is the whole yield-bearing-token pattern in two // lines of arithmetic: a fee sink, a staking reward, an airdrop to holders, all // the same operation. // // Two honest caveats: // // - Division truncates, so a share is always worth marginally less than its // exact fraction. The remainder stays in the pool, which is the safe // direction: a vault can never promise more than it holds. // - A first depositor can wrap one unit, donate a large amount, and make // every later deposit smaller than one share round to zero in their favour // (the ERC-4626 inflation attack). Seed the pool at creation, or keep the // wrapped token's decimals well above the underlying's, before using this // anywhere real. type Pool struct { OneToOne } func (Pool) WrapRate(v *Vault, in int64) (int64, error) { supply, held := v.Supply(), v.Held() if supply == 0 || held == 0 { return in, nil } return mulDiv(in, supply, held) } func (Pool) UnwrapRate(v *Vault, in int64) (int64, error) { supply := v.Supply() if supply == 0 { return 0, ErrDust } return mulDiv(in, v.Held(), supply) } func (Pool) Name() string { return "pool" } // Fee takes a basis-point haircut in both directions on top of Base (nil means // OneToOne). 100 BPS is 1%. // // The haircut is not paid to anyone: it stays escrowed. Over a plain base that // only strands value, so Fee is meant to sit over Pool, where the stranded // units raise what every remaining share redeems for. The wrapper then pays its // holders out of its own turnover, and paying twice - in and out - costs more // than holding. type Fee struct { Base Policy WrapBPS int64 UnwrapBPS int64 } func (f Fee) base() Policy { if f.Base == nil { return OneToOne{} } return f.Base } // cut removes bps basis points from amount. func cut(amount, bps int64) (int64, error) { if bps < 0 || bps > 10000 { return 0, ErrBadBPS } taken, err := mulDiv(amount, bps, 10000) if err != nil { return 0, err } return amount - taken, nil } func (f Fee) WrapRate(v *Vault, in int64) (int64, error) { out, err := f.base().WrapRate(v, in) if err != nil { return 0, err } return cut(out, f.WrapBPS) } func (f Fee) UnwrapRate(v *Vault, in int64) (int64, error) { out, err := f.base().UnwrapRate(v, in) if err != nil { return 0, err } return cut(out, f.UnwrapBPS) } func (f Fee) CanMove(v *Vault, from, to address, amount int64) error { return f.base().CanMove(v, from, to, amount) } func (f Fee) Name() string { return ufmt.Sprintf("fee(%d/%dbps)/%s", f.WrapBPS, f.UnwrapBPS, f.base().Name()) }
  12. #12policy_test.gno
  13. #13package grc20wrap import ( "math" "testing" "gno.land/p/nt/uassert/v0" ) // Every policy here is stateless, so it prices correctly against a nil vault. // Pool is the one that reads the vault, and it is covered end to end in // TestPoolSharesAppreciate. func TestStatelessRates(t *testing.T) { cases := []struct { name string pol Policy wrapIn int64 wrapOut int64 unwrapIn int64 unwrapOut int64 }{ {"one to one", OneToOne{}, 7, 7, 7, 7}, {"kilo", Ratio{Num: 1_000, Den: 1}, 7, 7_000, 7_000, 7}, {"kilo truncates on the way out", Ratio{Num: 1_000, Den: 1}, 1, 1_000, 2_500, 2}, {"milli", Ratio{Num: 1, Den: 1_000}, 7_000, 7, 7, 7_000}, {"two for three", Ratio{Num: 2, Den: 3}, 9, 6, 6, 9}, {"soulbound keeps its base rate", Soulbound{Base: Ratio{Num: 2, Den: 1}}, 5, 10, 10, 5}, {"soulbound with no base is 1:1", Soulbound{}, 5, 5, 5, 5}, {"one percent both ways", Fee{WrapBPS: 100, UnwrapBPS: 100}, 1_000, 990, 1_000, 990}, {"a haircut under one unit rounds to nothing", Fee{WrapBPS: 100}, 55, 55, 55, 55}, } for _, tc := range cases { got, err := tc.pol.WrapRate(nil, tc.wrapIn) uassert.NoError(t, err, tc.name) uassert.Equal(t, tc.wrapOut, got, tc.name+" (wrap)") got, err = tc.pol.UnwrapRate(nil, tc.unwrapIn) uassert.NoError(t, err, tc.name) uassert.Equal(t, tc.unwrapOut, got, tc.name+" (unwrap)") } } func TestRateErrors(t *testing.T) { cases := []struct { name string pol Policy in int64 wrapErr error unwrapErr error }{ {"zero numerator", Ratio{Num: 0, Den: 1}, 5, ErrBadRatio, ErrBadRatio}, {"negative denominator", Ratio{Num: 2, Den: -1}, 5, ErrBadRatio, ErrBadRatio}, // Only the multiplying direction can overflow; the dividing one // just returns a very small number. {"overflowing numerator", Ratio{Num: math.MaxInt64, Den: 1}, 3, ErrOverflow, nil}, {"basis points above 100%", Fee{WrapBPS: 10_001, UnwrapBPS: 10_001}, 100, ErrBadBPS, ErrBadBPS}, {"negative basis points", Fee{WrapBPS: -1, UnwrapBPS: -1}, 100, ErrBadBPS, ErrBadBPS}, } for _, tc := range cases { _, err := tc.pol.WrapRate(nil, tc.in) if tc.wrapErr == nil { uassert.NoError(t, err, tc.name+" (wrap)") } else { uassert.ErrorIs(t, err, tc.wrapErr, tc.name+" (wrap)") } _, err = tc.pol.UnwrapRate(nil, tc.in) if tc.unwrapErr == nil { uassert.NoError(t, err, tc.name+" (unwrap)") } else { uassert.ErrorIs(t, err, tc.unwrapErr, tc.name+" (unwrap)") } } } func TestPolicyNames(t *testing.T) { uassert.Equal(t, "1:1", OneToOne{}.Name()) uassert.Equal(t, "1000:1", Ratio{Num: 1_000, Den: 1}.Name()) uassert.Equal(t, "pool", Pool{}.Name()) uassert.Equal(t, "soulbound/1:1", Soulbound{}.Name()) uassert.Equal(t, "soulbound/pool", Soulbound{Base: Pool{}}.Name()) uassert.Equal(t, "fee(0/100bps)/pool", Fee{Base: Pool{}, UnwrapBPS: 100}.Name()) } func TestOnlySoulboundVetoes(t *testing.T) { uassert.NoError(t, OneToOne{}.CanMove(nil, alice, bob, 1)) uassert.NoError(t, Ratio{Num: 1, Den: 1}.CanMove(nil, alice, bob, 1)) uassert.NoError(t, Pool{}.CanMove(nil, alice, bob, 1)) uassert.NoError(t, Fee{Base: Pool{}}.CanMove(nil, alice, bob, 1)) uassert.ErrorIs(t, Soulbound{}.CanMove(nil, alice, bob, 1), ErrSoulbound) // A fee over a soulbound base inherits the veto. uassert.ErrorIs(t, Fee{Base: Soulbound{}}.CanMove(nil, alice, bob, 1), ErrSoulbound) }
  14. #14wrap.gno
  15. #15// Package grc20wrap builds derived GRC20 tokens on top of existing ones. // // Two shapes: // // - Vault wraps ONE underlying token. It escrows underlying units at the // host realm's address and issues its own GRC20 against that escrow. A // Policy sets the exchange rate in both directions and may veto a move of // the wrapped token, which is where a wrapper stops being plumbing and // becomes a pattern: 1:1 custody, a denomination change, a // non-transferable receipt, a share in a growing pool. // - Basket wraps SEVERAL. One meta-token is minted against fixed parts of // every leg and splits back into them on redemption. // // Neither type holds chain state of its own beyond the GRC20 ledger it // creates, so a realm keeps them wherever it likes. // // # How custody works // // Escrow lands at the HOST REALM's address. A vault moves it through // grc20.RealmTeller, which binds eagerly to that address, so a vault can only // ever spend what its own realm holds. Pulling a user's tokens in therefore // takes the ordinary allowance route: the user approves the host realm's // address on the underlying token, through that token realm's own entry // point, and the vault draws on the allowance as itself. // // # Reverting, not returning // // Wrapping is two ledger writes on two different tokens, and gno has no // rollback short of a panic: a returned error does not undo what already ran. // Every method here therefore validates while nothing has moved and returns an // error, then panics if a write fails after the point of no return. Aborting // the transaction is the only atomicity available. // // # What a Policy can and cannot enforce // // A Policy veto binds end users, not realms. Moving the wrapped token through // Vault.Move is the only path a signing account has, because MsgCall cannot // build the realm argument grc20's tellers require. Another realm holding the // wrapped token can always call grc20.RealmTeller on it and move its OWN // balance. Soulbound means "no user can pass it on", not "it can never move". // // Live demo: gno.land/r/moul/x/grc20wrapdemo/v0. package grc20wrap import ( "errors" "math/overflow" "gno.land/p/nt/grc20/v0" "gno.land/p/nt/seqid/v0" "gno.land/p/nt/ufmt/v0" ) var ( ErrNilToken = errors.New("grc20wrap: nil underlying token") ErrInvalidAmount = errors.New("grc20wrap: amount must be positive") ErrDust = errors.New("grc20wrap: amount rounds to zero") ErrUnbacked = errors.New("grc20wrap: payout exceeds this vault's escrow") ErrSoulbound = errors.New("grc20wrap: soulbound, the wrapped token cannot change hands") ErrOverflow = errors.New("grc20wrap: arithmetic overflow") ErrBadRatio = errors.New("grc20wrap: ratio terms must be positive") ErrBadBPS = errors.New("grc20wrap: basis points must be within 0..10000") ErrBadLegs = errors.New("grc20wrap: a basket needs at least two legs, each with a positive part") ErrShortBalance = errors.New("grc20wrap: insufficient balance on a leg") ErrShortAllowance = errors.New("grc20wrap: insufficient allowance on a leg") ) // Vault escrows one underlying GRC20 and issues another against it. // // The escrow account is the host realm's own address, shared with every other // vault that realm creates. Separation between them is the per-vault Held() // counter, not separate accounts: a vault only ever releases what it recorded // taking in, so the realm-wide invariant is sum(Held) <= Custody(). type Vault struct { under *grc20.Token teller grc20.Teller // bound to home, eagerly, at construction home address tok *grc20.Token led *grc20.PrivateLedger pol Policy held int64 } // NewVault issues a new GRC20 backed by units of under. // // Call it from the realm that will own the vault, forwarding that realm's own // cur: both the teller binding and the new token's origRealm are taken from it // and cannot be forged afterwards. id disambiguates several tokens minted by // the same realm - allocate it from one persistent seqid.ID. // // A nil pol means OneToOne. func NewVault(under *grc20.Token, pol Policy, name, symbol string, decimals int, id seqid.ID, rlm realm) *Vault { if under == nil { panic(ErrNilToken) } if pol == nil { pol = OneToOne{} } tok, led := grc20.NewToken(name, symbol, decimals, id, rlm) return &Vault{ under: under, teller: under.RealmTeller(0, rlm), home: rlm.Address(), tok: tok, led: led, pol: pol, } } // Token is the wrapped token this vault issues. It is safe to hand out: a // *grc20.Token carries metadata and read access, never the authority to debit // anybody. func (v *Vault) Token() *grc20.Token { return v.tok } // Underlying is the token held in escrow. func (v *Vault) Underlying() *grc20.Token { return v.under } // Policy is this vault's personality. func (v *Vault) Policy() Policy { return v.pol } // Home is the escrow account: the host realm's address. func (v *Vault) Home() address { return v.home } // Held is the underlying escrowed for THIS vault, by its own accounting. func (v *Vault) Held() int64 { return v.held } // Supply is the wrapped token in circulation. func (v *Vault) Supply() int64 { return v.tok.TotalSupply() } // Custody is the underlying actually sitting at the escrow account. It covers // every vault the host realm created over the same underlying, plus anything // sent there by mistake, so Custody >= Held is necessary for solvency and // sufficient only when this is the realm's single vault over that token. func (v *Vault) Custody() int64 { return v.under.BalanceOf(v.home) } // Solvent reports whether the escrow account still covers what this vault // recorded taking in. False means the host realm moved the underlying behind // the vault's back. func (v *Vault) Solvent() bool { return v.Custody() >= v.held } // Wrap escrows amount units of the underlying from `from` and mints the // wrapped token to `from`, returning how much was minted. // // `from` must already have granted v.Home() an allowance of at least amount on // the underlying token, set through that token realm's own entry point. func (v *Vault) Wrap(_ int, rlm realm, from address, amount int64) (int64, error) { if amount <= 0 { return 0, ErrInvalidAmount } out, err := v.pol.WrapRate(v, amount) if err != nil { return 0, err } if out <= 0 { return 0, ErrDust } // Last point at which nothing has moved. if err := v.teller.TransferFrom(0, rlm, from, v.home, amount); err != nil { return 0, err } held, ok := overflow.Add64(v.held, amount) if !ok { panic(ErrOverflow) } v.held = held if err := v.led.Mint(from, out); err != nil { // The escrow already moved; only aborting the tx can undo it. panic(err) } return out, nil } // Unwrap burns amount wrapped units held by `from` and releases the underlying // back to it, returning how much was released. func (v *Vault) Unwrap(_ int, rlm realm, from address, amount int64) (int64, error) { if amount <= 0 { return 0, ErrInvalidAmount } out, err := v.pol.UnwrapRate(v, amount) if err != nil { return 0, err } if out <= 0 { return 0, ErrDust } if out > v.held { return 0, ErrUnbacked } // Burn first: it is the step that can legitimately fail on a short // balance, and it fails before anything has moved. if err := v.led.Burn(from, amount); err != nil { return 0, err } v.held -= out if err := v.teller.Transfer(0, rlm, from, out); err != nil { // The burn already happened; only aborting the tx can undo it. panic(err) } return out, nil } // Donate escrows amount units of the underlying without minting anything. // // Under a pool policy this is how the wrapper gets interesting: the donation // raises what every outstanding wrapped unit redeems for, so anyone can make // every holder richer and nobody can take it back out except by holding. // `from` must have approved v.Home() on the underlying, as for Wrap. func (v *Vault) Donate(_ int, rlm realm, from address, amount int64) error { if amount <= 0 { return ErrInvalidAmount } if err := v.teller.TransferFrom(0, rlm, from, v.home, amount); err != nil { return err } held, ok := overflow.Add64(v.held, amount) if !ok { panic(ErrOverflow) } v.held = held return nil } // Move transfers wrapped units after the policy has had its say. It is the // only path a signing user has to move the wrapped token; see the package doc // for what that does and does not guarantee. func (v *Vault) Move(from, to address, amount int64) error { if amount <= 0 { return ErrInvalidAmount } if err := v.pol.CanMove(v, from, to, amount); err != nil { return err } return v.led.Transfer(from, to, amount) } // Allow sets `spender`'s allowance over `owner`'s wrapped balance. // // The policy is deliberately not consulted: an allowance moves nothing. It is // checked when the allowance is spent, in MoveFrom, so a policy that starts // vetoing later still binds allowances granted before. func (v *Vault) Allow(owner, spender address, amount int64) error { return v.led.Approve(owner, spender, amount) } // MoveFrom spends `spender`'s allowance over `owner`'s wrapped balance, after // the policy has had its say. func (v *Vault) MoveFrom(spender, owner, to address, amount int64) error { if amount <= 0 { return ErrInvalidAmount } if err := v.pol.CanMove(v, owner, to, amount); err != nil { return err } return v.led.TransferFrom(owner, spender, to, amount) } // Summary renders the vault as a markdown block. func (v *Vault) Summary() string { s := ufmt.Sprintf("**%s** (%s) - %s wrapper over %s\n\n", v.tok.GetName(), v.tok.GetSymbol(), v.pol.Name(), v.under.GetSymbol()) s += ufmt.Sprintf("- wrapped supply: %d\n", v.Supply()) s += ufmt.Sprintf("- escrowed underlying: %d\n", v.held) s += ufmt.Sprintf("- realm custody: %d\n", v.Custody()) if v.Solvent() { s += "- solvent: yes\n" } else { s += "- solvent: **NO**\n" } return s } // mulDiv returns a*b/c, rejecting an overflowing product and a zero divisor. // The division truncates toward zero, which always rounds in the vault's // favour rather than the holder's. func mulDiv(a, b, c int64) (int64, error) { if c <= 0 { return 0, ErrBadRatio } p, ok := overflow.Mul64(a, b) if !ok { return 0, ErrOverflow } return p / c, nil }
  16. #16wrap_test.gno
  17. #17package grc20wrap import ( "testing" "gno.land/p/nt/grc20/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) // The two realms every test needs: one that issues the underlying token, one // that wraps it. They MUST be distinct, because wrapping a token issued by your // own realm exercises none of the interesting machinery: the allowance, the // eagerly-bound teller, the foreign escrow. const ( underPath = "gno.land/r/test/under" wrapPath = "gno.land/r/test/wrapper" ) var ( alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") carl = testutils.TestAddress("carl") ) // mintUnder issues the underlying token from underPath and funds holders. func mintUnder(cur realm, t *testing.T, funded ...address) (*grc20.Token, *grc20.PrivateLedger) { t.Helper() testing.SetRealm(testing.NewCodeRealm(underPath)) tok, led := grc20.NewToken("Under", "UND", 4, 0, cur) for _, a := range funded { urequire.NoError(t, led.Mint(a, 1_000)) } return tok, led } // approve stands in for the underlying realm's own Approve entry point: on // chain the holder calls it, here the ledger does it on their behalf. func approve(cur realm, t *testing.T, led *grc20.PrivateLedger, owner, spender address, amount int64) { t.Helper() urequire.NoError(t, led.ImpersonateTeller(owner).Approve(0, cur, spender, amount)) } func TestOneToOneRoundTrip(cur realm, t *testing.T) { under, led := mintUnder(cur, t, alice) testing.SetRealm(testing.NewCodeRealm(wrapPath)) v := NewVault(under, OneToOne{}, "Wrapped Under", "wUND", 4, 0, cur) approve(cur, t, led, alice, v.Home(), 1_000) out, err := v.Wrap(0, cur, alice, 400) urequire.NoError(t, err) uassert.Equal(t, int64(400), out) uassert.Equal(t, int64(400), v.Token().BalanceOf(alice)) uassert.Equal(t, int64(600), under.BalanceOf(alice)) uassert.Equal(t, int64(400), v.Held()) uassert.Equal(t, int64(400), v.Custody()) uassert.True(t, v.Solvent()) back, err := v.Unwrap(0, cur, alice, 150) urequire.NoError(t, err) uassert.Equal(t, int64(150), back) uassert.Equal(t, int64(250), v.Token().BalanceOf(alice)) uassert.Equal(t, int64(250), v.Supply()) uassert.Equal(t, int64(750), under.BalanceOf(alice)) uassert.Equal(t, int64(250), v.Held()) uassert.True(t, v.Solvent()) } // Escrow is pulled with an allowance, so a wrapper can never take more than a // holder granted it. This is the property that makes a wrapper safe to publish // as a permissionless factory: it has no authority nobody handed it. func TestWrapStopsAtTheAllowance(cur realm, t *testing.T) { under, led := mintUnder(cur, t, alice) testing.SetRealm(testing.NewCodeRealm(wrapPath)) v := NewVault(under, OneToOne{}, "Wrapped Under", "wUND", 4, 0, cur) approve(cur, t, led, alice, v.Home(), 100) _, err := v.Wrap(0, cur, alice, 101) uassert.ErrorContains(t, err, "insufficient allowance") uassert.Equal(t, int64(0), v.Supply()) uassert.Equal(t, int64(1_000), under.BalanceOf(alice)) _, err = v.Wrap(0, cur, alice, 0) uassert.ErrorIs(t, err, ErrInvalidAmount) } // Unwrapping is bounded by the wrapped balance, not by the escrow: a holder // cannot redeem somebody else's deposit. func TestUnwrapStopsAtTheBalance(cur realm, t *testing.T) { under, led := mintUnder(cur, t, alice, bob) testing.SetRealm(testing.NewCodeRealm(wrapPath)) v := NewVault(under, OneToOne{}, "Wrapped Under", "wUND", 4, 0, cur) approve(cur, t, led, alice, v.Home(), 1_000) approve(cur, t, led, bob, v.Home(), 1_000) _, err := v.Wrap(0, cur, alice, 500) urequire.NoError(t, err) _, err = v.Wrap(0, cur, bob, 500) urequire.NoError(t, err) uassert.Equal(t, int64(1_000), v.Held()) _, err = v.Unwrap(0, cur, bob, 501) uassert.ErrorContains(t, err, "insufficient balance") uassert.Equal(t, int64(1_000), v.Held()) } // Ratio gives a token more decimals than the realm that issued it ever offered. func TestRatioRedenominates(cur realm, t *testing.T) { under, led := mintUnder(cur, t, alice) testing.SetRealm(testing.NewCodeRealm(wrapPath)) v := NewVault(under, Ratio{Num: 1_000, Den: 1}, "Kilo Under", "kUND", 7, 0, cur) approve(cur, t, led, alice, v.Home(), 1_000) out, err := v.Wrap(0, cur, alice, 5) urequire.NoError(t, err) uassert.Equal(t, int64(5_000), out) uassert.Equal(t, int64(5), v.Held()) back, err := v.Unwrap(0, cur, alice, 3_000) urequire.NoError(t, err) uassert.Equal(t, int64(3), back) uassert.Equal(t, int64(2), v.Held()) // A redemption too small to buy one underlying unit is refused rather // than silently burning the wrapped units for nothing. _, err = v.Unwrap(0, cur, alice, 999) uassert.ErrorIs(t, err, ErrDust) uassert.Equal(t, int64(2_000), v.Supply()) } // Soulbound: wrap in, unwrap out, but never hand the receipt to anybody. func TestSoulboundReceipt(cur realm, t *testing.T) { under, led := mintUnder(cur, t, alice) testing.SetRealm(testing.NewCodeRealm(wrapPath)) v := NewVault(under, Soulbound{}, "Bound Under", "sUND", 4, 0, cur) approve(cur, t, led, alice, v.Home(), 1_000) _, err := v.Wrap(0, cur, alice, 300) urequire.NoError(t, err) uassert.ErrorIs(t, v.Move(alice, bob, 1), ErrSoulbound) uassert.Equal(t, int64(0), v.Token().BalanceOf(bob)) // An allowance may be granted, and is still worthless when spent. urequire.NoError(t, v.Allow(alice, bob, 100)) uassert.ErrorIs(t, v.MoveFrom(bob, alice, carl, 100), ErrSoulbound) // The exit is always open: unwrap, then move the underlying. back, err := v.Unwrap(0, cur, alice, 300) urequire.NoError(t, err) uassert.Equal(t, int64(300), back) uassert.Equal(t, int64(0), v.Supply()) } // Pool is the yield-bearing pattern: a donation nobody can withdraw raises what // every share redeems for, and later depositors buy in at the new price. func TestPoolSharesAppreciate(cur realm, t *testing.T) { under, led := mintUnder(cur, t, alice, bob, carl) testing.SetRealm(testing.NewCodeRealm(wrapPath)) v := NewVault(under, Pool{}, "Pooled Under", "pUND", 4, 0, cur) for _, a := range []address{alice, bob, carl} { approve(cur, t, led, a, v.Home(), 1_000) } // First in, priced 1:1 because the pool is empty. out, err := v.Wrap(0, cur, alice, 100) urequire.NoError(t, err) uassert.Equal(t, int64(100), out) // bob donates: escrow doubles, share count does not move. urequire.NoError(t, v.Donate(0, cur, bob, 100)) uassert.Equal(t, int64(200), v.Held()) uassert.Equal(t, int64(100), v.Supply()) // carl now pays twice as much per share. out, err = v.Wrap(0, cur, carl, 100) urequire.NoError(t, err) uassert.Equal(t, int64(50), out) uassert.Equal(t, int64(300), v.Held()) uassert.Equal(t, int64(150), v.Supply()) // alice redeems 100 shares of 150 against 300 escrowed: 200 back, on a // 100 deposit. bob's donation went to whoever was holding. back, err := v.Unwrap(0, cur, alice, 100) urequire.NoError(t, err) uassert.Equal(t, int64(200), back) uassert.Equal(t, int64(1_100), under.BalanceOf(alice)) // carl is made whole, and the pool closes empty. back, err = v.Unwrap(0, cur, carl, 50) urequire.NoError(t, err) uassert.Equal(t, int64(100), back) uassert.Equal(t, int64(0), v.Held()) uassert.Equal(t, int64(0), v.Supply()) uassert.True(t, v.Solvent()) } // Fee over Pool: the haircut is not paid out, it is left behind, so the people // still holding are the ones who collect it. func TestFeeOverPoolPaysTheHolders(cur realm, t *testing.T) { under, led := mintUnder(cur, t, alice, bob) testing.SetRealm(testing.NewCodeRealm(wrapPath)) v := NewVault(under, Fee{Base: Pool{}, WrapBPS: 0, UnwrapBPS: 1_000}, "Sticky Under", "fUND", 4, 0, cur) approve(cur, t, led, alice, v.Home(), 1_000) approve(cur, t, led, bob, v.Home(), 1_000) _, err := v.Wrap(0, cur, alice, 500) urequire.NoError(t, err) _, err = v.Wrap(0, cur, bob, 500) urequire.NoError(t, err) uassert.Equal(t, int64(1_000), v.Supply()) // alice leaves and pays 10% on the way out: 500 - 50. back, err := v.Unwrap(0, cur, alice, 500) urequire.NoError(t, err) uassert.Equal(t, int64(450), back) // bob stayed, and the 50 alice left behind is now his. uassert.Equal(t, int64(550), v.Held()) uassert.Equal(t, int64(500), v.Supply()) back, err = v.Unwrap(0, cur, bob, 500) urequire.NoError(t, err) uassert.Equal(t, int64(495), back) // 550 pro rata, minus the same 10% uassert.Equal(t, int64(55), v.Held()) } // Two vaults over the same underlying share one escrow account, so Held() is // the only thing that keeps them apart. Pin that they do. func TestTwoVaultsShareOneEscrowAccount(cur realm, t *testing.T) { under, led := mintUnder(cur, t, alice) testing.SetRealm(testing.NewCodeRealm(wrapPath)) a := NewVault(under, OneToOne{}, "Vault A", "vA", 4, 0, cur) b := NewVault(under, OneToOne{}, "Vault B", "vB", 4, 1, cur) uassert.Equal(t, a.Home().String(), b.Home().String()) approve(cur, t, led, alice, a.Home(), 1_000) _, err := a.Wrap(0, cur, alice, 300) urequire.NoError(t, err) _, err = b.Wrap(0, cur, alice, 200) urequire.NoError(t, err) uassert.Equal(t, int64(300), a.Held()) uassert.Equal(t, int64(200), b.Held()) // Custody is realm-wide: it sees both. uassert.Equal(t, int64(500), a.Custody()) uassert.Equal(t, int64(500), b.Custody()) // Vault B refuses to release more than it took in, even though the // account it draws on holds A's escrow too. Without the per-vault // counter this is exactly where one vault would eat the other's. _, err = b.Unwrap(0, cur, alice, 201) uassert.ErrorIs(t, err, ErrUnbacked) uassert.Equal(t, int64(200), b.Held()) uassert.Equal(t, int64(500), b.Custody()) // Both still unwind cleanly and independently. _, err = b.Unwrap(0, cur, alice, 200) urequire.NoError(t, err) _, err = a.Unwrap(0, cur, alice, 300) urequire.NoError(t, err) uassert.Equal(t, int64(0), a.Custody()) uassert.Equal(t, int64(1_000), under.BalanceOf(alice)) } func TestMoveAndAllowance(cur realm, t *testing.T) { under, led := mintUnder(cur, t, alice) testing.SetRealm(testing.NewCodeRealm(wrapPath)) v := NewVault(under, OneToOne{}, "Wrapped Under", "wUND", 4, 0, cur) approve(cur, t, led, alice, v.Home(), 1_000) _, err := v.Wrap(0, cur, alice, 600) urequire.NoError(t, err) urequire.NoError(t, v.Move(alice, bob, 100)) uassert.Equal(t, int64(100), v.Token().BalanceOf(bob)) uassert.ErrorIs(t, v.Move(alice, bob, 0), ErrInvalidAmount) urequire.NoError(t, v.Allow(alice, bob, 250)) uassert.Equal(t, int64(250), v.Token().Allowance(alice, bob)) urequire.NoError(t, v.MoveFrom(bob, alice, carl, 250)) uassert.Equal(t, int64(250), v.Token().BalanceOf(carl)) uassert.ErrorContains(t, v.MoveFrom(bob, alice, carl, 1), "insufficient allowance") } func TestNewVaultRejectsNilUnderlying(cur realm, t *testing.T) { testing.SetRealm(testing.NewCodeRealm(wrapPath)) uassert.PanicsContains(t, cur, "nil underlying token", func() { NewVault(nil, OneToOne{}, "Broken", "BRK", 4, 0, cur) }) } func TestNilPolicyMeansOneToOne(cur realm, t *testing.T) { under, _ := mintUnder(cur, t) testing.SetRealm(testing.NewCodeRealm(wrapPath)) v := NewVault(under, nil, "Wrapped Under", "wUND", 4, 0, cur) uassert.Equal(t, "1:1", v.Policy().Name()) } func TestSummaryReportsSolvency(cur realm, t *testing.T) { under, led := mintUnder(cur, t, alice) testing.SetRealm(testing.NewCodeRealm(wrapPath)) v := NewVault(under, OneToOne{}, "Wrapped Under", "wUND", 4, 0, cur) approve(cur, t, led, alice, v.Home(), 1_000) _, err := v.Wrap(0, cur, alice, 250) urequire.NoError(t, err) want := "**Wrapped Under** (wUND) - 1:1 wrapper over UND\n\n" + "- wrapped supply: 250\n" + "- escrowed underlying: 250\n" + "- realm custody: 250\n" + "- solvent: yes\n" uassert.Equal(t, want, v.Summary()) }
#8AddPackagegno.land/p/moul/x/merkle/v011 arguments
Attached funds
11000000ugnot

Arguments · 11

  1. #1merkle
  2. #2README.md
  3. #3# merkle Merkle inclusion proofs on gno.land, built on the **native `crypto/merkle` stdlib**. That stdlib landed with the IBC crypto batch (gnolang/gno#5725) and is live on mainnet. Before this package it had **zero callers** anywhere: not in `gnolang/gno`'s `examples/`, not here. Meanwhile the only userland Merkle tree in the ecosystem, `p/demo/merkle`, is quarantined and not deployed on any chain. ## The scheme The **Tendermint simple tree**, byte for byte the one tm2 uses for block headers. Proofs produced here verify against any Tendermint tooling and vice versa. The tests pin golden roots and sibling lists generated by tm2's own `merkle.SimpleProofsFromByteSlices`, so a divergence fails CI rather than shipping. Two properties, neither optional: **Domain separation.** A leaf is `SHA256(0x00 || leaf)`, an inner node is `SHA256(0x01 || left || right)`. Without those tags an inner node hash is also a valid leaf hash, so anyone able to choose a 64-byte leaf preimage can prove membership of a leaf that was never in the tree. `TestSecondPreimageForgery` performs exactly that attack against the commutative scheme and then shows the tagged scheme refuse it. **Index binding.** A proof carries its leaf index and the total leaf count, and the verifier rebuilds the tree shape from them. A proof for index `i` cannot be replayed at index `j`, and a proof of the wrong length is rejected rather than folded. The tree is **not** padded to a power of two. Following Tendermint, an `n`-leaf tree splits at the largest power of two below `n`, so the shape is a function of `n` alone. Duplicating or promoting an odd trailing leaf, as Bitcoin and merkletreejs do, lets two different leaf sets produce one root. ## Usage ```go import "gno.land/p/moul/x/merkle/v0" tree := merkle.New([][]byte{[]byte("alice:100"), []byte("bob:250"), []byte("carol:500")}) root := tree.RootHex() // commit this p, _ := tree.Proof(1) // hand p.Index, p.Total, p.Hex() to the user // later, in a realm, against a root it already committed to: p, err := merkle.ParseProof(index, total, hexSiblings) if err == nil && p.Verify(committedRoot, []byte("bob:250")) { // bob really is in the committed set, at position 1 } ``` Verification is a **single native call**. Measured at depth 20 it costs roughly a seventh of the equivalent hand-rolled fold in Gno, and a third of calling `InnerHash` twenty times, because per-native-call overhead dominates. (Gas figures are directional: the calibration table in `native_gas.go` fits the `crypto/merkle` rows on a different CPU from the `crypto/sha256` rows.) `Verify` refuses proofs deeper than `MaxDepth` (64). An unbounded sibling list is a loop whose length an untrusted caller picks. ## `VerifySorted`: interop only `VerifySorted` folds the commutative sorted-pair scheme that OpenZeppelin's `MerkleProof` and merkletreejs produce, for verifying trees built by existing Solidity tooling. It is strictly weaker: no domain separation, no index binding. Pass an already-hashed leaf, and double-hash it if the producer does. Prefer `Proof.Verify` for anything new. ## What this package cannot do It verifies a leaf against a root **you supply**. A realm has no access to the block header or the app hash (`chain/runtime` exposes only `ChainID`, `ChainDomain`, `ChainHeight` and `GetSessionInfo`), so no realm can check that a root is the chain's own. Anything built on this is trust-minimised relative to a committed root, never trustless. Worse, and less obvious: **gno.land cannot prove its own realm state to anyone.** Realm objects live in a store that is explicitly not merkleized. See [`r/moul/x/provable/v0`](../../../../../r/moul/x/provable/v0) for the full picture. ## Gno gotcha found while building this A native function returning Go `nil` for a `[]byte` hands gno back a **non-nil, zero-length slice**. `merkle.HashFromByteSlices(malformed) == nil` is therefore always false. Test `len(x) == 0`, never `x == nil`, on anything that crossed the native boundary. ## Related - Append-only log with O(log n) state: [`p/moul/x/mmr/v0`](../../mmr/v0) - Live demo: [`r/moul/x/provable/v0`](../../../../../r/moul/x/provable/v0) <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4gnomod.toml
  5. #5module = "gno.land/p/moul/x/merkle/v0" gno = "0.9"
  6. #6internal.gno
  7. #7package merkle import "crypto/sha256" // encodeSlices renders items in the wire shape crypto/merkle.HashFromByteSlices // expects: a 4-byte big-endian count, then per item a 4-byte big-endian length // followed by its bytes. The native side decodes this back into [][]byte; gno // cannot hand a slice of slices to a native directly. func encodeSlices(items [][]byte) []byte { n := 0 for _, it := range items { n += 4 + len(it) } out := make([]byte, 0, 4+n) out = appendBE32(out, len(items)) for _, it := range items { out = appendBE32(out, len(it)) out = append(out, it...) } return out } func appendBE32(dst []byte, n int) []byte { return append(dst, byte(n>>24), byte(n>>16), byte(n>>8), byte(n)) } // hashSortedPair is the commutative node hash: SHA256 over the two children in // ascending byte order, so a proof needs no left/right flag. Used only by // VerifySorted. func hashSortedPair(a, b []byte) []byte { d := make([]byte, 0, len(a)+len(b)) if less(a, b) { d = append(append(d, a...), b...) } else { d = append(append(d, b...), a...) } h := sha256.Sum256(d) return h[:] } // less reports a < b in lexicographic byte order. gno's bytes package is // available, but keeping the comparison here keeps this package import-light. func less(a, b []byte) bool { n := len(a) if len(b) < n { n = len(b) } for i := 0; i < n; i++ { if a[i] != b[i] { return a[i] < b[i] } } return len(a) < len(b) } func equal(a, b []byte) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true }
  8. #8merkle.gno
  9. #9// Package merkle builds and verifies Merkle inclusion proofs on gno.land. // // It wraps the `crypto/merkle` stdlib, which is native (implemented in Go, // gas-metered) and shipped with the chain, and which had no callers anywhere // before this package. Verification is a single native call, so it costs about // a seventh of the equivalent hand-rolled loop in Gno. // // # The scheme // // The tree is the Tendermint simple tree, byte-for-byte the one tm2 uses for // block headers, so a proof produced here verifies against any Tendermint // tooling and vice versa. Two properties matter and neither is optional: // // - Leaves and inner nodes are DOMAIN SEPARATED. A leaf hashes as // SHA256(0x00 || leaf), an inner node as SHA256(0x01 || left || right). // Without that tag an attacker who can choose a 64-byte leaf preimage can // present an inner node as if it were a leaf: the second-preimage attack. // Schemes that hash leaves bare (OpenZeppelin's `MerkleProof`, // merkletreejs defaults, `p/demo/merkle`) are safe only as long as no leaf // preimage can be exactly 64 bytes, which is an accident of encoding // rather than a property of the design. // - Proofs are INDEX BOUND. A proof carries its leaf index and the total // leaf count, and the verifier recomputes the tree shape from them. A // proof for index i cannot be replayed at index j, and a proof of the // wrong length is rejected rather than folded. // // The tree is NOT padded to a power of two. Following Tendermint, a tree of n // leaves splits at the largest power of two below n, which makes the shape a // function of n alone. Duplicating or promoting an odd trailing leaf, as // Bitcoin and merkletreejs do, lets two different leaf sets produce one root. // // # Bounds // // Verify refuses proofs deeper than MaxDepth. An unbounded sibling list is a // loop whose length an untrusted caller picks; the caller pays the gas, but // there is no reason to accept 10,000 siblings for a tree that cannot hold // more than 2^64 leaves. // // # Reproducing a tree off chain // // Any Tendermint implementation agrees with this one. In Go: // // import "github.com/gnolang/gno/tm2/pkg/crypto/merkle" // root, proofs := merkle.SimpleProofsFromByteSlices(leaves) // // # What this package cannot do // // It verifies a leaf against a root YOU SUPPLY. A realm has no access to the // block header or the app hash (`chain/runtime` exposes only ChainID, // ChainDomain, ChainHeight and GetSessionInfo), so no realm can check that a // root is the chain's own. Anything built on this is trust-minimised relative // to a committed root, never trustless. See `r/moul/x/provable/v0` for what // gno.land can and cannot prove about itself. // // Live demo: gno.land/r/moul/x/provable/v0 package merkle import ( "crypto/merkle" "encoding/hex" "errors" "strings" ) // HashSize is the length in bytes of every node hash (SHA256). const HashSize = 32 // MaxDepth caps the sibling count Verify will fold. A tree of 2^64 leaves has // depth 64, so nothing legitimate ever exceeds it. const MaxDepth = 64 var ( ErrEmptyTree = errors.New("merkle: tree has no leaves") ErrIndexRange = errors.New("merkle: leaf index out of range") ErrProofTooDeep = errors.New("merkle: proof deeper than MaxDepth") ErrBadHex = errors.New("merkle: sibling is not valid hex") ErrBadHashSize = errors.New("merkle: sibling is not 32 bytes") ) // LeafHash returns SHA256(0x00 || leaf), the Tendermint leaf hash. func LeafHash(leaf []byte) []byte { return merkle.LeafHash(leaf) } // InnerHash returns SHA256(0x01 || left || right), the Tendermint inner hash. func InnerHash(left, right []byte) []byte { return merkle.InnerHash(left, right) } // Proof is an inclusion proof for one leaf of a tree of Total leaves. // // Siblings are ordered leaf first, root last: Siblings[0] is the leaf's // immediate sibling and the final entry is the other child of the root. This // is Tendermint's "aunts" order. type Proof struct { Index int Total int Siblings [][]byte } // Tree is an immutable Merkle tree over a fixed list of leaves. // // It holds the leaves, so it is meant to be built inside one call (from // arguments, or from a bounded realm collection) rather than kept in realm // storage. For an append-only log that stores only O(log n) state, use // gno.land/p/moul/x/mmr/v0 instead. type Tree struct { leaves [][]byte root []byte } // New builds a tree over leaves, in the order given. The order is part of the // commitment: the same set in a different order is a different root. func New(leaves [][]byte) *Tree { cp := make([][]byte, len(leaves)) for i, l := range leaves { b := make([]byte, len(l)) copy(b, l) cp[i] = b } t := &Tree{leaves: cp} if len(cp) > 0 { t.root = merkle.HashFromByteSlices(encodeSlices(cp)) } return t } // Size returns the number of leaves. func (t *Tree) Size() int { return len(t.leaves) } // Root returns the tree root. It is nil for an empty tree, matching // Tendermint, where the root of nothing is nothing rather than a hash of // nothing. // // The nil is produced here rather than passed through: a native that returns // Go nil hands gno back a NON-NIL zero-length slice, so `== nil` on a value // straight out of crypto/merkle never fires. Test len(), not nil, on anything // that crossed that boundary. func (t *Tree) Root() []byte { return t.root } // RootHex returns Root hex-encoded, the form to paste into a realm call. func (t *Tree) RootHex() string { return hex.EncodeToString(t.root) } // Proof returns the inclusion proof for the leaf at index. func (t *Tree) Proof(index int) (Proof, error) { if len(t.leaves) == 0 { return Proof{}, ErrEmptyTree } if index < 0 || index >= len(t.leaves) { return Proof{}, ErrIndexRange } return Proof{ Index: index, Total: len(t.leaves), Siblings: aunts(t.leaves, index), }, nil } // aunts collects the sibling hashes on the path from leaves[index] to the // root, leaf first. It mirrors Tendermint's split-point recursion exactly; any // divergence here produces proofs the native verifier rejects. func aunts(leaves [][]byte, index int) [][]byte { if len(leaves) <= 1 { return nil } k := splitPoint(len(leaves)) if index < k { sub := aunts(leaves[:k], index) return append(sub, subtreeRoot(leaves[k:])) } sub := aunts(leaves[k:], index-k) return append(sub, subtreeRoot(leaves[:k])) } func subtreeRoot(leaves [][]byte) []byte { return merkle.HashFromByteSlices(encodeSlices(leaves)) } // splitPoint returns the largest power of two strictly less than length. func splitPoint(length int) int { if length < 2 { return 0 } k := 1 for k<<1 < length { k <<= 1 } return k } // Verify reports whether leaf really sits at p.Index of a tree of p.Total // leaves whose root is root. It is one native call plus the bounds checks. // // Every failure is a false rather than a panic, so a realm can decide whether // a bad proof is a revert or a branch. func (p Proof) Verify(root, leaf []byte) bool { if len(root) != HashSize || p.Total <= 0 || p.Index < 0 || p.Index >= p.Total { return false } if len(p.Siblings) > MaxDepth { return false } flat := make([]byte, 0, len(p.Siblings)*HashSize) for _, s := range p.Siblings { if len(s) != HashSize { return false } flat = append(flat, s...) } return merkle.VerifySimpleProof(root, leaf, p.Index, p.Total, flat) } // Hex renders the siblings as a comma-separated hex list, the form a user // pastes into a transaction. Index and Total travel as their own arguments. func (p Proof) Hex() string { parts := make([]string, len(p.Siblings)) for i, s := range p.Siblings { parts[i] = hex.EncodeToString(s) } return strings.Join(parts, ",") } // ParseProof rebuilds a Proof from the arguments of a realm call. An empty or // whitespace-only sibling list is valid: it is the proof for a one-leaf tree. func ParseProof(index, total int, hexSiblings string) (Proof, error) { p := Proof{Index: index, Total: total} s := strings.TrimSpace(hexSiblings) if s == "" { return p, nil } parts := strings.Split(s, ",") if len(parts) > MaxDepth { return Proof{}, ErrProofTooDeep } for _, raw := range parts { raw = strings.TrimSpace(raw) if raw == "" { continue } b, err := hex.DecodeString(raw) if err != nil { return Proof{}, ErrBadHex } if len(b) != HashSize { return Proof{}, ErrBadHashSize } p.Siblings = append(p.Siblings, b) } return p, nil } // VerifySorted folds a commutative, sorted-pair proof: node = SHA256(min || max) // at every step, which is what OpenZeppelin's MerkleProof and merkletreejs // produce. Provided for verifying trees built by existing Solidity tooling. // // PREFER Proof.Verify. This scheme is strictly weaker: // // - It is not domain separated, so it is sound only while no leaf preimage // can be 64 bytes long. Pass an ALREADY HASHED leaf, and double-hash it if // the producer does (OpenZeppelin's StandardMerkleTree does). // - It is not index bound, so a proof carries no position and the fold // accepts any depth up to MaxDepth. // // leafHash must be the 32-byte hash of the leaf, not the leaf. func VerifySorted(root, leafHash []byte, siblings [][]byte) bool { if len(root) != HashSize || len(leafHash) != HashSize { return false } if len(siblings) > MaxDepth { return false } node := leafHash for _, s := range siblings { if len(s) != HashSize { return false } node = hashSortedPair(node, s) } return equal(node, root) }
  10. #10merkle_test.gno
  11. #11package merkle import ( "crypto/sha256" "encoding/hex" "strconv" "testing" ) // leaves returns n deterministic leaves, "leaf-0" … "leaf-n-1". The golden // vectors below were produced from these exact inputs by tm2's own // merkle.SimpleProofsFromByteSlices, so a mismatch means this package has // diverged from Tendermint, not that the expectation is stale. func leaves(n int) [][]byte { out := make([][]byte, n) for i := 0; i < n; i++ { out[i] = []byte("leaf-" + strconv.Itoa(i)) } return out } var goldenRoots = map[int]string{ 1: "305df59f9590c3c9ac63d2b2743c388e3792449078cebf7fb3dbe6471643b2b7", 2: "60a53eed0de87a90c8e59427c59c46253c33a76a09502a51801300927b7e6bdc", 3: "cf763a041c81ceef1578a6083f75c61bef2e0014f2a3e683a97fcfca5be7f19a", 4: "bdd1c5ff55b19cb6b0e7c761bf9a6ccaa27fbbfc07b74f1fabb6e911a0bd2ab3", 5: "00d21829a5503145348abcf712513eacf2a274211ad83e970202bb5b6d80b286", 7: "0b007fb915eb9b2a146f54b1c86ec53b664f8e455b7660b0b6ee13edc0d921c0", } // goldenAunts[n][i] is the comma-separated hex sibling list tm2 produces for // leaf i of an n-leaf tree. var goldenAunts = map[int][]string{ 1: {""}, 2: { "3145c409f259b7c53e32036090ff76751025a2498ba9823ef718cac50b4e616f", "305df59f9590c3c9ac63d2b2743c388e3792449078cebf7fb3dbe6471643b2b7", }, 3: { "3145c409f259b7c53e32036090ff76751025a2498ba9823ef718cac50b4e616f,fca89f57c9f8c8eb4047a7ff9d333acf9e0f3384b20b255bceab0f216dcca267", "305df59f9590c3c9ac63d2b2743c388e3792449078cebf7fb3dbe6471643b2b7,fca89f57c9f8c8eb4047a7ff9d333acf9e0f3384b20b255bceab0f216dcca267", "60a53eed0de87a90c8e59427c59c46253c33a76a09502a51801300927b7e6bdc", }, 4: { "3145c409f259b7c53e32036090ff76751025a2498ba9823ef718cac50b4e616f,bd45ff28796704d88bdac51b1df553fda59837b616d6d1cb2114dbc3b087ff69", "305df59f9590c3c9ac63d2b2743c388e3792449078cebf7fb3dbe6471643b2b7,bd45ff28796704d88bdac51b1df553fda59837b616d6d1cb2114dbc3b087ff69", "f76836325aec5699d8d71f8e42e9d47c5c29b08059ba296384f7ca40ad3a40ae,60a53eed0de87a90c8e59427c59c46253c33a76a09502a51801300927b7e6bdc", "fca89f57c9f8c8eb4047a7ff9d333acf9e0f3384b20b255bceab0f216dcca267,60a53eed0de87a90c8e59427c59c46253c33a76a09502a51801300927b7e6bdc", }, 7: { "3145c409f259b7c53e32036090ff76751025a2498ba9823ef718cac50b4e616f,bd45ff28796704d88bdac51b1df553fda59837b616d6d1cb2114dbc3b087ff69,8eae6bd3b3a07f1f75ee72a531629e6eb31e42e62f760e47de52a53c3641ef23", "305df59f9590c3c9ac63d2b2743c388e3792449078cebf7fb3dbe6471643b2b7,bd45ff28796704d88bdac51b1df553fda59837b616d6d1cb2114dbc3b087ff69,8eae6bd3b3a07f1f75ee72a531629e6eb31e42e62f760e47de52a53c3641ef23", "f76836325aec5699d8d71f8e42e9d47c5c29b08059ba296384f7ca40ad3a40ae,60a53eed0de87a90c8e59427c59c46253c33a76a09502a51801300927b7e6bdc,8eae6bd3b3a07f1f75ee72a531629e6eb31e42e62f760e47de52a53c3641ef23", "fca89f57c9f8c8eb4047a7ff9d333acf9e0f3384b20b255bceab0f216dcca267,60a53eed0de87a90c8e59427c59c46253c33a76a09502a51801300927b7e6bdc,8eae6bd3b3a07f1f75ee72a531629e6eb31e42e62f760e47de52a53c3641ef23", "8f1593cb92f429d9340b9bbc1f0bb122adf8026c42a4a42142e2168931727236,676f3782f5b3a5fb4370ed49572cedc523f4a66322269c85f2af0509d17b0a4d,bdd1c5ff55b19cb6b0e7c761bf9a6ccaa27fbbfc07b74f1fabb6e911a0bd2ab3", "ea9fc1a1b6e191b460d0d6306e3e870c173f39330f13cda1b70cfc72bdc398ba,676f3782f5b3a5fb4370ed49572cedc523f4a66322269c85f2af0509d17b0a4d,bdd1c5ff55b19cb6b0e7c761bf9a6ccaa27fbbfc07b74f1fabb6e911a0bd2ab3", "985bb5d36b927800876871da925a7e82abe83a9ddba5882920a007a55ea2b376,bdd1c5ff55b19cb6b0e7c761bf9a6ccaa27fbbfc07b74f1fabb6e911a0bd2ab3", }, } func TestRootMatchesTendermint(t *testing.T) { for _, n := range []int{1, 2, 3, 4, 5, 7} { got := New(leaves(n)).RootHex() if got != goldenRoots[n] { t.Errorf("n=%d: root = %s, tm2 says %s", n, got, goldenRoots[n]) } } } func TestProofMatchesTendermint(t *testing.T) { for n, want := range goldenAunts { tree := New(leaves(n)) for i, w := range want { p, err := tree.Proof(i) if err != nil { t.Fatalf("n=%d i=%d: Proof: %v", n, i, err) } if got := p.Hex(); got != w { t.Errorf("n=%d i=%d:\n got %s\n want %s", n, i, got, w) } } } } // Every leaf of every tree shape up to 33 must produce a proof the native // verifier accepts. This is the property that matters; the golden vectors only // pin the encoding. func TestEveryProofVerifies(t *testing.T) { for n := 1; n <= 33; n++ { ls := leaves(n) tree := New(ls) root := tree.Root() for i := 0; i < n; i++ { p, err := tree.Proof(i) if err != nil { t.Fatalf("n=%d i=%d: Proof: %v", n, i, err) } if !p.Verify(root, ls[i]) { t.Errorf("n=%d i=%d: valid proof rejected", n, i) } } } } func TestRejects(t *testing.T) { ls := leaves(8) tree := New(ls) root := tree.Root() p0, _ := tree.Proof(0) p1, _ := tree.Proof(1) tests := []struct { name string proof Proof root []byte leaf []byte }{ {"forged leaf", p0, root, []byte("not-a-leaf")}, {"another real leaf at the wrong index", p0, root, ls[1]}, {"proof replayed at a different index", Proof{Index: 1, Total: p0.Total, Siblings: p0.Siblings}, root, ls[1]}, {"sibling list of another leaf", Proof{Index: 0, Total: 8, Siblings: p1.Siblings}, root, ls[0]}, {"wrong root", p0, LeafHash([]byte("nope")), ls[0]}, {"index beyond total", Proof{Index: 8, Total: 8, Siblings: p0.Siblings}, root, ls[0]}, {"negative index", Proof{Index: -1, Total: 8, Siblings: p0.Siblings}, root, ls[0]}, {"zero total", Proof{Index: 0, Total: 0, Siblings: p0.Siblings}, root, ls[0]}, {"truncated proof", Proof{Index: 0, Total: 8, Siblings: p0.Siblings[:1]}, root, ls[0]}, {"short root", p0, root[:31], ls[0]}, } for _, tc := range tests { if tc.proof.Verify(tc.root, tc.leaf) { t.Errorf("%s: accepted, must be rejected", tc.name) } } } func TestMaxDepthCap(t *testing.T) { sibs := make([][]byte, MaxDepth+1) for i := range sibs { sibs[i] = make([]byte, HashSize) } p := Proof{Index: 0, Total: 1 << 20, Siblings: sibs} if p.Verify(make([]byte, HashSize), []byte("x")) { t.Error("proof deeper than MaxDepth accepted") } if !VerifySorted(make([]byte, HashSize), make([]byte, HashSize), sibs[:MaxDepth]) == false { t.Error("unreachable") } if VerifySorted(make([]byte, HashSize), make([]byte, HashSize), sibs) { t.Error("sorted proof deeper than MaxDepth accepted") } } func TestParseProofRoundTrip(t *testing.T) { tree := New(leaves(7)) for i := 0; i < 7; i++ { want, _ := tree.Proof(i) got, err := ParseProof(i, 7, want.Hex()) if err != nil { t.Fatalf("i=%d: ParseProof: %v", i, err) } if got.Hex() != want.Hex() || got.Index != want.Index || got.Total != want.Total { t.Errorf("i=%d: round trip lost data", i) } if !got.Verify(tree.Root(), []byte("leaf-"+strconv.Itoa(i))) { t.Errorf("i=%d: reparsed proof does not verify", i) } } } func TestParseProofErrors(t *testing.T) { tests := []struct { name string in string want error }{ {"not hex", "zz", ErrBadHex}, {"wrong size", "abcd", ErrBadHashSize}, {"too deep", tooDeepHex(), ErrProofTooDeep}, } for _, tc := range tests { if _, err := ParseProof(0, 2, tc.in); err != tc.want { t.Errorf("%s: err = %v, want %v", tc.name, err, tc.want) } } // An empty sibling list is the one-leaf proof, not an error. p, err := ParseProof(0, 1, " ") if err != nil { t.Fatalf("empty siblings: %v", err) } one := New(leaves(1)) if !p.Verify(one.Root(), []byte("leaf-0")) { t.Error("one-leaf proof with no siblings rejected") } } func tooDeepHex() string { h := hex.EncodeToString(make([]byte, HashSize)) out := h for i := 0; i < MaxDepth; i++ { out += "," + h } return out } func TestTreeErrors(t *testing.T) { if _, err := New(nil).Proof(0); err != ErrEmptyTree { t.Errorf("empty tree: err = %v, want %v", err, ErrEmptyTree) } if New(nil).Root() != nil { t.Error("empty tree root should be nil, like Tendermint") } tree := New(leaves(3)) for _, i := range []int{-1, 3, 99} { if _, err := tree.Proof(i); err != ErrIndexRange { t.Errorf("index %d: err = %v, want %v", i, err, ErrIndexRange) } } } // New must copy its input: a caller mutating the slice afterwards cannot be // allowed to change what the tree committed to. func TestNewCopiesLeaves(t *testing.T) { ls := [][]byte{[]byte("a"), []byte("b")} tree := New(ls) before := tree.RootHex() ls[0][0] = 'z' if tree.RootHex() != before { t.Error("mutating the caller's slice changed the committed root") } } // The reason this package exists. In a scheme without domain separation an // INNER NODE hash is also a valid LEAF hash, so anyone who can choose a // 64-byte leaf preimage can prove membership of a leaf that was never in the // tree. This test performs that forgery against VerifySorted, then shows the // Tendermint scheme refuses the same move. func TestSecondPreimageForgery(t *testing.T) { // A 4-leaf commutative tree, built the OpenZeppelin way. h := make([][]byte, 4) for i := range h { s := sha256.Sum256([]byte("leaf-" + strconv.Itoa(i))) h[i] = s[:] } n01 := hashSortedPair(h[0], h[1]) n23 := hashSortedPair(h[2], h[3]) root := hashSortedPair(n01, n23) // Sanity: a real leaf really does verify. if !VerifySorted(root, h[0], [][]byte{h[1], n23}) { t.Fatal("honest sorted proof rejected") } // The forgery: present the inner node n01 as if it were a leaf hash. Its // preimage is the 64 bytes sorted(h0, h1), so an application whose leaves // can be 64 bytes long hands the attacker a membership proof for data that // was never committed. if !VerifySorted(root, n01, [][]byte{n23}) { t.Fatal("expected the second-preimage forgery to succeed against VerifySorted") } // Same shape, Tendermint scheme. Leaves are tagged 0x00 and inner nodes // 0x01, so no leaf preimage can ever hash to an inner node, and the proof // is bound to an index besides. tree := New(leaves(4)) inner := InnerHash(LeafHash(tree.leaves[0]), LeafHash(tree.leaves[1])) forged := Proof{Index: 0, Total: 2, Siblings: [][]byte{InnerHash(LeafHash(tree.leaves[2]), LeafHash(tree.leaves[3]))}} if forged.Verify(tree.Root(), inner) { t.Error("domain separation failed: an inner node was accepted as a leaf") } } func TestLeafAndInnerHashAreTagged(t *testing.T) { leafOfEmpty := LeafHash(nil) taggedLeaf := sha256.Sum256([]byte{0x00}) if hex.EncodeToString(leafOfEmpty) != hex.EncodeToString(taggedLeaf[:]) { t.Error("LeafHash is not SHA256(0x00 || leaf)") } l := make([]byte, 32) r := make([]byte, 32) r[0] = 1 got := InnerHash(l, r) want := sha256.Sum256(append(append([]byte{0x01}, l...), r...)) if hex.EncodeToString(got) != hex.EncodeToString(want[:]) { t.Error("InnerHash is not SHA256(0x01 || left || right)") } } func TestSplitPointMatchesTendermint(t *testing.T) { // largest power of two strictly below length tests := []struct{ in, want int }{ {2, 1}, {3, 2}, {4, 2}, {5, 4}, {7, 4}, {8, 4}, {9, 8}, {16, 8}, {17, 16}, } for _, tc := range tests { if got := splitPoint(tc.in); got != tc.want { t.Errorf("splitPoint(%d) = %d, want %d", tc.in, got, tc.want) } } }
#9AddPackagegno.land/p/moul/x/pair/v09 arguments
Attached funds
10000000ugnot

Arguments · 9

  1. #1pair
  2. #2README.md
  3. #3# `gno.land/p/moul/x/pair/v0` One constant-product AMM pair (`x*y=k`, 30 bps to the liquidity providers), as a pure package, so that the realm holding a pair can be twenty lines of glue. This is the shared half of the **instance-per-realm** pattern: gno's answer to the Ethereum factory shape, where an ecosystem is a frontend over thousands of tiny identical contracts, one per token couple. | artifact | what it is | |---|---| | `p/moul/x/pair/v0` | this package, the whole behaviour, deployed once | | an instance realm | ~20 lines: two constants, an `init` that registers, one-line re-exports. See `r/moul/x/pairs/aaabbb/v0` | | `r/moul/x/pairreg/v0` | the registry every instance announces itself to, and the unified frontend over all of them | | `tools/pairgen` | the factory, which runs on your machine because gno has no on-chain deploy | ## What gno changes about the Ethereum pattern - **No on-chain factory.** `vm/add_package` is permanently denied to realm code, so an instance is an `addpkg` transaction signed by a human, priced in gas and a storage deposit. `tools/pairgen` fills the template and prints the command. - **Spawning is still permissionless.** Any address may deploy under `gno.land/r/<its own g1 address>/**` with no registered username, so anyone can run their own instance and land in the registry. - **No clone trick needed.** Ethereum copies bytecode or delegatecalls through an EIP-1167 proxy; an import stores this package once for every instance. - **The registry sees live state.** Instances register a `*pair.Pair` pointer, so one `vm/qrender` renders every instance's real reserves, with no indexer. - **Nothing attests the code.** No realm can read another package's source, so an instance's code can only be verified off chain, by diffing it against freshly generated output. Unforgeable identity, no code attestation: the mirror image of CREATE2. ## Using it from an instance ```go var p *pair.Pair func init(cur realm) { p = pair.New(keyA, keyB, grc20reg.MustGet(keyA), grc20reg.MustGet(keyB)) pairreg.Register(cross(cur), p) } func Swap(cur realm, keyIn string, amountIn, minOut int64) int64 { return p.Swap(0, cur, keyIn, amountIn, minOut) } ``` Every state-changing method is shaped `(_ int, rlm realm, ...)`. That is forced, not stylistic: a pure package cannot declare a crossing function (`func F(cur realm, ...)` fails to compile there), and the parameter cannot be named `cur` either. The realm value arrives non-crossing, which is exactly what is wanted: the frame stays the instance realm, so funds move to and from the **instance's** address and grc20's `rlm.IsCurrent()` spoof check still passes. `p/moul/x/framelab/v0` is the 30-line probe that pins this property. A pure package also cannot import a realm, so this one never resolves a token by path: the instance passes `*grc20.Token` handles in. ## Economics, and what is deliberately absent Reserves are stored fields, never derived from `BalanceOf`, which removes `MINIMUM_LIQUIDITY`, `skim`/`sync` and the first-depositor inflation attack in one decision. The price: tokens sent directly to an instance's address are permanently stuck. The first deposit mints `shares = amountA` with no `sqrt`; later deposits mint `min(A-side, B-side)` with floor division, so an off-ratio deposit rounds against the depositor. No oracle, no flash swap, no routing, no protocol fee, no governance. All amounts are `int64` and the fee denominator is 1000, so every reserve is capped at `MaxReserve = MaxInt64/1000`. **Unusable with 18-decimal tokens**; six to nine decimals is the practical band. Full design study of the economics: [gno-contracts#135](https://github.com/moul/gno-contracts/issues/135). ## When to copy this pattern, and when not to Uniswap V4 moved from a pair-per-contract factory to a singleton, and [PR #137](https://github.com/moul/gno-contracts/pull/137) is the same AMM as one realm holding N pools. An AMM is therefore the contested case, kept here as a deliberate A/B. Reach for instance-per-realm when instances have **different owners, trust and lifecycles** (a user's shop, a DAO, a game table). Keep one realm with N objects when instances are fungible parts of one shared network (liquidity, an order book, a global index). <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/p/moul/x/pair/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/p/moul/x/pair/v0/deps.png) > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4gnomod.toml
  5. #5module = "gno.land/p/moul/x/pair/v0" gno = "0.9"
  6. #6pair.gno
  7. #7// Package pair is the whole behaviour of one constant-product AMM pair, as a // pure package, so that a realm holding a single pair can be twenty lines of // glue. // // It is the shared half of the instance-per-realm pattern: deploy this once, // then deploy one tiny realm per token couple, each owning its own state, its // own funds and its own storage deposit, all running this identical code. The // Ethereum shape it copies (a factory spawning one UniswapV2Pair per couple) // needs EIP-1167 clones to make the duplication affordable; an import makes it // free. // // # How an instance uses it // // var p *pair.Pair // // func init(cur realm) { // p = pair.New(keyA, keyB, grc20reg.MustGet(keyA), grc20reg.MustGet(keyB)) // pairreg.Register(cross(cur), p) // } // // func Swap(cur realm, keyIn string, amountIn, minOut int64) int64 { // return p.Swap(0, cur, keyIn, amountIn, minOut) // } // // # Why the methods look like that // // Every state-changing method is shaped (_ int, rlm realm, ...). The leading // dummy is mandatory: gno rejects a function whose FIRST parameter is realm // when it is declared in a pure package ("crossing function declared in // non-realm package"), and the parameter cannot be named cur either. Same // reason grc20's tellers are Transfer(_ int, rlm realm, ...). The realm value // arrives non-crossing, which is exactly what is wanted: the frame stays the // instance realm, so funds move to and from the INSTANCE's address, and // rlm.IsCurrent() still holds for grc20's spoof check. // // A pure package also cannot import a realm at all, so this package never // resolves a token by path. The instance passes *grc20.Token handles in. // // # The economics, unchanged from the single-realm design // // Constant product x*y=k with a 30 bps fee kept by the pool, reserves stored // and never derived from BalanceOf (so a direct transfer to the realm is // inert, and permanently stuck), first deposit minting shares equal to the // token-A amount with no sqrt and no MINIMUM_LIQUIDITY burn, and every reserve // capped so the int64 arithmetic cannot overflow. The reasoning behind each of // those is in https://github.com/moul/gno-contracts/issues/135 and is not // repeated here; this package is that design with one pool per realm instead // of many pools in one realm. // // Consequence worth repeating, because it bites: with a 1000x fee denominator // on int64 amounts, this is unusable with 18-decimal tokens. Six to nine // decimals is the practical band. package pair import ( "chain" "math" "math/bits" "strconv" "gno.land/p/nt/avl/v0" "gno.land/p/nt/grc20/v0" "gno.land/p/nt/ufmt/v0" ) const ( // feeNum/feeDen is the swap fee kept by the pool: 997/1000, i.e. 30 bps. feeNum = 997 feeDen = 1000 // maxReserve is the largest reserve a pair will hold, in base units. // // Pricing computes den = reserveIn*feeDen + amountIn*feeNum in plain // int64 before handing off to the 128-bit mulDiv. Capping every reserve, // and every post-swap reserve, at MaxInt64/feeDen makes that sum provably // safe: // // den < (reserveIn + amountIn) * feeDen <= maxReserve * feeDen // = 9223372036854775000 <= MaxInt64 = 9223372036854775807 MaxReserve = math.MaxInt64 / feeDen // 9223372036854775 ) // Pair is one token couple and its liquidity. Fields are concrete by design: // the registry realm holds a live pointer to this struct and reads it while // rendering, so an interface or a func field here would let a hostile // instance hand foreign code to the registry's frame. // // keyA < keyB always holds; New canonicalises. type Pair struct { keyA, keyB string tokA, tokB *grc20.Token resA, resB int64 totalShares int64 shares *avl.Tree // address string -> int64 } // New builds an empty pair from two grc20reg keys and the matching token // handles, in canonical order. The caller (the instance realm) is responsible // for resolving the handles, because a pure package cannot import the // registry realm that holds them. func New(keyA, keyB string, tokA, tokB *grc20.Token) *Pair { if keyA == "" || keyB == "" { panic("pair: empty token key") } if keyA == keyB { panic("pair: a pair needs two different tokens") } if tokA == nil || tokB == nil { panic("pair: nil token") } if keyA > keyB { keyA, keyB = keyB, keyA tokA, tokB = tokB, tokA } return &Pair{keyA: keyA, keyB: keyB, tokA: tokA, tokB: tokB, shares: avl.NewTree()} } // // Writes. Each one takes the instance realm's own cur, forwarded. // // AddLiquidity deposits up to maxA of token A and maxB of token B and mints LP // shares to the caller. On an existing pair the deposit is trimmed to the // current reserve ratio, so pass the amounts you are willing to spend, not the // amounts you insist on spending. // // The caller must first Approve the INSTANCE realm's address on both tokens. // Returns the shares minted. func (p *Pair) AddLiquidity(_ int, rlm realm, maxA, maxB int64) int64 { if maxA <= 0 || maxB <= 0 { panic("pair: both deposit amounts must be > 0") } amtA, amtB := maxA, maxB var minted int64 if p.totalShares == 0 { // First position: the depositor sets the price and the share unit is // token A at seed time. No sqrt, no MINIMUM_LIQUIDITY burn. minted = amtA } else { // Trim to the current ratio, then mint from whichever side is scarcer. // Both divisions floor, and taking the minimum means an off-ratio // deposit is rounded against the depositor, never against the pool. if want := mulDiv(amtA, p.resB, p.resA); want <= amtB { amtB = want } else { amtA = mulDiv(amtB, p.resA, p.resB) } if amtA <= 0 || amtB <= 0 { panic("pair: deposit too small for the current ratio") } minted = min64( mulDiv(amtA, p.totalShares, p.resA), mulDiv(amtB, p.totalShares, p.resB), ) } if minted <= 0 { panic("pair: deposit mints zero shares") } if p.resA+amtA > MaxReserve || p.resB+amtB > MaxReserve { panic("pair: reserve cap exceeded") } provider := rlm.Previous().Address() self := rlm.Address() pull(0, rlm, p.tokA, provider, self, amtA) pull(0, rlm, p.tokB, provider, self, amtB) p.resA += amtA p.resB += amtB p.totalShares += minted p.setShares(provider, p.SharesOf(provider)+minted) chain.Emit("AddLiquidity", "pair", p.ID(), "provider", provider.String(), "amountA", itoa(amtA), "amountB", itoa(amtB), "shares", itoa(minted), ) return minted } // RemoveLiquidity burns shares held by the caller and returns the // proportional amounts of both tokens, in (A, B) order. // // Burning the entire share supply pays out the whole reserve, so the last // provider out leaves nothing unclaimable behind and the pair can be reseeded // at a fresh price. func (p *Pair) RemoveLiquidity(_ int, rlm realm, shares int64) (int64, int64) { if shares <= 0 { panic("pair: shares must be > 0") } owner := rlm.Previous().Address() held := p.SharesOf(owner) if held < shares { panic("pair: insufficient shares") } var amtA, amtB int64 if shares == p.totalShares { amtA, amtB = p.resA, p.resB } else { amtA = mulDiv(shares, p.resA, p.totalShares) amtB = mulDiv(shares, p.resB, p.totalShares) } if amtA <= 0 || amtB <= 0 { panic("pair: burn would return nothing on one side") } p.resA -= amtA p.resB -= amtB p.totalShares -= shares p.setShares(owner, held-shares) push(0, rlm, p.tokA, owner, amtA) push(0, rlm, p.tokB, owner, amtB) chain.Emit("RemoveLiquidity", "pair", p.ID(), "provider", owner.String(), "amountA", itoa(amtA), "amountB", itoa(amtB), "shares", itoa(shares), ) return amtA, amtB } // Swap sells amountIn of keyIn for the other token and aborts unless at least // minOut comes back. The caller must first Approve the instance realm's // address on keyIn. // // minOut is the only protection against being sandwiched or against the pair // moving between quoting and execution. Pass a real bound; passing 0 means // accepting any price at all. func (p *Pair) Swap(_ int, rlm realm, keyIn string, amountIn, minOut int64) int64 { if amountIn <= 0 { panic("pair: amountIn must be > 0") } if minOut < 0 { panic("pair: minOut must be >= 0") } flipped := p.mustSide(keyIn) resIn, resOut := p.resA, p.resB tokIn, tokOut := p.tokA, p.tokB if flipped { resIn, resOut = p.resB, p.resA tokIn, tokOut = p.tokB, p.tokA } out := AmountOut(amountIn, resIn, resOut) if out <= 0 { panic("pair: output rounds to zero") } if out < minOut { panic("pair: slippage, output below minOut") } trader := rlm.Previous().Address() pull(0, rlm, tokIn, trader, rlm.Address(), amountIn) newIn, newOut := resIn+amountIn, resOut-out // The invariant holds by construction (out is floored), so this can only // fire if the pricing above is ever edited into being wrong. It is exact: // both products are compared in 128 bits. if cmpProd(newIn, newOut, resIn, resOut) < 0 { panic("pair: constant product regression") } if flipped { p.resB, p.resA = newIn, newOut } else { p.resA, p.resB = newIn, newOut } push(0, rlm, tokOut, trader, out) chain.Emit("Swap", "pair", p.ID(), "trader", trader.String(), "tokenIn", tokIn.GetSymbol(), "amountIn", itoa(amountIn), "amountOut", itoa(out), ) return out } // // Reads. Safe for the registry realm to call on another realm's Pair. // // AmountOut is the pricing function, fee included, as a pure function of the // two reserves. Exported so a caller can quote off chain against reserves it // already holds, and so the arithmetic is testable on its own. func AmountOut(amountIn, reserveIn, reserveOut int64) int64 { if amountIn <= 0 { panic("pair: amountIn must be > 0") } if reserveIn <= 0 || reserveOut <= 0 { panic("pair: pair has an empty reserve") } if reserveIn > MaxReserve || reserveOut > MaxReserve { panic("pair: reserve above cap") } if amountIn > MaxReserve-reserveIn { panic("pair: reserve cap exceeded") } inFee := amountIn * feeNum den := reserveIn*feeDen + inFee return mulDiv(inFee, reserveOut, den) } // Quote prices amountIn of keyIn against the live reserves. It is the number // Swap would return right now, which is not a promise about the next block. func (p *Pair) Quote(keyIn string, amountIn int64) int64 { if p.totalShares == 0 { return 0 } if p.mustSide(keyIn) { return AmountOut(amountIn, p.resB, p.resA) } return AmountOut(amountIn, p.resA, p.resB) } // Keys returns the two grc20reg keys in canonical order. func (p *Pair) Keys() (string, string) { return p.keyA, p.keyB } // Symbols returns the two token symbols in canonical order. func (p *Pair) Symbols() (string, string) { return p.tokA.GetSymbol(), p.tokB.GetSymbol() } // ID is the canonical identifier of the couple, "keyA~keyB". The separator is // "~" and not "|": a "|" inside a markdown table cell breaks the row. func (p *Pair) ID() string { return p.keyA + "~" + p.keyB } // Reserves returns the two reserves in canonical order. func (p *Pair) Reserves() (int64, int64) { return p.resA, p.resB } // TotalShares returns the LP share supply. func (p *Pair) TotalShares() int64 { return p.totalShares } // Providers returns how many addresses hold shares. func (p *Pair) Providers() int { return p.shares.Size() } // SharesOf returns owner's LP shares. func (p *Pair) SharesOf(owner address) int64 { v := p.shares.Get(owner.String()) if v == nil { return 0 } return v.(int64) } // Render is the instance realm's whole page: what the couple is, what it // holds, and how to trade it. func (p *Pair) Render(path string) string { symA, symB := p.Symbols() out := "# " + symA + " / " + symB + "\n\n" out += "One constant-product pair, `x*y=k`, 30 bps to the liquidity providers. " out += "Logic lives in [p/moul/x/pair/v0](/p/moul/x/pair/v0); this realm is the instance.\n\n" if p.totalShares == 0 { out += "_Empty. The first `AddLiquidity` sets the price._\n\n" } else { out += ufmt.Sprintf("| side | reserve | key |\n|---|---|---|\n| %s | %d | `%s` |\n| %s | %d | `%s` |\n\n", symA, p.resA, p.keyA, symB, p.resB, p.keyB) out += ufmt.Sprintf("- LP shares: **%d** across %d provider(s)\n", p.totalShares, p.shares.Size()) out += ufmt.Sprintf("- Spot: 1 %s buys ~%d %s (before fee and slippage)\n\n", symA, p.spot(), symB) } out += "Approve this realm's address on the token you are selling, then call `Swap`.\n" return out } // // Internals. // // spot is the display-only mid price, floor(resB/resA). Never price anything // off it: it is a spot reserve ratio that any trader can move in one tx. func (p *Pair) spot() int64 { if p.resA == 0 { return 0 } return p.resB / p.resA } // mustSide reports whether key is the B side, and panics if it is neither. func (p *Pair) mustSide(key string) bool { switch key { case p.keyA: return false case p.keyB: return true } panic("pair: " + key + " is not in this pair") } func (p *Pair) setShares(owner address, n int64) { if n == 0 { p.shares.Remove(owner.String()) return } p.shares.Set(owner.String(), n) } // pull moves amount of tok from `from` into the instance realm, spending the // allowance `from` granted to the instance realm's address. // // Non-crossing on purpose: `_ int, rlm realm` is the only shape a pure // package can declare, and it keeps rlm the instance's own live frame. // RealmTeller binds the spender eagerly to rlm.Address(), which is therefore // the instance, not this package (a pure package has no address at all). func pull(_ int, rlm realm, tok *grc20.Token, from, to address, amount int64) { err := tok.RealmTeller(0, rlm).TransferFrom(0, rlm, from, to, amount) if err != nil { panic("pair: cannot take " + tok.GetSymbol() + ": " + err.Error()) } } // push sends amount of tok from the instance realm to `to`. func push(_ int, rlm realm, tok *grc20.Token, to address, amount int64) { err := tok.RealmTeller(0, rlm).Transfer(0, rlm, to, amount) if err != nil { panic("pair: cannot send " + tok.GetSymbol() + ": " + err.Error()) } } // mulDiv returns floor(a*b/c) through a 128-bit intermediate, which plain // int64 arithmetic cannot do: a*b is routinely wider than 63 bits here even // when the quotient is small. func mulDiv(a, b, c int64) int64 { if a < 0 || b < 0 { panic("pair: negative operand") } if c <= 0 { panic("pair: division by a non-positive value") } hi, lo := bits.Mul64(uint64(a), uint64(b)) // bits.Div64 panics for y <= hi; refusing here turns that into a named // abort and also covers every quotient that would not fit in 64 bits. if hi >= uint64(c) { panic("pair: quotient overflows int64") } q, _ := bits.Div64(hi, lo, uint64(c)) if q > uint64(math.MaxInt64) { panic("pair: quotient overflows int64") } return int64(q) } // cmpProd compares a*b with c*d exactly, in 128 bits, and returns -1, 0 or 1. func cmpProd(a, b, c, d int64) int { h1, l1 := bits.Mul64(uint64(a), uint64(b)) h2, l2 := bits.Mul64(uint64(c), uint64(d)) if h1 != h2 { if h1 < h2 { return -1 } return 1 } if l1 != l2 { if l1 < l2 { return -1 } return 1 } return 0 } func min64(a, b int64) int64 { if a < b { return a } return b } func itoa(v int64) string { return strconv.FormatInt(v, 10) }
  8. #8pair_test.gno
  9. #9package pair import ( "testing" "gno.land/p/nt/uassert/v0" ) // The pricing function, on its own, is the part worth pinning numerically: it // is pure, it is where the 128-bit arithmetic lives, and every instance in the // ecosystem runs this exact code. func TestAmountOut(t *testing.T) { cases := []struct { name string in, resIn, resOut, want int64 }{ {"balanced pool, small trade", 1_000, 1_000_000, 1_000_000, 996}, {"balanced pool, 1%", 10_000, 1_000_000, 1_000_000, 9_871}, {"balanced pool, 10%", 100_000, 1_000_000, 1_000_000, 90_661}, {"balanced pool, 50%", 500_000, 1_000_000, 1_000_000, 332_665}, {"skewed pool", 100_000, 1_000_000, 4_000_000, 362_644}, {"near drain", 1_000, 1, 1_000_000, 998_997}, {"dust rounds to zero", 1, 1_000_000, 1_000_000, 0}, {"reserves at the cap", MaxReserve / 2, MaxReserve / 2, MaxReserve, 4_604_758_097_518_382}, } for _, tc := range cases { got := AmountOut(tc.in, tc.resIn, tc.resOut) uassert.Equal(t, tc.want, got, tc.name) } } // The fee is what stays behind: k must never decrease across a swap. func TestConstantProductNeverRegresses(t *testing.T) { resIn, resOut := int64(1_000_000), int64(4_000_000) for _, in := range []int64{1, 1_000, 100_000, 999_999} { out := AmountOut(in, resIn, resOut) uassert.True(t, cmpProd(resIn+in, resOut-out, resIn, resOut) >= 0, "k must not regress") } } func TestAmountOutGuards(t *testing.T) { mustPanic(t, "pair: amountIn must be > 0", func() { AmountOut(0, 1_000, 1_000) }) mustPanic(t, "pair: pair has an empty reserve", func() { AmountOut(1, 0, 1_000) }) mustPanic(t, "pair: reserve above cap", func() { AmountOut(1, MaxReserve+1, 1_000) }) mustPanic(t, "pair: reserve cap exceeded", func() { AmountOut(2, MaxReserve-1, 1_000) }) } // mulDiv is the only place a 128-bit intermediate is required, and the only // place bits.Div64 could panic. Both ends are asserted rather than argued. func TestMulDiv(t *testing.T) { uassert.Equal(t, int64(2), mulDiv(4, 3, 6)) uassert.Equal(t, int64(0), mulDiv(1, 1, 3), "floors") uassert.Equal(t, int64(4_611_686_018_427_387_903), mulDiv(9_223_372_036_854_775_807, 1, 2), "operands near MaxInt64 stay exact through the 128-bit path") mustPanic(t, "pair: division by a non-positive value", func() { mulDiv(1, 1, 0) }) mustPanic(t, "pair: quotient overflows int64", func() { mulDiv(9_223_372_036_854_775_807, 9_223_372_036_854_775_807, 1) }) } func TestCmpProd(t *testing.T) { uassert.Equal(t, 0, cmpProd(6, 7, 42, 1)) uassert.Equal(t, -1, cmpProd(6, 6, 42, 1)) uassert.Equal(t, 1, cmpProd(7, 7, 42, 1)) // Both products exceed int64 and differ only in the low word. uassert.Equal(t, -1, cmpProd(4_000_000_000, 4_000_000_000, 4_000_000_000, 4_000_000_001)) } // mustPanic is uassert.PanicsWithMessage without the realm argument: a pure // package cannot produce a `cur` to hand it, since it can never declare a // crossing function in the first place. func mustPanic(t *testing.T, want string, f func()) { t.Helper() defer func() { r := recover() if r == nil { t.Errorf("expected panic %q, got none", want) return } got, ok := r.(string) if !ok { t.Errorf("expected a string panic %q, got %v", want, r) return } if got != want { t.Errorf("expected panic %q, got %q", want, got) } }() f() }
#10AddPackagegno.land/p/moul/x/plan9/ninep/v09 arguments
Attached funds
8000000ugnot

Arguments · 9

  1. #1ninep
  2. #2README.md
  3. #3# `gno.land/p/moul/x/plan9/ninep/v0` **A Plan 9 shaped file abstraction for gno**: `File`, `Mutable`, `Qid`, `Stat`, `Perm`, the 9P error set, and lexical path handling. ```go import ninep "gno.land/p/moul/x/plan9/ninep/v0" type File interface { Stat() Stat Walk(name string) (File, error) // exactly one element Read(off, count int64) (string, error) ReadDir() ([]Stat, error) } ``` This implements the *semantics* of [9P2000](https://ericvh.github.io/9p-rfc/rfc9p2000.html), not its wire format. There is no socket on a chain: the VM call is the transport. What survives the translation is the part that made 9P useful, namely that every resource answers the same four questions, so a client written today can browse a file server deployed tomorrow. **One interface, not two.** 9P reads a directory with the same `Tread` it uses for a file, so splitting `File` from `Dir` would be less faithful, and a single interface means no type assertion across a realm boundary. **`File` is read-only, on purpose.** A crossing write method would mint the *caller's* realm frame for the callee, which is the confused-deputy shape that `r/gov/dao`'s `Executor` relies on deliberately and `p/nt/grc20`'s `Teller` refuses deliberately. So mutation lives in a separate `Mutable`, which is only safe on a tree your own realm owns. That is what makes it safe to hand a `File` to a stranger's namespace. Deliberate divergences, each one forced: - **Data is a `string`, not `[]byte`.** Every consumer on this chain is text and `Render` returns a string. - **`Mtime` is a block height.** It is the only clock every validating node agrees on. - **There is no `open`/`clunk`.** Without a session there are no fids, so `Walk` returns the file itself and nothing has to be released. - **`..` never reaches a server.** `Clean` resolves it lexically first, per [Lexical File Names in Plan 9](https://9p.io/sys/doc/lexnames.html), so `..` undoes the name you typed rather than the directory you landed in. - **`MaxDepth` caps a walk at 32 elements.** Resolution can cost one cross-realm call per element, so depth is bounded rather than trusted. Used by [`memfs`](../../memfs/v0) (a RAM server), [`synfs`](../../synfs/v0) (a computed server), [`ns`](../../ns/v0) (namespaces) and [`rc`](../../rc/v0) (the shell). Design and analysis: [moul/gno-contracts#136](https://github.com/moul/gno-contracts/issues/136). --- **Not affiliated with Plan 9.** Plan 9 from Bell Labs is the work of the Computing Science Research Center at Bell Labs; the name and the marks are theirs, and the copyright is held by the [Plan 9 Foundation](https://p9f.org). This package borrows the vocabulary and none of the code: it is an independent homage, asking what that ecosystem's spirit looks like on a chain. Full attribution: [NOTICE](../../../../../NOTICE.md). <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4gnomod.toml
  5. #5module = "gno.land/p/moul/x/plan9/ninep/v0" gno = "0.9"
  6. #6ninep.gno
  7. #7// Package ninep is a Plan 9 shaped file abstraction for gno. // // It implements the SEMANTICS of 9P2000, not its wire format: there is no // socket on a chain, the VM call is the transport. What it keeps is the part // that made 9P useful, namely that every resource answers the same four // questions (stat, walk, read, readdir), so a client written today can browse // a file server deployed tomorrow. // // One interface, not two. 9P reads a directory with the same Tread it uses for // a file, so splitting File from Dir would be less faithful, and a single // interface means no type assertion across a realm boundary. // // Deliberate divergences from 9P2000, all of them forced: // // - Data is a string, not []byte. Every consumer on this chain is text and // Render returns a string. // - Mtime is a block height, not a wall clock. It is the only clock that // every validating node agrees on. // - There is no open/clunk pair. Without a session there are no fids, so // Walk returns the file itself and nothing has to be released. // - File is READ-ONLY. A crossing method would mint the caller's realm frame // for the callee, which is a confused-deputy hazard (compare r/gov/dao's // Executor, and p/nt/grc20's deliberate refusal to make Teller crossing). // Mutation lives in Mutable, which is only safe on a tree your own realm // owns. // // See gno.land/p/moul/x/plan9/ns for the namespace that binds these trees // together, and gno.land/p/moul/x/plan9/memfs for the reference server. // // NOTICE. Plan 9 from Bell Labs is the work of the Computing Science Research // Center at Bell Labs; the name and the marks are theirs, and the copyright is // held by the Plan 9 Foundation (https://p9f.org). This package is not // affiliated with, endorsed by, or sponsored by them, and contains no Plan 9 // code: it borrows the vocabulary so that the design reads without a glossary, // and it is an homage, asking what that ecosystem's spirit looks like on a // chain. Full attribution: NOTICE.md at the root of moul/gno-contracts. package ninep import ( "errors" "path" "strconv" "strings" ) // Qid type bits, as in 9P2000. const ( QTFILE uint8 = 0x00 // a plain file QTTMP uint8 = 0x04 // not archived QTAUTH uint8 = 0x08 // authentication file QTMOUNT uint8 = 0x10 // mounted channel QTEXCL uint8 = 0x20 // exclusive use QTAPPEND uint8 = 0x40 // append only QTDIR uint8 = 0x80 // a directory ) // Perm holds the 9P mode word: the low nine bits are rwx for owner, group and // other, the high bits are the DM* kind flags. type Perm uint32 // Mode bits, as in 9P2000. const ( DMTMP Perm = 0x04000000 DMAUTH Perm = 0x08000000 DMMOUNT Perm = 0x10000000 DMEXCL Perm = 0x20000000 DMAPPEND Perm = 0x40000000 DMDIR Perm = 0x80000000 PermMask Perm = 0777 // the rwxrwxrwx bits // Conventional defaults, matching what Plan 9's ramfs hands out. DirPerm Perm = DMDIR | 0755 FilePerm Perm = 0644 ) // IsDir reports whether the mode marks a directory. func (p Perm) IsDir() bool { return p&DMDIR != 0 } // String renders the mode the way ls -l does: a kind letter then nine rwx // bits. The kind letter is 'd' for a directory, 'a' for append-only, 'l' for // exclusive-use, '-' otherwise. func (p Perm) String() string { var b strings.Builder switch { case p&DMDIR != 0: b.WriteByte('d') case p&DMAPPEND != 0: b.WriteByte('a') case p&DMEXCL != 0: b.WriteByte('l') default: b.WriteByte('-') } const rwx = "rwxrwxrwx" for i := 0; i < 9; i++ { if p&(1<<uint(8-i)) != 0 { b.WriteByte(rwx[i]) } else { b.WriteByte('-') } } return b.String() } // Qid is the server's unique handle for a file. Path identifies the file // within one server for its whole lifetime; Version increments on every write, // so a client can tell "same file, changed" from "different file" without // reading either. type Qid struct { Type uint8 Version uint32 Path uint64 } // IsDir reports whether the qid marks a directory. func (q Qid) IsDir() bool { return q.Type&QTDIR != 0 } // String renders the qid as Plan 9 does, "(path version type)", with the path // in hex. func (q Qid) String() string { kind := "f" if q.IsDir() { kind = "d" } return "(" + strconv.FormatUint(q.Path, 16) + " " + strconv.FormatUint(uint64(q.Version), 10) + " " + kind + ")" } // Stat is 9P's directory entry, minus the fields that only mean something on a // wire (type, dev) or on a host clock (atime). type Stat struct { Qid Qid Mode Perm Mtime int64 // block height of the last write Length int64 // in bytes; zero for a directory, as in 9P Name string Uid string // owner; a bech32 address, or a well-known name Gid string // group Muid string // last writer } // IsDir reports whether the entry is a directory. func (s Stat) IsDir() bool { return s.Mode.IsDir() } // Line renders the entry the way ls -l does. func (s Stat) Line() string { return s.Mode.String() + " " + pad(s.Uid, 12) + " " + pad(s.Gid, 8) + " " + lpad(strconv.FormatInt(s.Length, 10), 7) + " " + s.Name } // pad right-pads to n runes. ufmt has no width flags in gno, so padding is by // hand everywhere in this suite. func pad(s string, n int) string { for len(s) < n { s += " " } return s } func lpad(s string, n int) string { for len(s) < n { s = " " + s } return s } // File is a 9P file server's whole read surface. Every method must be free of // side effects: a File is routinely reached across a realm boundary, where the // running frame belongs to the CALLER, so mutating anything here would be both // a VM error and a confused deputy. type File interface { // Stat returns the entry for this file. Stat() Stat // Walk resolves exactly one path element. It returns ErrNotDir on a // plain file and ErrNotExist when the name is absent. It never sees // "." or "..": both are removed lexically before resolution starts. Walk(name string) (File, error) // Read returns at most count bytes starting at off. A negative count // means "to the end". It returns ErrIsDir on a directory. Read(off, count int64) (string, error) // ReadDir returns the directory's entries in a deterministic order. It // returns ErrNotDir on a plain file. ReadDir() ([]Stat, error) } // Mutable is the write half, kept out of File on purpose. // // It is NOT safe across a realm boundary: a non-crossing method runs in the // caller's frame, so a foreign realm calling these would be trying to mutate // objects it does not own. Only call Mutable on a tree your own realm created. // The caller supplies now (a block height) rather than the tree reading the // chain itself, so the same code is testable off chain. type Mutable interface { File Create(name string, perm Perm, now int64) (File, error) Remove(name string) error Write(off int64, data string, now int64) (int64, error) Truncate(size int64, now int64) error } // Plan 9 error strings, kept lowercase and verbatim where they exist. var ( ErrNotExist = errors.New("file does not exist") ErrNotDir = errors.New("not a directory") ErrIsDir = errors.New("is a directory") ErrExist = errors.New("file already exists") ErrPerm = errors.New("permission denied") ErrNoCreate = errors.New("create prohibited") ErrReadOnly = errors.New("read-only file server") ErrBadName = errors.New("bad character in file name") ErrNotEmpty = errors.New("directory not empty") ErrTooDeep = errors.New("path too deep") ) // MaxDepth bounds a walk. Resolution costs one cross-realm call per element // per union member, so depth is capped rather than trusted. const MaxDepth = 32 // ValidName reports whether name is usable as a single path element. Plan 9 // rejects the empty name, "." and "..", and any name containing a slash. func ValidName(name string) bool { if name == "" || name == "." || name == ".." { return false } return !strings.Contains(name, "/") } // Clean returns p as a cleaned absolute path. "." and ".." are resolved // lexically, before any server sees them, which is what makes ".." undo the // name you typed rather than the directory you landed in (see "Lexical File // Names in Plan 9"). func Clean(p string) string { if !strings.HasPrefix(p, "/") { p = "/" + p } return path.Clean(p) } // Abs resolves p against cwd, then cleans it. func Abs(cwd, p string) string { if p == "" { return Clean(cwd) } if strings.HasPrefix(p, "/") { return Clean(p) } if cwd == "" { cwd = "/" } return Clean(cwd + "/" + p) } // Elems splits a cleaned absolute path into its elements. The root yields nil. func Elems(p string) []string { p = Clean(p) if p == "/" { return nil } return strings.Split(p[1:], "/") } // Base returns the last element of p, or "/" for the root. func Base(p string) string { return path.Base(Clean(p)) } // Dir returns p's parent. func Dir(p string) string { return path.Dir(Clean(p)) } // Join appends name to dir. func Join(dir, name string) string { return Clean(dir + "/" + name) } // Walk resolves elems from f, one element at a time. It is the plain, // namespace-free walk: no binds, no unions. Use ns.Ns for those. func Walk(f File, elems []string) (File, error) { if len(elems) > MaxDepth { return nil, ErrTooDeep } for _, e := range elems { next, err := f.Walk(e) if err != nil { return nil, err } f = next } return f, nil } // ReadAll reads a whole file. func ReadAll(f File) (string, error) { return f.Read(0, -1) } // Slice applies 9P's read window to s: at most count bytes from off, with a // negative count meaning "to the end". An offset past the end reads empty, // which is what makes a read loop terminate rather than fail. func Slice(s string, off, count int64) string { if off < 0 { off = 0 } n := int64(len(s)) if off >= n { return "" } end := n if count >= 0 && off+count < n { end = off + count } return s[off:end] }
  8. #8ninep_test.gno
  9. #9package ninep import "testing" func TestPermString(t *testing.T) { tests := []struct { name string perm Perm want string }{ {"dir 0755", DMDIR | 0755, "drwxr-xr-x"}, {"file 0644", 0644, "-rw-r--r--"}, {"file 0600", 0600, "-rw-------"}, {"file 0777", 0777, "-rwxrwxrwx"}, {"file 0000", 0, "----------"}, {"append only", DMAPPEND | 0644, "arw-r--r--"}, {"exclusive", DMEXCL | 0600, "lrw-------"}, {"dir default", DirPerm, "drwxr-xr-x"}, {"file default", FilePerm, "-rw-r--r--"}, } for _, tt := range tests { if got := tt.perm.String(); got != tt.want { t.Errorf("%s: got %q, want %q", tt.name, got, tt.want) } } } func TestPermIsDir(t *testing.T) { if !(DMDIR | 0755).IsDir() { t.Error("DMDIR|0755 should be a directory") } if Perm(0644).IsDir() { t.Error("0644 should not be a directory") } } func TestQidString(t *testing.T) { tests := []struct { name string qid Qid want string }{ {"root", Qid{Type: QTDIR, Version: 0, Path: 0}, "(0 0 d)"}, {"file", Qid{Type: QTFILE, Version: 3, Path: 17}, "(11 3 f)"}, {"big path", Qid{Type: QTFILE, Version: 1, Path: 255}, "(ff 1 f)"}, } for _, tt := range tests { if got := tt.qid.String(); got != tt.want { t.Errorf("%s: got %q, want %q", tt.name, got, tt.want) } } } func TestStatLine(t *testing.T) { s := Stat{ Qid: Qid{Type: QTFILE, Path: 2, Version: 1}, Mode: 0644, Length: 12, Name: "greeting", Uid: "glenda", Gid: "sys", } want := "-rw-r--r-- glenda sys 12 greeting" if got := s.Line(); got != want { t.Errorf("got %q, want %q", got, want) } } func TestValidName(t *testing.T) { tests := []struct { name string want bool }{ {"dev", true}, {"a.b", true}, {"with space", true}, {"", false}, {".", false}, {"..", false}, {"a/b", false}, {"/", false}, } for _, tt := range tests { if got := ValidName(tt.name); got != tt.want { t.Errorf("ValidName(%q): got %v, want %v", tt.name, got, tt.want) } } } func TestClean(t *testing.T) { tests := []struct { in string want string }{ {"", "/"}, {"/", "/"}, {"dev", "/dev"}, {"/dev/", "/dev"}, {"/dev//height", "/dev/height"}, {"/dev/./height", "/dev/height"}, {"/dev/../srv", "/srv"}, {"/../..", "/"}, {"/a/b/../../c", "/c"}, } for _, tt := range tests { if got := Clean(tt.in); got != tt.want { t.Errorf("Clean(%q): got %q, want %q", tt.in, got, tt.want) } } } func TestAbs(t *testing.T) { tests := []struct { cwd string in string want string }{ {"/", "dev", "/dev"}, {"/usr/glenda", "bin", "/usr/glenda/bin"}, {"/usr/glenda", "/dev", "/dev"}, {"/usr/glenda", "..", "/usr"}, {"/usr/glenda", "", "/usr/glenda"}, {"", "dev", "/dev"}, } for _, tt := range tests { if got := Abs(tt.cwd, tt.in); got != tt.want { t.Errorf("Abs(%q, %q): got %q, want %q", tt.cwd, tt.in, got, tt.want) } } } func TestElems(t *testing.T) { tests := []struct { in string want []string }{ {"/", nil}, {"", nil}, {"/dev", []string{"dev"}}, {"/dev/height", []string{"dev", "height"}}, {"/a/b/../c", []string{"a", "c"}}, } for _, tt := range tests { got := Elems(tt.in) if len(got) != len(tt.want) { t.Errorf("Elems(%q): got %v, want %v", tt.in, got, tt.want) continue } for i := range got { if got[i] != tt.want[i] { t.Errorf("Elems(%q)[%d]: got %q, want %q", tt.in, i, got[i], tt.want[i]) } } } } func TestBaseDirJoin(t *testing.T) { if got := Base("/dev/height"); got != "height" { t.Errorf("Base: got %q", got) } if got := Base("/"); got != "/" { t.Errorf("Base of root: got %q", got) } if got := Dir("/dev/height"); got != "/dev" { t.Errorf("Dir: got %q", got) } if got := Join("/dev", "height"); got != "/dev/height" { t.Errorf("Join: got %q", got) } if got := Join("/", "dev"); got != "/dev" { t.Errorf("Join at root: got %q", got) } } func TestSlice(t *testing.T) { const s = "hello world" tests := []struct { name string off int64 count int64 want string }{ {"whole", 0, -1, "hello world"}, {"prefix", 0, 5, "hello"}, {"middle", 6, 5, "world"}, {"past end", 99, 5, ""}, {"count past end", 6, 99, "world"}, {"negative offset clamps", -4, 5, "hello"}, {"zero count", 0, 0, ""}, } for _, tt := range tests { if got := Slice(s, tt.off, tt.count); got != tt.want { t.Errorf("%s: got %q, want %q", tt.name, got, tt.want) } } }
#11AddPackagegno.land/p/moul/x/vm/vmkit/v019 arguments
Attached funds
15000000ugnot

Arguments · 19

  1. #1vmkit
  2. #2README.md
  3. #3# p/moul/x/vm/vmkit The host ABI shared by every guest virtual machine that runs inside a gno realm: a stepping contract with an explicit fuel budget, a capability-scoped `Host`, and a snapshot format that lets a guest program outlive the transaction that started it. A guest VM implements `Machine`. Everything else here is the machinery a VM should not have to write twice: the `Meter`, the canonical `Writer`/`Reader` snapshots are built from, an in-memory `TestHost`, and the avl-backed `Store` a realm keeps its instances in. First consumer: [`p/moul/x/vm/bf`](../bf). Live demo: [`r/moul/x/vm/bfdemo`](/r/moul/x/vm/bfdemo/v0). ```go type Machine interface { Step(h Host, fuel int64) (used int64, status Status) Snapshot() []byte Restore(b []byte) error } ``` `Status` is one of `Running`, `Halted`, `Trapped`, `OutOfFuel`. ## What the ABI is for **Fuel is the interface, not the backstop.** Running out of fuel yields `OutOfFuel` plus a machine that can be snapshotted, never a panic. Gas still bounds the transaction; it is just not the thing a guest program is written against. **Continuations.** `Snapshot` and `Restore` mean a program runs across blocks. The realm stores the bytes, the next caller pays for the next slice. Gno realm code cannot pause itself; a guest can. The property that makes this real is tested rather than asserted: five slices of one fuel unit must produce exactly what one slice of five produces, for every program in `bf`'s corpus. **Capabilities, not ambient authority.** A guest gets exactly the `Host` it was handed. `Send` returns `ErrNotGranted` unless the deploying realm funded a budget, storage is scoped to the instance, and nothing is looked up. Gno itself has ambient authority through the realm frame, so the guest is where the capability-secure version can actually be tried. ## Meter is not a hot-path type `Meter` exists for the API boundary: compute a slice budget, charge it once, report what was used. Calling `Meter.Charge` once per guest instruction was measured at **+86% on top of an entire interpreter dispatch loop**, and more than tripled its allocation count. A machine should count fuel in a local and settle up with the caller. The numbers are in [`bf`'s README](../bf/README.md). `Charge` spends nothing when the budget cannot cover the request, so a machine that stops for lack of fuel is exactly at the instruction it could not pay for, and resuming re-executes that instruction and no other. ## The snapshot codec `Writer` and `Reader` are fixed-width big-endian with length-prefixed bytes, so the encoding is canonical: the same machine state always produces the same bytes, on every node. A snapshot is consensus state, so two nodes encoding it differently is a fork, and a short read is a hard error rather than a zero value. Machines are not required to use it, but a machine that invents its own layout owes the zoo an explanation: the cross-VM snapshot cost comparison only means something when the encodings match. ## Instance and Store `Instance` is one guest program as a realm stores it: code, snapshot between slices, status, and the fuel accounting that survives the transaction. It holds bytes rather than a `Machine` on purpose, which is what makes the storage cost of a paused program measurable. `Instance.Run` folds one slice back into the instance and leaves it untouched when `Restore` fails, so a snapshot that cannot be decoded costs the caller gas but never corrupts the stored program. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/p/moul/x/vm/vmkit/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/p/moul/x/vm/vmkit/v0/deps.png) > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4codec.gno
  5. #5package vmkit import "errors" // ErrTruncated is returned by every Reader method that runs past the end of // the buffer. A snapshot is consensus state, so a short read is always a hard // error and never a zero value. var ErrTruncated = errors.New("vmkit: truncated snapshot") // ErrBadSnapshot is returned when a snapshot is well-formed but not for this // machine: wrong magic, wrong version, or a field outside its legal range. var ErrBadSnapshot = errors.New("vmkit: snapshot is not for this machine") // Writer builds a snapshot. Every integer is fixed-width big-endian and every // byte slice is length-prefixed, so the encoding is canonical: the same // machine state always produces the same bytes, on every node. // // Machines are not required to use it, but a machine that invents its own // layout owes the zoo an explanation, because the cross-VM snapshot cost // comparison only means something when the encodings match. type Writer struct { buf []byte } // NewWriter returns a Writer with room for n bytes reserved up front. func NewWriter(n int) *Writer { if n < 0 { n = 0 } return &Writer{buf: make([]byte, 0, n)} } // Byte appends one byte. func (w *Writer) Byte(b byte) { w.buf = append(w.buf, b) } // Uint32 appends a 4-byte big-endian value. func (w *Writer) Uint32(v uint32) { w.buf = append(w.buf, byte(v>>24), byte(v>>16), byte(v>>8), byte(v)) } // Uint64 appends an 8-byte big-endian value. func (w *Writer) Uint64(v uint64) { w.buf = append(w.buf, byte(v>>56), byte(v>>48), byte(v>>40), byte(v>>32), byte(v>>24), byte(v>>16), byte(v>>8), byte(v)) } // Int appends a signed value, zig-zag encoded into a Uint64 so that small // negative numbers do not cost eight 0xff bytes. func (w *Writer) Int(v int64) { u := uint64(v) << 1 if v < 0 { u = ^u } w.Uint64(u) } // Bytes appends a length-prefixed byte slice. func (w *Writer) Bytes(p []byte) { w.Uint32(uint32(len(p))) w.buf = append(w.buf, p...) } // String appends a length-prefixed string. func (w *Writer) String(s string) { w.Bytes([]byte(s)) } // Out returns the encoded bytes. func (w *Writer) Out() []byte { return w.buf } // Reader consumes a snapshot written by [Writer]. // // It latches the first error it hits: a caller may decode a whole struct and // check [Reader.Err] once at the end, instead of after every field. Every // method returns a zero value once the Reader is in error. type Reader struct { buf []byte pos int err error } // NewReader returns a Reader over b. func NewReader(b []byte) *Reader { return &Reader{buf: b} } // Err returns the first error hit, or nil. func (r *Reader) Err() error { return r.err } // Remaining reports how many bytes are left unread. func (r *Reader) Remaining() int { if r.pos > len(r.buf) { return 0 } return len(r.buf) - r.pos } // Fail latches err, so a machine can reject a field its own rules forbid and // have it surface through [Reader.Err] like any decoding failure. func (r *Reader) Fail(err error) { if r.err == nil { r.err = err } } func (r *Reader) take(n int) []byte { if r.err != nil { return nil } if n < 0 || r.pos+n > len(r.buf) { r.err = ErrTruncated return nil } out := r.buf[r.pos : r.pos+n] r.pos += n return out } // Byte reads one byte. func (r *Reader) Byte() byte { p := r.take(1) if p == nil { return 0 } return p[0] } // Uint32 reads a 4-byte big-endian value. func (r *Reader) Uint32() uint32 { p := r.take(4) if p == nil { return 0 } return uint32(p[0])<<24 | uint32(p[1])<<16 | uint32(p[2])<<8 | uint32(p[3]) } // Uint64 reads an 8-byte big-endian value. func (r *Reader) Uint64() uint64 { p := r.take(8) if p == nil { return 0 } return uint64(p[0])<<56 | uint64(p[1])<<48 | uint64(p[2])<<40 | uint64(p[3])<<32 | uint64(p[4])<<24 | uint64(p[5])<<16 | uint64(p[6])<<8 | uint64(p[7]) } // Int reads a zig-zag encoded signed value written by [Writer.Int]. func (r *Reader) Int() int64 { u := r.Uint64() v := int64(u >> 1) if u&1 != 0 { v = ^v } return v } // Bytes reads a length-prefixed byte slice. The result is a copy, so a // machine can keep it without aliasing the snapshot it was handed. func (r *Reader) Bytes() []byte { n := int(r.Uint32()) p := r.take(n) if p == nil { return nil } out := make([]byte, n) copy(out, p) return out } // String reads a length-prefixed string. func (r *Reader) String() string { p := r.take(int(r.Uint32())) if p == nil { return "" } return string(p) }
  6. #6fuel.gno
  7. #7package vmkit // Unmetered is the fuel value that disables the budget: a [Meter] built with // it never reports exhaustion. It exists so a benchmark can measure the // dispatch loop without the meter in it, and so a trusted caller can run a // program to completion in one slice. Gas remains the real backstop. const Unmetered int64 = -1 // Meter is a fuel budget being spent. It allocates nothing after // construction, because it sits in the hot path of every guest instruction. type Meter struct { budget int64 // Unmetered, or the number of units this slice may spend used int64 } // NewMeter returns a meter good for `budget` units, or an unmetered one when // budget is [Unmetered]. A budget of zero is a meter with nothing to spend, // which is a legitimate way to ask "is this instance still runnable". func NewMeter(budget int64) *Meter { if budget < 0 { budget = Unmetered } return &Meter{budget: budget} } // Charge spends n units and reports whether they were available. On false // nothing is spent, so the caller can stop before executing the instruction // it could not pay for. That ordering is what makes a resumed machine // re-execute exactly the instruction it stopped at, and no other. func (m *Meter) Charge(n int64) bool { if m.budget == Unmetered { m.used += n return true } if m.used+n > m.budget { return false } m.used += n return true } // Used reports how much fuel has been spent. func (m *Meter) Used() int64 { return m.used } // Remaining reports what is left, or [Unmetered]. func (m *Meter) Remaining() int64 { if m.budget == Unmetered { return Unmetered } return m.budget - m.used } // Exhausted reports whether the next single unit would fail. func (m *Meter) Exhausted() bool { return !m.affordable(1) } func (m *Meter) affordable(n int64) bool { return m.budget == Unmetered || m.used+n <= m.budget }
  8. #8gnomod.toml
  9. #9module = "gno.land/p/moul/x/vm/vmkit/v0" gno = "0.9"
  10. #10host.gno
  11. #11package vmkit import "errors" // ErrNotGranted is returned by a [Host] for a capability the instance was // never given. A guest that ignores the error and keeps going is a guest bug; // a machine should turn it into a [Trapped] status. var ErrNotGranted = errors.New("vmkit: capability not granted") // Host is everything a guest program can reach outside its own memory. // // It is deliberately small, and deliberately handed in rather than looked up: // a guest has exactly the authority its [Host] carries. Gno realm code has // ambient authority through the realm frame, so the guest is where the // capability-secure version can actually be tried. // // Every method must be deterministic across replays of the same block. Now // and Height come from the chain, never from a wall clock. type Host interface { // Caller is the address that called the realm running this guest. Caller() address // Origin is the address that signed the transaction. Origin() address // Now is block time in Unix seconds, never wall time. Now() int64 // Height is the block height. Height() int64 // Get reads from storage scoped to this instance. A miss is nil. Get(key []byte) []byte // Set writes to storage scoped to this instance. Set(key, val []byte) // Input is the immutable call input for this slice of execution: the // guest's calldata, argv, or stdin depending on the machine. A machine // tracks its own read cursor, in its snapshot, so that resuming reads // the byte it had not read yet. Input() []byte // Output appends to the guest's output buffer. Output(p []byte) // Emit writes a chain event. kv is a flat list of alternating keys and // values; an odd trailing element is dropped. Emit(typ string, kv ...string) // Send transfers amount ugnot to `to`, and returns [ErrNotGranted] // unless the deploying realm funded this instance with a budget. Send(to address, amount int64) error // Log records a diagnostic line. Never consensus-relevant. Log(msg string) }
  12. #12instance.gno
  13. #13package vmkit import ( "errors" "gno.land/p/nt/avl/v0" ) // ErrNoInstance is returned when an id names nothing. var ErrNoInstance = errors.New("vmkit: no such instance") // ErrBudgetExhausted is returned when an instance has already spent its total // fuel budget, so there is nothing left to step. var ErrBudgetExhausted = errors.New("vmkit: instance fuel budget exhausted") // Instance is one guest program as a realm stores it: the code, the machine // state between slices, and the accounting that survives the transaction. // // It holds a snapshot rather than a [Machine], on purpose. A realm keeps // bytes; the machine is rebuilt for the slice that needs it and thrown away // after. That is what makes the storage cost of a paused program measurable, // which is the whole kill criterion for continuations: if resuming costs more // than re-running, continuations are theater. type Instance struct { ID string Owner address VM string // which guest machine the Program is for Program []byte Snapshot []byte Status Status Trap string // FuelUsed is the total across every slice run so far. FuelUsed int64 // FuelBudget caps FuelUsed across the instance's whole life, or is // [Unmetered] for no cap. FuelBudget int64 // Slices counts how many transactions have stepped this instance. Slices int64 Output []byte } // NewInstance returns an instance ready for its first slice. func NewInstance(id string, owner address, vm string, program []byte, budget int64) *Instance { if budget < 0 { budget = Unmetered } prog := make([]byte, len(program)) copy(prog, program) return &Instance{ ID: id, Owner: owner, VM: vm, Program: prog, Status: Running, FuelBudget: budget, } } // Remaining reports the fuel left in the instance's total budget, or // [Unmetered]. func (i *Instance) Remaining() int64 { if i.FuelBudget == Unmetered { return Unmetered } left := i.FuelBudget - i.FuelUsed if left < 0 { return 0 } return left } // Slice returns how much fuel the next call to [Instance.Run] may spend when // the caller asks for `want`: `want` clamped to what the budget still allows. func (i *Instance) Slice(want int64) int64 { left := i.Remaining() if left == Unmetered { return want } if want == Unmetered || want > left { return left } return want } // Run steps m for one slice of at most `fuel` units and folds the result back // into the instance: the new snapshot, the status, the fuel spent, and // anything the guest wrote to h. // // m must be a fresh machine loaded from i.Program; Run restores i.Snapshot // into it when there is one, so the caller never has to remember the order. // The instance is left untouched when Restore fails, which means a snapshot // that cannot be decoded costs the caller gas but never corrupts the stored // program. func (i *Instance) Run(m Machine, h Host, fuel int64) error { if i.Status.Done() && i.Status != Running { return ErrBudgetExhausted } if len(i.Snapshot) > 0 { if err := m.Restore(i.Snapshot); err != nil { return err } } slice := i.Slice(fuel) if slice == 0 { i.Status = OutOfFuel return ErrBudgetExhausted } used, status := m.Step(h, slice) i.FuelUsed += used i.Slices++ i.Snapshot = m.Snapshot() i.Status = status i.Trap = TrapReason(m) if status == Running && i.Remaining() == 0 { i.Status = OutOfFuel } return nil } // Store is the avl-backed set of instances a realm owns, keyed by id. type Store struct { tree *avl.Tree } // NewStore returns an empty store. func NewStore() *Store { return &Store{tree: avl.NewTree()} } // Set writes an instance, replacing any instance with the same id. func (s *Store) Set(i *Instance) { if i == nil { return } s.tree.Set(i.ID, i) } // Get returns the instance with this id, or nil. func (s *Store) Get(id string) *Instance { v := s.tree.Get(id) if v == nil { return nil } inst, ok := v.(*Instance) if !ok { return nil } return inst } // Remove deletes an instance and reports whether it existed. func (s *Store) Remove(id string) bool { _, removed := s.tree.Remove(id) return removed } // Size returns how many instances are stored. func (s *Store) Size() int { return s.tree.Size() } // Iterate walks every instance in key order, stopping early when fn returns // true. func (s *Store) Iterate(fn func(*Instance) bool) { s.tree.Iterate("", "", func(_ string, v any) bool { inst, ok := v.(*Instance) if !ok { return false } return fn(inst) }) } // ReverseIterate walks every instance in descending key order, which is how a // realm renders newest-first when ids are zero-padded and ascending. func (s *Store) ReverseIterate(fn func(*Instance) bool) { s.tree.ReverseIterate("", "", func(_ string, v any) bool { inst, ok := v.(*Instance) if !ok { return false } return fn(inst) }) }
  14. #14machine.gno
  15. #15// Package vmkit is the host ABI shared by every guest virtual machine that // runs inside a gno realm: a stepping contract with an explicit fuel budget, // a capability-scoped [Host], and a snapshot format that lets a guest program // outlive the transaction that started it. // // A guest VM implements [Machine]. Everything else in this package is the // machinery a VM should not have to write twice: the [Meter], the [Codec] the // snapshots are built from, an in-memory [TestHost], and the avl-backed // [Store] a realm keeps its instances in. // // The three things the ABI exists to fix: // // - Fuel is the interface, not the backstop. Running out of fuel yields // [OutOfFuel] plus a machine that can be snapshotted, never a panic. Gas // still bounds the transaction, but a guest that stops is a normal outcome. // - Continuations. Snapshot and Restore mean a program runs across blocks. // The realm stores the bytes, and the next caller pays for the next slice. // Gno realm code itself cannot pause; a guest can. // - Capabilities, not ambient authority. A guest gets exactly the [Host] it // was handed. No Send without a grant, no storage outside its own scope. // // Live demo: [r/moul/x/vm/bfdemo](/r/moul/x/vm/bfdemo/v0), running the // [p/moul/x/vm/bf](/p/moul/x/vm/bf/v0) guest. package vmkit // Status is the outcome of a call to [Machine.Step]. type Status int const ( // Running means the machine stopped because it ran out of the fuel // handed to this slice, but the program has not finished. It is // resumable: snapshot it, and step it again later. Running Status = iota // Halted means the program reached its end. Terminal. Halted // Trapped means the guest did something the machine refuses to do: // an invalid instruction, an out-of-range access, a capability it was // not granted. Terminal. Trapped // OutOfFuel means the instance exhausted its total budget, not just the // fuel for this slice. Terminal unless the owner raises the budget. OutOfFuel ) // String renders the status as the lowercase word used in realm output. func (s Status) String() string { switch s { case Running: return "running" case Halted: return "halted" case Trapped: return "trapped" case OutOfFuel: return "out of fuel" } return "unknown" } // Done reports whether the status is terminal, i.e. stepping again is // pointless without operator intervention. func (s Status) Done() bool { return s != Running } // Machine is one guest virtual machine, mid-execution. // // Step runs until the program halts, traps, or burns `fuel` units, whichever // comes first, and reports how much fuel it actually used. A Machine must // charge at least one unit per guest instruction so that a fuel budget is a // real bound on work; beyond that the unit is the VM's own business, and // [r/moul/x/vm/bfdemo](/r/moul/x/vm/bfdemo/v0) compares them by measurement // rather than by trusting the number. // // Snapshot must round-trip through Restore: a machine stepped to exhaustion, // snapshotted, restored and stepped again must produce exactly what the same // machine stepped in one go would have. That property is what makes a guest // program a contract instead of a function call. type Machine interface { Step(h Host, fuel int64) (used int64, status Status) Snapshot() []byte Restore(b []byte) error } // Trapper is an optional refinement of [Machine]: a machine that can explain // why it trapped. Kept out of [Machine] so the core ABI stays three methods. type Trapper interface { // Trap returns the reason for a [Trapped] status, or "" when the // machine has not trapped. Trap() string } // TrapReason returns m's trap reason when m implements [Trapper], else "". func TrapReason(m Machine) string { if t, ok := m.(Trapper); ok { return t.Trap() } return "" }
  16. #16testhost.gno
  17. #17package vmkit import "gno.land/p/nt/avl/v0" // TestHost is a deterministic in-memory [Host] for unit tests and for // measuring a machine without a chain under it. // // Nothing here reads the chain, so a test that uses it produces the same // numbers on every host. Storage is an avl tree rather than a map because gno // map iteration order is unspecified, and [TestHost.Keys] has to be stable for // a test to assert on it. // // Send is denied unless [TestHost.Grant] funded a budget, which is how the // capability rule gets tested: a guest that tries to send without a grant must // come back [Trapped], not silently succeed. type TestHost struct { caller address origin address now int64 height int64 store *avl.Tree // hex key -> []byte in []byte out []byte events []string logs []string sendBudget int64 sends []Transfer } // Transfer records one successful [TestHost.Send]. type Transfer struct { To address Amount int64 } // NewTestHost returns a host with no capabilities granted, height 1, time 0, // and empty input. func NewTestHost() *TestHost { return &TestHost{ height: 1, store: avl.NewTree(), } } // WithCaller sets the address the guest sees as its caller and origin. func (h *TestHost) WithCaller(a address) *TestHost { h.caller, h.origin = a, a return h } // WithOrigin overrides the origin separately from the caller. func (h *TestHost) WithOrigin(a address) *TestHost { h.origin = a; return h } // WithTime sets the block time in Unix seconds. func (h *TestHost) WithTime(t int64) *TestHost { h.now = t; return h } // WithHeight sets the block height. func (h *TestHost) WithHeight(n int64) *TestHost { h.height = n; return h } // WithInput sets the guest's call input. func (h *TestHost) WithInput(p []byte) *TestHost { h.in = make([]byte, len(p)) copy(h.in, p) return h } // Grant funds the send capability with a budget in ugnot. Without it, Send // returns [ErrNotGranted]. func (h *TestHost) Grant(budget int64) *TestHost { h.sendBudget = budget; return h } func (h *TestHost) Caller() address { return h.caller } func (h *TestHost) Origin() address { return h.origin } func (h *TestHost) Now() int64 { return h.now } func (h *TestHost) Height() int64 { return h.height } func (h *TestHost) Input() []byte { return h.in } func (h *TestHost) Get(key []byte) []byte { v := h.store.Get(hexKey(key)) if v == nil { return nil } stored := v.([]byte) out := make([]byte, len(stored)) copy(out, stored) return out } func (h *TestHost) Set(key, val []byte) { cp := make([]byte, len(val)) copy(cp, val) h.store.Set(hexKey(key), cp) } func (h *TestHost) Output(p []byte) { h.out = append(h.out, p...) } func (h *TestHost) Emit(typ string, kv ...string) { line := typ for i := 0; i+1 < len(kv); i += 2 { line += " " + kv[i] + "=" + kv[i+1] } h.events = append(h.events, line) } func (h *TestHost) Send(to address, amount int64) error { if amount <= 0 || amount > h.sendBudget { return ErrNotGranted } h.sendBudget -= amount h.sends = append(h.sends, Transfer{To: to, Amount: amount}) return nil } func (h *TestHost) Log(msg string) { h.logs = append(h.logs, msg) } // Out returns everything the guest has written, as bytes. func (h *TestHost) Out() []byte { return h.out } // OutString returns everything the guest has written, as a string. func (h *TestHost) OutString() string { return string(h.out) } // ResetOut discards the output buffer, so one host can measure several runs. func (h *TestHost) ResetOut() { h.out = nil } // Events returns the rendered events, in emission order. func (h *TestHost) Events() []string { return h.events } // Logs returns the diagnostic lines, in emission order. func (h *TestHost) Logs() []string { return h.logs } // Sends returns the transfers that succeeded, in order. func (h *TestHost) Sends() []Transfer { return h.sends } // Keys returns every storage key the guest wrote, hex-encoded, in sorted // order. func (h *TestHost) Keys() []string { out := []string{} h.store.Iterate("", "", func(k string, _ any) bool { out = append(out, k) return false }) return out } const hexDigits = "0123456789abcdef" // hexKey encodes a raw key as hex so that arbitrary bytes, including a zero // byte, survive being used as an avl key. func hexKey(key []byte) string { out := make([]byte, 0, len(key)*2) for _, c := range key { out = append(out, hexDigits[c>>4], hexDigits[c&0x0f]) } return string(out) }
  18. #18vmkit_test.gno
  19. #19package vmkit import ( "testing" "gno.land/p/nt/uassert/v0" ) // counter is a toy [Machine] used to test the kit itself without dragging in // a real guest VM: it writes one byte per step and halts after `total` of // them. Anything that works here is a property of the ABI, not of brainfuck. type counter struct { total int64 done int64 trap string } func (c *counter) Step(h Host, fuel int64) (int64, Status) { m := NewMeter(fuel) var used int64 for c.done < c.total { if !m.Charge(1) { return used, Running } used++ c.done++ h.Output([]byte{byte('a' + (c.done-1)%26)}) } return used, Halted } func (c *counter) Snapshot() []byte { w := NewWriter(24) w.Int(c.total) w.Int(c.done) w.String(c.trap) return w.Out() } func (c *counter) Restore(b []byte) error { r := NewReader(b) total := r.Int() done := r.Int() trap := r.String() if err := r.Err(); err != nil { return err } c.total, c.done, c.trap = total, done, trap return nil } func (c *counter) Trap() string { return c.trap } func TestStatusString(t *testing.T) { cases := []struct { s Status want string done bool }{ {Running, "running", false}, {Halted, "halted", true}, {Trapped, "trapped", true}, {OutOfFuel, "out of fuel", true}, {Status(99), "unknown", true}, } for _, tc := range cases { uassert.Equal(t, tc.want, tc.s.String()) uassert.Equal(t, tc.done, tc.s.Done()) } } func TestMeterCharge(t *testing.T) { m := NewMeter(10) uassert.True(t, m.Charge(4)) uassert.Equal(t, int64(4), m.Used()) uassert.Equal(t, int64(6), m.Remaining()) // A charge that does not fit spends nothing, so the caller can stop // before the instruction it cannot pay for. uassert.False(t, m.Charge(7)) uassert.Equal(t, int64(4), m.Used()) uassert.True(t, m.Charge(6)) uassert.True(t, m.Exhausted()) uassert.False(t, m.Charge(1)) } func TestMeterUnmetered(t *testing.T) { m := NewMeter(Unmetered) uassert.True(t, m.Charge(1 << 40)) uassert.False(t, m.Exhausted()) uassert.Equal(t, Unmetered, m.Remaining()) } func TestMeterZeroBudget(t *testing.T) { m := NewMeter(0) uassert.True(t, m.Exhausted()) uassert.False(t, m.Charge(1)) uassert.Equal(t, int64(0), m.Used()) } func TestCodecRoundTrip(t *testing.T) { w := NewWriter(0) w.Byte(0xab) w.Uint32(0xdeadbeef) w.Uint64(0x0102030405060708) w.Int(-1234567) w.Int(0) w.Int(1234567) w.Bytes([]byte{0, 1, 2, 255}) w.String("gno.land") r := NewReader(w.Out()) uassert.Equal(t, uint64(0xab), uint64(r.Byte())) uassert.Equal(t, uint64(0xdeadbeef), uint64(r.Uint32())) uassert.Equal(t, uint64(0x0102030405060708), r.Uint64()) uassert.Equal(t, int64(-1234567), r.Int()) uassert.Equal(t, int64(0), r.Int()) uassert.Equal(t, int64(1234567), r.Int()) uassert.Equal(t, 4, len(r.Bytes())) uassert.Equal(t, "gno.land", r.String()) uassert.NoError(t, r.Err()) uassert.Equal(t, 0, r.Remaining()) } func TestCodecIsCanonical(t *testing.T) { // The same state must always produce the same bytes: a snapshot is // consensus state, so two nodes encoding it differently is a fork. build := func() []byte { w := NewWriter(0) w.Int(-7) w.String("x") w.Uint32(9) return w.Out() } uassert.Equal(t, string(build()), string(build())) } func TestCodecTruncated(t *testing.T) { r := NewReader([]byte{1, 2}) r.Uint64() uassert.ErrorIs(t, r.Err(), ErrTruncated) // Once latched, every later read is a zero value and the error stands. uassert.Equal(t, "", r.String()) uassert.ErrorIs(t, r.Err(), ErrTruncated) } func TestCodecBytesAreCopied(t *testing.T) { w := NewWriter(0) w.Bytes([]byte{1, 2, 3}) buf := w.Out() r := NewReader(buf) got := r.Bytes() buf[len(buf)-1] = 99 // mutate the snapshot under the reader uassert.Equal(t, 3, len(got)) uassert.Equal(t, uint64(3), uint64(got[2])) } func TestTestHostStorage(t *testing.T) { h := NewTestHost() uassert.Equal(t, 0, len(h.Get([]byte("missing")))) h.Set([]byte{0, 1}, []byte("zero-prefixed")) h.Set([]byte("k"), []byte("v")) uassert.Equal(t, "zero-prefixed", string(h.Get([]byte{0, 1}))) uassert.Equal(t, "v", string(h.Get([]byte("k")))) // Keys are hex so a zero byte survives, and sorted so a test can pin // them. keys := h.Keys() uassert.Equal(t, 2, len(keys)) uassert.Equal(t, "0001", keys[0]) uassert.Equal(t, "6b", keys[1]) } func TestTestHostStorageIsCopied(t *testing.T) { h := NewTestHost() val := []byte("abc") h.Set([]byte("k"), val) val[0] = 'z' uassert.Equal(t, "abc", string(h.Get([]byte("k")))) got := h.Get([]byte("k")) got[0] = 'z' uassert.Equal(t, "abc", string(h.Get([]byte("k")))) } func TestTestHostSendNeedsAGrant(t *testing.T) { to := address("g1manfred47kzduec920z88wfr64ylksmdcedlf5") h := NewTestHost() uassert.ErrorIs(t, h.Send(to, 100), ErrNotGranted) uassert.Equal(t, 0, len(h.Sends())) h.Grant(150) uassert.NoError(t, h.Send(to, 100)) uassert.Equal(t, 1, len(h.Sends())) uassert.Equal(t, int64(100), h.Sends()[0].Amount) // The grant is a budget, not a switch. uassert.ErrorIs(t, h.Send(to, 100), ErrNotGranted) uassert.NoError(t, h.Send(to, 50)) } func TestTestHostEventsAndInput(t *testing.T) { h := NewTestHost().WithInput([]byte("hi")).WithHeight(42).WithTime(1700000000) uassert.Equal(t, "hi", string(h.Input())) uassert.Equal(t, int64(42), h.Height()) uassert.Equal(t, int64(1700000000), h.Now()) h.Emit("run", "id", "1", "status", "halted") h.Emit("odd", "dangling") uassert.Equal(t, 2, len(h.Events())) uassert.Equal(t, "run id=1 status=halted", h.Events()[0]) uassert.Equal(t, "odd", h.Events()[1]) // odd trailing element dropped } func TestInstanceRunsToCompletion(t *testing.T) { inst := NewInstance("1", address("g1x"), "counter", []byte("5"), Unmetered) h := NewTestHost() uassert.NoError(t, inst.Run(&counter{total: 5}, h, Unmetered)) uassert.Equal(t, "halted", inst.Status.String()) uassert.Equal(t, int64(5), inst.FuelUsed) uassert.Equal(t, int64(1), inst.Slices) uassert.Equal(t, "abcde", h.OutString()) } func TestInstanceResumesAcrossSlices(t *testing.T) { // The property that makes a guest program a contract: five slices of // one unit must equal one slice of five. inst := NewInstance("1", address("g1x"), "counter", []byte("5"), Unmetered) h := NewTestHost() for i := 0; i < 5; i++ { // A realm holds bytes, not a machine: every slice loads a fresh // one from the program and lets Restore carry the state over. uassert.NoError(t, inst.Run(&counter{total: 5}, h, 1)) } uassert.Equal(t, "halted", inst.Status.String()) uassert.Equal(t, int64(5), inst.FuelUsed) uassert.Equal(t, int64(5), inst.Slices) uassert.Equal(t, "abcde", h.OutString()) } func TestInstanceStopsAtItsBudget(t *testing.T) { inst := NewInstance("1", address("g1x"), "counter", []byte("10"), 3) h := NewTestHost() uassert.NoError(t, inst.Run(&counter{total: 10}, h, Unmetered)) uassert.Equal(t, "out of fuel", inst.Status.String()) uassert.Equal(t, int64(3), inst.FuelUsed) uassert.Equal(t, int64(0), inst.Remaining()) uassert.Equal(t, "abc", h.OutString()) // A second call has nothing to spend and says so instead of looping. uassert.ErrorIs(t, inst.Run(&counter{}, h, Unmetered), ErrBudgetExhausted) } func TestInstanceSliceClamps(t *testing.T) { inst := NewInstance("1", address("g1x"), "counter", nil, 10) inst.FuelUsed = 7 uassert.Equal(t, int64(3), inst.Slice(100)) uassert.Equal(t, int64(2), inst.Slice(2)) uassert.Equal(t, int64(3), inst.Slice(Unmetered)) open := NewInstance("2", address("g1x"), "counter", nil, Unmetered) uassert.Equal(t, int64(5), open.Slice(5)) uassert.Equal(t, Unmetered, open.Slice(Unmetered)) } func TestInstanceRejectsABadSnapshot(t *testing.T) { inst := NewInstance("1", address("g1x"), "counter", []byte("5"), Unmetered) inst.Snapshot = []byte{1, 2, 3} // too short for the counter's three fields err := inst.Run(&counter{}, NewTestHost(), Unmetered) uassert.ErrorIs(t, err, ErrTruncated) // The instance is untouched: a snapshot that cannot be decoded costs // gas but never corrupts the stored program. uassert.Equal(t, int64(0), inst.Slices) uassert.Equal(t, "5", string(inst.Program)) } func TestStore(t *testing.T) { s := NewStore() uassert.Equal(t, 0, s.Size()) uassert.Equal(t, true, s.Get("nope") == nil) s.Set(NewInstance("001", address("g1a"), "counter", nil, Unmetered)) s.Set(NewInstance("002", address("g1b"), "counter", nil, Unmetered)) s.Set(NewInstance("003", address("g1c"), "counter", nil, Unmetered)) uassert.Equal(t, 3, s.Size()) uassert.Equal(t, "002", s.Get("002").ID) ids := "" s.Iterate(func(i *Instance) bool { ids += i.ID + " "; return false }) uassert.Equal(t, "001 002 003 ", ids) rev := "" s.ReverseIterate(func(i *Instance) bool { rev += i.ID + " "; return false }) uassert.Equal(t, "003 002 001 ", rev) // Early stop. first := "" s.Iterate(func(i *Instance) bool { first = i.ID; return true }) uassert.Equal(t, "001", first) uassert.True(t, s.Remove("002")) uassert.False(t, s.Remove("002")) uassert.Equal(t, 2, s.Size()) } func TestTrapReason(t *testing.T) { uassert.Equal(t, "boom", TrapReason(&counter{trap: "boom"})) uassert.Equal(t, "", TrapReason(&counter{})) }
#12AddPackagegno.land/p/moul/x/wesh/v024 arguments
Attached funds
19000000ugnot

Arguments · 24

  1. #1wesh
  2. #2README.md
  3. #3# `gno.land/p/moul/x/wesh/v0` **The publishable half of the Wesh protocol** (weshnet, the network behind [Berty](https://berty.tech)) as a pure gno library: `Contact`, `WebLink`, `ParseWebLink`, `RendezvousPoint`, `SeedCommitment`, `BindStatement`, `DeviceStatement`, `VerifyBind`, `VerifyDevice`, `HMACSHA256`. ```go import "gno.land/p/moul/x/wesh/v0" c := wesh.Contact{AccountPK: pk, Seed: seed, DisplayName: "Alice"} link, _ := c.WebLink() // https://berty.tech/id#contact/<base58>/name=Alice pt, _ := c.RendezvousPointAt(now, wesh.DefaultRotationInterval) ``` ## Why a chain at all Wesh is off-grid, peer-to-peer and end-to-end encrypted. A public chain is the opposite in nearly every dimension, so this is deliberately **not** "weshnet on chain". It carries only the parts that need an authenticated, ordered, publicly auditable record, the one thing a peer-to-peer network cannot give itself, and which Wesh is missing in three specific places: - **Identities do not resolve.** A Berty ID travels out of band as a QR code or a `https://berty.tech/id#` link, and there is no lookup. - **Seed resets are silent.** `ContactRequestResetReference` gives the account a new rendezvous seed, which kills every link ever shared, with no channel to announce it. - **Devices can never be revoked.** The protocol documentation says so outright: the account metadata log is append-only and no authority can void an entry. ## What is in here | piece | what it is | |---|---| | `Contact` | weshnet's `ShareableContact`: account key + public rendezvous seed + display name, with weshnet's own `CheckFormat` length rules | | `Blob`, `WebLink`, `ParseWebLink` | the Berty link codec, hand-rolled protobuf + base58 | | `RendezvousPoint`, `RoundPeriod`, `NextPeriod` | weshnet's rotating DHT topic derivation | | `SeedCommitment`, `OpenCommitment` | publish `H(seed ‖ salt)` instead of the seed | | `BindStatement`, `DeviceStatement`, `VerifyBind`, `VerifyDevice` | the canonical texts an account signs, and their ed25519 verification | | `HMACSHA256` | RFC 2104, because gno has `sha256.Sum256` but no `crypto/hmac` | **The link codec is conformance-tested**, not guessed: `TestBlobEncodingMatches BertyGoldenVector` reproduces the exact base58 payload from berty's own `links_test.go`, byte for byte. The rendezvous derivation is pinned against vectors computed with weshnet's `GenerateRendezvousPointForPeriod`, and the HMAC against RFC 4231, including case 6, the longer-than-block-size key. ## What is deliberately absent There is **no type here that can hold a secret**. Group secrets, device chain keys, message keys and ciphertexts never touch a chain: publishing a group secret hands the group to everyone, and publishing ciphertext is permanent, expensive, and leaks the social graph through access patterns. ## The privacy trade-off Publishing a rendezvous seed is equivalent to printing your Berty QR code on a billboard: anyone can then derive today's rendezvous point and watch the DHT for it. That is the right trade only for an identity that *wants* to be found. `SeedCommitment` is the alternative: the chain attests the binding, the seed travels out of band, and the rendezvous point stays private. ## Cost `WebLink` is base58 over a ~70-byte payload, which is big-integer work: `TestWebLink` reports ~19M gas for one encode, `TestWebLinkRoundTrip` ~61M per encode-plus-decode. That is fine in a `Render` (a query) and worth avoiding per-row in a list. Signature verification is a native op and costs far less. **Live realm:** [`r/moul/x/wesh`](https://github.com/moul/gno-contracts/tree/main/r/moul/x/wesh/v0) · render it at [`/r/moul/x/wesh/v0`](https://gno.land/r/moul/x/wesh/v0). <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/p/moul/x/wesh/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/p/moul/x/wesh/v0/deps.png) > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4gnomod.toml
  5. #5module = "gno.land/p/moul/x/wesh/v0" gno = "0.9"
  6. #6hmac.gno
  7. #7package wesh import "crypto/sha256" // blockSize is SHA-256's internal block size, the padding width HMAC uses. const blockSize = 64 // HMACSHA256 computes HMAC-SHA256 as specified in RFC 2104. // // gno's standard library exposes sha256.Sum256 but no streaming hash.Hash and // no crypto/hmac, so the construction is spelled out here: // // HMAC(K, m) = H((K' ⊕ opad) ‖ H((K' ⊕ ipad) ‖ m)) // // with K' the key hashed down when longer than the block size, then // zero-padded up to it. This is the primitive weshnet's rendezvous point // derivation is built on, so it has to agree byte-for-byte; see // [RendezvousPoint]. func HMACSHA256(key, msg []byte) []byte { k := key if len(k) > blockSize { sum := sha256.Sum256(k) k = sum[:] } ipad := make([]byte, blockSize) opad := make([]byte, blockSize) copy(ipad, k) copy(opad, k) for i := 0; i < blockSize; i++ { ipad[i] ^= 0x36 opad[i] ^= 0x5c } inner := sha256.Sum256(append(ipad, msg...)) outer := sha256.Sum256(append(opad, inner[:]...)) return outer[:] }
  8. #8hmac_test.gno
  9. #9package wesh import ( "encoding/hex" "testing" "gno.land/p/nt/uassert/v0" ) // TestHMACSHA256RFC4231 checks the HMAC construction against the published // RFC 4231 vectors. Case 6 matters most: its key is longer than the block // size, which is the branch that hashes the key down first and the one most // easily got wrong. func TestHMACSHA256RFC4231(t *testing.T) { cases := []struct { name string key []byte data []byte want string }{ { "rfc4231 case 1", repeat(0x0b, 20), []byte("Hi There"), "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7", }, { "rfc4231 case 2", []byte("Jefe"), []byte("what do ya want for nothing?"), "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843", }, { "rfc4231 case 6 (key longer than the block size)", repeat(0xaa, 131), []byte("Test Using Larger Than Block-Size Key - Hash Key First"), "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54", }, } for _, tc := range cases { got := hex.EncodeToString(HMACSHA256(tc.key, tc.data)) uassert.Equal(t, tc.want, got, tc.name) } } // repeat returns n copies of b. func repeat(b byte, n int) []byte { out := make([]byte, n) for i := range out { out[i] = b } return out }
  10. #10link.gno
  11. #11package wesh import ( "encoding/binary" "errors" "net/url" "strings" "gno.land/p/moul/x/daily/b58/v0" ) // WebLinkPrefix is the prefix of a shareable Berty web link. The fragment // marker is part of it on purpose: everything after '#' stays in the browser // and is never sent to berty.tech, so the web host never learns the identity // being shared. const WebLinkPrefix = "https://berty.tech/id#" // contactKind is the human-readable link kind for a contact invite, // berty's BertyLink_ContactInviteV1Kind. const contactKind = "contact" // Protobuf field numbers, from berty's api/messengertypes/messengertypes.proto // and weshnet's api/protocol/protocoltypes.proto. They are wire-format // constants: changing one silently produces a link no Berty client can read. const ( fieldBertyLinkBertyID = 2 // BertyLink.berty_id fieldBertyIDSeed = 1 // BertyID.public_rendezvous_seed fieldBertyIDAccountPK = 2 // BertyID.account_pk ) var ( ErrNotAWebLink = errors.New("wesh: not a berty web link") ErrNotAContactLink = errors.New("wesh: link is not a contact invite") ErrBadLinkPayload = errors.New("wesh: link payload is not a valid contact") ) // Blob returns the protobuf-encoded machine payload of a contact invite link: // a BertyLink carrying only a BertyID with the rendezvous seed and account key. // // The kind and the display name are deliberately absent. berty's MarshalLink // puts the kind in the human-readable path segment and the display name in the // query string, so a blob that carried them would not match a link produced by // a real Berty client. The encoding here is verified against berty's own // golden test vector; see link_test.gno. func (c Contact) Blob() ([]byte, error) { if err := c.Validate(); err != nil { return nil, err } id := appendBytesField(nil, fieldBertyIDSeed, c.Seed) id = appendBytesField(id, fieldBertyIDAccountPK, c.AccountPK) return appendBytesField(nil, fieldBertyLinkBertyID, id), nil } // WebLink returns the shareable https://berty.tech/id# link for the contact. // Scanning or opening it in Berty starts a contact request. func (c Contact) WebLink() (string, error) { blob, err := c.Blob() if err != nil { return "", err } link := WebLinkPrefix + contactKind + "/" + b58.Encode(blob) if c.DisplayName != "" { link += "/name=" + url.QueryEscape(c.DisplayName) } return link, nil } // ParseWebLink decodes a Berty contact web link back into a [Contact]. // // It accepts links with or without a trailing query segment, and ignores query // keys other than "name", matching berty's own UnmarshalLink leniency. func ParseWebLink(link string) (Contact, error) { var c Contact if !strings.HasPrefix(link, WebLinkPrefix) { return c, ErrNotAWebLink } parts := strings.Split(strings.TrimPrefix(link, WebLinkPrefix), "/") if len(parts) < 2 { return c, ErrNotAWebLink } if parts[0] != contactKind { return c, ErrNotAContactLink } if !b58.IsValid(parts[1]) { return c, ErrBadLinkPayload } inner, ok := lookupBytesField(b58.Decode(parts[1]), fieldBertyLinkBertyID) if !ok { return c, ErrBadLinkPayload } seed, ok := lookupBytesField(inner, fieldBertyIDSeed) if !ok { return c, ErrBadLinkPayload } pk, ok := lookupBytesField(inner, fieldBertyIDAccountPK) if !ok { return c, ErrBadLinkPayload } c.AccountPK = pk c.Seed = seed if len(parts) > 2 { c.DisplayName = queryName(parts[2]) } if err := c.Validate(); err != nil { return Contact{}, err } return c, nil } // queryName pulls the "name" key out of an encoded query segment, returning "" // when it is absent or malformed. A bad display name must never fail a link // that is otherwise valid: the name is decoration, the keys are the payload. func queryName(q string) string { for _, pair := range strings.Split(q, "&") { if !strings.HasPrefix(pair, "name=") { continue } name, err := url.QueryUnescape(strings.TrimPrefix(pair, "name=")) if err != nil || len(name) > MaxDisplayNameLen { return "" } return name } return "" } // appendBytesField appends a length-delimited (wire type 2) protobuf field. // An empty value encodes to nothing, which is what proto3 does for a zero // bytes field, and is why an invalid contact can never produce a short-but- // plausible blob: Validate has already rejected it. func appendBytesField(dst []byte, num int, val []byte) []byte { if len(val) == 0 { return dst } dst = append(dst, byte(num<<3|2)) dst = binary.AppendUvarint(dst, uint64(len(val))) return append(dst, val...) } // lookupBytesField scans a protobuf message for the first length-delimited // field with the given number, skipping every other field whatever its wire // type. Unknown fields are skipped rather than rejected so a link produced by // a newer Berty client still parses. func lookupBytesField(buf []byte, num int) ([]byte, bool) { i := 0 for i < len(buf) { tag, n := binary.Uvarint(buf[i:]) if n <= 0 { return nil, false } i += n fieldNum := int(tag >> 3) wireType := int(tag & 0x7) switch wireType { case 0: // varint _, n := binary.Uvarint(buf[i:]) if n <= 0 { return nil, false } i += n case 1: // 64-bit if i+8 > len(buf) { return nil, false } i += 8 case 2: // length-delimited l, n := binary.Uvarint(buf[i:]) if n <= 0 { return nil, false } i += n end := i + int(l) if end < i || end > len(buf) { return nil, false } if fieldNum == num { return buf[i:end], true } i = end case 5: // 32-bit if i+4 > len(buf) { return nil, false } i += 4 default: return nil, false } } return nil, false }
  12. #12link_test.gno
  13. #13package wesh import ( "encoding/hex" "testing" "gno.land/p/moul/x/daily/b58/v0" "gno.land/p/nt/uassert/v0" ) // bertyGoldenBlob is the base58 payload from berty's own links_test.go // ("simple-contact"), produced by MarshalLink from // PublicRendezvousSeed = 16 x 0x01 and AccountPk = 16 x 0x02. // // It is the ground truth for this package's wire format. The keys are 16 bytes // because that test predates the length rule, so the vector is checked against // the raw encoder rather than through Contact.Blob, which would (correctly) // reject them. const bertyGoldenBlob = "3geQXHmsW9rxRfQFJdu8CEuPtWkfTWgJH13NzAoGatcnh4brusu3" func TestBlobEncodingMatchesBertyGoldenVector(t *testing.T) { id := appendBytesField(nil, fieldBertyIDSeed, repeat(0x01, 16)) id = appendBytesField(id, fieldBertyIDAccountPK, repeat(0x02, 16)) blob := appendBytesField(nil, fieldBertyLinkBertyID, id) uassert.Equal(t, "12240a1001010101010101010101010101010101121002020202020202020202020202020202", hex.EncodeToString(blob), "protobuf wire bytes") uassert.Equal(t, bertyGoldenBlob, b58.Encode(blob), "base58 payload berty produces") } // TestWebLink pins the full link for a real 32-byte identity. func TestWebLink(t *testing.T) { c := Contact{ AccountPK: mustHex(t, testAccountPKHex), Seed: mustHex(t, testSeedHex), } link, err := c.WebLink() uassert.NoError(t, err) uassert.Equal(t, "https://berty.tech/id#contact/oZBLFpzghxrATkepWvDPNX9pHYqi6BWgP45xGWhqxcwmqN2bnMMbU7UcwUuTaCcDyUvMjmWRMDWcP96bXAndcjNiAZ1Vz9X", link) } // TestWebLinkDisplayNameEscaping matches berty's url.Values encoding: space // becomes '+', and everything outside the unreserved set is percent-escaped. func TestWebLinkDisplayNameEscaping(t *testing.T) { c := Contact{ AccountPK: mustHex(t, testAccountPKHex), Seed: mustHex(t, testSeedHex), DisplayName: "Hello World!", } link, err := c.WebLink() uassert.NoError(t, err) uassert.True(t, endsWith(link, "/name=Hello+World%21"), "display name is query-escaped: "+link) } func TestWebLinkRoundTrip(t *testing.T) { cases := []struct { name string in Contact }{ {"no display name", Contact{AccountPK: repeat(0x11, 32), Seed: repeat(0x22, 32)}}, {"with a display name", Contact{AccountPK: repeat(0x11, 32), Seed: repeat(0x22, 32), DisplayName: "gno support"}}, {"name needing escapes", Contact{AccountPK: repeat(0x11, 32), Seed: repeat(0x22, 32), DisplayName: "a/b c&d=e"}}, {"leading zero bytes", Contact{AccountPK: repeat(0x00, 32), Seed: repeat(0x00, 32)}}, } for _, tc := range cases { link, err := tc.in.WebLink() uassert.NoError(t, err, tc.name) got, err := ParseWebLink(link) uassert.NoError(t, err, tc.name) uassert.Equal(t, hex.EncodeToString(tc.in.AccountPK), hex.EncodeToString(got.AccountPK), tc.name+": account key") uassert.Equal(t, hex.EncodeToString(tc.in.Seed), hex.EncodeToString(got.Seed), tc.name+": seed") uassert.Equal(t, tc.in.DisplayName, got.DisplayName, tc.name+": display name") } } func TestParseWebLinkRejects(t *testing.T) { valid, err := Contact{AccountPK: repeat(0x11, 32), Seed: repeat(0x22, 32)}.WebLink() uassert.NoError(t, err) cases := []struct { name string in string want error }{ {"not a berty link", "https://example.com/id#contact/abc", ErrNotAWebLink}, {"no payload segment", WebLinkPrefix + "contact", ErrNotAWebLink}, {"a group link, not a contact", WebLinkPrefix + "group/" + b58.Encode([]byte{1, 2, 3}), ErrNotAContactLink}, {"payload is not base58", WebLinkPrefix + "contact/not-base58-0OIl", ErrBadLinkPayload}, {"payload is not a BertyLink", WebLinkPrefix + "contact/" + b58.Encode([]byte{0xff, 0xff}), ErrBadLinkPayload}, {"berty's own 16-byte golden vector is too short now", WebLinkPrefix + "contact/" + bertyGoldenBlob, ErrBadSeedLen}, {"truncated payload", valid[:len(valid)-8], ErrBadLinkPayload}, } for _, tc := range cases { _, err := ParseWebLink(tc.in) uassert.ErrorIs(t, err, tc.want, tc.name) } } // TestParseWebLinkIgnoresUnknownQueryKeys matches berty's UnmarshalLink, which // accepts a link carrying query keys it does not know. func TestParseWebLinkIgnoresUnknownQueryKeys(t *testing.T) { link, err := Contact{AccountPK: repeat(0x11, 32), Seed: repeat(0x22, 32)}.WebLink() uassert.NoError(t, err) got, err := ParseWebLink(link + "/foo=bar&name=Alice") uassert.NoError(t, err) uassert.Equal(t, "Alice", got.DisplayName) } // TestLookupBytesFieldSkipsOtherWireTypes covers the decoder's skip paths: a // varint, a 64-bit and a 32-bit field ahead of the one being looked up. func TestLookupBytesFieldSkipsOtherWireTypes(t *testing.T) { buf := []byte{0x08, 0x96, 0x01} // field 1, varint 150 buf = append(buf, 0x11) // field 2, 64-bit buf = append(buf, repeat(0xaa, 8)...) buf = append(buf, 0x1d) // field 3, 32-bit buf = append(buf, repeat(0xbb, 4)...) buf = appendBytesField(buf, 4, []byte("found")) got, ok := lookupBytesField(buf, 4) uassert.True(t, ok, "the length-delimited field is reachable past the others") uassert.Equal(t, "found", string(got)) _, ok = lookupBytesField(buf, 9) uassert.False(t, ok, "an absent field is reported missing, not guessed") } func TestBlobRejectsAnInvalidContact(t *testing.T) { _, err := Contact{AccountPK: repeat(1, 31), Seed: repeat(2, 32)}.Blob() uassert.ErrorIs(t, err, ErrBadAccountPKLen) } func endsWith(s, suffix string) bool { return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix }
  14. #14rendezvous.gno
  15. #15package wesh import "encoding/binary" // DefaultRotationInterval is weshnet's rendezvous rotation period, in seconds // (rendezvous.DefaultRotationInterval = 24h). The rendezvous point an account // announces on changes once per interval, so an observer who learns one point // does not learn every future one. const DefaultRotationInterval int64 = 86400 // RoundPeriod returns the start of the rotation period containing unixSec. // Mirrors weshnet's rendezvous.RoundTimePeriod. func RoundPeriod(unixSec, interval int64) int64 { if interval < 0 { interval = -interval } if interval == 0 { panic("wesh: rotation interval must not be zero") } return (unixSec / interval) * interval } // NextPeriod returns the start of the period after the one containing unixSec. // Mirrors weshnet's rendezvous.NextTimePeriod. func NextPeriod(unixSec, interval int64) int64 { if interval < 0 { interval = -interval } return RoundPeriod(unixSec, interval) + interval } // RendezvousPoint derives the rotating DHT topic an account announces on // during the period starting at periodStart. // // It reproduces weshnet's rendezvous.GenerateRendezvousPointForPeriod exactly: // // HMAC-SHA256(key = topic ‖ seed, msg = big-endian uint64(periodStart)) // // For contact requests, weshnet passes the account public key as the topic and // the public rendezvous seed as the seed (see swiper.WatchTopic in // contact_request_manager.go), which is what [Contact.RendezvousPointAt] does. // // Deriving this on chain is what makes a published identity checkable: anyone // can confirm that the seed in the directory really is the one the account is // announcing under, without trusting the directory. func RendezvousPoint(topic, seed []byte, periodStart int64) []byte { key := make([]byte, 0, len(topic)+len(seed)) key = append(key, topic...) key = append(key, seed...) buf := make([]byte, 8) binary.BigEndian.PutUint64(buf, uint64(periodStart)) return HMACSHA256(key, buf) } // RendezvousPointAt returns the contact's rendezvous point for the rotation // period containing unixSec. func (c Contact) RendezvousPointAt(unixSec, interval int64) ([]byte, error) { if err := c.Validate(); err != nil { return nil, err } return RendezvousPoint(c.AccountPK, c.Seed, RoundPeriod(unixSec, interval)), nil }
  16. #16rendezvous_test.gno
  17. #17package wesh import ( "encoding/hex" "testing" "gno.land/p/nt/uassert/v0" ) // Reference vectors produced by running weshnet's own algorithm // (rendezvous.GenerateRendezvousPointForPeriod) against these inputs: // topic = accountPK, seed = seed, interval = 24h. const ( testAccountPKHex = "2152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12" testSeedHex = "abababababababababababababababababababababababababababababababab" ) func TestRoundPeriod(t *testing.T) { cases := []struct { name string unixSec int64 interval int64 want int64 }{ {"epoch", 0, DefaultRotationInterval, 0}, {"exactly on a boundary", 1789171200, DefaultRotationInterval, 1789171200}, {"one second before the next", 1789257599, DefaultRotationInterval, 1789171200}, {"one second after a boundary", 1789171201, DefaultRotationInterval, 1789171200}, {"a negative interval is taken as positive", 1789257599, -DefaultRotationInterval, 1789171200}, {"an hourly interval", 1789174800 + 59, 3600, 1789174800}, } for _, tc := range cases { uassert.Equal(t, tc.want, RoundPeriod(tc.unixSec, tc.interval), tc.name) } } func TestNextPeriod(t *testing.T) { uassert.Equal(t, int64(1789257600), NextPeriod(1789171200, DefaultRotationInterval)) uassert.Equal(t, int64(1789257600), NextPeriod(1789257599, DefaultRotationInterval)) } // TestRendezvousPointMatchesWeshnet pins the derivation against vectors // computed with weshnet's implementation. A divergence here means a gno-derived // rendezvous point would not match the one the account actually announces on, // which makes a published identity unverifiable. func TestRendezvousPointMatchesWeshnet(t *testing.T) { pk := mustHex(t, testAccountPKHex) seed := mustHex(t, testSeedHex) cases := []struct { name string periodStart int64 want string }{ { "period 0", 0, "04500fdede2a684f4e61cd67968b46ecdbf10d45604041bb7e0350572b8cc9cc", }, { "period starting 1789171200", 1789171200, "aad2926c39dacabaabbbde48522d043557c8945b6a2dc7b08499c112c72587da", }, } for _, tc := range cases { got := hex.EncodeToString(RendezvousPoint(pk, seed, tc.periodStart)) uassert.Equal(t, tc.want, got, tc.name) } } // TestRendezvousPointAtIsStableWithinAPeriod is the property that makes // rotation useful: every instant inside one interval maps to the same point, // and the next interval maps somewhere unrelated. func TestRendezvousPointAtIsStableWithinAPeriod(t *testing.T) { c := Contact{AccountPK: mustHex(t, testAccountPKHex), Seed: mustHex(t, testSeedHex)} start, err := c.RendezvousPointAt(1789171200, DefaultRotationInterval) uassert.NoError(t, err) end, err := c.RendezvousPointAt(1789257599, DefaultRotationInterval) uassert.NoError(t, err) next, err := c.RendezvousPointAt(1789257600, DefaultRotationInterval) uassert.NoError(t, err) uassert.Equal(t, hex.EncodeToString(start), hex.EncodeToString(end), "the point is stable for the whole period") uassert.False(t, hex.EncodeToString(start) == hex.EncodeToString(next), "the point rotates at the period boundary") } func TestRendezvousPointAtRejectsAnInvalidContact(t *testing.T) { _, err := Contact{AccountPK: repeat(1, 32), Seed: repeat(2, 31)}.RendezvousPointAt(0, DefaultRotationInterval) uassert.ErrorIs(t, err, ErrBadSeedLen) } func mustHex(t *testing.T, s string) []byte { t.Helper() b, err := hex.DecodeString(s) if err != nil { t.Fatalf("bad hex fixture: %v", err) } return b }
  18. #18statement.gno
  19. #19package wesh import ( "crypto/ed25519" "crypto/sha256" "encoding/hex" "errors" "strconv" "strings" ) // Statement kinds. A signature is only ever valid for the kind it was made // under, so a binding signature can never be replayed as a device statement. const ( kindBind = "gno.wesh/v0:bind" kindDevice = "gno.wesh/v0:device" ) // Device sigchain operations. const ( OpAdd = "add" OpRevoke = "revoke" ) var ( ErrBadOp = errors.New("wesh: operation must be add or revoke") ErrBadSignature = errors.New("wesh: signature does not verify under the account key") ErrBadSeq = errors.New("wesh: sequence number must not be negative") ErrBadPrevLen = errors.New("wesh: previous digest must be 32 bytes") ) // DigestLen is the length of a statement digest (SHA-256). const DigestLen = 32 // ValidOp reports whether op is a known sigchain operation. func ValidOp(op string) bool { return op == OpAdd || op == OpRevoke } // BindStatement returns the canonical text an account signs, with its Wesh // account private key, to claim a directory entry. // // The signature is what makes a directory entry meaningful. Without it anyone // could publish anyone else's account key next to a seed of their choosing and // harvest the contact requests that followed. With it, a directory entry is a // statement by the account itself, verifiable by anyone, replayable nowhere: // // - the kind prefix stops a signature made for one purpose being reused for // another; // - chainID stops a binding signed for a testnet being replayed on mainnet; // - gnoAddr binds the Wesh identity to exactly one gno account, so a leaked // signature cannot be used to claim the identity from a different address; // - revision is monotonic, so an old, superseded binding cannot be replayed // to roll a rotation back. // // payload is the seed for a publicly published identity, or the seed // commitment from [SeedCommitment] for one that keeps its rendezvous point off // chain. The two are indistinguishable on the wire, which is deliberate: an // observer cannot tell a committed identity from a published one without the // realm's own mode flag. // // Fields are newline-framed and hex-encoded, never concatenated raw: with // plain concatenation a different split of the same bytes would produce the // same statement. func BindStatement(chainID, gnoAddr string, accountPK, payload []byte, revision int) string { var b strings.Builder b.WriteString(kindBind) b.WriteString("\n") b.WriteString(chainID) b.WriteString("\n") b.WriteString(gnoAddr) b.WriteString("\n") b.WriteString(hex.EncodeToString(accountPK)) b.WriteString("\n") b.WriteString(hex.EncodeToString(payload)) b.WriteString("\n") b.WriteString(strconv.Itoa(revision)) return b.String() } // DeviceStatement returns the canonical text an account signs to append one // entry to its device sigchain. // // This is the piece the Wesh protocol has no answer for. Its own documentation // says a device, once linked, can never be revoked: the account metadata log is // append-only and no authority can mark an entry void. A chain cannot undo // that (the ratchets are already out there and forward secrecy is a local // property), but it can host the record that was missing, so a revocation // becomes publicly visible, ordered, and attributable to the account key. // // prev is the digest of the preceding statement, or [GenesisDigest] for seq 0. // Chaining each entry to its predecessor means the log cannot be reordered or // have an entry quietly dropped: any gap changes every digest after it. func DeviceStatement(chainID string, accountPK []byte, seq int, prev []byte, op string, devicePK []byte) string { var b strings.Builder b.WriteString(kindDevice) b.WriteString("\n") b.WriteString(chainID) b.WriteString("\n") b.WriteString(hex.EncodeToString(accountPK)) b.WriteString("\n") b.WriteString(strconv.Itoa(seq)) b.WriteString("\n") b.WriteString(hex.EncodeToString(prev)) b.WriteString("\n") b.WriteString(op) b.WriteString("\n") b.WriteString(hex.EncodeToString(devicePK)) return b.String() } // StatementDigest returns SHA-256 of a canonical statement. It is the value // the next sigchain entry chains to. func StatementDigest(statement string) []byte { sum := sha256.Sum256([]byte(statement)) return sum[:] } // GenesisDigest is the all-zero digest that the first sigchain entry chains to. func GenesisDigest() []byte { return make([]byte, DigestLen) } // VerifyBind checks a binding signature against the account key. func VerifyBind(chainID, gnoAddr string, accountPK, payload []byte, revision int, sig []byte) error { if len(accountPK) != AccountPKLen { return ErrBadAccountPKLen } stmt := BindStatement(chainID, gnoAddr, accountPK, payload, revision) if !ed25519.Verify(accountPK, []byte(stmt), sig) { return ErrBadSignature } return nil } // VerifyDevice checks a sigchain entry's shape and its signature, and returns // the digest the next entry must chain to. func VerifyDevice(chainID string, accountPK []byte, seq int, prev []byte, op string, devicePK, sig []byte) ([]byte, error) { if len(accountPK) != AccountPKLen { return nil, ErrBadAccountPKLen } if len(devicePK) != DevicePKLen { return nil, ErrBadDevicePKLen } if seq < 0 { return nil, ErrBadSeq } if len(prev) != DigestLen { return nil, ErrBadPrevLen } if !ValidOp(op) { return nil, ErrBadOp } stmt := DeviceStatement(chainID, accountPK, seq, prev, op, devicePK) if !ed25519.Verify(accountPK, []byte(stmt), sig) { return nil, ErrBadSignature } return StatementDigest(stmt), nil }
  20. #20statement_test.gno
  21. #21package wesh import ( "encoding/hex" "testing" "gno.land/p/nt/uassert/v0" ) // Fixtures. The signatures were produced with Go's crypto/ed25519 over the // exact statement text this package builds, using the account key whose seed // is 32 x 0x42, the same key as testAccountPKHex. const ( testChainID = "gnoland-1" testGnoAddr = "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5" testDev1Hex = "0707070707070707070707070707070707070707070707070707070707070707" testDev2Hex = "0909090909090909090909090909090909090909090909090909090909090909" bindRev1Sig = "4cd53e6096994d2801de1b16cfd1b2d2aa34ff35b265c53fa176cfb18c949be7" + "0078161fe2afaf503cf808b2f52a321e15a625f56b1b2d0aaaf8c460f6d64702" dev0Sig = "19d0bd2517ca6308b089196528e6c27359117b80e1972c71c25434a6f260d62a" + "703a28c2f973d89056e7c691b4f879d38e1b016745508b357d217eb54c984305" dev0Digest = "003570bf50d50213f9f3e9c76a8271435a8f2e4b7c24010a8e8383dedd0ed2a6" dev1Sig = "c26b338045373f7504a0d251c2126e31ff3330f08463672a82467daf4dedca51" + "1da4681715a6c3afe4115622cfcca9ec3f44d5a35f9973c3b9856cc06859810c" dev1Digest = "35a8219e60f6cd375e1378879de8921f9d554f054cd8a70ea2d3914b20b77bbb" ) func TestBindStatementIsCanonical(t *testing.T) { got := BindStatement(testChainID, testGnoAddr, mustHex(t, testAccountPKHex), mustHex(t, testSeedHex), 1) want := "gno.wesh/v0:bind\n" + testChainID + "\n" + testGnoAddr + "\n" + testAccountPKHex + "\n" + testSeedHex + "\n1" uassert.Equal(t, want, got) } func TestVerifyBind(t *testing.T) { pk := mustHex(t, testAccountPKHex) seed := mustHex(t, testSeedHex) sig := mustHex(t, bindRev1Sig) uassert.NoError(t, VerifyBind(testChainID, testGnoAddr, pk, seed, 1, sig), "a signature made by the account key verifies") } // TestVerifyBindRejectsEveryTamperedField is the heart of the scheme: each // field is in the signed text precisely so that changing it invalidates the // signature. One case per field, so a regression names the field it broke. func TestVerifyBindRejectsEveryTamperedField(t *testing.T) { pk := mustHex(t, testAccountPKHex) seed := mustHex(t, testSeedHex) sig := mustHex(t, bindRev1Sig) cases := []struct { name string chainID string addr string payload []byte rev int }{ {"replayed on another chain", "test7", testGnoAddr, seed, 1}, {"claimed from another gno address", testChainID, "g1manfred47kzduec920z88wfr64ylksmdcedlf5", seed, 1}, {"a different seed swapped in", testChainID, testGnoAddr, repeat(0xEE, 32), 1}, {"an older revision replayed", testChainID, testGnoAddr, seed, 0}, {"a newer revision forged", testChainID, testGnoAddr, seed, 2}, } for _, tc := range cases { err := VerifyBind(tc.chainID, tc.addr, pk, tc.payload, tc.rev, sig) uassert.ErrorIs(t, err, ErrBadSignature, tc.name) } } func TestVerifyBindRejectsAnotherAccountsKey(t *testing.T) { err := VerifyBind(testChainID, testGnoAddr, repeat(0x33, 32), mustHex(t, testSeedHex), 1, mustHex(t, bindRev1Sig)) uassert.ErrorIs(t, err, ErrBadSignature) err = VerifyBind(testChainID, testGnoAddr, repeat(0x33, 31), mustHex(t, testSeedHex), 1, mustHex(t, bindRev1Sig)) uassert.ErrorIs(t, err, ErrBadAccountPKLen) } func TestGenesisDigest(t *testing.T) { uassert.Equal(t, DigestLen, len(GenesisDigest())) uassert.Equal(t, "0000000000000000000000000000000000000000000000000000000000000000", hex.EncodeToString(GenesisDigest())) } // TestVerifyDeviceChain walks a three-entry sigchain: add, add, revoke. Each // entry chains to the digest of the previous one, which is what stops an entry // being reordered or silently dropped. func TestVerifyDeviceChain(t *testing.T) { pk := mustHex(t, testAccountPKHex) d0, err := VerifyDevice(testChainID, pk, 0, GenesisDigest(), OpAdd, mustHex(t, testDev1Hex), mustHex(t, dev0Sig)) uassert.NoError(t, err, "first entry") uassert.Equal(t, dev0Digest, hex.EncodeToString(d0), "digest the next entry must chain to") d1, err := VerifyDevice(testChainID, pk, 1, d0, OpAdd, mustHex(t, testDev2Hex), mustHex(t, dev1Sig)) uassert.NoError(t, err, "second entry") uassert.Equal(t, dev1Digest, hex.EncodeToString(d1)) } func TestVerifyDeviceRejectsABrokenChain(t *testing.T) { pk := mustHex(t, testAccountPKHex) dev1 := mustHex(t, testDev1Hex) dev2 := mustHex(t, testDev2Hex) cases := []struct { name string seq int prev []byte op string device []byte sig string wantErr error }{ {"wrong previous digest", 1, GenesisDigest(), OpAdd, dev2, dev1Sig, ErrBadSignature}, {"wrong sequence number", 2, mustHexRaw(dev0Digest), OpAdd, dev2, dev1Sig, ErrBadSignature}, {"operation flipped to revoke", 0, GenesisDigest(), OpRevoke, dev1, dev0Sig, ErrBadSignature}, {"a different device substituted", 0, GenesisDigest(), OpAdd, dev2, dev0Sig, ErrBadSignature}, {"unknown operation", 0, GenesisDigest(), "delete", dev1, dev0Sig, ErrBadOp}, {"negative sequence", -1, GenesisDigest(), OpAdd, dev1, dev0Sig, ErrBadSeq}, {"short previous digest", 0, repeat(0, 31), OpAdd, dev1, dev0Sig, ErrBadPrevLen}, {"short device key", 0, GenesisDigest(), OpAdd, repeat(7, 31), dev0Sig, ErrBadDevicePKLen}, } for _, tc := range cases { _, err := VerifyDevice(testChainID, pk, tc.seq, tc.prev, tc.op, tc.device, mustHexRaw(tc.sig)) uassert.ErrorIs(t, err, tc.wantErr, tc.name) } } func TestValidOp(t *testing.T) { uassert.True(t, ValidOp(OpAdd)) uassert.True(t, ValidOp(OpRevoke)) uassert.False(t, ValidOp("Add"), "operations are case-sensitive") uassert.False(t, ValidOp("")) } func mustHexRaw(s string) []byte { b, err := hex.DecodeString(s) if err != nil { panic("bad hex fixture: " + s) } return b }
  22. #22wesh.gno
  23. #23// Package wesh implements the publishable, non-secret half of the Wesh // protocol (weshnet, the network layer behind Berty) as a pure gno library. // // # What this is, and what it deliberately is not // // Wesh is an off-grid, peer-to-peer, end-to-end-encrypted messaging protocol. // A public blockchain is the opposite of that in almost every dimension: // globally replicated, permanent, publicly readable and totally ordered. So // "put Wesh on chain" is the wrong goal, and this package does not pursue it. // // What a chain *is* good at is the one thing Wesh has no answer for: an // authenticated, ordered, publicly auditable record that no single party owns. // Wesh has three gaps of exactly that shape: // // 1. A Berty identity travels out of band, as a QR code or a // https://berty.tech/id# link. There is no way to look one up, and the // web prefix is a single host that can be blocked or spoofed. // 2. Resetting the public rendezvous seed (ContactRequestResetReference) // silently invalidates every link ever shared. There is no revocation // channel, so a business card keeps pointing at a dead rendezvous point. // 3. A device, once linked to an account, can never be revoked. The Wesh // protocol documentation states this outright. The account metadata log is // append-only and there is no authority that can mark an entry void. // // This package carries the pieces needed to address those on chain, and // nothing else. Everything here is public by construction: // // - [Contact], the publishable identity, weshnet's ShareableContact // (account public key + public rendezvous seed + display name). // - The Berty link codec, byte-compatible with berty's own MarshalLink. // - [RendezvousPoint], the rotating DHT address derivation. // - The canonical statements an account signs to bind itself to a gno // address, rotate its seed, and append to a device sigchain. // // # What must never reach a chain // // Group secrets (weshnet's Group.secret, Group.link_key), device chain keys, // message keys, ciphertexts. Publishing a group secret hands the group to // everyone; publishing ciphertext is permanent, expensive, and leaks the // social graph through access patterns. This package has no type that can // hold one, which is the point: there is no struct field here for a secret to // accidentally land in. // // # The privacy trade-off, stated plainly // // Publishing a rendezvous seed is equivalent to printing your Berty QR code on // a billboard. Anyone can then derive today's rendezvous point with // [RendezvousPoint] and watch the DHT for who shows up. That is a real // deanonymization surface, and it is why publication must be an explicit, // opt-in act for an identity that *wants* to be found: a support line, a shop, // a DAO's public channel. // // For an identity that does not want that, [SeedCommitment] publishes // H(seed ‖ salt) instead. The chain then proves that a seed handed over out of // band really does belong to the named account, without broadcasting the // rendezvous point to the world. // // Live realm using this library: [r/moul/x/wesh](/r/moul/x/wesh/v0). package wesh import ( "crypto/sha256" "encoding/hex" "errors" ) const ( // SeedLen is the length of a public rendezvous seed. Matches weshnet's // protocoltypes.RendezvousSeedLength. SeedLen = 32 // AccountPKLen is the length of an account public key. weshnet validates // it with libp2p's UnmarshalEd25519PublicKey, which accepts raw 32-byte // ed25519 public keys only. AccountPKLen = 32 // DevicePKLen is the length of a device public key: also a raw ed25519 // public key. DevicePKLen = 32 // MaxDisplayNameLen bounds the display name so a link stays scannable and // gas stays predictable. Not a protocol constant. MaxDisplayNameLen = 64 ) var ( ErrBadSeedLen = errors.New("wesh: public rendezvous seed must be 32 bytes") ErrBadAccountPKLen = errors.New("wesh: account public key must be a raw 32-byte ed25519 key") ErrBadDevicePKLen = errors.New("wesh: device public key must be a raw 32-byte ed25519 key") ErrDisplayNameTooLong = errors.New("wesh: display name exceeds MaxDisplayNameLen") ErrBadCommitmentLen = errors.New("wesh: seed commitment must be 32 bytes") ErrBadHex = errors.New("wesh: value is not valid hex") ) // Contact is the publishable identity of a Wesh account: weshnet's // ShareableContact. It is everything a stranger needs to send a contact // request, and it contains no secret. // // AccountPK is the raw ed25519 account public key. Seed is the public // rendezvous seed; together they derive the rotating rendezvous point the // account announces on, see [RendezvousPoint]. DisplayName is free-form app // metadata and is not authenticated by anything. type Contact struct { AccountPK []byte Seed []byte DisplayName string } // Validate applies weshnet's own ShareableContact.CheckFormat rules: the seed // must be exactly SeedLen bytes, and the account key must be a raw ed25519 // public key. It deliberately does not verify that AccountPK is a valid curve // point; that costs a scalar multiplication and the chain gets the same // guarantee for free the first time it verifies a signature under the key. func (c Contact) Validate() error { if len(c.Seed) != SeedLen { return ErrBadSeedLen } if len(c.AccountPK) != AccountPKLen { return ErrBadAccountPKLen } if len(c.DisplayName) > MaxDisplayNameLen { return ErrDisplayNameTooLong } return nil } // SeedCommitment returns H(seed ‖ salt), the value published instead of the // seed itself when an account wants the chain to attest the binding without // broadcasting its rendezvous point. // // The salt is not optional: a seed is 32 bytes of entropy, but a commitment // with no salt is a deterministic function of the seed, so two accounts that // (impossibly, but still) shared a seed would be linkable, and a seed later // disclosed out of band retroactively confirms every past commitment. The salt // keeps disclosure a deliberate act. func SeedCommitment(seed, salt []byte) ([]byte, error) { if len(seed) != SeedLen { return nil, ErrBadSeedLen } buf := make([]byte, 0, len(seed)+len(salt)) buf = append(buf, seed...) buf = append(buf, salt...) sum := sha256.Sum256(buf) return sum[:], nil } // OpenCommitment reports whether commitment is H(seed ‖ salt). // // The comparison is constant-time over the digest: a short-circuiting compare // leaks, through gas, how many leading bytes of a guess were right. func OpenCommitment(commitment, seed, salt []byte) bool { want, err := SeedCommitment(seed, salt) if err != nil { return false } return constantTimeEqual(commitment, want) } // constantTimeEqual compares two byte slices without an early return. func constantTimeEqual(a, b []byte) bool { if len(a) != len(b) { return false } var diff byte for i := 0; i < len(a); i++ { diff |= a[i] ^ b[i] } return diff == 0 } // Every key this package handles is 32 bytes, so a single length-checking // helper cannot say which one was wrong. These four wrappers exist so the // error a realm surfaces names the field the caller actually got wrong. // DecodeAccountPK parses a hex-encoded account public key. func DecodeAccountPK(s string) ([]byte, error) { return decodeFixed(s, AccountPKLen, ErrBadAccountPKLen) } // DecodeSeed parses a hex-encoded public rendezvous seed. func DecodeSeed(s string) ([]byte, error) { return decodeFixed(s, SeedLen, ErrBadSeedLen) } // DecodeDevicePK parses a hex-encoded device public key. func DecodeDevicePK(s string) ([]byte, error) { return decodeFixed(s, DevicePKLen, ErrBadDevicePKLen) } // DecodeCommitment parses a hex-encoded seed commitment. func DecodeCommitment(s string) ([]byte, error) { return decodeFixed(s, DigestLen, ErrBadCommitmentLen) } // decodeFixed parses hex and enforces an exact byte length. Realms take keys // as hex strings because that is what a transaction argument can carry; this // is the single place the length rule is applied. func decodeFixed(s string, want int, badLen error) ([]byte, error) { b, err := hex.DecodeString(s) if err != nil { return nil, ErrBadHex } if len(b) != want { return nil, badLen } return b, nil }
  24. #24wesh_test.gno
#13AddPackagegno.land/p/moul/x/wiki/v024 arguments
Attached funds
41000000ugnot

Arguments · 24

  1. #1wiki
  2. #2README.md
  3. #3# `gno.land/p/moul/x/wiki/v0` A Wikipedia-shaped wiki engine: namespaced titles, an append-only revision chain, wikilinks with backlinks, categories, redirects, protection levels, per-page discussion threads, line diffs, and markdown rendering. Pure: no realm globals, no chain imports. Every mutating call takes the author address, the wall clock and the block height from its caller, so the engine is unit-testable off-chain and the realm keeps all the authority. Live demo: [`gno.land/r/moul/x/wiki/v0`](../../../../../r/moul/x/wiki). ## The storage model A realm write locks a storage deposit proportional to the bytes it adds (100ugnot per byte, `gnolang/gno#6171`). A wiki that kept every revision in full would therefore charge its editors rent on the whole history forever: at 5 KB per revision that is 0.5 GNOT locked per edit, permanently. This engine keeps a **content-addressed spine plus a bounded body window**: | kept forever, per revision | kept only inside the window | |---|---| | id, parent, kind, author, time, height, summary, byte size, **SHA-256 of the body**, minor flag | the body text | Roughly 200 bytes per revision regardless of article size, plus the bodies of the newest `Retention` revisions of each page (3 by default). An evicted body is not lost. Its bytes were an argument of the transaction that wrote it, so they are still in the chain's transaction history, and the retained hash proves which bytes were the real ones. The realm stops paying rent for deep history; the archive lives where archives belong. What you lose is the ability to diff or revert to an evicted revision from inside the realm, and both failures are explicit (`ErrBodyEvicted`) rather than silent. `Purge` evicts every body of a page at once and returns the bytes released. It is the lever for content that must stop being served out of realm state; it cannot and does not remove the transactions that wrote it. ## Who gets the deposit back Not who paid it. Verified in `processStorageDeposit` (`gno.land/pkg/sdk/vm/keeper.go`, gno master, 2026-09-19): - A write sends the **caller's** ugnot to a per-realm deposit address and adds to two realm-wide pools, `rlm.Deposit` and `rlm.Storage`. There is no per-depositor accounting. - A release refunds `rlm.Deposit * released / rlm.Storage` (big-integer, truncating, so dust accrues in the pool) to the **caller of the transaction that frees the bytes**, at the realm's blended rate. - If ugnot is a restricted denom at the time, the refund goes to `params.StorageFeeCollector` instead, not to any user. For a wiki that inverts a comfortable assumption. Adding bytes costs the adder, but **removing bytes pays the remover**. Replacing a long article with a short one evicts an old body in the same transaction, so deletion can be profitable, and reverting the damage costs the good actor who reverts it. That asymmetry is why `Blank`, `Purge` and `HideComment` are steward-gated, and why the realm keeps a ban list, protection levels and an optional cooldown rather than relying on the deposit alone. ## Rendering untrusted markdown Article bodies are attacker-controlled markdown rendered by gnoweb, so the render path has a fixed order: 1. `sanitize.BlockRich` the body (`gno.land/p/nt/markdown/sanitize/v0`). 2. **Then** rewrite the wikilinks. Not the other way around. The sanitizer escapes every `[`, so `[[Gno land]]` becomes `\[\[Gno land\]\]` in its output; a rewriter that ran first would hand the markdown links it just generated to the escaper and every link on the wiki would render as literal text. That is why `ScanLinks` takes its delimiters as parameters: indexing scans the raw body, rendering scans the escaped one. The blank lines `BlockRich` adds around its output are load-bearing, not cosmetic. A CommonMark HTML block of type 6 or 7 is not escaped in any mode, and without the surrounding blank line it would absorb the realm chrome appended after the body. Two layers, two jobs: `sanitize` stops markdown **structure** injection, and gnoweb's own link extension stops **URL scheme** abuse (`javascript:` and friends). Neither replaces the other. The title charset is narrower than MediaWiki's for the same reason. `/`, `|`, `#`, `*`, `[`, `<`, `?`, `%` and `&` are rejected rather than escaped, which keeps `Title.String`, `Title.Slug` and the rendered link byte-identical. `TestTitleSurvivesTheRenderPipeline` pins that coupling end to end. ## Determinism and gas `Render` runs under `maxGasQuery` (3e9 in `gno.land/pkg/sdk/vm/keeper.go`), which a reader cannot raise, so an unbounded render makes a page permanently unreadable. Three bounds keep it away from that ceiling: - `MaxBody` (32 KiB) caps a revision, and therefore caps every render. - `DiffMaxLines` (80) caps the changed region a diff computes exactly. The common prefix and suffix are trimmed first, so an ordinary edit to a long article still diffs exactly; past the bound a diff degrades to a block replacement instead of failing. - Histories, indexes and listings are paginated by the caller. Measured with `gno test -v` on gno master.184 (2026-09-17): the diff of a 33 KB body against itself plus one line costs 691M gas, and the article render of a 33 KB body with links costs 693M, each about 23% of the ceiling. Everything else follows gno's determinism rules: no map iteration anywhere in a render path, `avl` for every ordered index, and namespace keys padded by hand because `ufmt` has no width flags (`ufmt.Sprintf("%02d", 7)` silently returns `"7"`, which would sort `User` between two main-namespace pages). ## Discussion Discussion is an append-only comment store attached to a page, not a `Talk:` article. A talk page is an article, so whoever edits last can rewrite what someone else said, and moderating one bad message means editing the whole page. A comment store gives each message its own author, timestamp and moderation: `HideComment` clears one body and releases its bytes while the message stays in the thread, marked as removed. Replies nest exactly one level (`MaxReplyDepth`). Deeper nesting needs recursive rendering with no natural bound, which is the shape a query gas ceiling punishes hardest. Comments are sanitized like article bodies but are not a wikilink slot: brackets in a comment stay literal. ## Wiki syntax | syntax | meaning | |---|---| | `[[Target]]` | link, rendered from the canonical title | | `[[Target\|label]]` | link with a display label | | `[[Category:Name]]` | join a category; removed from the text flow | | `[[:Category:Name]]` | link to the category instead of joining it | | `#REDIRECT [[Target]]` on line 1 | redirect, followed one hop only | Links to pages that do not exist yet are still indexed, so creating a page immediately knows who was already pointing at it. ## Known limits - `Page.Revision(id)` is a linear scan of the page's history, and so is comment lookup within a thread. - Redirects are followed one hop; chains are not resolved. - Templates and transclusion are not implemented. - There is no full-text search, and there cannot be a cheap one on chain. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/p/moul/x/wiki/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/p/moul/x/wiki/v0/deps.png) > 🧪 **Highly experimental — potentially vibe-coded.** Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4diff.gno
  5. #5package wiki import "strings" // DiffOp is what happened to one line between two revisions. type DiffOp uint8 const ( OpEqual DiffOp = iota OpInsert OpDelete ) // DiffLine is one line of a rendered diff. type DiffLine struct { Op DiffOp Text string } // DiffMaxLines bounds the changed region a diff will compute exactly. // // The cost of an LCS over the changed region is O(n*m), and this runs inside // vm/qrender, which has a gas ceiling a reader cannot raise. 80 lines caps the // table at 6,400 cells; beyond it, a diff degrades to "this block was replaced" // rather than making the page unrenderable. Both revisions are still available // in full through the raw view, so nothing is hidden by the degradation. const DiffMaxLines = 80 // TODO(#140 Q5): decide whether diffing belongs on chain at all. Both options // are still open: keep this bounded server-side diff, or drop it and let // clients diff two /raw responses themselves, which costs the chain nothing // and removes DiffMaxLines entirely. Keeping it for now because a diff a // reader can reach from a plain URL is most of what makes a history readable, // and 691M gas at the body cap leaves room. Revisit if MaxBody ever rises. // DiffLines compares two bodies line by line. // // exact reports whether the result is a real line diff. When it is false the // changed region was larger than DiffMaxLines and the result is the coarse // form: every removed line, then every added line. func DiffLines(older, newer string) (out []DiffLine, exact bool) { a := splitLines(older) b := splitLines(newer) // Trim the common prefix and suffix. Ordinary wiki edits touch a few // lines of a long article, so this alone brings almost every real diff // under the exact-computation bound. head := 0 for head < len(a) && head < len(b) && a[head] == b[head] { head++ } tailA, tailB := len(a), len(b) for tailA > head && tailB > head && a[tailA-1] == b[tailB-1] { tailA-- tailB-- } midA := a[head:tailA] midB := b[head:tailB] out = []DiffLine{} for _, l := range a[:head] { out = append(out, DiffLine{OpEqual, l}) } exact = len(midA) <= DiffMaxLines && len(midB) <= DiffMaxLines if exact { out = append(out, lcsDiff(midA, midB)...) } else { for _, l := range midA { out = append(out, DiffLine{OpDelete, l}) } for _, l := range midB { out = append(out, DiffLine{OpInsert, l}) } } for _, l := range a[tailA:] { out = append(out, DiffLine{OpEqual, l}) } return out, exact } // DiffStat counts added and removed lines. func DiffStat(lines []DiffLine) (added, removed int) { for _, l := range lines { switch l.Op { case OpInsert: added++ case OpDelete: removed++ } } return added, removed } // lcsDiff is the textbook longest-common-subsequence diff, bounded by the // caller to DiffMaxLines on each side. func lcsDiff(a, b []string) []DiffLine { n, m := len(a), len(b) if n == 0 && m == 0 { return []DiffLine{} } // table[i][j] is the LCS length of a[i:] and b[j:]. table := make([][]int, n+1) for i := range table { table[i] = make([]int, m+1) } for i := n - 1; i >= 0; i-- { for j := m - 1; j >= 0; j-- { if a[i] == b[j] { table[i][j] = table[i+1][j+1] + 1 } else if table[i+1][j] >= table[i][j+1] { table[i][j] = table[i+1][j] } else { table[i][j] = table[i][j+1] } } } out := []DiffLine{} i, j := 0, 0 for i < n && j < m { switch { case a[i] == b[j]: out = append(out, DiffLine{OpEqual, a[i]}) i++ j++ case table[i+1][j] >= table[i][j+1]: out = append(out, DiffLine{OpDelete, a[i]}) i++ default: out = append(out, DiffLine{OpInsert, b[j]}) j++ } } for ; i < n; i++ { out = append(out, DiffLine{OpDelete, a[i]}) } for ; j < m; j++ { out = append(out, DiffLine{OpInsert, b[j]}) } return out } func splitLines(s string) []string { if s == "" { return []string{} } s = strings.TrimSuffix(s, "\n") return strings.Split(s, "\n") }
  6. #6diff_test.gno
  7. #7package wiki import ( "strings" "testing" "gno.land/p/nt/uassert/v0" ) // render turns a diff into the compact "+a -b =c" form the tests assert on. func render(lines []DiffLine) string { var b strings.Builder for _, l := range lines { switch l.Op { case OpInsert: b.WriteString("+") case OpDelete: b.WriteString("-") default: b.WriteString("=") } b.WriteString(l.Text) b.WriteString(";") } return b.String() } func TestDiffLines(t *testing.T) { cases := []struct { name string old, new string want string }{ {"identical", "a\nb\n", "a\nb\n", "=a;=b;"}, {"append", "a\n", "a\nb\n", "=a;+b;"}, {"prepend", "b\n", "a\nb\n", "+a;=b;"}, {"delete", "a\nb\n", "a\n", "=a;-b;"}, {"replace middle", "a\nb\nc\n", "a\nx\nc\n", "=a;-b;+x;=c;"}, {"from empty", "", "a\n", "+a;"}, {"to empty", "a\n", "", "-a;"}, {"both empty", "", "", ""}, {"no trailing newline", "a\nb", "a\nc", "=a;-b;+c;"}, } for _, tc := range cases { got, exact := DiffLines(tc.old, tc.new) uassert.True(t, exact, tc.name+" must be exact") uassert.Equal(t, tc.want, render(got), tc.name) } } func TestDiffStat(t *testing.T) { lines, _ := DiffLines("a\nb\nc\n", "a\nx\ny\nc\n") added, removed := DiffStat(lines) uassert.Equal(t, 2, added) uassert.Equal(t, 1, removed) } // TestDiffCommonPrefixKeepsLongArticlesExact is the property that makes the // DiffMaxLines bound usable in practice: a one-line edit to a 500-line article // still diffs exactly, because only the changed region is fed to the LCS. func TestDiffCommonPrefixKeepsLongArticlesExact(t *testing.T) { var b strings.Builder for i := 0; i < 500; i++ { b.WriteString("line\n") } old := b.String() updated := old + "added\n" lines, exact := DiffLines(old, updated) uassert.True(t, exact, "a one-line change to a 500-line article must diff exactly") added, removed := DiffStat(lines) uassert.Equal(t, 1, added) uassert.Equal(t, 0, removed) } // TestDiffDegradesInsteadOfBlowingTheGasBudget locks the documented fallback: // past DiffMaxLines the diff becomes a block replacement rather than an // unrenderable page. func TestDiffDegradesInsteadOfBlowingTheGasBudget(t *testing.T) { var a, b strings.Builder for i := 0; i < DiffMaxLines+1; i++ { a.WriteString("old\n") b.WriteString("new\n") } lines, exact := DiffLines(a.String(), b.String()) uassert.False(t, exact, "past the bound the diff must report itself inexact") added, removed := DiffStat(lines) uassert.Equal(t, DiffMaxLines+1, added) uassert.Equal(t, DiffMaxLines+1, removed) }
  8. #8doc.gno
  9. #9// Package wiki is a Wikipedia-shaped wiki engine for gno.land: namespaced // titles, an append-only revision chain, wikilinks with backlinks, categories, // redirects, protection levels, line diffs, and markdown rendering. // // It is pure: no realm globals, no chain imports. Every call takes the context // it needs (author address, wall clock, block height) from the caller, so the // whole engine is unit-testable off-chain. The live demo realm is // gno.land/r/moul/x/wiki/v0. // // # Storage model // // A realm write locks a storage deposit proportional to the bytes it adds // (100ugnot per byte since gnolang/gno#6171), so a wiki that kept every // revision in full would charge its editors for the whole history forever. // This engine instead stores a content-addressed spine plus a bounded body // window: // // - Every revision keeps its metadata permanently: id, parent, author, // time, height, summary, byte size, and the SHA-256 of the body. That is // roughly 200 bytes, independent of article size. // - Only the newest Retention revisions of a page keep their body. Older // bodies are evicted (set to ""), which releases their deposit. // // An evicted body is not lost: its bytes were an argument of the transaction // that wrote it, so they remain in the chain's transaction history, and the // retained hash proves which bytes were the real ones. The realm stops paying // rent for deep history; the archive lives where archives belong. // // # Untrusted markdown // // Article bodies are attacker-controlled markdown. Render passes every body // through gno.land/p/nt/markdown/sanitize/v0 before any wikilink rewriting, // never after: sanitize escapes "[" to "\\[", so a rewriter that ran first // would hand its own generated links to the escaper. See links.gno. package wiki
  10. #10gnomod.toml
  11. #11module = "gno.land/p/moul/x/wiki/v0" gno = "0.9"
  12. #12limits_test.gno
  13. #13package wiki import ( "strings" "testing" "gno.land/p/nt/uassert/v0" ) // maxLines is how many typical lines fit in DefaultMaxBody: 32 KiB at 43 // bytes per line. const maxLines = 758 func buildLines(n int) string { var b strings.Builder for i := 0; i < n; i++ { b.WriteString("some article line of moderate length here.\n") } return b.String() } // TestWorstCaseRendersStayWithinTheQueryBudget exercises the two render paths // at the largest body MaxBody admits. // // vm/qrender runs under a fixed ceiling (maxGasQuery = 3e9 in // gno.land/pkg/sdk/vm/keeper.go), which a reader cannot raise, so a render // that exceeds it makes the page permanently unreadable. Measured with // `gno test -v` on gno master.184 (2026-09-17): // // diff of a 33 KB body against itself plus one line 691M gas 23% of the ceiling // article render of a 33 KB body with links 693M gas 23% of the ceiling // // gno test reports gas per test function but a test cannot assert on it, so // this test pins the shape, not the number: it fails if either path starts // panicking or truncating at the limit. Re-measure with `gno test -v` and // update the table when either path changes. func TestWorstCaseRendersStayWithinTheQueryBudget(t *testing.T) { w := New(3, DefaultMaxBody) body := buildLines(maxLines-2) + "see [[Other page]]\n[[Category:Big]]\n" uassert.True(t, len(body) < DefaultMaxBody, "the fixture must fit in MaxBody") now, h := at(1) _, err := w.Edit(alice, now, h, "Big", body, "", false) uassert.NoError(t, err) p, err := w.Page("Big") uassert.NoError(t, err) c := Ctx{Base: "/r/moul/x/wiki/v0", Exists: w.Exists} uassert.True(t, len(RenderArticle(c, w, p, nil)) > len(body), "the article must render in full") now, h = at(2) _, err = w.Edit(alice, now, h, "Big", body+"added\n", "", false) uassert.NoError(t, err) hist := p.History(0, 2) out := RenderDiff(c, p, hist[1], hist[0]) uassert.True(t, strings.Contains(out, "+1 −0 lines"), "a one-line append to a 32 KiB body diffs exactly") }
  14. #14links.gno
  15. #15package wiki import ( "strings" "gno.land/p/nt/markdown/sanitize/v0" ) // Link is one wikilink occurrence in a body. type Link struct { Target string // target text as written, before ParseTitle Label string // display text; "" means render the target Explicit bool // written [[:Category:X]]: link to the category, do not join it Start int // byte offset of the opening delimiter End int // byte offset just past the closing delimiter } // ScanLinks finds every [[target|label]] occurrence delimited by open and // close. // // The delimiters are parameters because the same syntax has to be found twice // with different bytes. Indexing reads the raw body, where a link is // "[[X]]". Rendering reads the body after sanitize.BlockRich, where the very // same link is "\[\[X\]\]" because the sanitizer escapes every "[". Rendering // must sanitize first and rewrite second: a rewriter that ran first would // hand the markdown links it just generated to the escaper, and every link on // the wiki would render as literal text. // // A link whose inner text spans a newline or is empty is not a link. When a // nearer opener appears inside the inner text, scanning restarts from it, so // "[[a [[b]]" yields b rather than a mis-parsed a. func ScanLinks(s, openTok, closeTok string) []Link { out := []Link{} i := 0 for i < len(s) { a := strings.Index(s[i:], openTok) if a < 0 { break } a += i rest := a + len(openTok) b := strings.Index(s[rest:], closeTok) if b < 0 { break } b += rest inner := s[rest:b] if n := strings.Index(inner, openTok); n >= 0 { i = rest + n continue } i = b + len(closeTok) if inner == "" || strings.Contains(inner, "\n") { continue } l := Link{Start: a, End: i} if p := strings.Index(inner, "|"); p >= 0 { l.Target = strings.TrimSpace(inner[:p]) l.Label = strings.TrimSpace(inner[p+1:]) } else { l.Target = strings.TrimSpace(inner) } if strings.HasPrefix(l.Target, ":") { l.Explicit = true l.Target = strings.TrimSpace(l.Target[1:]) } if l.Target == "" { continue } out = append(out, l) } return out } // redirectTarget returns the canonical title a body redirects to, or "". // The syntax is MediaWiki's: "#REDIRECT [[Target]]" on the first line. func redirectTarget(body string) string { line := body if i := strings.Index(line, "\n"); i >= 0 { line = line[:i] } line = strings.TrimSpace(line) if len(line) < len("#REDIRECT") || !strings.EqualFold(line[:len("#REDIRECT")], "#REDIRECT") { return "" } links := ScanLinks(line, "[[", "]]") if len(links) == 0 { return "" } t, err := ParseTitle(links[0].Target) if err != nil { return "" } return t.String() } // Ctx carries what rendering needs from the realm: where the realm lives, and // which titles exist. Its function fields are read during a single Render call // and never stored, so no closure is ever persisted. type Ctx struct { Base string // realm path prefix, e.g. "/r/moul/x/wiki/v0" Exists func(Title) bool // nil treats every title as existing } func (c Ctx) exists(t Title) bool { if c.Exists == nil { return true } return c.Exists(t) } // URL is the render path of a title under this realm. func (c Ctx) URL(t Title) string { return c.Sub(t, "") } // Sub is the render path of a sub-route of a title, e.g. "history". func (c Ctx) Sub(t Title, route string) string { u := c.Base + ":" + t.Slug() if route != "" { u += "/" + route } return escapeURL(u) } // SpecialURL is the render path of a Special: page. func (c Ctx) SpecialURL(name, query string) string { u := c.Base + ":Special:" + name if query != "" { u += "?" + query } return escapeURL(u) } // escapeURL percent-encodes the characters a title may contain that would // otherwise terminate a markdown link destination or split a path. Titles // admit "(", ")", " ", "'" and "," (see validTitleRune), and an unencoded ")" // ends the "(...)" of a markdown link at the first occurrence, which turns // [[Mercury (planet)]] into a broken link plus stray text. func escapeURL(u string) string { r := strings.NewReplacer( " ", "%20", "(", "%28", ")", "%29", "'", "%27", ",", "%2C", ) return sanitize.URL(r.Replace(u)) } // RewriteLinks turns the wikilinks of an already-sanitized body into markdown // links. Category declarations are removed from the flow: membership is shown // by the rendered footer, not inline, which is also what MediaWiki does. // // s MUST be the output of sanitize.BlockRich or sanitize.Block, and open/close // MUST be the escaped delimiters. Passing a raw body here would emit links // built from unsanitized bytes. func RewriteLinks(c Ctx, s string) string { links := ScanLinks(s, `\[\[`, `\]\]`) if len(links) == 0 { return s } var out strings.Builder prev := 0 for _, l := range links { out.WriteString(s[prev:l.Start]) prev = l.End t, err := ParseTitle(unescapeInline(l.Target)) if err != nil { // Not a usable title: leave the sanitized text in place. It is // already escaped, so it renders as the literal brackets the // author typed. out.WriteString(s[l.Start:l.End]) continue } if t.NS == NSCategory && !l.Explicit { continue } label := l.Label if label == "" { label = t.String() } if c.exists(t) { out.WriteString("[" + label + "](" + c.URL(t) + ")") } else { // A red link: the page does not exist yet. Point at the same // path, which renders the "create this page" stub. out.WriteString("[" + label + "](" + c.URL(t) + ") ⁺") } } out.WriteString(s[prev:]) return out.String() } // unescapeInline undoes the backslash escaping the sanitizer applies inside a // link's inner text, so ParseTitle sees the title the author typed. func unescapeInline(s string) string { if !strings.Contains(s, `\`) { return s } var b strings.Builder for i := 0; i < len(s); i++ { if s[i] == '\\' && i+1 < len(s) { i++ } b.WriteByte(s[i]) } return b.String() }
  16. #16links_test.gno
  17. #17package wiki import ( "testing" "gno.land/p/nt/markdown/sanitize/v0" "gno.land/p/nt/uassert/v0" ) func TestScanLinks(t *testing.T) { cases := []struct { name string in string targets []string labels []string explicits []bool }{ {"none", "plain text", nil, nil, nil}, {"one", "see [[Gno land]] here", []string{"Gno land"}, []string{""}, []bool{false}}, {"labelled", "[[Gno land|the chain]]", []string{"Gno land"}, []string{"the chain"}, []bool{false}}, {"two", "[[A]] and [[B]]", []string{"A", "B"}, []string{"", ""}, []bool{false, false}}, {"category", "[[Category:Chains]]", []string{"Category:Chains"}, []string{""}, []bool{false}}, {"explicit category", "[[:Category:Chains]]", []string{"Category:Chains"}, []string{""}, []bool{true}}, {"whitespace is trimmed", "[[ A | b ]]", []string{"A"}, []string{"b"}, []bool{false}}, {"unclosed is not a link", "[[A", nil, nil, nil}, {"empty is not a link", "[[]]", nil, nil, nil}, {"newline inside is not a link", "[[A\nB]]", nil, nil, nil}, {"nested opener wins", "[[a [[b]]", []string{"b"}, []string{""}, []bool{false}}, } for _, tc := range cases { got := ScanLinks(tc.in, "[[", "]]") if !uassert.Equal(t, len(tc.targets), len(got), tc.name) { continue } for i, l := range got { uassert.Equal(t, tc.targets[i], l.Target, tc.name) uassert.Equal(t, tc.labels[i], l.Label, tc.name) uassert.Equal(t, tc.explicits[i], l.Explicit, tc.name) } } } func TestScanLinksOffsets(t *testing.T) { in := "see [[A]] end" got := ScanLinks(in, "[[", "]]") uassert.Equal(t, 1, len(got)) uassert.Equal(t, "[[A]]", in[got[0].Start:got[0].End]) } // TestScanLinksAfterSanitize is the reason ScanLinks takes its delimiters as // parameters: the sanitizer escapes every "[", so the rendering pass has to // look for a different byte sequence than the indexing pass. func TestScanLinksAfterSanitize(t *testing.T) { escaped := sanitize.BlockRich("see [[Gno land|the chain]] here\n") uassert.Equal(t, 0, len(ScanLinks(escaped, "[[", "]]")), "the raw delimiters must find nothing in sanitized text") got := ScanLinks(escaped, `\[\[`, `\]\]`) uassert.Equal(t, 1, len(got)) uassert.Equal(t, "Gno land", got[0].Target) uassert.Equal(t, "the chain", got[0].Label) } func TestRedirectTarget(t *testing.T) { cases := []struct{ in, want string }{ {"#REDIRECT [[Gno land]]\n", "Gno land"}, {"#redirect [[gno_land]]", "Gno land"}, {" #REDIRECT [[User:Gno land]] \nrest", "User:Gno land"}, {"not a redirect [[Gno land]]", ""}, {"#REDIRECT no link", ""}, {"#REDIRECT [[bad|title]]", "Bad"}, {"text\n#REDIRECT [[Gno land]]", ""}, {"", ""}, } for _, tc := range cases { uassert.Equal(t, tc.want, redirectTarget(tc.in), tc.in) } } func TestRewriteLinks(t *testing.T) { c := Ctx{ Base: "/r/moul/x/wiki/v0", Exists: func(tt Title) bool { return tt.Name == "Gno land" }, } body := sanitize.BlockRich("See [[Gno land]], [[Missing page]] and [[Gno land|the chain]].\n" + "[[Category:Chains]]\n") got := RewriteLinks(c, body) uassert.True(t, contains(got, "[Gno land](/r/moul/x/wiki/v0:Gno_land)"), "existing page links plainly") uassert.True(t, contains(got, "[Missing page](/r/moul/x/wiki/v0:Missing_page) ⁺"), "a redlink is marked") uassert.True(t, contains(got, "[the chain](/r/moul/x/wiki/v0:Gno_land)"), "labels are honored") uassert.False(t, contains(got, "Category:Chains"), "category declarations leave the flow") } func TestRewriteLinksEscapesURLs(t *testing.T) { c := Ctx{Base: "/r/moul/x/wiki/v0"} got := RewriteLinks(c, sanitize.BlockRich("[[Mercury (planet)]]\n")) // An unescaped ")" would terminate the markdown destination early and // leave "planet))" as visible text. uassert.True(t, contains(got, "(/r/moul/x/wiki/v0:Mercury_%28planet%29)"), got) } func TestRewriteLinksLeavesNonTitlesAlone(t *testing.T) { c := Ctx{Base: "/r/moul/x/wiki/v0"} // "[[ ]]" has no usable target, and "[[a/b]]" is not a legal title: both // must render as the literal text the author typed, never as a link. got := RewriteLinks(c, sanitize.BlockRich("[[a/b]]\n")) uassert.True(t, contains(got, `\[\[a/b\]\]`), got) } func contains(haystack, needle string) bool { for i := 0; i+len(needle) <= len(haystack); i++ { if haystack[i:i+len(needle)] == needle { return true } } return false }
  18. #18page.gno
  19. #19package wiki import ( "time" "gno.land/p/moul/ulist/v0" ) // Protection is a page's edit gate. The engine stores it and reports it; it // never enforces it, because authority belongs to the realm that owns the // wiki, not to a pure library. See Wiki.Edit. type Protection uint8 const ( Open Protection = iota // anyone the realm lets through SemiProtected // realm-defined trusted editors Locked // stewards only ) // String returns the protection level's display name. func (p Protection) String() string { switch p { case SemiProtected: return "semi-protected" case Locked: return "locked" default: return "open" } } // ParseProtection maps a user-supplied level name onto a Protection. func ParseProtection(s string) (Protection, error) { switch s { case "open", "": return Open, nil case "semi", "semi-protected": return SemiProtected, nil case "locked": return Locked, nil } return Open, ErrBadProtection } // Page is one title's history plus the indexes derived from its current // revision. Revisions live in a ulist: append is O(1) and does not rewrite the // existing entries, which matters when a popular page accumulates thousands of // edits and every write would otherwise re-serialize the whole slice. type Page struct { Title Title Protection Protection Created time.Time Blanked bool // a steward blanked it; history is retained revs *ulist.List // *Revision, oldest first head *Revision redirect string // target title, "" when this page is not a redirect links []string // outgoing wikilink target keys of head cats []string // category keys head belongs to } // Head returns the current revision, or nil for a page with no revisions. func (p *Page) Head() *Revision { return p.head } // NumRevisions returns how many revisions the page has. func (p *Page) NumRevisions() int { return p.revs.Size() } // Redirect returns the title this page redirects to, or "" if it does not. func (p *Page) Redirect() string { return p.redirect } // Body returns the current text and whether it is held on chain. func (p *Page) Body() (string, bool) { if p.head == nil { return "", false } return p.head.Body() } // Revision returns the revision with the given id, or nil. func (p *Page) Revision(id uint64) *Revision { var found *Revision p.revs.Iterator(0, p.revs.Size()-1, func(_ int, v any) bool { r := v.(*Revision) if r.ID == id { found = r return true } return false }) return found } // History returns up to count revisions, newest first, skipping the newest // offset of them. func (p *Page) History(offset, count int) []*Revision { out := []*Revision{} if count <= 0 { return out } size := p.revs.Size() // Walk backwards: index size-1 is the newest. for i := size - 1 - offset; i >= 0 && len(out) < count; i-- { out = append(out, p.revs.MustGet(i).(*Revision)) } return out } // Contributors returns the distinct authors of the page, oldest edit first. func (p *Page) Contributors() []address { seen := map[string]bool{} out := []address{} p.revs.Iterator(0, p.revs.Size()-1, func(_ int, v any) bool { r := v.(*Revision) if !seen[r.Author.String()] { seen[r.Author.String()] = true out = append(out, r.Author) } return false }) return out }
  20. #20render.gno
  21. #21package wiki import ( "strings" "gno.land/p/moul/md/v0" "gno.land/p/nt/markdown/sanitize/v0" "gno.land/p/nt/ufmt/v0" ) // DepositPerByte is the storage deposit a realm write locks per byte, in // ugnot (gnolang/gno#6171). It is used only to show a reader what a page // costs; the chain, not this package, does the accounting. const DepositPerByte = 100 // Action builds a transaction link for a realm function. The realm supplies // it (txlink.Realm("…").Call is the usual value); a nil Action renders a page // with no edit controls, which is what an archived or read-only mirror wants. type Action func(fn string, args ...string) string // RenderArticle renders a page for reading: the sanitized body with its // wikilinks resolved, a header identifying the current revision, and a footer // with categories and cost. func RenderArticle(c Ctx, w *Wiki, p *Page, act Action) string { var out strings.Builder out.WriteString(md.H1(p.Title.String())) if p.head == nil { out.WriteString(md.Paragraph("_This page has no revisions._")) return out.String() } out.WriteString(articleMeta(c, w, p, act)) if p.Blanked { out.WriteString(md.Blockquote("This page was blanked. Its history is still on chain: " + md.Link("view history", c.Sub(p.Title, "history")))) return out.String() } body, kept := p.Body() switch { case !kept: out.WriteString(md.Blockquote("The current revision's body is no longer held on chain. " + "Recover it from the transaction that wrote it and check it against `" + p.head.ShortHash() + "`.")) case p.redirect != "": out.WriteString(md.Paragraph("Redirects to " + md.Link(p.redirect, escapeURL(c.Base+":"+strings.ReplaceAll(p.redirect, " ", "_"))))) default: // sanitize.BlockRich wraps its output in blank lines on both sides, // and that padding is load-bearing: a CommonMark HTML block of type // 6 or 7 is not escaped in any mode, and without the blank line it // would swallow the footer this function appends next. Do not trim // it to tidy the output. out.WriteString(RewriteLinks(c, sanitize.BlockRich(body))) } out.WriteString(articleFooter(c, w, p)) return out.String() } func articleMeta(c Ctx, w *Wiki, p *Page, act Action) string { h := p.head parts := []string{ ufmt.Sprintf("rev %d", h.ID), h.Time.Format("2006-01-02 15:04"), "by " + shortAddr(h.Author), } if p.Protection != Open { parts = append(parts, p.Protection.String()) } line := strings.Join(parts, " · ") nav := []string{ md.Link("history", c.Sub(p.Title, "history")), md.Link("source", c.Sub(p.Title, "raw")), md.Link("what links here", c.SpecialURL("Backlinks", "page="+p.Title.Slug())), } if act != nil { nav = append(nav, md.Link("edit", act("Edit", "title", p.Title.String()))) } // The count sits outside the link text on purpose: md.Link sanitizes its // label, so parentheses inside it would render as "\(1\)". nav = append(nav, md.Link("discussion", c.Sub(p.Title, "talk"))+ ufmt.Sprintf(" (%d)", w.NumComments(p.Title))) return md.Paragraph(line + "\n\n" + strings.Join(nav, " · ")) } func articleFooter(c Ctx, w *Wiki, p *Page) string { var out strings.Builder out.WriteString(md.HorizontalRule()) if len(p.cats) > 0 { cats := []string{} for _, key := range p.cats { t := Title{NS: NSCategory, Name: key[len(NSCategory.Prefix()):]} cats = append(cats, md.Link(t.Name, c.URL(t))) } out.WriteString(md.Paragraph("**Categories:** " + strings.Join(cats, " · "))) } held := 0 p.revs.Iterator(0, p.revs.Size()-1, func(_ int, v any) bool { r := v.(*Revision) if r.kept { held += r.Size } return false }) out.WriteString(md.Paragraph(ufmt.Sprintf( "%s · %d bytes of text held on chain · %s of storage deposit · %s link here", plural(p.NumRevisions(), "revision", "revisions"), held, formatGNOT(held*DepositPerByte), plural(len(w.Backlinks(p.Title)), "page", "pages")))) return out.String() } // RenderMissing renders the stub shown for a title with no page: the red-link // destination, listing whoever already points at it. func RenderMissing(c Ctx, w *Wiki, t Title, act Action) string { var out strings.Builder out.WriteString(md.H1(t.String())) out.WriteString(md.Paragraph("_This page does not exist yet._")) if act != nil { out.WriteString(md.Paragraph(md.Link("Create it", act("Edit", "title", t.String())))) } if in := w.Backlinks(t); len(in) > 0 { out.WriteString(md.H2("Pages that already link here")) out.WriteString(titleList(c, in)) } return out.String() } // RenderHistory renders a page's revision list, newest first. func RenderHistory(c Ctx, p *Page, offset, count int, act Action) string { var out strings.Builder out.WriteString(md.H1("History of " + p.Title.String())) out.WriteString(md.Paragraph(md.Link("← back to the article", c.URL(p.Title)))) revs := p.History(offset, count) if len(revs) == 0 { out.WriteString(md.Paragraph("_No revisions in this range._")) return out.String() } items := []string{} for _, r := range revs { line := ufmt.Sprintf("**rev %d** · %s · %s · %s · %d bytes · `%s`", r.ID, r.Time.Format("2006-01-02 15:04"), shortAddr(r.Author), string(r.Kind), r.Size, r.ShortHash()) if r.Summary != "" { line += "\n\n" + sanitize.InlineText(r.Summary) } extra := []string{} if r.Prev != 0 { extra = append(extra, md.Link("diff", c.Sub(p.Title, "diff")+"?from="+ufmt.Sprintf("%d", r.Prev)+"&to="+ufmt.Sprintf("%d", r.ID))) } if r.kept { extra = append(extra, md.Link("view", c.Sub(p.Title, "rev")+"/"+ufmt.Sprintf("%d", r.ID))) if act != nil { extra = append(extra, md.Link("revert to this", act("Revert", "title", p.Title.String(), "rev", ufmt.Sprintf("%d", r.ID)))) } } else { extra = append(extra, "_body evicted_") } items = append(items, line+"\n\n"+strings.Join(extra, " · ")) } out.WriteString(md.BulletList(items)) if offset+len(revs) < p.NumRevisions() { out.WriteString(md.Paragraph(md.Link("older →", c.Sub(p.Title, "history")+"?offset="+ufmt.Sprintf("%d", offset+count)))) } return out.String() } // RenderRevision renders one stored revision verbatim. func RenderRevision(c Ctx, p *Page, r *Revision) string { var out strings.Builder out.WriteString(md.H1(ufmt.Sprintf("%s: revision %d", p.Title.String(), r.ID))) out.WriteString(md.Paragraph(ufmt.Sprintf("%s · %s · `%s`", r.Time.Format("2006-01-02 15:04"), shortAddr(r.Author), r.Hash))) body, kept := r.Body() if !kept { out.WriteString(md.Blockquote("This revision's body is no longer held on chain.")) return out.String() } out.WriteString(RewriteLinks(c, sanitize.BlockRich(body))) return out.String() } // RenderRaw renders a revision's source inside a code block, which is what a // reader needs before editing and what a verifier needs to re-hash. func RenderRaw(c Ctx, p *Page, r *Revision) string { var out strings.Builder out.WriteString(md.H1(ufmt.Sprintf("Source of %s (rev %d)", p.Title.String(), r.ID))) out.WriteString(md.Paragraph("sha256 `" + r.Hash + "`")) body, kept := r.Body() if !kept { out.WriteString(md.Blockquote("This revision's body is no longer held on chain.")) return out.String() } out.WriteString(sanitize.CodeBlock(body)) out.WriteString(md.Paragraph(md.Link("← back to the article", c.URL(p.Title)))) return out.String() } // RenderDiff renders the line diff between two revisions of a page. func RenderDiff(c Ctx, p *Page, from, to *Revision) string { var out strings.Builder out.WriteString(md.H1(ufmt.Sprintf("%s: rev %d → rev %d", p.Title.String(), from.ID, to.ID))) a, okA := from.Body() b, okB := to.Body() if !okA || !okB { out.WriteString(md.Blockquote("One of these revisions' bodies is no longer held on chain, so the diff cannot be computed. " + "Their hashes are `" + from.ShortHash() + "` and `" + to.ShortHash() + "`.")) return out.String() } lines, exact := DiffLines(a, b) added, removed := DiffStat(lines) note := ufmt.Sprintf("+%d −%d lines", added, removed) if !exact { note += " · changed region larger than " + ufmt.Sprintf("%d", DiffMaxLines) + " lines, shown as a block replacement" } out.WriteString(md.Paragraph(note)) var d strings.Builder for _, l := range lines { switch l.Op { case OpInsert: d.WriteString("+" + l.Text + "\n") case OpDelete: d.WriteString("-" + l.Text + "\n") default: d.WriteString(" " + l.Text + "\n") } } out.WriteString(sanitize.LanguageCodeBlock("diff", d.String())) out.WriteString(md.Paragraph(md.Link("← back to the article", c.URL(p.Title)))) return out.String() } // RenderIndex renders the wiki's front page: recent changes and a page count. func RenderIndex(c Ctx, w *Wiki, recent int) string { var out strings.Builder s := w.Stats() out.WriteString(md.H1("Wiki")) out.WriteString(md.Paragraph(strings.Join([]string{ md.Link("all pages", c.SpecialURL("AllPages", "")), md.Link("categories", c.SpecialURL("Categories", "")), md.Link("recent changes", c.SpecialURL("RecentChanges", "")), md.Link("stats", c.SpecialURL("Stats", "")), }, " · "))) out.WriteString(md.Paragraph(ufmt.Sprintf("%d pages · %d revisions · %d bytes on chain", s.Pages, s.Revisions, s.BytesHeld))) out.WriteString(md.H2("Recent changes")) out.WriteString(changeList(c, w.Recent(recent))) return out.String() } // RenderRecent renders the recent-changes feed. func RenderRecent(c Ctx, w *Wiki, n int) string { return md.H1("Recent changes") + changeList(c, w.Recent(n)) } // RenderAllPages renders the page index for a namespace prefix. func RenderAllPages(c Ctx, w *Wiki, ns Namespace, offset, count int) string { var out strings.Builder out.WriteString(md.H1("All pages")) tabs := []string{} for i := range namespaces { n := Namespace(i) if n == NSSpecial { continue } label := n.String() if label == "" { label = "Articles" } if n == ns { label = "**" + label + "**" } tabs = append(tabs, md.Link(label, c.SpecialURL("AllPages", "ns="+ufmt.Sprintf("%d", i)))) } out.WriteString(md.Paragraph(strings.Join(tabs, " · "))) titles := w.Titles(ns.Prefix(), offset, count) if len(titles) == 0 { out.WriteString(md.Paragraph("_No pages in this namespace._")) return out.String() } out.WriteString(titleList(c, titles)) if len(titles) == count { out.WriteString(md.Paragraph(md.Link("next →", c.SpecialURL("AllPages", ufmt.Sprintf("ns=%d&offset=%d", uint8(ns), offset+count))))) } return out.String() } // RenderCategory renders a category page: its own text, then its members. func RenderCategory(c Ctx, w *Wiki, t Title, p *Page, act Action) string { var out strings.Builder if p != nil { out.WriteString(RenderArticle(c, w, p, act)) } else { out.WriteString(md.H1(t.String())) out.WriteString(md.Paragraph("_This category has no description page._")) } members := w.CategoryMembers(t) out.WriteString(md.H2(ufmt.Sprintf("Pages in this category (%d)", len(members)))) if len(members) == 0 { out.WriteString(md.Paragraph("_None._")) return out.String() } out.WriteString(titleList(c, members)) return out.String() } // RenderCategories lists every category that has at least one member. func RenderCategories(c Ctx, w *Wiki) string { cats := w.Categories() var out strings.Builder out.WriteString(md.H1("Categories")) if len(cats) == 0 { out.WriteString(md.Paragraph("_No categories yet._")) return out.String() } items := []string{} for _, t := range cats { items = append(items, ufmt.Sprintf("%s (%d)", md.Link(t.Name, c.URL(t)), len(w.CategoryMembers(t)))) } out.WriteString(md.BulletList(items)) return out.String() } // RenderBacklinks renders "what links here" for a title. func RenderBacklinks(c Ctx, w *Wiki, t Title) string { var out strings.Builder out.WriteString(md.H1("Pages that link to " + t.String())) out.WriteString(md.Paragraph(md.Link("← back to the article", c.URL(t)))) in := w.Backlinks(t) if len(in) == 0 { out.WriteString(md.Paragraph("_Nothing links here._")) return out.String() } out.WriteString(titleList(c, in)) return out.String() } // RenderStats renders the wiki's size and what it is paying the chain. func RenderStats(c Ctx, w *Wiki) string { s := w.Stats() rows := []string{ ufmt.Sprintf("pages: %d", s.Pages), ufmt.Sprintf("revisions: %d", s.Revisions), ufmt.Sprintf("comments: %d", s.Comments), ufmt.Sprintf("body bytes on chain: %d", s.BytesHeld), ufmt.Sprintf("storage deposit locked by bodies: %s", formatGNOT(s.BytesHeld*DepositPerByte)), ufmt.Sprintf("bodies kept per page: %d", s.Retention), } return md.H1("Wiki stats") + md.BulletList(rows) } // RenderTalk renders a page's discussion: top-level messages oldest first, // each with its replies. func RenderTalk(c Ctx, w *Wiki, p *Page, offset, count int, act Action) string { var out strings.Builder out.WriteString(md.H1("Discussion: " + p.Title.String())) nav := []string{md.Link("← back to the article", c.URL(p.Title))} if act != nil { nav = append(nav, md.Link("add a message", act("Comment", "title", p.Title.String(), "replyTo", "0"))) } out.WriteString(md.Paragraph(strings.Join(nav, " · "))) threads := w.Comments(p.Title, offset, count) if len(threads) == 0 { out.WriteString(md.Paragraph("_No messages yet._")) return out.String() } items := []string{} for _, th := range threads { item := commentLine(c, th.Root, act, p.Title) for _, r := range th.Replies { item += "\n" + md.Nested(commentLine(c, r, nil, p.Title), " - ") } items = append(items, item) } out.WriteString(md.BulletList(items)) total := w.NumComments(p.Title) if offset+len(threads) < total { out.WriteString(md.Paragraph(md.Link("older →", c.Sub(p.Title, "talk")+"?offset="+ufmt.Sprintf("%d", offset+count)))) } return out.String() } func commentLine(c Ctx, cm *Comment, act Action, t Title) string { head := ufmt.Sprintf("**#%d** · %s · %s", cm.ID, cm.Time.Format("2006-01-02 15:04"), shortAddr(cm.Author)) if cm.Hidden { return head + "\n\n_This message was removed by a steward._" } body := sanitize.Block(cm.Body) if act != nil { body += "\n\n" + md.Link("reply", act("Comment", "title", t.String(), "replyTo", ufmt.Sprintf("%d", cm.ID))) } return head + body } func titleList(c Ctx, titles []Title) string { items := []string{} for _, t := range titles { items = append(items, md.Link(t.String(), c.URL(t))) } return md.BulletList(items) } func changeList(c Ctx, changes []*Change) string { if len(changes) == 0 { return md.Paragraph("_Nothing has happened yet._") } items := []string{} for _, ch := range changes { line := ufmt.Sprintf("%s · %s · %s · %s · rev %d", md.Link(ch.Title.String(), c.URL(ch.Title)), string(ch.Rev.Kind), ch.Rev.Time.Format("2006-01-02 15:04"), shortAddr(ch.Rev.Author), ch.Rev.ID) if ch.Rev.Summary != "" { line += " · " + sanitize.InlineText(ch.Rev.Summary) } items = append(items, line) } return md.BulletList(items) } // shortAddr abbreviates an address for display without losing its prefix. func shortAddr(a address) string { s := a.String() if len(s) <= 12 { return "`" + s + "`" } return "`" + s[:10] + "…" + s[len(s)-4:] + "`" } // formatGNOT renders a ugnot amount as GNOT. // // The zero padding is written out by hand: gno's ufmt supports no width or // padding flags, so ufmt.Sprintf("%06d", 42) returns "42" silently and the // fractional part of every amount would be wrong by three orders of magnitude. func formatGNOT(ugnot int) string { whole := ugnot / 1000000 frac := ugnot % 1000000 s := ufmt.Sprintf("%d", frac) for len(s) < 6 { s = "0" + s } s = strings.TrimRight(s, "0") if s == "" { return ufmt.Sprintf("%d GNOT", whole) } return ufmt.Sprintf("%d.%s GNOT", whole, s) } // plural renders "1 revision" and "2 revisions". func plural(n int, one, many string) string { if n == 1 { return ufmt.Sprintf("%d %s", n, one) } return ufmt.Sprintf("%d %s", n, many) }
  22. #22render_test.gno
  23. #23package wiki import ( "strings" "testing" "gno.land/p/nt/uassert/v0" ) func fixture(t *testing.T) (*Wiki, Ctx) { t.Helper() w := New(2, DefaultMaxBody) edit(t, w, 1, alice, "Gno land", "A chain.\n\nSee [[Tendermint2]] and [[Absent page]].\n\n[[Category:Chains]]\n", "create") edit(t, w, 2, bob, "Gno land", "A smart-contract chain.\n\nSee [[Tendermint2]].\n\n[[Category:Chains]]\n", "tighten") edit(t, w, 3, alice, "Tendermint2", "The consensus engine.\n", "create") return w, Ctx{Base: "/r/moul/x/wiki/v0", Exists: w.Exists} } func act(fn string, args ...string) string { out := "/r/moul/x/wiki/v0$help&func=" + fn for i := 0; i+1 < len(args); i += 2 { out += "&" + args[i] + "=" + args[i+1] } return out } func TestRenderArticle(t *testing.T) { w, c := fixture(t) p, _ := w.Page("Gno land") out := RenderArticle(c, w, p, act) uassert.True(t, strings.Contains(out, "# Gno land"), "heading") uassert.True(t, strings.Contains(out, "A smart-contract chain."), "body text") uassert.True(t, strings.Contains(out, "[Tendermint2](/r/moul/x/wiki/v0:Tendermint2)"), "resolved wikilink") uassert.False(t, strings.Contains(out, "[[Tendermint2]]"), "no raw wikilink survives") uassert.True(t, strings.Contains(out, "**Categories:**"), "category footer") uassert.True(t, strings.Contains(out, "[Chains](/r/moul/x/wiki/v0:Category:Chains)"), "category link") uassert.True(t, strings.Contains(out, "func=Edit"), "edit action") uassert.True(t, strings.Contains(out, "GNOT of storage deposit"), "cost is shown to the reader") } func TestRenderArticleWithoutActionsHasNoEditControls(t *testing.T) { w, c := fixture(t) p, _ := w.Page("Gno land") out := RenderArticle(c, w, p, nil) uassert.False(t, strings.Contains(out, "func=Edit"), "a nil Action renders a read-only page") } func TestRenderArticleEscapesHostileMarkdown(t *testing.T) { w := New(2, DefaultMaxBody) body := "<script>alert(1)</script>\n\n# Fake realm heading\n" edit(t, w, 1, alice, "Hostile", body, "") p, _ := w.Page("Hostile") out := RenderArticle(Ctx{Base: "/r/moul/x/wiki/v0"}, w, p, nil) uassert.True(t, strings.Contains(out, `\<script>`), "an HTML block is escaped, not dropped: the reader still sees what was written") uassert.False(t, strings.Contains(out, "\n<script>"), "no unescaped HTML block reaches the renderer") // The realm's own footer must still be there: a CommonMark HTML block of // type 1-5 does not close on a blank line, so an unescaped one would // swallow every byte the realm appends after the body. uassert.True(t, strings.Contains(out, "GNOT of storage deposit"), "user content cannot absorb the realm chrome that follows it") } func TestRenderMissing(t *testing.T) { w, c := fixture(t) out := RenderMissing(c, w, MustParseTitle("Absent page"), act) uassert.True(t, strings.Contains(out, "does not exist yet")) uassert.True(t, strings.Contains(out, "func=Edit")) // The first revision of Gno land linked here; the second dropped the link, // so nothing should be listed. uassert.True(t, strings.Contains(out, "does not exist yet")) } func TestRenderHistory(t *testing.T) { w, c := fixture(t) p, _ := w.Page("Gno land") out := RenderHistory(c, p, 0, 10, act) uassert.True(t, strings.Contains(out, "# History of Gno land")) uassert.True(t, strings.Contains(out, "**rev 2**")) uassert.True(t, strings.Contains(out, "**rev 1**")) uassert.True(t, strings.Contains(out, "from=1&to=2"), "consecutive revisions get a diff link") uassert.True(t, strings.Contains(out, "func=Revert")) } func TestRenderDiff(t *testing.T) { w, c := fixture(t) p, _ := w.Page("Gno land") h := p.History(0, 2) out := RenderDiff(c, p, h[1], h[0]) uassert.True(t, strings.Contains(out, "rev 1 → rev 2")) uassert.True(t, strings.Contains(out, "-A chain.")) uassert.True(t, strings.Contains(out, "+A smart-contract chain.")) uassert.True(t, strings.Contains(out, "```diff")) } func TestRenderDiffRefusesEvictedBodies(t *testing.T) { w := New(1, DefaultMaxBody) edit(t, w, 1, alice, "A", "one\n", "") edit(t, w, 2, alice, "A", "two\n", "") p, _ := w.Page("A") h := p.History(0, 2) out := RenderDiff(Ctx{Base: "/r/moul/x/wiki/v0"}, p, h[1], h[0]) uassert.True(t, strings.Contains(out, "no longer held on chain"), "an evicted body must be explained, not rendered as an empty diff") } func TestRenderRawShowsTheHashAReaderNeeds(t *testing.T) { w, c := fixture(t) p, _ := w.Page("Gno land") out := RenderRaw(c, p, p.Head()) uassert.True(t, strings.Contains(out, p.Head().Hash), "the full hash, so the bytes can be verified") uassert.True(t, strings.Contains(out, "[[Tendermint2]]"), "raw view shows the source, unrewritten") } func TestRenderIndexAndSpecials(t *testing.T) { w, c := fixture(t) idx := RenderIndex(c, w, 10) uassert.True(t, strings.Contains(idx, "2 pages")) uassert.True(t, strings.Contains(idx, "Recent changes")) all := RenderAllPages(c, w, NSMain, 0, 10) uassert.True(t, strings.Contains(all, "[Gno land]")) uassert.True(t, strings.Contains(all, "[Tendermint2]")) cats := RenderCategories(c, w) uassert.True(t, strings.Contains(cats, "[Chains](/r/moul/x/wiki/v0:Category:Chains) (1)")) cat := RenderCategory(c, w, MustParseTitle("Category:Chains"), nil, nil) uassert.True(t, strings.Contains(cat, "no description page")) uassert.True(t, strings.Contains(cat, "Pages in this category (1)")) back := RenderBacklinks(c, w, MustParseTitle("Tendermint2")) uassert.True(t, strings.Contains(back, "[Gno land]")) stats := RenderStats(c, w) uassert.True(t, strings.Contains(stats, "bodies kept per page: 2")) } func TestRenderBlankedPage(t *testing.T) { w, c := fixture(t) now, h := at(9) w.Blank(mod, now, h, "Gno land", "policy") p, _ := w.Page("Gno land") out := RenderArticle(c, w, p, nil) uassert.True(t, strings.Contains(out, "was blanked")) uassert.True(t, strings.Contains(out, "view history")) } func TestRenderRedirect(t *testing.T) { w, c := fixture(t) edit(t, w, 4, alice, "Gno", "#REDIRECT [[Gno land]]\n", "") p, _ := w.Page("Gno") out := RenderArticle(c, w, p, nil) uassert.True(t, strings.Contains(out, "Redirects to [Gno land](/r/moul/x/wiki/v0:Gno_land)")) }
  24. #24revision.gno
#14AddPackagegno.land/r/moul/agents/capwallet/v09 arguments
Attached funds
6000000ugnot

Arguments · 9

  1. #1capwallet
  2. #2README.md
  3. #3# Capability Wallet — don't give your agent a wallet, give it a capability The default way to let an agent spend or act on-chain is to hand it a private key and hope its prompt is safe. That is a category error. A key is *all your authority*; a prompt is a suggestion. The moment the agent is confused, jailbroken, or just wrong, it has the full key. Three things get conflated and shouldn't: - **identity** — "this is agent Percy" - **authorization** — "Percy may call `Assign` on `r/moul/issues`" - **approval** — "Percy may do it **at most twice**, for **≤ 5 GNOT each**, **before block H**, and I can **revoke** it instantly" Identity is a passport. Authorization is a role. *Approval* is a capability: a narrow, bounded, expiring, revocable grant. This realm issues the third kind. ## The grant ```go type Capability struct { Granter address Principal address // the ONLY address that may exercise it TargetRealm string // scoped to one realm Function string // scoped to one function MaxCoins int64 // per-exercise ceiling ValidUntil int64 // absolute block height, 0 = never RemainingUses uint32 // a use counter Revoked bool // a kill switch Uses []Use // an audit trail } ``` Issuing one is a single call: ```go // Percy may call r/moul/issues.Assign, ≤5 GNOT, twice, no expiry. id := capwallet.Grant(cross(cur), percyAddr, "gno.land/r/moul/issues", "Assign", 5_000_000, // per-use ceiling (ugnot) 0, // no expiry 2, // two uses "issue IDs 100-200") ``` ## Two consumers, two shapes A **target realm** gates an action with a read-only, side-effect-free check: ```go if !capwallet.Authorized(id, caller, coins) { panic("not authorized") } ``` The **agent** consumes a use when it acts. Every bound is enforced at once — right principal, not revoked, not expired, uses remaining, under the ceiling — and the exercise is logged: ```go capwallet.Exercise(cross(cur), id, 3_000_000) // 1 use left capwallet.Exercise(cross(cur), id, 1_000_000) // exhausted ``` And the granter kills it the instant something looks wrong: ```go capwallet.Revoke(cross(cur), id) // Authorized() now returns false for everyone ``` ## Why this is a real safety primitive The blast radius of a compromised agent is exactly the union of its live capabilities — not its whole wallet. You can reason about "what is the worst this agent can do right now" by reading a table, and shrink it to zero with one transaction. That is not something a system prompt can give you. Browse the wallet at the realm root; each capability's bounds and full exercise log live at `:<id>`. ```sh gno test . ``` ## Limitations - `ArgsPolicy` is a human-readable note, not an enforced predicate. Real argument-level constraints ("only issue IDs 100–200") need the target realm to check them, or a richer on-chain policy language. This demo scopes to *realm + function + coins + uses + expiry*, which is already most of the value. - There's no delegation graph (a capability that can mint narrower capabilities). That's a natural extension, not a starting point. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/agents/capwallet/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/agents/capwallet/v0/deps.png) > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4capwallet.gno
  5. #5// Package capwallet issues narrow, bounded, revocable capabilities to // agents — instead of handing an agent a wallet key and hoping its prompt // stays safe. // // The demo makes three ideas that are usually conflated stand apart: // // - identity — "this is agent Percy" // - authorization — "Percy may call this function on this realm" // - approval — "Percy may do it at most N times, for ≤ M coins, // before block H, and I can revoke it at any moment" // // A capability is the third thing: a least-privilege grant with a ceiling, // an expiry, a use counter and a kill switch. Other realms consult // Authorized() before honoring an agent action; the granter revokes with // one call the instant something looks wrong. package capwallet import ( "chain" "chain/runtime" "strings" "gno.land/p/nt/ufmt/v0" ) // Use records one exercise of a capability. type Use struct { By address Coins int64 Height int64 } // Capability is a least-privilege grant from a granter to a principal. type Capability struct { ID uint64 Granter address Principal address // the only address that may exercise it TargetRealm string // realm the capability is scoped to Function string // function the capability authorizes MaxCoins int64 // per-exercise ceiling in ugnot (0 = none allowed) ValidUntil int64 // block height after which it expires (0 = never) RemainingUses uint32 // exercises left (0 = exhausted) ArgsPolicy string // human-readable note on allowed arguments Revoked bool GrantedAt int64 Uses []Use } var caps []*Capability // index 0 => ID 1 // Grant issues a capability. The caller becomes its granter and can revoke // it later. validUntil is an absolute block height (0 = no expiry). func Grant( cur realm, principal address, targetRealm, function string, maxCoins, validUntil int64, uses uint32, argsPolicy string, ) uint64 { assert(targetRealm != "" && function != "", "target realm and function required") assert(uses > 0, "must grant at least one use") id := uint64(len(caps)) + 1 c := &Capability{ ID: id, Granter: cur.Previous().Address(), Principal: principal, TargetRealm: targetRealm, Function: function, MaxCoins: maxCoins, ValidUntil: validUntil, RemainingUses: uses, ArgsPolicy: argsPolicy, GrantedAt: runtime.ChainHeight(), } caps = append(caps, c) chain.Emit("CapabilityGranted", "id", ufmt.Sprintf("%d", id), "principal", principal.String(), "target", targetRealm+"."+function, ) return id } // Exercise consumes one use of a capability. Only the principal may call it, // and every bound is checked: not revoked, not expired, uses remaining, and // coins within the ceiling. On success the use is recorded and the counter // decremented. func Exercise(cur realm, id uint64, coins int64) { c := mustGet(id) caller := cur.Previous().Address() assert(caller == c.Principal, "caller is not the capability principal") assertUsable(c) assert(coins <= c.MaxCoins, ufmt.Sprintf("coins %d exceed capability ceiling %d", coins, c.MaxCoins)) c.RemainingUses-- c.Uses = append(c.Uses, Use{By: caller, Coins: coins, Height: runtime.ChainHeight()}) chain.Emit("CapabilityExercised", "id", ufmt.Sprintf("%d", id), "coins", ufmt.Sprintf("%d", coins), "remaining", ufmt.Sprintf("%d", c.RemainingUses), ) } // Revoke disables a capability immediately. Only the granter may revoke. func Revoke(cur realm, id uint64) { c := mustGet(id) assert(cur.Previous().Address() == c.Granter, "only the granter may revoke") c.Revoked = true chain.Emit("CapabilityRevoked", "id", ufmt.Sprintf("%d", id)) } // ---- read-only API for gating realms ---- // Authorized reports whether principal may exercise capability id for the // given coin amount right now — the check a target realm runs before acting. // It never mutates state or consumes a use. func Authorized(id uint64, principal address, coins int64) bool { if id < 1 || id > uint64(len(caps)) { return false } c := caps[id-1] if c.Principal != principal || !usable(c) { return false } return coins <= c.MaxCoins } // Get returns a copy of a capability. func Get(id uint64) Capability { return *mustGet(id) } // Count returns the number of capabilities issued. func Count() int { return len(caps) } // ---- internal ---- func usable(c *Capability) bool { if c.Revoked || c.RemainingUses == 0 { return false } if c.ValidUntil != 0 && runtime.ChainHeight() > c.ValidUntil { return false } return true } func assertUsable(c *Capability) { assert(!c.Revoked, "capability revoked") assert(c.RemainingUses > 0, "capability exhausted") assert(c.ValidUntil == 0 || runtime.ChainHeight() <= c.ValidUntil, "capability expired") } func mustGet(id uint64) *Capability { assert(id >= 1 && id <= uint64(len(caps)), "unknown capability") return caps[id-1] } func assert(cond bool, msg string) { if !cond { panic(msg) } } // Render shows all capabilities, or one capability's detail + usage at :<id>. func Render(path string) string { if path == "" { return renderIndex() } return renderCap(parseID(path)) } func renderIndex() string { var sb strings.Builder sb.WriteString("# Capability Wallet\n\n") sb.WriteString("_Don't give an agent a wallet. Give it a capability._\n\n") if len(caps) == 0 { sb.WriteString("_No capabilities granted yet._\n") return sb.String() } sb.WriteString("| # | Principal | Scope | Ceiling | Uses left | State |\n") sb.WriteString("|---|---|---|---|---|---|\n") for _, c := range caps { sb.WriteString(ufmt.Sprintf("| [%d](/r/moul/agents/capwallet/v0:%d) | %s | `%s.%s` | %d ugnot | %d | %s |\n", c.ID, c.ID, short(c.Principal), c.TargetRealm, c.Function, c.MaxCoins, c.RemainingUses, state(c))) } return sb.String() } func renderCap(id uint64) string { c := mustGet(id) var sb strings.Builder sb.WriteString(ufmt.Sprintf("# Capability #%d\n\n", c.ID)) sb.WriteString(ufmt.Sprintf("- **State:** %s\n", state(c))) sb.WriteString(ufmt.Sprintf("- **Granter:** %s\n", c.Granter.String())) sb.WriteString(ufmt.Sprintf("- **Principal:** %s\n", c.Principal.String())) sb.WriteString(ufmt.Sprintf("- **Scope:** `%s.%s`\n", c.TargetRealm, c.Function)) sb.WriteString(ufmt.Sprintf("- **Per-use ceiling:** %d ugnot\n", c.MaxCoins)) if c.ValidUntil == 0 { sb.WriteString("- **Expires:** never\n") } else { sb.WriteString(ufmt.Sprintf("- **Expires at height:** %d (now %d)\n", c.ValidUntil, runtime.ChainHeight())) } sb.WriteString(ufmt.Sprintf("- **Uses remaining:** %d\n", c.RemainingUses)) sb.WriteString(ufmt.Sprintf("- **Args policy:** %s\n\n", c.ArgsPolicy)) sb.WriteString(ufmt.Sprintf("## Exercise log (%d)\n\n", len(c.Uses))) if len(c.Uses) == 0 { sb.WriteString("_Never exercised._\n") return sb.String() } sb.WriteString("| By | Coins | Height |\n|---|---|---|\n") for _, u := range c.Uses { sb.WriteString(ufmt.Sprintf("| %s | %d | %d |\n", short(u.By), u.Coins, u.Height)) } return sb.String() } func state(c *Capability) string { if c.Revoked { return "🚫 revoked" } if c.RemainingUses == 0 { return "· exhausted" } if c.ValidUntil != 0 && runtime.ChainHeight() > c.ValidUntil { return "⏳ expired" } return "✅ live" } func parseID(s string) uint64 { var n uint64 for i := 0; i < len(s); i++ { ch := s[i] assert(ch >= '0' && ch <= '9', "invalid capability path: "+s) n = n*10 + uint64(ch-'0') } return n } func short(a address) string { s := a.String() if len(s) <= 12 { return s } return s[:8] + "…" + s[len(s)-4:] }
  6. #6capwallet_test.gno
  7. #7package capwallet import ( "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) var ( owner = testutils.TestAddress("owner") percy = testutils.TestAddress("percy") mallory = testutils.TestAddress("mallory") ) func TestGrantAndExercise(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(owner)) // Percy may call r/moul/issues.Assign, ≤5 GNOT, 2 times, no expiry. id := Grant(cross(cur), percy, "gno.land/r/moul/issues", "Assign", 5000000, 0, 2, "issue IDs 100-200") // Read-only gate other realms use. uassert.True(t, Authorized(id, percy, 5000000)) uassert.False(t, Authorized(id, percy, 5000001)) // over ceiling uassert.False(t, Authorized(id, mallory, 1)) // wrong principal testing.SetRealm(testing.NewUserRealm(percy)) Exercise(cross(cur), id, 3000000) uassert.Equal(t, uint32(1), Get(id).RemainingUses) Exercise(cross(cur), id, 1000000) uassert.Equal(t, uint32(0), Get(id).RemainingUses) // Exhausted. uassert.False(t, Authorized(id, percy, 1)) uassert.AbortsWithMessage(t, cur, "capability exhausted", func(cur realm) { Exercise(cur, id, 1) }) } func TestCeilingEnforced(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(owner)) id := Grant(cross(cur), percy, "r/x", "F", 100, 0, 3, "") testing.SetRealm(testing.NewUserRealm(percy)) uassert.AbortsWithMessage(t, cur, "coins 101 exceed capability ceiling 100", func(cur realm) { Exercise(cur, id, 101) }) } func TestOnlyPrincipal(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(owner)) id := Grant(cross(cur), percy, "r/x", "F", 100, 0, 3, "") testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "caller is not the capability principal", func(cur realm) { Exercise(cur, id, 1) }) } func TestRevoke(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(owner)) id := Grant(cross(cur), percy, "r/x", "F", 100, 0, 3, "") // Only the granter may revoke. testing.SetRealm(testing.NewUserRealm(mallory)) uassert.AbortsWithMessage(t, cur, "only the granter may revoke", func(cur realm) { Revoke(cur, id) }) testing.SetRealm(testing.NewUserRealm(owner)) Revoke(cross(cur), id) uassert.False(t, Authorized(id, percy, 1)) testing.SetRealm(testing.NewUserRealm(percy)) uassert.AbortsWithMessage(t, cur, "capability revoked", func(cur realm) { Exercise(cur, id, 1) }) } func TestExpiry(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(owner)) h := int64(0) // ChainHeight in tests starts at 0; set expiry in the past-ish _ = h // Grant valid only up to height 1. id := Grant(cross(cur), percy, "r/x", "F", 100, 1, 3, "") testing.SkipHeights(5) // now well past height 1 uassert.False(t, Authorized(id, percy, 1)) testing.SetRealm(testing.NewUserRealm(percy)) uassert.AbortsWithMessage(t, cur, "capability expired", func(cur realm) { Exercise(cur, id, 1) }) uassert.True(t, len(Render("")) > 0) uassert.True(t, len(Render("1")) > 0) }
  8. #8gnomod.toml
  9. #9module = "gno.land/r/moul/agents/capwallet/v0" gno = "0.9" private = true
#15AddPackagegno.land/r/moul/agents/gnomem/v09 arguments
Attached funds
8000000ugnot

Arguments · 9

  1. #1gnomem
  2. #2README.md
  3. #3# GnoMem — can autonomous agents share a memory without sharing a database operator? Most "agent memory" products are a vector store: dump text, retrieve similar chunks. That is fine when one agent is talking to itself. It falls apart the moment several *independent* agents — different owners, different models, run at different times — have to maintain a **shared** understanding of the same world and occasionally disagree about it. Whoever operates the database wins every disagreement. They can silently edit a memory, drop an inconvenient finding, or reorder history, and no other agent can tell. The question this demo asks is: *what does shared agent memory look like when nobody owns the database?* ## Memory as a contestable claim graph The core move is to stop storing memory as text and start storing it as a graph of structured **claims**: ```go type Claim struct { ID uint64 Subject string Predicate string Object string // the (S, P, O) triple Author address Confidence uint8 // 0..100 Status Status Supports []Endorsement Contests []Endorsement Evidence []Evidence // commitments to off-chain material SupersededBy uint64 Supersedes uint64 // ... } ``` A claim moves through an explicit lifecycle: ``` proposed → supported → contested → { accepted | retracted | superseded } ``` The terminal states are the interesting design decision. An agent that dislikes a claim **cannot delete it**. It can: - **contest** it (with refuting evidence), flipping it to `contested`; - **supersede** it with a better claim — the old one becomes `superseded` and is *linked* to its replacement, so the correction is a visible edge in the graph, not an overwrite; - **retract** it via adjudication. History stays visible and ordered in every case. Support never silently overrides a live contest, either: a contested claim stays contested until someone adjudicates it. ## The canonical run ```go // A researcher proposes. id := gnomem.ProposeClaim(cross(cur), "foo/v2", "is", "safe to deploy", "report#H1", 82) // A skeptic contests, with an exploit. gnomem.ContestClaim(cross(cur), id, "unbounded allocation", "exploit#H2") // An adjudicator supersedes with the corrected claim. newID := gnomem.SupersedeClaim(cross(cur), id, "foo/v2", "is", "unsafe before commit abc123", "report#H3", 95) ``` Render the graph at the realm root, and any claim's full argument tree — support, contest, evidence, supersession links — at `:<id>`. You get a *"view source for the argument"*: not just the current answer, but how the agents got there and who dissented. ``` # Claim #1 > foo/v2 — is — safe to deploy - Status: ♻️ superseded - Superseded by: #2 ## ⚔ Contest (1) | skeptic | 0 | unbounded allocation | 12 | ``` ## Why Gno specifically - The claim graph is **ordinary persistent objects** — structs, slices, pointers — not hand-serialized contract storage. You write Go, the graph persists. - Every realm has `Render`, so the argument tree is browsable in a normal web view with no separate frontend. - Because it's an importable package, a *research-protocol* realm can sit on top and enforce process: "≥3 independent agents, each must cite evidence, one assigned devil's advocate, 2/3 needed to accept, unresolved disagreements stay visible." ## What this demo intentionally leaves out Access control. **Anyone can write** to any open claim here, because the point is the *structure* of contested knowledge, not who's allowed to touch it. Gating "which agent may support/contest/resolve which claim, under what budget" is a capability question — a natural next realm, not this one's job. There's also no economic layer: no staking behind a claim, no reward for finding the flaw that gets a claim superseded, no bond to slash for bad adjudication. Those make the incentives real, and they compose on top of this graph rather than being baked into it. Run the tests: ```sh gno test . ``` The uncomfortable truth it's built around: different agents will always produce incompatible, mutable, poorly-sourced views of the same world. You can't prevent that. You *can* refuse to let any one of them quietly win. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/agents/gnomem/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/agents/gnomem/v0/deps.png) > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4gnomem.gno
  5. #5// Package gnomem is a shared, contestable memory for multiple agents. // // Most "agent memory" products store text and retrieve similar chunks. // That works for one agent talking to itself. It breaks the moment several // independent agents — possibly run by different people, on different // models — must maintain a *shared* understanding of the same world and // sometimes disagree about it. // // gnomem models memory not as text but as a graph of structured claims. // Each claim is a (subject, predicate, object) triple with an author, // evidence and a confidence. Other agents can support it, contest it, add // evidence, supersede it with a better claim, or trigger adjudication. No // agent can silently erase an inconvenient finding: claims can be // superseded or retracted, but the history stays visible and ordered. // // The point of the demo is the structure, not access control: anyone may // write. Gating *who* may write to which claim is exactly what the // capability-wallet demo adds on top. package gnomem import ( "chain" "chain/runtime" "strings" "gno.land/p/nt/ufmt/v0" ) // Status is a claim's position in the argument lifecycle. type Status string const ( StatusProposed Status = "proposed" // asserted, no endorsements yet StatusSupported Status = "supported" // has support, no live contest StatusContested Status = "contested" // at least one live contest StatusAccepted Status = "accepted" // adjudicated true (terminal) StatusRetracted Status = "retracted" // adjudicated false / withdrawn (terminal) StatusSuperseded Status = "superseded" // replaced by a better claim (terminal) ) // Endorsement is a support or contest signal from one agent. type Endorsement struct { Agent address Confidence uint8 // 0..100 Note string Height int64 } // Evidence points at off-chain material backing (or refuting) a claim. type Evidence struct { By address Kind string // "report", "exploit", "dataset", "citation", ... Hash string // commitment to the off-chain artifact Height int64 } // Claim is one node in the shared memory graph. type Claim struct { ID uint64 Subject string Predicate string Object string Author address Confidence uint8 Status Status CreatedAt int64 Supports []Endorsement Contests []Endorsement Evidence []Evidence SupersededBy uint64 // 0 if none Supersedes uint64 // 0 if none Resolver address ResolvedAt int64 } var claims []*Claim // index 0 => ID 1 // ProposeClaim asserts a new (subject, predicate, object) triple. func ProposeClaim(cur realm, subject, predicate, object, evidenceHash string, confidence uint8) uint64 { assert(subject != "" && predicate != "" && object != "", "empty triple field") assertConfidence(confidence) id := uint64(len(claims)) + 1 c := &Claim{ ID: id, Subject: subject, Predicate: predicate, Object: object, Author: cur.Previous().Address(), Confidence: confidence, Status: StatusProposed, CreatedAt: runtime.ChainHeight(), } if evidenceHash != "" { c.Evidence = append(c.Evidence, Evidence{ By: c.Author, Kind: "initial", Hash: evidenceHash, Height: c.CreatedAt, }) } claims = append(claims, c) chain.Emit("ClaimProposed", "id", ufmt.Sprintf("%d", id), "subject", subject, "predicate", predicate) return id } // SupportClaim endorses a claim. Support never overrides a live contest — // a contested claim stays contested until adjudicated. func SupportClaim(cur realm, id uint64, confidence uint8, note string) { c := mustOpen(id) assertConfidence(confidence) c.Supports = append(c.Supports, Endorsement{ Agent: cur.Previous().Address(), Confidence: confidence, Note: note, Height: runtime.ChainHeight(), }) if c.Status == StatusProposed { c.Status = StatusSupported } chain.Emit("ClaimSupported", "id", ufmt.Sprintf("%d", id)) } // ContestClaim challenges a claim, optionally attaching refuting evidence. func ContestClaim(cur realm, id uint64, note, evidenceHash string) { c := mustOpen(id) agent := cur.Previous().Address() h := runtime.ChainHeight() c.Contests = append(c.Contests, Endorsement{ Agent: agent, Confidence: 0, Note: note, Height: h, }) if evidenceHash != "" { c.Evidence = append(c.Evidence, Evidence{By: agent, Kind: "refutation", Hash: evidenceHash, Height: h}) } c.Status = StatusContested chain.Emit("ClaimContested", "id", ufmt.Sprintf("%d", id)) } // AddEvidence attaches supporting or contextual off-chain material. func AddEvidence(cur realm, id uint64, kind, hash string) { c := mustOpen(id) assert(hash != "", "empty evidence hash") c.Evidence = append(c.Evidence, Evidence{ By: cur.Previous().Address(), Kind: kind, Hash: hash, Height: runtime.ChainHeight(), }) } // SupersedeClaim replaces an open claim with a new one. The old claim is // marked superseded (terminal) and linked to its replacement, so the // correction is visible rather than a silent overwrite. func SupersedeClaim(cur realm, oldID uint64, subject, predicate, object, evidenceHash string, confidence uint8) uint64 { old := mustOpen(oldID) newID := ProposeClaim(cur, subject, predicate, object, evidenceHash, confidence) newC := claims[newID-1] newC.Supersedes = oldID old.Status = StatusSuperseded old.SupersededBy = newID chain.Emit("ClaimSuperseded", "old", ufmt.Sprintf("%d", oldID), "new", ufmt.Sprintf("%d", newID)) return newID } // ResolveClaim adjudicates an open claim as accepted (true) or retracted // (false/withdrawn). The resolver's address is recorded — resolution is // itself a provenance-bearing act, not an anonymous verdict. func ResolveClaim(cur realm, id uint64, accepted bool) { c := mustOpen(id) if accepted { c.Status = StatusAccepted } else { c.Status = StatusRetracted } c.Resolver = cur.Previous().Address() c.ResolvedAt = runtime.ChainHeight() chain.Emit("ClaimResolved", "id", ufmt.Sprintf("%d", id), "status", string(c.Status)) } // ---- read-only API ---- // Get returns a copy of a claim by id. func Get(id uint64) Claim { return *mustGet(id) } // Count returns the number of claims in the graph. func Count() int { return len(claims) } // IsOpen reports whether a claim can still be supported/contested/resolved. func IsOpen(id uint64) bool { return isOpen(mustGet(id).Status) } // ---- internal ---- func isOpen(s Status) bool { return s == StatusProposed || s == StatusSupported || s == StatusContested } func mustGet(id uint64) *Claim { assert(id >= 1 && id <= uint64(len(claims)), "unknown claim") return claims[id-1] } func mustOpen(id uint64) *Claim { c := mustGet(id) assert(isOpen(c.Status), "claim is resolved (terminal): "+string(c.Status)) return c } func assertConfidence(c uint8) { assert(c <= 100, "confidence must be 0..100") } func assert(cond bool, msg string) { if !cond { panic(msg) } } // Render shows the full graph, or one claim's argument tree at :<id>. func Render(path string) string { if path == "" { return renderIndex() } return renderClaim(parseID(path)) } func renderIndex() string { var sb strings.Builder sb.WriteString("# GnoMem — Contested Shared Memory\n\n") sb.WriteString(ufmt.Sprintf("A graph of %d structured claim(s) maintained by multiple agents.\n\n", len(claims))) if len(claims) == 0 { sb.WriteString("_No claims yet._\n") return sb.String() } sb.WriteString("| # | Claim | Status | ✋ support | ⚔ contest |\n") sb.WriteString("|---|---|---|---|---|\n") for _, c := range claims { sb.WriteString(ufmt.Sprintf("| [%d](/r/moul/agents/gnomem/v0:%d) | %s | %s | %d | %d |\n", c.ID, c.ID, triple(c), statusBadge(c.Status), len(c.Supports), len(c.Contests))) } return sb.String() } func renderClaim(id uint64) string { c := mustGet(id) var sb strings.Builder sb.WriteString(ufmt.Sprintf("# Claim #%d\n\n", c.ID)) sb.WriteString(ufmt.Sprintf("> **%s**\n\n", triple(c))) sb.WriteString(ufmt.Sprintf("- **Status:** %s\n", statusBadge(c.Status))) sb.WriteString(ufmt.Sprintf("- **Author:** %s\n", short(c.Author))) sb.WriteString(ufmt.Sprintf("- **Author confidence:** %d/100\n", c.Confidence)) sb.WriteString(ufmt.Sprintf("- **Created at height:** %d\n", c.CreatedAt)) if c.Supersedes != 0 { sb.WriteString(ufmt.Sprintf("- **Supersedes:** [#%d](/r/moul/agents/gnomem/v0:%d)\n", c.Supersedes, c.Supersedes)) } if c.SupersededBy != 0 { sb.WriteString(ufmt.Sprintf("- **Superseded by:** [#%d](/r/moul/agents/gnomem/v0:%d)\n", c.SupersededBy, c.SupersededBy)) } if c.ResolvedAt != 0 { sb.WriteString(ufmt.Sprintf("- **Resolved by:** %s at height %d\n", short(c.Resolver), c.ResolvedAt)) } sb.WriteString("\n") sb.WriteString(ufmt.Sprintf("## ✋ Support (%d)\n\n", len(c.Supports))) renderEndorsements(&sb, c.Supports) sb.WriteString(ufmt.Sprintf("## ⚔ Contest (%d)\n\n", len(c.Contests))) renderEndorsements(&sb, c.Contests) sb.WriteString(ufmt.Sprintf("## 📎 Evidence (%d)\n\n", len(c.Evidence))) if len(c.Evidence) == 0 { sb.WriteString("_None._\n") } else { sb.WriteString("| By | Kind | Commitment | Height |\n|---|---|---|---|\n") for _, e := range c.Evidence { sb.WriteString(ufmt.Sprintf("| %s | %s | `%s` | %d |\n", short(e.By), e.Kind, shortHash(e.Hash), e.Height)) } } return sb.String() } func renderEndorsements(sb *strings.Builder, es []Endorsement) { if len(es) == 0 { sb.WriteString("_None._\n\n") return } sb.WriteString("| Agent | Confidence | Note | Height |\n|---|---|---|---|\n") for _, e := range es { sb.WriteString(ufmt.Sprintf("| %s | %d | %s | %d |\n", short(e.Agent), e.Confidence, e.Note, e.Height)) } sb.WriteString("\n") } func triple(c *Claim) string { return ufmt.Sprintf("%s — %s — %s", c.Subject, c.Predicate, c.Object) } func statusBadge(s Status) string { switch s { case StatusAccepted: return "✅ accepted" case StatusRetracted: return "🚫 retracted" case StatusSuperseded: return "♻️ superseded" case StatusContested: return "⚔ contested" case StatusSupported: return "✋ supported" default: return "· proposed" } } func parseID(s string) uint64 { var n uint64 for i := 0; i < len(s); i++ { c := s[i] assert(c >= '0' && c <= '9', "invalid claim path: "+s) n = n*10 + uint64(c-'0') } return n } func short(a address) string { s := a.String() if len(s) <= 12 { return s } return s[:8] + "…" + s[len(s)-4:] } func shortHash(s string) string { if len(s) <= 12 { return s } return s[:6] + "…" + s[len(s)-4:] }
  6. #6gnomem_test.gno
  7. #7package gnomem import ( "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) var ( researcher = testutils.TestAddress("researcher") skeptic = testutils.TestAddress("skeptic") judge = testutils.TestAddress("judge") ) // The canonical scenario from the design: a researcher proposes, a skeptic // contests with an exploit, an adjudicator supersedes with the corrected // claim. func TestContestedThenSuperseded(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(researcher)) id := ProposeClaim(cross(cur), "foo/v2", "is", "safe to deploy", "report#H1", 82) uassert.Equal(t, uint64(1), id) uassert.Equal(t, string(StatusProposed), string(Get(id).Status)) // Skeptic contests with a refutation. testing.SetRealm(testing.NewUserRealm(skeptic)) ContestClaim(cross(cur), id, "unbounded allocation", "exploit#H2") uassert.Equal(t, string(StatusContested), string(Get(id).Status)) uassert.True(t, IsOpen(id)) // Adjudicator supersedes with the corrected claim. testing.SetRealm(testing.NewUserRealm(judge)) newID := SupersedeClaim(cross(cur), id, "foo/v2", "is", "unsafe before commit abc123", "report#H3", 95) uassert.Equal(t, uint64(2), newID) old := Get(id) uassert.Equal(t, string(StatusSuperseded), string(old.Status)) uassert.Equal(t, newID, old.SupersededBy) uassert.False(t, IsOpen(id)) // superseded is terminal newC := Get(newID) uassert.Equal(t, id, newC.Supersedes) uassert.Equal(t, 1, len(newC.Evidence)) // the initial report#H3 uassert.True(t, len(Render("")) > 0) uassert.True(t, len(Render("1")) > 0) uassert.True(t, len(Render("2")) > 0) } func TestSupportThenAccept(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(researcher)) id := ProposeClaim(cross(cur), "gnovm", "supports", "persistent object graphs", "", 90) testing.SetRealm(testing.NewUserRealm(skeptic)) SupportClaim(cross(cur), id, 88, "confirmed in docs") uassert.Equal(t, string(StatusSupported), string(Get(id).Status)) testing.SetRealm(testing.NewUserRealm(judge)) ResolveClaim(cross(cur), id, true) uassert.Equal(t, string(StatusAccepted), string(Get(id).Status)) uassert.False(t, IsOpen(id)) } func TestCannotMutateTerminal(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(researcher)) id := ProposeClaim(cross(cur), "x", "is", "y", "", 50) ResolveClaim(cross(cur), id, false) // retracted, terminal uassert.AbortsWithMessage(t, cur, "claim is resolved (terminal): retracted", func(cur realm) { SupportClaim(cur, id, 10, "too late") }) } func TestBadInputs(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(researcher)) uassert.AbortsWithMessage(t, cur, "empty triple field", func(cur realm) { ProposeClaim(cur, "", "is", "y", "", 10) }) uassert.AbortsWithMessage(t, cur, "confidence must be 0..100", func(cur realm) { ProposeClaim(cur, "a", "b", "c", "", 200) }) }
  8. #8gnomod.toml
  9. #9module = "gno.land/r/moul/agents/gnomem/v0" gno = "0.9" private = true
#16AddPackagegno.land/r/moul/agents/jury/v09 arguments
Attached funds
6000000ugnot

Arguments · 9

  1. #1jury
  2. #2README.md
  3. #3# Agent Jury — who checks the agents? Paying agents is the easy part of an agent economy. The hard part is what happens when an agent's output is **disputed**: a patch that may be wrong, a research claim that may be fabricated, an oracle reading someone contests. Someone has to adjudicate — and if that someone is a single trusted party, you've just recreated the thing blockchains were supposed to remove. This realm runs a small **commit-reveal jury**: a fixed panel of reviewers each lock in a *hidden* verdict, then reveal it. Because verdicts are committed blind, no juror can copy another's vote, and none can be swayed by watching the tally form. ## The two phases ``` commit: each juror submits commit.Verdict(verdict, salt) ← hash, hides the vote reveal: each juror submits (verdict, salt) ← must reproduce the hash close: majority of revealed verdicts decides the outcome ``` The commitment scheme is shared with the other demos (`p/moul/agents/commit/v0`), so a juror computes their commitment off-chain: ```go commitment := commit.Verdict(true, "a-random-salt") // "I vote YES", hidden jury.Commit(cross(cur), caseID, commitment) // during commit phase // ...later... jury.Reveal(cross(cur), caseID, true, "a-random-salt") // reproduces it, or the reveal is rejected ``` When the last juror commits, the case auto-advances to reveal; when the last reveals, it closes and records the majority outcome. Dissenting jurors (those who voted against the majority) are marked in the render — minority reports stay visible, which matters when the minority turns out to be right. ## Composability A case's *subject* is just an opaque string — a receipt sequence from the receipt demo, a claim id from gnomem, a raw artifact hash. The jury doesn't import any of them; it adjudicates references. That's the point of small, single-purpose realms: they snap together without hard dependencies. ```go id := jury.OpenCase(cross(cur), "receipt#7", []address{j1, j2, j3}) // ... commit + reveal ... jury.Outcome(id) // "upheld" | "rejected" | "tie" ``` Browse cases at the realm root; each panel and tally is at `:<id>`. ```sh gno test . ``` ## What's missing (on purpose) The mechanism is here; the **money** is not. A production jury bonds each juror (stake coins to serve) and slashes provably bad verdicts — that's what makes honest review the profitable strategy. It also wants juror *selection* (random or reputation-weighted, to resist packing) and an *appeal* path (a larger panel on challenge). All of those compose on top of this commit-reveal core; none of them change it. The uncomfortable design truth: you can't prove a verdict is *correct*. You can make it *blind, independent, and accountable* — and that's what turns "one trusted adjudicator" back into "a protocol." <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/agents/jury/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/agents/jury/v0/deps.png) > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4gnomod.toml
  5. #5module = "gno.land/r/moul/agents/jury/v0" gno = "0.9" private = true
  6. #6jury.gno
  7. #7// Package jury is a commit-reveal adversarial review protocol for disputes // over agent work. // // The hard part of an agent economy is not paying agents — it is deciding // what happens when an agent's output is disputed. This realm runs a small // jury: a fixed panel of reviewers each *commit* to a hidden verdict, then // *reveal* it. Commit-reveal means no juror can copy another's vote or be // swayed after seeing the tally; every verdict is locked in blind. // // The subject of a case is an opaque string — a receipt sequence, a claim // id, an off-chain artifact hash — so this realm composes with the receipt // and gnomem demos without depending on them. // // Bonding and slashing (a juror stakes coins, loses them for provably bad // verdicts) are the natural economic layer on top; this demo shows the // mechanism without the money. package jury import ( "chain" "chain/runtime" "strings" "gno.land/p/moul/agents/commit/v0" "gno.land/p/nt/ufmt/v0" ) // Phase is a case's position in the commit-reveal lifecycle. type Phase string const ( PhaseCommit Phase = "commit" PhaseReveal Phase = "reveal" PhaseClosed Phase = "closed" ) // Juror is one panelist and their (eventually revealed) verdict. type Juror struct { Addr address Commitment string // commit.Verdict(verdict, salt) Committed bool Revealed bool Verdict bool // meaningful once Revealed Height int64 } // Case is a single dispute under review by a fixed panel. type Case struct { ID uint64 Subject string // opaque reference to the disputed thing Opener address Phase Phase Jurors []*Juror OpenedAt int64 Outcome string // "upheld" | "rejected" | "tie" once closed Yes int No int } var cases []*Case // index 0 => ID 1 // OpenCase starts a dispute with a fixed panel of jurors. An odd panel size // avoids ties; duplicates are rejected. func OpenCase(cur realm, subject string, jurors []address) uint64 { assert(subject != "", "empty subject") assert(len(jurors) >= 1, "need at least one juror") seen := make(map[string]bool) panel := make([]*Juror, 0, len(jurors)) for _, j := range jurors { key := j.String() assert(!seen[key], "duplicate juror: "+key) seen[key] = true panel = append(panel, &Juror{Addr: j}) } id := uint64(len(cases)) + 1 cases = append(cases, &Case{ ID: id, Subject: subject, Opener: cur.Previous().Address(), Phase: PhaseCommit, Jurors: panel, OpenedAt: runtime.ChainHeight(), }) chain.Emit("CaseOpened", "id", ufmt.Sprintf("%d", id), "jurors", ufmt.Sprintf("%d", len(panel))) return id } // Commit locks in a juror's hidden verdict. commitment must equal // commit.Verdict(verdict, salt) — computed off-chain. When the last juror // commits, the case advances to the reveal phase automatically. func Commit(cur realm, caseID uint64, commitment string) { c := mustCase(caseID) assert(c.Phase == PhaseCommit, "not in commit phase") assert(commitment != "", "empty commitment") j := mustJuror(c, cur.Previous().Address()) assert(!j.Committed, "already committed") j.Commitment = commitment j.Committed = true j.Height = runtime.ChainHeight() if allCommitted(c) { c.Phase = PhaseReveal chain.Emit("CasePhase", "id", ufmt.Sprintf("%d", caseID), "phase", string(PhaseReveal)) } } // Reveal opens a juror's verdict. The (verdict, salt) must reproduce the // commitment made earlier, or the reveal is rejected. When the last juror // reveals, the case closes and the majority outcome is recorded. func Reveal(cur realm, caseID uint64, verdict bool, salt string) { c := mustCase(caseID) assert(c.Phase == PhaseReveal, "not in reveal phase") j := mustJuror(c, cur.Previous().Address()) assert(j.Committed && !j.Revealed, "nothing to reveal") assert(commit.Verdict(verdict, salt) == j.Commitment, "reveal does not match commitment") j.Revealed = true j.Verdict = verdict if verdict { c.Yes++ } else { c.No++ } chain.Emit("VerdictRevealed", "id", ufmt.Sprintf("%d", caseID), "verdict", boolStr(verdict)) if allRevealed(c) { closeCase(c) } } // ---- read-only API ---- // Get returns a copy of a case (jurors flattened separately via Jury). func Get(caseID uint64) Case { return *mustCase(caseID) } // Outcome returns the recorded outcome, or "" if the case is still open. func Outcome(caseID uint64) string { return mustCase(caseID).Outcome } // Count returns the number of cases. func Count() int { return len(cases) } // ---- internal ---- func closeCase(c *Case) { switch { case c.Yes > c.No: c.Outcome = "upheld" case c.No > c.Yes: c.Outcome = "rejected" default: c.Outcome = "tie" } c.Phase = PhaseClosed chain.Emit("CaseClosed", "id", ufmt.Sprintf("%d", c.ID), "outcome", c.Outcome) } func allCommitted(c *Case) bool { for _, j := range c.Jurors { if !j.Committed { return false } } return true } func allRevealed(c *Case) bool { for _, j := range c.Jurors { if !j.Revealed { return false } } return true } func mustCase(id uint64) *Case { assert(id >= 1 && id <= uint64(len(cases)), "unknown case") return cases[id-1] } func mustJuror(c *Case, addr address) *Juror { for _, j := range c.Jurors { if j.Addr == addr { return j } } panic("caller is not on this jury") } func boolStr(b bool) string { if b { return "yes" } return "no" } func assert(cond bool, msg string) { if !cond { panic(msg) } } // Render shows all cases, or one case's panel + tally at :<id>. func Render(path string) string { if path == "" { return renderIndex() } return renderCase(parseID(path)) } func renderIndex() string { var sb strings.Builder sb.WriteString("# Agent Jury\n\n") sb.WriteString("_Who checks the agents?_ Blind commit-reveal review by a fixed panel.\n\n") if len(cases) == 0 { sb.WriteString("_No cases yet._\n") return sb.String() } sb.WriteString("| # | Subject | Phase | ✅ | ❌ | Outcome |\n") sb.WriteString("|---|---|---|---|---|---|\n") for _, c := range cases { out := c.Outcome if out == "" { out = "—" } sb.WriteString(ufmt.Sprintf("| [%d](/r/moul/agents/jury/v0:%d) | %s | %s | %d | %d | %s |\n", c.ID, c.ID, c.Subject, string(c.Phase), c.Yes, c.No, out)) } return sb.String() } func renderCase(id uint64) string { c := mustCase(id) var sb strings.Builder sb.WriteString(ufmt.Sprintf("# Case #%d\n\n", c.ID)) sb.WriteString(ufmt.Sprintf("- **Subject:** %s\n", c.Subject)) sb.WriteString(ufmt.Sprintf("- **Phase:** %s\n", string(c.Phase))) sb.WriteString(ufmt.Sprintf("- **Opened by:** %s at height %d\n", short(c.Opener), c.OpenedAt)) if c.Outcome != "" { sb.WriteString(ufmt.Sprintf("- **Outcome:** **%s** (%d ✅ / %d ❌)\n", c.Outcome, c.Yes, c.No)) } sb.WriteString("\n## Panel\n\n") sb.WriteString("| Juror | Committed | Revealed | Verdict | Dissent |\n|---|---|---|---|---|\n") majority := c.Yes >= c.No // for dissent marking once closed for _, j := range c.Jurors { verdict, dissent := "—", "" if j.Revealed { verdict = boolStr(j.Verdict) if c.Phase == PhaseClosed && c.Outcome != "tie" && j.Verdict != majority { dissent = "⚠" } } sb.WriteString(ufmt.Sprintf("| %s | %t | %t | %s | %s |\n", short(j.Addr), j.Committed, j.Revealed, verdict, dissent)) } return sb.String() } func parseID(s string) uint64 { var n uint64 for i := 0; i < len(s); i++ { ch := s[i] assert(ch >= '0' && ch <= '9', "invalid case path: "+s) n = n*10 + uint64(ch-'0') } return n } func short(a address) string { s := a.String() if len(s) <= 12 { return s } return s[:8] + "…" + s[len(s)-4:] }
  8. #8jury_test.gno
  9. #9package jury import ( "testing" "gno.land/p/moul/agents/commit/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) var ( opener = testutils.TestAddress("opener") j1 = testutils.TestAddress("juror1") j2 = testutils.TestAddress("juror2") j3 = testutils.TestAddress("juror3") nobody = testutils.TestAddress("nobody") ) // Full happy path: 3 jurors commit blind, then reveal; majority upholds. func TestCommitRevealMajority(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(opener)) id := OpenCase(cross(cur), "receipt#7", []address{j1, j2, j3}) uassert.Equal(t, string(PhaseCommit), string(Get(id).Phase)) // Each juror commits a hidden verdict (hash computed off-chain). commitAs(cur, j1, id, true, "s1") commitAs(cur, j2, id, false, "s2") uassert.Equal(t, string(PhaseCommit), string(Get(id).Phase)) // not all in yet commitAs(cur, j3, id, true, "s3") uassert.Equal(t, string(PhaseReveal), string(Get(id).Phase)) // auto-advanced revealAs(cur, j1, id, true, "s1") revealAs(cur, j2, id, false, "s2") revealAs(cur, j3, id, true, "s3") c := Get(id) uassert.Equal(t, string(PhaseClosed), string(c.Phase)) uassert.Equal(t, "upheld", c.Outcome) // 2 yes vs 1 no uassert.Equal(t, 2, c.Yes) uassert.Equal(t, 1, c.No) uassert.True(t, len(Render("")) > 0) uassert.True(t, len(Render("1")) > 0) } // A juror cannot change their vote at reveal time: the commitment binds it. func TestRevealMustMatchCommitment(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(opener)) id := OpenCase(cross(cur), "claim#3", []address{j1}) commitAs(cur, j1, id, true, "salt") testing.SetRealm(testing.NewUserRealm(j1)) uassert.AbortsWithMessage(t, cur, "reveal does not match commitment", func(cur realm) { Reveal(cur, id, false, "salt") // flipped verdict }) } func TestOnlyPanelJurors(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(opener)) id := OpenCase(cross(cur), "x", []address{j1}) testing.SetRealm(testing.NewUserRealm(nobody)) uassert.AbortsWithMessage(t, cur, "caller is not on this jury", func(cur realm) { Commit(cur, id, commit.Verdict(true, "s")) }) } func TestNoDuplicateJurors(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(opener)) uassert.AbortsWithMessage(t, cur, "duplicate juror: "+j1.String(), func(cur realm) { OpenCase(cur, "x", []address{j1, j1}) }) } // helpers func commitAs(cur realm, juror address, id uint64, verdict bool, salt string) { testing.SetRealm(testing.NewUserRealm(juror)) Commit(cross(cur), id, commit.Verdict(verdict, salt)) } func revealAs(cur realm, juror address, id uint64, verdict bool, salt string) { testing.SetRealm(testing.NewUserRealm(juror)) Reveal(cross(cur), id, verdict, salt) }
#17AddPackagegno.land/r/moul/agents/maintainer/v09 arguments
Attached funds
7000000ugnot

Arguments · 9

  1. #1maintainer
  2. #2README.md
  3. #3# Agent Maintainer — an AI maintainer for an on-chain package that still can't ship on its own "Give the AI commit access" is where most agent-automation stories quietly become horror stories. The useful version is narrower and much safer: let the agent drive the *process* — the tedious, valuable, around-the-clock parts — while an explicit policy still gates the one irreversible step, the ship. This realm is that split, made concrete for a versioned on-chain package. ## What the agent may do - open a version proposal committing to a package artifact hash, - collect independent reviews, - record CI test attestations, - write release notes, keep a compatibility matrix, - deprecate a version when a vulnerability lands. ## What it cannot do Deploy on its own say-so. `Approve` is gated by a machine-checked policy, and it panics with the *first* unmet condition rather than quietly proceeding: ``` tests pass AND ≥ MinReviewers (2) independent reviews AND every review scores ≥ MinScore (80) AND the challenge window has elapsed (height > ChallengeUntil) ``` The render turns that policy into a live checklist: ``` ## Policy checklist - ✅ tests pass - ✅ ≥2 independent reviews (have 2) - ❌ every review ≥80 ← one reviewer scored 70 - ✅ challenge window elapsed ``` ## The flow ```go id := maintainer.Propose(cross(cur), "v3", "artifact#H3", 100) // 100-block challenge window maintainer.AttestTests(cross(cur), id, true) // CI attests maintainer.AddReview(cross(cur), id, 85, "ok") // reviewer 1 (not the proposer) maintainer.AddReview(cross(cur), id, 95, "great")// reviewer 2 // ...after the challenge window passes... maintainer.Approve(cross(cur), id) // promotes to a release, or panics with the reason maintainer.LatestVersion() // "v3" maintainer.Deprecate(cross(cur), "v3", "CVE-xyz") maintainer.IsDeprecated("v3") // true ``` Two independence rules keep the reviews honest: the proposer can't review its own proposal, and each address reviews at most once. Note the realm records the *approved release* — the actual package publish (`addpkg`) is a separate, deliberately human/authority step. The agent produces a green checklist; it never holds the deploy key. Browse the release matrix + open proposals at the realm root; each proposal's policy checklist and reviews are at `:<id>`. ```sh gno test . ``` ## Where it goes next - Wire `Approve` to actually authorize a downstream deploy via a **capability** (see the capwallet demo) instead of a trusted caller — then even "approved" can't over-spend or over-deploy. - Route contested proposals to a **jury** instead of a simple score gate. - Add a maintainer *budget* so the agent's activity itself is bounded. Each of those is a different demo in this series clicking into place — which is the real thesis: agent trust is not one primitive, it's a stack of small, composable ones. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/agents/maintainer/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/agents/maintainer/v0/deps.png) > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4gnomod.toml
  5. #5module = "gno.land/r/moul/agents/maintainer/v0" gno = "0.9" private = true
  6. #6maintainer.gno
  7. #7// Package maintainer models an AI agent stewarding a versioned on-chain // package — without ever letting it deploy on its own authority. // // The agent can do the tedious, valuable parts: open a version proposal, // collect reviews, record CI test attestations, write release notes, // deprecate a vulnerable version, keep a compatibility matrix. What it // cannot do is ship. Approval is gated by an explicit, machine-checked // policy: // // tests pass AND ≥ MinReviewers independent reviews AND // every review scores ≥ MinScore AND the challenge window has elapsed // // This is the difference between "an agent with commit access" and "an // agent that drives a process a human (or a DAO, or a policy) still gates." // The realm records *approved* releases; the actual package publish is a // separate, deliberately human/authority step. package maintainer import ( "chain" "chain/runtime" "strings" "gno.land/p/nt/ufmt/v0" ) const ( MinReviewers = 2 // independent reviews required MinScore = 80 // minimum score every reviewer must give ) // Review is one reviewer's score for a proposal. type Review struct { By address Score uint8 // 0..100 Note string Height int64 } // Proposal is a candidate new version working its way toward approval. type Proposal struct { ID uint64 Version string ArtifactHash string // commitment to the package source Proposer address Reviews []Review TestsPass bool TestsAttestor address OpenedAt int64 ChallengeUntil int64 // approval blocked until height passes this Approved bool ApprovedAt int64 } // Release is an approved (and possibly later deprecated) version. type Release struct { Version string ArtifactHash string ReleasedAt int64 Deprecated bool DeprecatedReason string } var ( proposals []*Proposal // index 0 => ID 1 releases []*Release // in release order byVersion = map[string]*Release{} ) // Propose opens a version proposal with a challenge window of the given // number of blocks (during which reviews accrue and approval is blocked). func Propose(cur realm, version, artifactHash string, challengeBlocks int64) uint64 { assert(version != "", "empty version") assert(artifactHash != "", "empty artifact hash") assert(challengeBlocks >= 0, "negative challenge window") _, exists := byVersion[version] assert(!exists, "version already released: "+version) id := uint64(len(proposals)) + 1 h := runtime.ChainHeight() proposals = append(proposals, &Proposal{ ID: id, Version: version, ArtifactHash: artifactHash, Proposer: cur.Previous().Address(), OpenedAt: h, ChallengeUntil: h + challengeBlocks, }) chain.Emit("VersionProposed", "id", ufmt.Sprintf("%d", id), "version", version) return id } // AddReview records a reviewer's score. Reviewers must be independent of the // proposer, and each address reviews at most once. func AddReview(cur realm, propID uint64, score uint8, note string) { p := mustProposal(propID) assert(!p.Approved, "proposal already approved") assert(score <= 100, "score must be 0..100") reviewer := cur.Previous().Address() assert(reviewer != p.Proposer, "proposer cannot review own proposal") for _, r := range p.Reviews { assert(r.By != reviewer, "reviewer already reviewed") } p.Reviews = append(p.Reviews, Review{By: reviewer, Score: score, Note: note, Height: runtime.ChainHeight()}) chain.Emit("VersionReviewed", "id", ufmt.Sprintf("%d", propID), "score", ufmt.Sprintf("%d", score)) } // AttestTests records whether CI's test suite passed for the proposal. func AttestTests(cur realm, propID uint64, pass bool) { p := mustProposal(propID) assert(!p.Approved, "proposal already approved") p.TestsPass = pass p.TestsAttestor = cur.Previous().Address() chain.Emit("TestsAttested", "id", ufmt.Sprintf("%d", propID), "pass", boolStr(pass)) } // Approve promotes a proposal to a release iff every policy condition holds. // It panics with the first unmet condition — the policy is the gate, not a // suggestion. func Approve(cur realm, propID uint64) { p := mustProposal(propID) assert(!p.Approved, "proposal already approved") assert(p.TestsPass, "policy: tests have not passed") assert(len(p.Reviews) >= MinReviewers, ufmt.Sprintf("policy: need %d reviews, have %d", MinReviewers, len(p.Reviews))) for _, r := range p.Reviews { assert(r.Score >= MinScore, ufmt.Sprintf("policy: review from %s scored %d < %d", short(r.By), r.Score, MinScore)) } assert(runtime.ChainHeight() > p.ChallengeUntil, "policy: challenge window still open") p.Approved = true p.ApprovedAt = runtime.ChainHeight() rel := &Release{Version: p.Version, ArtifactHash: p.ArtifactHash, ReleasedAt: p.ApprovedAt} releases = append(releases, rel) byVersion[p.Version] = rel chain.Emit("VersionApproved", "id", ufmt.Sprintf("%d", propID), "version", p.Version) } // Deprecate flags a released version (e.g. a vulnerability was found). func Deprecate(cur realm, version, reason string) { rel, ok := byVersion[version] assert(ok, "unknown version: "+version) rel.Deprecated = true rel.DeprecatedReason = reason chain.Emit("VersionDeprecated", "version", version) } // ---- read-only API ---- // LatestVersion returns the most recently released version, or "". func LatestVersion() string { if len(releases) == 0 { return "" } return releases[len(releases)-1].Version } // IsDeprecated reports whether a released version is deprecated. func IsDeprecated(version string) bool { rel, ok := byVersion[version] return ok && rel.Deprecated } // GetProposal returns a copy of a proposal. func GetProposal(id uint64) Proposal { return *mustProposal(id) } // ---- internal ---- func mustProposal(id uint64) *Proposal { assert(id >= 1 && id <= uint64(len(proposals)), "unknown proposal") return proposals[id-1] } func boolStr(b bool) string { if b { return "yes" } return "no" } func assert(cond bool, msg string) { if !cond { panic(msg) } } // Render shows the release matrix + open proposals, or one proposal at :<id>. func Render(path string) string { if path == "" { return renderIndex() } return renderProposal(parseID(path)) } func renderIndex() string { var sb strings.Builder sb.WriteString("# Agent Package Maintainer\n\n") sb.WriteString(ufmt.Sprintf("_The agent drives the process; the policy still gates the ship._ ")) sb.WriteString(ufmt.Sprintf("Approval needs tests + ≥%d reviews each ≥%d + an elapsed challenge window.\n\n", MinReviewers, MinScore)) sb.WriteString("## Releases\n\n") if len(releases) == 0 { sb.WriteString("_No releases yet._\n\n") } else { sb.WriteString("| Version | Released @ | State |\n|---|---|---|\n") for _, r := range releases { st := "✅ current" if r.Deprecated { st = "⚠ deprecated: " + r.DeprecatedReason } sb.WriteString(ufmt.Sprintf("| %s | %d | %s |\n", r.Version, r.ReleasedAt, st)) } sb.WriteString("\n") } sb.WriteString("## Proposals\n\n") if len(proposals) == 0 { sb.WriteString("_None._\n") return sb.String() } sb.WriteString("| # | Version | Tests | Reviews | Approved |\n|---|---|---|---|---|\n") for _, p := range proposals { sb.WriteString(ufmt.Sprintf("| [%d](/r/moul/agents/maintainer/v0:%d) | %s | %s | %d | %t |\n", p.ID, p.ID, p.Version, boolStr(p.TestsPass), len(p.Reviews), p.Approved)) } return sb.String() } func renderProposal(id uint64) string { p := mustProposal(id) var sb strings.Builder sb.WriteString(ufmt.Sprintf("# Proposal #%d — %s\n\n", p.ID, p.Version)) sb.WriteString(ufmt.Sprintf("- **Artifact:** `%s`\n", shortHash(p.ArtifactHash))) sb.WriteString(ufmt.Sprintf("- **Proposer:** %s\n", short(p.Proposer))) sb.WriteString(ufmt.Sprintf("- **Tests pass:** %t\n", p.TestsPass)) sb.WriteString(ufmt.Sprintf("- **Challenge window until height:** %d (now %d)\n", p.ChallengeUntil, runtime.ChainHeight())) sb.WriteString(ufmt.Sprintf("- **Approved:** %t\n\n", p.Approved)) sb.WriteString(ufmt.Sprintf("## Policy checklist\n\n")) sb.WriteString(check(p.TestsPass, "tests pass")) sb.WriteString(check(len(p.Reviews) >= MinReviewers, ufmt.Sprintf("≥%d independent reviews (have %d)", MinReviewers, len(p.Reviews)))) minOK := true for _, r := range p.Reviews { if r.Score < MinScore { minOK = false } } sb.WriteString(check(len(p.Reviews) > 0 && minOK, ufmt.Sprintf("every review ≥%d", MinScore))) sb.WriteString(check(runtime.ChainHeight() > p.ChallengeUntil, "challenge window elapsed")) sb.WriteString("\n") sb.WriteString(ufmt.Sprintf("## Reviews (%d)\n\n", len(p.Reviews))) if len(p.Reviews) == 0 { sb.WriteString("_None._\n") return sb.String() } sb.WriteString("| Reviewer | Score | Note | Height |\n|---|---|---|---|\n") for _, r := range p.Reviews { sb.WriteString(ufmt.Sprintf("| %s | %d | %s | %d |\n", short(r.By), r.Score, r.Note, r.Height)) } return sb.String() } func check(ok bool, label string) string { box := "❌" if ok { box = "✅" } return ufmt.Sprintf("- %s %s\n", box, label) } func parseID(s string) uint64 { var n uint64 for i := 0; i < len(s); i++ { ch := s[i] assert(ch >= '0' && ch <= '9', "invalid proposal path: "+s) n = n*10 + uint64(ch-'0') } return n } func short(a address) string { s := a.String() if len(s) <= 12 { return s } return s[:8] + "…" + s[len(s)-4:] } func shortHash(s string) string { if len(s) <= 12 { return s } return s[:6] + "…" + s[len(s)-4:] }
  8. #8maintainer_test.gno
  9. #9package maintainer import ( "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) var ( bot = testutils.TestAddress("maintainerbot") rev1 = testutils.TestAddress("rev1") rev2 = testutils.TestAddress("rev2") ) func TestPolicyGate(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(bot)) id := Propose(cross(cur), "v2", "artifact#H", 3) // 3-block challenge window // Approval blocked: no tests, no reviews, window open. uassert.AbortsWithMessage(t, cur, "policy: tests have not passed", func(cur realm) { Approve(cur, id) }) AttestTests(cross(cur), id, true) uassert.AbortsWithMessage(t, cur, "policy: need 2 reviews, have 0", func(cur realm) { Approve(cur, id) }) // One low review blocks on the min-score rule. testing.SetRealm(testing.NewUserRealm(rev1)) AddReview(cross(cur), id, 90, "looks good") testing.SetRealm(testing.NewUserRealm(rev2)) AddReview(cross(cur), id, 70, "concerns") testing.SetRealm(testing.NewUserRealm(bot)) uassert.AbortsWithMessage(t, cur, "policy: review from "+short(rev2)+" scored 70 < 80", func(cur realm) { Approve(cur, id) }) } func TestFullApprovalPath(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(bot)) id := Propose(cross(cur), "v3", "artifact#H3", 2) AttestTests(cross(cur), id, true) testing.SetRealm(testing.NewUserRealm(rev1)) AddReview(cross(cur), id, 85, "ok") testing.SetRealm(testing.NewUserRealm(rev2)) AddReview(cross(cur), id, 95, "great") // Challenge window still open -> blocked. testing.SetRealm(testing.NewUserRealm(bot)) uassert.AbortsWithMessage(t, cur, "policy: challenge window still open", func(cur realm) { Approve(cur, id) }) testing.SkipHeights(10) Approve(cross(cur), id) uassert.True(t, GetProposal(id).Approved) uassert.Equal(t, "v3", LatestVersion()) // Deprecate it. Deprecate(cross(cur), "v3", "CVE-xyz") uassert.True(t, IsDeprecated("v3")) uassert.True(t, len(Render("")) > 0) uassert.True(t, len(Render("1")) > 0) } func TestProposerCannotReview(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(bot)) id := Propose(cross(cur), "v4", "a#4", 0) uassert.AbortsWithMessage(t, cur, "proposer cannot review own proposal", func(cur realm) { AddReview(cur, id, 100, "self five") }) }
#18AddPackagegno.land/r/moul/agents/passport/v09 arguments
Attached funds
7000000ugnot

Arguments · 9

  1. #1passport
  2. #2README.md
  3. #3# Agent Passport — what does an on-chain identity actually *prove* about an AI agent? There is a popular idea that if you register an AI agent on a blockchain, it becomes trustworthy — "the agent lives forever on-chain." This is mostly false, and the interesting part is *why* it's false. An agent is not its identity record. The thing that actually acts — the model weights, the prompt, the tools, the keys, the operator paying the inference bill — lives off-chain and can change or disappear at any moment. A chain cannot preserve cognition. It can only preserve a record of **what an identity claimed to be, who controlled it, and in what order those things changed** — and it can make that record impossible to rewrite. So this realm doesn't try to prove an agent is safe. It does something more honest and more useful: it tracks the **drift** of an identity over time and puts it in front of you. ## The model An agent is a persistent Gno object, not NFT metadata scattered across contracts: ```go type Agent struct { ID string Owner address Operators []address Runtime string // e.g. "claude-opus-4-8@<commit>" Endpoints []Endpoint // MCP / A2A / DID / URL Caps []string // self-declared capabilities Status Status // active | suspended | retired CreatedAt int64 UpdatedAt int64 History []Event // append-only lifecycle log } ``` Because it's a normal object graph, other realms don't parse it — they *ask* it: ```go passport.IsActive("percy") // is this identity live? passport.IsOperator("percy", caller) // may this key act as the agent? ``` That is the composability angle Gno gives you for free: the identity layer is an importable package, not an ABI you reverse-engineer. ## Why "drift" is the whole point Every mutation is appended to an immutable log, and the profile page surfaces the two changes that most weaken an identity as evidence: - **ownership changes** — the operator behind the identity may now be someone else entirely; - **runtime changes** — the thing acting under this name is (partly) a different thing than the one that built the reputation. ``` ## Drift - Ownership changes since creation: 1 - Runtime changes since creation: 3 ``` A reputation attached to "percy" means very little if percy changed owners last week and swapped models three times. The chain can't tell you the agent is good — but it *can* stop that history from being quietly erased. ## Try it ```go // register (caller becomes owner) passport.Register(cross(cur), "percy", "claude-opus-4-8@abc123") // authorize a second signing key passport.AddOperator(cross(cur), "percy", operatorAddr) // record a model change — logged loudly as drift passport.SetRuntime(cross(cur), "percy", "claude-opus-4-8@def456") // lifecycle passport.Suspend(cross(cur), "percy") passport.Reactivate(cross(cur), "percy") passport.Retire(cross(cur), "percy") // terminal ``` (From `gnokey maketx call`, the crossing is implicit — you just call `Register percy "claude-opus-4-8@abc123"`.) Browse the registry at the realm root, and any agent's profile + full lifecycle log at `:<id>` (e.g. `.../passport:percy`). Run the tests: ```sh gno test . ``` ## What it deliberately does *not* do - It does not prove capabilities. `DeclareCapability` records a *claim*; proving work happened belongs to an execution-receipt realm, and validating it belongs to a review/jury realm. - It does not authorize specific actions. "Who is this agent" and "may this agent do this exact thing, once, under this budget" are different questions — the second is a capability, not an identity. Identity is the floor, not the ceiling. It tells you *who is claiming to act* and *how much that claim has drifted* — and then gets out of the way of the mechanisms that actually establish trust. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/agents/passport/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/agents/passport/v0/deps.png) > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4gnomod.toml
  5. #5module = "gno.land/r/moul/agents/passport/v0" gno = "0.9" private = true
  6. #6passport.gno
  7. #7// Package passport is an on-chain identity and lifecycle registry for // autonomous (AI) agents. // // The premise: a long-lived agent identity is *weak* evidence. The code, // model, prompt, owner, operating keys and runtime behind an identity can // all change silently. A blockchain cannot preserve an agent's cognition; // it can only preserve the *history* of what an identity claimed to be and // who controlled it, in an order nobody can rewrite. // // So this realm does not try to prove an agent is trustworthy. It records // registration, ownership transfers, operator changes and — crucially — // runtime changes, and it surfaces them so a reader can judge how much the // identity has drifted since it was created. package passport import ( "chain" "chain/runtime" "strings" "gno.land/p/nt/avl/v0" "gno.land/p/nt/ufmt/v0" ) // Status is the lifecycle state of an agent identity. type Status string const ( StatusActive Status = "active" StatusSuspended Status = "suspended" StatusRetired Status = "retired" ) // Endpoint advertises where the agent can be reached or discovered // (e.g. an MCP server, an A2A endpoint, a DID document, an ENS name). type Endpoint struct { Kind string // "mcp", "a2a", "did", "url", ... URI string } // Event is one entry in an agent's append-only lifecycle log. type Event struct { Height int64 Actor address Kind string // "register", "transfer", "add-operator", "set-runtime", ... Detail string } // Agent is a persistent, importable identity object. Other realms can // import this package and ask questions like IsActive / IsOperator // directly, instead of parsing NFT metadata scattered across contracts. type Agent struct { ID string Owner address Operators []address Runtime string // free-form runtime descriptor, e.g. "claude-opus-4-8@<commit>" Endpoints []Endpoint Caps []string // declared capability slugs Status Status CreatedAt int64 UpdatedAt int64 History []Event } var agents = avl.NewTree() // id -> *Agent // Register creates a new agent identity owned by the caller. The id must // be a non-empty, previously unused slug. func Register(cur realm, id, runtimeDesc string) { assert(id != "", "empty agent id") assert(!agents.Has(id), "agent id already registered: "+id) caller := cur.Previous().Address() h := runtime.ChainHeight() a := &Agent{ ID: id, Owner: caller, Runtime: runtimeDesc, Status: StatusActive, CreatedAt: h, UpdatedAt: h, } a.log(h, caller, "register", "runtime="+runtimeDesc) agents.Set(id, a) chain.Emit("AgentRegistered", "id", id, "owner", caller.String()) } // Transfer hands ownership of an agent to a new address. Only the current // owner may call it. Ownership changes are the single most important thing // to keep visible: the operator behind an identity may become someone else. func Transfer(cur realm, id string, newOwner address) { a, caller := mustOwn(cur, id) old := a.Owner a.Owner = newOwner a.touch(caller, "transfer", old.String()+" -> "+newOwner.String()) chain.Emit("AgentTransferred", "id", id, "from", old.String(), "to", newOwner.String()) } // AddOperator authorizes an additional address to act as this agent. func AddOperator(cur realm, id string, op address) { a, caller := mustOwn(cur, id) for _, e := range a.Operators { assert(e != op, "already an operator") } a.Operators = append(a.Operators, op) a.touch(caller, "add-operator", op.String()) chain.Emit("AgentOperatorAdded", "id", id, "operator", op.String()) } // SetRuntime records a change of the agent's runtime/model. This is logged // loudly: a runtime change means the thing acting under this identity is // (partly) a different thing than before. func SetRuntime(cur realm, id, runtimeDesc string) { a, caller := mustOwn(cur, id) old := a.Runtime a.Runtime = runtimeDesc a.touch(caller, "set-runtime", old+" -> "+runtimeDesc) chain.Emit("AgentRuntimeChanged", "id", id, "runtime", runtimeDesc) } // AddEndpoint advertises a discovery/interaction endpoint. func AddEndpoint(cur realm, id, kind, uri string) { a, caller := mustOwn(cur, id) a.Endpoints = append(a.Endpoints, Endpoint{Kind: kind, URI: uri}) a.touch(caller, "add-endpoint", kind+":"+uri) } // DeclareCapability records a capability the agent claims to offer. Note: // this is a *claim*, not proof. Validation belongs to other realms // (see the receipt and gnomem demos). func DeclareCapability(cur realm, id, capab string) { a, caller := mustOwn(cur, id) a.Caps = append(a.Caps, capab) a.touch(caller, "declare-capability", capab) } // Suspend and Retire move the lifecycle forward. Retire is terminal. func Suspend(cur realm, id string) { setStatus(cur, id, StatusSuspended) } func Retire(cur realm, id string) { setStatus(cur, id, StatusRetired) } // Reactivate returns a suspended agent to active. A retired agent cannot // be reactivated. func Reactivate(cur realm, id string) { a, caller := mustOwn(cur, id) assert(a.Status != StatusRetired, "retired agents cannot be reactivated") a.Status = StatusActive a.touch(caller, "reactivate", "") } // ---- read-only API other realms and readers can rely on ---- // Get returns a copy of the agent record; panics if unknown. func Get(id string) Agent { return *mustGet(id) } // Exists reports whether an id is registered. func Exists(id string) bool { return agents.Has(id) } // IsActive reports whether the agent exists and is in the active state. func IsActive(id string) bool { if !agents.Has(id) { return false } return agents.Get(id).(*Agent).Status == StatusActive } // IsOperator reports whether addr is the owner or a listed operator of an // active agent — the check other realms should gate agent actions on. func IsOperator(id string, addr address) bool { if !agents.Has(id) { return false } a := agents.Get(id).(*Agent) if a.Status != StatusActive { return false } if a.Owner == addr { return true } for _, op := range a.Operators { if op == addr { return true } } return false } // Count returns the number of registered agents. func Count() int { return agents.Size() } // ---- internal helpers ---- func setStatus(cur realm, id string, s Status) { a, caller := mustOwn(cur, id) assert(a.Status != StatusRetired, "agent is retired") a.Status = s a.touch(caller, "status", string(s)) chain.Emit("AgentStatusChanged", "id", id, "status", string(s)) } func mustGet(id string) *Agent { assert(agents.Has(id), "unknown agent: "+id) return agents.Get(id).(*Agent) } func mustOwn(cur realm, id string) (*Agent, address) { a := mustGet(id) caller := cur.Previous().Address() assert(caller == a.Owner, "caller is not the owner of "+id) return a, caller } func (a *Agent) log(h int64, actor address, kind, detail string) { a.History = append(a.History, Event{Height: h, Actor: actor, Kind: kind, Detail: detail}) } func (a *Agent) touch(actor address, kind, detail string) { h := runtime.ChainHeight() a.UpdatedAt = h a.log(h, actor, kind, detail) } func assert(cond bool, msg string) { if !cond { panic(msg) } } // Render shows the registry, or a single agent's profile + lifecycle log // at r/moul/agents/passport/v0:<id>. The profile deliberately foregrounds // drift: how many owner and runtime changes have happened since creation. func Render(path string) string { if path == "" { return renderIndex() } return renderAgent(path) } func renderIndex() string { var sb strings.Builder sb.WriteString("# Agent Passport\n\n") sb.WriteString(ufmt.Sprintf("On-chain identity & lifecycle registry — %d agent(s).\n\n", agents.Size())) if agents.Size() == 0 { sb.WriteString("_No agents registered yet._\n") return sb.String() } sb.WriteString("| Agent | Owner | Runtime | Status |\n") sb.WriteString("|---|---|---|---|\n") agents.Iterate("", "", func(key string, v any) bool { a := v.(*Agent) sb.WriteString(ufmt.Sprintf("| [%s](/r/moul/agents/passport/v0:%s) | %s | %s | %s |\n", a.ID, a.ID, short(a.Owner), a.Runtime, string(a.Status))) return false }) return sb.String() } func renderAgent(id string) string { a := mustGet(id) var sb strings.Builder sb.WriteString(ufmt.Sprintf("# Agent: %s\n\n", a.ID)) sb.WriteString(ufmt.Sprintf("- **Status:** %s\n", string(a.Status))) sb.WriteString(ufmt.Sprintf("- **Owner:** %s\n", a.Owner.String())) sb.WriteString(ufmt.Sprintf("- **Runtime:** %s\n", a.Runtime)) sb.WriteString(ufmt.Sprintf("- **Created at height:** %d\n", a.CreatedAt)) sb.WriteString(ufmt.Sprintf("- **Last updated at height:** %d\n\n", a.UpdatedAt)) owners, runtimes := 0, 0 for _, e := range a.History { switch e.Kind { case "transfer": owners++ case "set-runtime": runtimes++ } } sb.WriteString("## Drift\n\n") sb.WriteString(ufmt.Sprintf("What the chain actually proves about this identity — how much it has changed:\n\n")) sb.WriteString(ufmt.Sprintf("- Ownership changes since creation: **%d**\n", owners)) sb.WriteString(ufmt.Sprintf("- Runtime changes since creation: **%d**\n\n", runtimes)) if len(a.Operators) > 0 { sb.WriteString("## Operators\n\n") for _, op := range a.Operators { sb.WriteString("- " + op.String() + "\n") } sb.WriteString("\n") } if len(a.Endpoints) > 0 { sb.WriteString("## Endpoints\n\n") for _, e := range a.Endpoints { sb.WriteString(ufmt.Sprintf("- `%s`: %s\n", e.Kind, e.URI)) } sb.WriteString("\n") } if len(a.Caps) > 0 { sb.WriteString("## Declared capabilities\n\n") sb.WriteString("> These are self-declared claims, not proofs.\n\n") for _, c := range a.Caps { sb.WriteString("- " + c + "\n") } sb.WriteString("\n") } sb.WriteString("## Lifecycle log (append-only)\n\n") sb.WriteString("| Height | Actor | Event | Detail |\n") sb.WriteString("|---|---|---|---|\n") for _, e := range a.History { sb.WriteString(ufmt.Sprintf("| %d | %s | %s | %s |\n", e.Height, short(e.Actor), e.Kind, e.Detail)) } return sb.String() } func short(a address) string { s := a.String() if len(s) <= 12 { return s } return s[:8] + "…" + s[len(s)-4:] }
  8. #8passport_test.gno
  9. #9package passport import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) var ( alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") carol = testutils.TestAddress("carol") ) func TestLifecycle(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) Register(cross(cur), "percy", "claude-opus-4-8@abc123") uassert.True(t, Exists("percy")) uassert.True(t, IsActive("percy")) uassert.True(t, IsOperator("percy", alice)) uassert.False(t, IsOperator("percy", bob)) uassert.Equal(t, 1, Count()) // Owner adds an operator; the operator now passes the gate. AddOperator(cross(cur), "percy", bob) uassert.True(t, IsOperator("percy", bob)) // A runtime change is recorded as drift. SetRuntime(cross(cur), "percy", "claude-opus-4-8@def456") a := Get("percy") uassert.Equal(t, "claude-opus-4-8@def456", a.Runtime) // Suspending drops the operator gate for everyone. Suspend(cross(cur), "percy") uassert.False(t, IsActive("percy")) uassert.False(t, IsOperator("percy", alice)) Reactivate(cross(cur), "percy") uassert.True(t, IsActive("percy")) // Render must not panic and must mention the agent. uassert.True(t, len(Render("")) > 0) uassert.True(t, strings.Contains(Render("percy"), "percy")) } func TestOwnershipGate(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) Register(cross(cur), "gate", "rt") // bob is not the owner and cannot mutate. testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsWithMessage(t, cur, "caller is not the owner of gate", func(cur realm) { SetRuntime(cur, "gate", "hacked") }) // Transfer to carol, then carol controls it. testing.SetRealm(testing.NewUserRealm(alice)) Transfer(cross(cur), "gate", carol) testing.SetRealm(testing.NewUserRealm(carol)) SetRuntime(cross(cur), "gate", "carol-rt") uassert.Equal(t, "carol-rt", Get("gate").Runtime) } func TestDuplicateRegister(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) Register(cross(cur), "dup", "rt") uassert.AbortsWithMessage(t, cur, "agent id already registered: dup", func(cur realm) { Register(cur, "dup", "rt") }) } func TestRetireIsTerminal(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(alice)) Register(cross(cur), "term", "rt") Retire(cross(cur), "term") uassert.AbortsWithMessage(t, cur, "retired agents cannot be reactivated", func(cur realm) { Reactivate(cur, "term") }) }
#19AddPackagegno.land/r/moul/agents/receipt/v09 arguments
Attached funds
7000000ugnot

Arguments · 9

  1. #1receipt
  2. #2README.md
  3. #3# Agent Receipts — provenance is not correctness "Put it on the blockchain and now it's trustworthy" is one of the most durable misunderstandings in the space. A chain can prove a handful of things very well: - **who** committed to an output (a signature), - **when** they committed to it (ordering), - that the bytes **haven't changed** since (integrity), - **what** was staked or attested behind it. It cannot, by itself, prove the output is *correct*. Integrity is "these bytes haven't changed." Truth is "the content of these bytes is right." A hash gives you the first for free and tells you nothing about the second. This realm takes that limitation seriously and builds the useful thing that remains: an append-only log of **execution receipts** whose trust level is exactly the set of independent attestations they've collected — no more. ## A receipt is commitments, not payloads ```go type Receipt struct { Seq uint64 Agent string // e.g. a passport agent id TaskHash string // commitment to the task spec InputCommitment string OutputCommitment string RuntimeID string // which runtime/model produced it PolicyVersion string // policy in force at execution time ToolCallsRoot string // root hash over the tool-call trace HumanIntervened bool Author address Height int64 Attestations []Attestation } ``` Nothing here is the actual data. The transcript, the diff, the dataset, the full tool trace — all of that lives off-chain (content-addressed storage, a git commit, an artifact store). The chain stores **commitments** to them plus the lifecycle. This is the sane split: chains are terrible databases and excellent notaries. ## Validation is a separate, explicit step A fresh receipt is *unverified by construction*. Its render says so: > Unverified. This receipt proves the commitment above was made, nothing more. Independent validators then attach verdicts: ```go receipt.Attest(cross(cur), seq, receipt.VerdictReproduced, "re-ran, matched") receipt.Attest(cross(cur), seq, receipt.VerdictTestsPass, "suite green") ``` Two rules make the attestations mean something: - **the author cannot attest to their own receipt** — self-attestation proves nothing; - **a validator cannot attest twice** — one identity, one voice. `Confirmations(seq)` returns `(positive, rejections)` as a raw count, and deliberately **not** a boolean "is it true." The realm refuses to collapse independent verdicts into a verdict of its own; the *caller* decides what threshold it trusts (2-of-3? a specific validator set? a bonded quorum?). That decision doesn't belong to the log. ## The demo scenario Three coding agents each fix a failing package and record a receipt committing to their patch. CI publishes a `tests-pass` attestation; a reviewer agent publishes `reproduced` or `rejected`. Every receipt — winner and losers — stays browsable at `:<seq>`, so "why did we ship this patch and not that one" is answerable months later. ```go seq := receipt.Record(cross(cur), "percy", // agent "task#H", // task commitment "in#H", "out#H", // input / output commitments "claude-opus-4-8", "policy-v1", "tools#H", // tool-call trace root false, // human intervened? ) ``` Run the tests: ```sh gno test . ``` ## Honest limitations - Attestations are only as good as the validators. This realm gives you the *structure* for independent verification; it does not solve validator Sybil resistance or collusion — that needs bonding, random selection, or a jury protocol layered on top. - Commitments prove integrity, not that the committed artifact was ever produced honestly. A receipt is evidence in an audit, not a guarantee. Which is the whole point: the receipt makes agent actions **auditable and non-repudiable**, and stops there. Correctness is earned by the attestations, out in the open, one verdict at a time. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/agents/receipt/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/agents/receipt/v0/deps.png) > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4gnomod.toml
  5. #5module = "gno.land/r/moul/agents/receipt/v0" gno = "0.9" private = true
  6. #6receipt.gno
  7. #7// Package receipt is an append-only log of agent execution receipts. // // The thesis: provenance is not correctness. A chain can prove *who* // committed to an output, *when*, that the bytes have not changed since, // and *what* was staked or attested — but it cannot, by itself, prove the // output was any good. Correctness comes from an external validation // mechanism. // // So a receipt stores commitments (hashes) to the task, inputs, outputs, // tool calls and runtime, never the raw data — that lives off-chain. The // realm then lets independent validators attach attestations: "I re-ran // this and it reproduced", "the tests pass", "the output matches the // commitment", or "rejected". The receipt's trust level is exactly the sum // of the attestations it has collected, and nothing more. package receipt import ( "chain" "chain/runtime" "strings" "gno.land/p/nt/ufmt/v0" ) // Verdicts a validator can record against a receipt. const ( VerdictReproduced = "reproduced" // re-ran and got the same output commitment VerdictTestsPass = "tests-pass" // the referenced test suite passed VerdictOutputMatch = "output-match" // output matches its commitment VerdictPolicyOK = "policy-ok" // no forbidden tool / policy respected VerdictRejected = "rejected" // the work is wrong or invalid ) // Attestation is an independent validator's verdict on a receipt. type Attestation struct { Validator address Verdict string Note string Height int64 } // Receipt commits to one agent action. Every field except Attestations is // immutable once recorded. type Receipt struct { Seq uint64 Agent string // agent id (e.g. a gno.land/r/moul/agents/passport/v0 id) TaskHash string // commitment to the task spec InputCommitment string OutputCommitment string RuntimeID string // which runtime/model produced it PolicyVersion string // policy in force at execution time ToolCallsRoot string // root hash over the tool-call trace HumanIntervened bool Author address Height int64 Attestations []Attestation } var receipts []*Receipt // index 0 => Seq 1 // Record appends a new execution receipt and returns its sequence number. // The caller commits to opaque hashes; the realm never sees raw payloads. func Record( cur realm, agent, taskHash, inputCommitment, outputCommitment, runtimeID, policyVersion, toolCallsRoot string, humanIntervened bool, ) uint64 { assert(agent != "", "empty agent id") assert(taskHash != "", "empty task hash") assert(outputCommitment != "", "empty output commitment") seq := uint64(len(receipts)) + 1 r := &Receipt{ Seq: seq, Agent: agent, TaskHash: taskHash, InputCommitment: inputCommitment, OutputCommitment: outputCommitment, RuntimeID: runtimeID, PolicyVersion: policyVersion, ToolCallsRoot: toolCallsRoot, HumanIntervened: humanIntervened, Author: cur.Previous().Address(), Height: runtime.ChainHeight(), } receipts = append(receipts, r) chain.Emit("ReceiptRecorded", "seq", ufmt.Sprintf("%d", seq), "agent", agent, "output", outputCommitment, ) return seq } // Attest attaches an independent validator's verdict to a receipt. The same // validator cannot attest twice; the author of a receipt cannot attest to // their own work (self-attestation proves nothing). func Attest(cur realm, seq uint64, verdict, note string) { r := mustGet(seq) validator := cur.Previous().Address() assert(validator != r.Author, "author cannot attest to their own receipt") assertVerdict(verdict) for _, a := range r.Attestations { assert(a.Validator != validator, "validator already attested") } r.Attestations = append(r.Attestations, Attestation{ Validator: validator, Verdict: verdict, Note: note, Height: runtime.ChainHeight(), }) chain.Emit("ReceiptAttested", "seq", ufmt.Sprintf("%d", seq), "validator", validator.String(), "verdict", verdict, ) } // ---- read-only API ---- // Get returns a copy of a receipt by sequence number. func Get(seq uint64) Receipt { return *mustGet(seq) } // Count returns the number of receipts recorded. func Count() int { return len(receipts) } // Confirmations returns (positive, rejections) attestation counts. It is a // count of independent verdicts, deliberately NOT a truth value: a caller // decides what threshold it trusts. func Confirmations(seq uint64) (positive, rejections int) { for _, a := range mustGet(seq).Attestations { if a.Verdict == VerdictRejected { rejections++ } else { positive++ } } return positive, rejections } // ---- internal ---- func mustGet(seq uint64) *Receipt { assert(seq >= 1 && seq <= uint64(len(receipts)), "unknown receipt") return receipts[seq-1] } func assertVerdict(v string) { switch v { case VerdictReproduced, VerdictTestsPass, VerdictOutputMatch, VerdictPolicyOK, VerdictRejected: return } panic("unknown verdict: " + v) } func assert(cond bool, msg string) { if !cond { panic(msg) } } // Render shows the receipt log, or a single receipt at :<seq>. func Render(path string) string { if path == "" { return renderIndex() } seq := parseSeq(path) return renderReceipt(seq) } func renderIndex() string { var sb strings.Builder sb.WriteString("# Agent Execution Receipts\n\n") sb.WriteString("_Provenance is not correctness._ Each row commits to an agent action; ") sb.WriteString("trust comes only from independent attestations.\n\n") if len(receipts) == 0 { sb.WriteString("_No receipts recorded yet._\n") return sb.String() } sb.WriteString("| # | Agent | Output commitment | Human? | ✅ | ❌ |\n") sb.WriteString("|---|---|---|---|---|---|\n") for _, r := range receipts { pos, rej := 0, 0 for _, a := range r.Attestations { if a.Verdict == VerdictRejected { rej++ } else { pos++ } } human := "no" if r.HumanIntervened { human = "yes" } sb.WriteString(ufmt.Sprintf("| [%d](/r/moul/agents/receipt/v0:%d) | %s | `%s` | %s | %d | %d |\n", r.Seq, r.Seq, r.Agent, shortHash(r.OutputCommitment), human, pos, rej)) } return sb.String() } func renderReceipt(seq uint64) string { r := mustGet(seq) var sb strings.Builder sb.WriteString(ufmt.Sprintf("# Receipt #%d\n\n", r.Seq)) sb.WriteString(ufmt.Sprintf("- **Agent:** %s\n", r.Agent)) sb.WriteString(ufmt.Sprintf("- **Author:** %s\n", r.Author.String())) sb.WriteString(ufmt.Sprintf("- **Recorded at height:** %d\n", r.Height)) sb.WriteString(ufmt.Sprintf("- **Runtime:** %s\n", r.RuntimeID)) sb.WriteString(ufmt.Sprintf("- **Policy version:** %s\n", r.PolicyVersion)) sb.WriteString(ufmt.Sprintf("- **Human intervened:** %t\n\n", r.HumanIntervened)) sb.WriteString("## Commitments\n\n") sb.WriteString("The realm stores only these hashes; the payloads live off-chain.\n\n") sb.WriteString(ufmt.Sprintf("- **Task:** `%s`\n", r.TaskHash)) sb.WriteString(ufmt.Sprintf("- **Input:** `%s`\n", r.InputCommitment)) sb.WriteString(ufmt.Sprintf("- **Output:** `%s`\n", r.OutputCommitment)) sb.WriteString(ufmt.Sprintf("- **Tool-calls root:** `%s`\n\n", r.ToolCallsRoot)) pos, rej := Confirmations(seq) sb.WriteString(ufmt.Sprintf("## Attestations (%d ✅ / %d ❌)\n\n", pos, rej)) if len(r.Attestations) == 0 { sb.WriteString("> Unverified. This receipt proves the commitment above was made, nothing more.\n") return sb.String() } sb.WriteString("| Validator | Verdict | Note | Height |\n") sb.WriteString("|---|---|---|---|\n") for _, a := range r.Attestations { sb.WriteString(ufmt.Sprintf("| %s | %s | %s | %d |\n", shortHash(a.Validator.String()), a.Verdict, a.Note, a.Height)) } return sb.String() } func parseSeq(s string) uint64 { var n uint64 for i := 0; i < len(s); i++ { c := s[i] assert(c >= '0' && c <= '9', "invalid receipt path: "+s) n = n*10 + uint64(c-'0') } return n } func shortHash(s string) string { if len(s) <= 12 { return s } return s[:6] + "…" + s[len(s)-4:] }
  8. #8receipt_test.gno
  9. #9package receipt import ( "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) var ( agentKey = testutils.TestAddress("agent") val1 = testutils.TestAddress("validator1") val2 = testutils.TestAddress("validator2") ) func TestRecordAndAttest(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(agentKey)) seq := Record(cross(cur), "percy", "task#1", "in#1", "out#1", "claude-opus-4-8", "policy-v1", "tools#1", false) uassert.Equal(t, uint64(1), seq) uassert.Equal(t, 1, Count()) // Fresh receipt is unverified: no attestations. pos, rej := Confirmations(seq) uassert.Equal(t, 0, pos) uassert.Equal(t, 0, rej) // Two independent validators attest. testing.SetRealm(testing.NewUserRealm(val1)) Attest(cross(cur), seq, VerdictReproduced, "re-ran, matched") testing.SetRealm(testing.NewUserRealm(val2)) Attest(cross(cur), seq, VerdictTestsPass, "suite green") pos, rej = Confirmations(seq) uassert.Equal(t, 2, pos) uassert.Equal(t, 0, rej) uassert.True(t, len(Render("")) > 0) uassert.True(t, len(Render("1")) > 0) } func TestNoSelfAttest(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(agentKey)) seq := Record(cross(cur), "percy", "task#2", "in#2", "out#2", "rt", "p", "tr", false) // The author cannot attest to their own receipt. uassert.AbortsWithMessage(t, cur, "author cannot attest to their own receipt", func(cur realm) { Attest(cur, seq, VerdictReproduced, "trust me") }) } func TestNoDoubleAttest(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(agentKey)) seq := Record(cross(cur), "percy", "task#3", "in#3", "out#3", "rt", "p", "tr", false) testing.SetRealm(testing.NewUserRealm(val1)) Attest(cross(cur), seq, VerdictOutputMatch, "ok") uassert.AbortsWithMessage(t, cur, "validator already attested", func(cur realm) { Attest(cur, seq, VerdictRejected, "changed my mind") }) } func TestBadVerdict(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(agentKey)) seq := Record(cross(cur), "percy", "task#4", "in#4", "out#4", "rt", "p", "tr", false) testing.SetRealm(testing.NewUserRealm(val1)) uassert.AbortsWithMessage(t, cur, "unknown verdict: maybe", func(cur realm) { Attest(cur, seq, "maybe", "") }) }
#20AddPackagegno.land/r/moul/forge/v015 arguments
Attached funds
21000000ugnot

Arguments · 15

  1. #1forge
  2. #2README.md
  3. #3# forge An on-chain software forge, browsable in gnoweb and writable only through signed transactions. The domain model lives in [`gno.land/p/moul/forge/v0`](../../../../p/moul/forge/v0); this realm is the wiring, the routes and the events. The code itself is not here. Git objects stay in git, behind whatever mirror a repo declares. What the chain keeps is the part a forge is trusted for: which object a branch points at, in what order, on whose authority, and what was reviewed before it moved. ## Routes | path | page | | --- | --- | | `/` | every repo, with counts | | `/<ns>/<name>` | repo overview: refs, recent log, open changes and issues | | `/<ns>/<name>/log` | the full reference log, paginated with `?page=N` | | `/<ns>/<name>/issues` | issues, `?page=N` | | `/<ns>/<name>/issues/<id>` | one issue and its replies | | `/<ns>/<name>/changes` | change requests, `?page=N` | | `/<ns>/<name>/changes/<id>` | one change, its reviews and its replies | | `/help` | what the realm is and how to call it | Every action link on those pages is a `txlink` into the matching function, so the whole forge is usable from gnoweb without a client. ## Writing ```sh gnokey maketx call -pkgpath gno.land/r/moul/forge/v0 \ -func CreateRepo -args "moul/forge" -args "an on-chain forge" -args "" \ -gas-fee 1000000ugnot -gas-wanted 3000000 \ -broadcast -chainid <chain> -remote <rpc> <key> ``` ```sh # move a branch: the fourth argument is the tip you expect to replace gnokey maketx call -pkgpath gno.land/r/moul/forge/v0 \ -func SetRef -args "moul/forge" -args "refs/heads/main" \ -args "<expected-oid>" -args "<new-oid>" -args "ship it" \ -gas-fee 1000000ugnot -gas-wanted 3000000 \ -broadcast -chainid <chain> -remote <rpc> <key> ``` A stale expectation aborts the transaction instead of overwriting the branch. A move with no expectation is `ForceSetRef`, needs `maintainer`, and is recorded as a force forever. Arguments that would be slices are comma-separated strings (`SetMirrors`, `OpenIssue` labels): a transaction can only carry strings, so a `[]string` parameter would not be callable from gnokey. ## Reading Read paths are free. Browse the routes above, or query: ```sh gnokey query vm/qeval -data 'gno.land/r/moul/forge/v0.RefOID("moul/forge","refs/heads/main")' -remote <rpc> gnokey query vm/qeval -data 'gno.land/r/moul/forge/v0.LogHead("moul/forge")' -remote <rpc> ``` `LogHead` is the digest of the last log entry, committing to every entry before it. Pin it in a release note or a package manifest and the repo's whole history becomes falsifiable by anyone who can read the chain. ## Namespaces A repo id is `<namespace>/<name>`. The namespace is either a name the caller holds in `r/sys/users` or the caller's own bech32 address, checked on every `CreateRepo` and `Fork`: - `g1.../forge` works for any account with nothing to register and nothing to lose, including a realm, whose address is its namespace. That is what makes a DAO-owned repo work. - `moul/forge` requires the `moul` name to resolve to the caller, renames included, since the registry resolves aliases to the same record. Nobody can claim a namespace they do not own, so there is no squatting to arbitrate and no reservation list to maintain. ## Roles `reader < writer < maintainer < admin < owner`. Writers move refs, maintainers force and merge, admins manage members and the merge policy, and the last owner cannot be demoted. Opening an issue or a change request needs no role at all: the author pays gas and locks the storage deposit for their own bytes, which is the spam gate. A role can be held by **another realm**, because the realm resolves its caller without requiring an end user. A repo whose owner is a DAO realm is a repo whose merge button is a governance vote, which is the thing a centralized forge structurally cannot offer. ## Events Every mutation emits one: `RepoCreated`, `RepoForked`, `RepoUpdated`, `MemberSet`, `RefLog`, `IssueOpened`, `IssueComment`, `IssueClosed`, `IssueReopened`, `IssueLabeled`, `ChangeOpened`, `ChangeUpdated`, `ChangeReviewed`, `ChangeComment`, `ChangeClosed`, `ChangeMerged`. `RefLog` carries the sequence number and the chain digest, so an indexer can mirror the log without replaying state. ## Costs A realm write locks 100ugnot per byte, refundable when the data is deleted. A log entry is roughly 200 bytes including the digest, so a push costs about 0.02 GNOT of deposit plus gas: the reason this design anchors refs instead of storing objects. An issue with a 500-byte body is about 0.05 GNOT. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/forge/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/forge/v0/deps.png) > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4forge.gno
  5. #5// Package forge is an on-chain software forge: repos, an append-only reference // log, issues, change requests and reviews, browsable through gnoweb and // writable only through signed transactions. // // It is the realm half of gno.land/p/moul/forge/v0, which holds the whole // domain model; this file is wiring. Every exported mutation is a crossing // function that resolves the caller, forwards to the library, aborts on error // (the only way to revert state in gno) and emits an event for indexers. // // What the chain stores is NOT the code. Git objects stay in git (a mirror, an // IPFS CID, a peer) and the realm records what a forge is actually trusted for: // which object a ref points at, in what order, on whose authority, under // what review policy, and what got merged. See the package README for the // design, the threat model and what this deliberately does not do. // // A caller may be a user or another realm: a repo whose owner is a DAO realm is // a repo whose merge button is a governance vote. package forge import ( "chain" "chain/runtime" "errors" "strings" fg "gno.land/p/moul/forge/v0" "gno.land/r/sys/users" ) // errForeignNamespace is the one rule this realm adds on top of the library: // a namespace is yours or it is not. var errForeignNamespace = errors.New("forge: namespace is not yours") var f = fg.New() // caller is the address that crossed into this realm. It is deliberately not // restricted to end users: a realm holding a role is the point (see the doc // comment above). func caller(cur realm) address { if !cur.IsCurrent() { panic("forge: spoofed realm") } return cur.Previous().Address() } func height() int64 { return runtime.ChainHeight() } // repo resolves a repo id or aborts. Read paths use f.Repo directly and return // a rendered "not found" instead. func repo(id string) *fg.Repo { r := f.Repo(id) if r == nil { panic(fg.ErrRepoNotFound) } return r } // requireNamespace enforces the ownership rule. Two namespaces exist and they // cannot collide, since an address is 40 characters and a name caps at 39: // // - an address namespace ("g1.../forge") belongs to that account, always, with // nothing to register and nothing to lose; // - a name namespace ("moul/forge") belongs to whoever holds that name in // r/sys/users, including through a rename, since the lookup resolves aliases // to the same record. // // A realm caller passes the same way a user does, which is what makes a // DAO-owned repo possible: the DAO's address is its namespace. func requireNamespace(a address, id string) { ns, _, ok := fg.SplitRepoID(id) if !ok { panic(fg.ErrInvalidRepoID) } if fg.AddressNamespace(ns) { if ns != a.String() { panic(errForeignNamespace) } return } data, _ := users.ResolveName(ns) if data.IsDeleted() || data.Addr() != a { panic(errForeignNamespace) } } func must(err error) { if err != nil { panic(err) } } // splitList parses the comma-separated lists the transaction API has to use: // gnokey can only pass strings, so a []string parameter would not be callable. func splitList(s string) []string { s = strings.TrimSpace(s) if s == "" { return nil } parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } return out } // --------------------------------------------------------------------------- // Repos // --------------------------------------------------------------------------- // CreateRepo registers "<namespace>/<name>" with the caller as owner. // defaultRef may be empty for refs/heads/main. // // The namespace must be one the caller owns: an r/sys/users name registered to // their address, or their own bech32 address. Nobody squats anybody. func CreateRepo(cur realm, id, description, defaultRef string) { a := caller(cur) requireNamespace(a, id) _, err := f.CreateRepo(a, height(), id, description, defaultRef) must(err) chain.Emit("RepoCreated", "repo", id, "owner", a.String()) } // Fork registers newID as a fork of srcID, snapshotting the parent's current // refs into the fork's log so the lineage records exactly what was forked. func Fork(cur realm, srcID, newID string) { a := caller(cur) requireNamespace(a, newID) _, err := f.Fork(a, height(), srcID, newID) must(err) chain.Emit("RepoForked", "repo", newID, "parent", srcID, "owner", a.String()) } // SetDescription updates the repo description (admin). func SetDescription(cur realm, repoID, description string) { must(repo(repoID).SetDescription(caller(cur), description)) chain.Emit("RepoUpdated", "repo", repoID, "field", "description") } // SetMirrors replaces the fetch locators, comma-separated, first one canonical // (maintainer). The chain records them; it never fetches. func SetMirrors(cur realm, repoID, mirrors string) { must(repo(repoID).SetMirrors(caller(cur), splitList(mirrors))) chain.Emit("RepoUpdated", "repo", repoID, "field", "mirrors") } // SetDefaultRef points the repo at another default branch (maintainer). func SetDefaultRef(cur realm, repoID, name string) { must(repo(repoID).SetDefaultRef(caller(cur), name)) chain.Emit("RepoUpdated", "repo", repoID, "field", "default_ref") } // SetPolicy sets how many writer approvals a change needs, and whether the // author's own approval counts (admin). func SetPolicy(cur realm, repoID string, requiredApprovals int, allowSelfApproval bool) { must(repo(repoID).SetPolicy(caller(cur), requiredApprovals, allowSelfApproval)) chain.Emit("RepoUpdated", "repo", repoID, "field", "policy") } // SetMember grants a role: none, reader, writer, maintainer, admin, owner. func SetMember(cur realm, repoID string, member address, role string) { parsed, err := fg.ParseRole(role) must(err) must(repo(repoID).SetMember(caller(cur), member, parsed)) chain.Emit("MemberSet", "repo", repoID, "member", member.String(), "role", role) } // SetArchived freezes or unfreezes a repo (admin). The log stays readable. func SetArchived(cur realm, repoID string, archived bool) { must(repo(repoID).SetArchived(caller(cur), archived)) chain.Emit("RepoUpdated", "repo", repoID, "field", "archived") } // --------------------------------------------------------------------------- // Refs: the reference log // --------------------------------------------------------------------------- // SetRef moves a ref by compare-and-swap (writer). expectedOID is the tip the // caller last saw, empty to create. A stale expectation aborts instead of // overwriting: git's --force-with-lease, with consensus holding the lease. func SetRef(cur realm, repoID, name, expectedOID, newOID, note string) { a := caller(cur) e, err := repo(repoID).SetRef(a, height(), name, expectedOID, newOID, note) must(err) emitRef(repoID, e) } // ForceSetRef moves a ref with no expectation (maintainer). It is not // forbidden, it is recorded as a force, in a log nobody can rewrite. func ForceSetRef(cur realm, repoID, name, newOID, note string) { a := caller(cur) e, err := repo(repoID).ForceSetRef(a, height(), name, newOID, note) must(err) emitRef(repoID, e) } // DeleteRef removes a ref by compare-and-swap (maintainer). The default branch // is not deletable. func DeleteRef(cur realm, repoID, name, expectedOID, note string) { a := caller(cur) e, err := repo(repoID).DeleteRef(a, height(), name, expectedOID, note) must(err) emitRef(repoID, e) } func emitRef(repoID string, e *fg.LogEntry) { chain.Emit("RefLog", "repo", repoID, "ref", e.Ref, "kind", e.Kind, "old", e.OldOID, "new", e.NewOID, "seq", itoa(e.Seq), "digest", e.Digest, ) } // --------------------------------------------------------------------------- // Issues // --------------------------------------------------------------------------- // OpenIssue files an issue. Permissionless: the author pays for their bytes. // labels is comma-separated. Returns the issue id. func OpenIssue(cur realm, repoID, title, body, labels string) int64 { a := caller(cur) i, err := repo(repoID).OpenIssue(a, height(), title, body, splitList(labels)) must(err) chain.Emit("IssueOpened", "repo", repoID, "issue", itoa(i.ID), "author", a.String()) return i.ID } // CommentIssue appends a reply. func CommentIssue(cur realm, repoID string, issueID int64, body string) { a := caller(cur) _, err := repo(repoID).CommentIssue(a, height(), issueID, body) must(err) chain.Emit("IssueComment", "repo", repoID, "issue", itoa(issueID), "author", a.String()) } // CloseIssue closes an issue (author or maintainer). func CloseIssue(cur realm, repoID string, issueID int64) { must(repo(repoID).SetIssueOpen(caller(cur), height(), issueID, false)) chain.Emit("IssueClosed", "repo", repoID, "issue", itoa(issueID)) } // ReopenIssue reopens an issue (author or maintainer). func ReopenIssue(cur realm, repoID string, issueID int64) { must(repo(repoID).SetIssueOpen(caller(cur), height(), issueID, true)) chain.Emit("IssueReopened", "repo", repoID, "issue", itoa(issueID)) } // SetIssueLabels replaces an issue's labels, comma-separated (maintainer). func SetIssueLabels(cur realm, repoID string, issueID int64, labels string) { must(repo(repoID).SetIssueLabels(caller(cur), height(), issueID, splitList(labels))) chain.Emit("IssueLabeled", "repo", repoID, "issue", itoa(issueID), "labels", labels) } // --------------------------------------------------------------------------- // Change requests // --------------------------------------------------------------------------- // OpenChange proposes moving targetRef to include headOID. sourceRepo may be // another forge repo id, a mirror locator, or empty for this repo. Returns the // change id. func OpenChange(cur realm, repoID, title, body, sourceRepo, sourceRef, headOID, targetRef string) int64 { a := caller(cur) c, err := repo(repoID).OpenChange(a, height(), title, body, sourceRepo, sourceRef, headOID, targetRef) must(err) chain.Emit("ChangeOpened", "repo", repoID, "change", itoa(c.ID), "author", a.String(), "head", headOID, "target", targetRef) return c.ID } // UpdateChangeHead repoints an open change at a new object (author or writer). // Every approval of the previous head stops counting, by construction. func UpdateChangeHead(cur realm, repoID string, changeID int64, headOID string) { must(repo(repoID).UpdateChangeHead(caller(cur), height(), changeID, headOID)) chain.Emit("ChangeUpdated", "repo", repoID, "change", itoa(changeID), "head", headOID) } // ReviewChange records a verdict: approve, request-changes, comment: against // the change's current head. Anyone may review; a writer's approval counts. func ReviewChange(cur realm, repoID string, changeID int64, verdict, body string) { a := caller(cur) must(repo(repoID).ReviewChange(a, height(), changeID, verdict, body)) chain.Emit("ChangeReviewed", "repo", repoID, "change", itoa(changeID), "reviewer", a.String(), "verdict", verdict) } // CommentChange appends a reply to a change request. func CommentChange(cur realm, repoID string, changeID int64, body string) { a := caller(cur) _, err := repo(repoID).CommentChange(a, height(), changeID, body) must(err) chain.Emit("ChangeComment", "repo", repoID, "change", itoa(changeID), "author", a.String()) } // CloseChange withdraws or rejects a change (author or maintainer). func CloseChange(cur realm, repoID string, changeID int64) { must(repo(repoID).CloseChange(caller(cur), height(), changeID)) chain.Emit("ChangeClosed", "repo", repoID, "change", itoa(changeID)) } // MergeChange moves the target ref to mergedOID and records the move as a merge // entry naming the change (maintainer). expectedTargetOID is a compare-and-swap // on the target: a change approved against a base that has moved is refused, // not silently rebased. func MergeChange(cur realm, repoID string, changeID int64, expectedTargetOID, mergedOID, note string) { a := caller(cur) e, err := repo(repoID).MergeChange(a, height(), changeID, expectedTargetOID, mergedOID, note) must(err) chain.Emit("ChangeMerged", "repo", repoID, "change", itoa(changeID), "merger", a.String(), "new", mergedOID, "digest", e.Digest) emitRef(repoID, e) } // --------------------------------------------------------------------------- // Read-only helpers, for other realms and for vm/qeval // --------------------------------------------------------------------------- // RefOID returns the object a ref points at, or "" if there is no such ref. func RefOID(repoID, name string) string { r := f.Repo(repoID) if r == nil { return "" } ref := r.Ref(name) if ref == nil { return "" } return ref.OID } // LogHead returns the repo's chain digest: pin it off chain and the whole // history of every ref becomes falsifiable. func LogHead(repoID string) string { r := f.Repo(repoID) if r == nil { return "" } return r.LogHead() } // HasRepo reports whether a repo id is registered. func HasRepo(repoID string) bool { return f.HasRepo(repoID) } // RepoCount is the number of repos on this forge. func RepoCount() int { return f.Size() }
  6. #6forge_test.gno
  7. #7package forge import ( "testing" fg "gno.land/p/moul/forge/v0" "gno.land/p/nt/uassert/v0" ) // TestLifecycle drives the realm exactly as a user would: one signed call at a // time, each from a different address. func TestLifecycle(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) CreateRepo(cross(cur), demoID, "a demo repo", "") uassert.True(t, HasRepo(demoID)) uassert.Equal(t, 1, RepoCount()) uassert.Equal(t, "", LogHead(demoID), "a fresh repo has an empty log") // The creator is the owner, so roles are theirs to hand out. SetMember(cross(cur), demoID, carol, "writer") SetMember(cross(cur), demoID, bob, "maintainer") SetMirrors(cross(cur), demoID, "https://github.com/moul/demo.git, ipfs://bafyfakefakefakefakefakefakefakefakefake") uassert.Equal(t, 2, len(f.Repo(demoID).Mirrors), "comma-separated lists are how a tx passes a slice") // A writer creates the branch, then moves it by compare-and-swap. testing.SetRealm(testing.NewUserRealm(carol)) SetRef(cross(cur), demoID, "refs/heads/main", "", oid("a"), "initial import") uassert.Equal(t, oid("a"), RefOID(demoID, "refs/heads/main")) head := LogHead(demoID) uassert.NotEqual(t, "", head) testing.SetRealm(testing.NewUserRealm(carol)) SetRef(cross(cur), demoID, "refs/heads/main", oid("a"), oid("b"), "second commit") uassert.NotEqual(t, head, LogHead(demoID), "each entry advances the chain digest") // A stale expectation aborts: this is the lost-push case, caught. testing.SetRealm(testing.NewUserRealm(carol)) uassert.AbortsContains(t, cur, "stale ref", func() { SetRef(cross(cur), demoID, "refs/heads/main", oid("a"), oid("c"), "") }) // A stranger cannot move a ref at all. testing.SetRealm(testing.NewUserRealm(eve)) uassert.AbortsContains(t, cur, "unauthorized", func() { SetRef(cross(cur), demoID, "refs/heads/main", oid("b"), oid("c"), "") }) // ...but anyone may file an issue and propose a change. testing.SetRealm(testing.NewUserRealm(eve)) issueID := OpenIssue(cross(cur), demoID, "the log needs a UI", "hard to read over RPC", "ux,help wanted") uassert.Equal(t, int64(0), issueID) testing.SetRealm(testing.NewUserRealm(eve)) changeID := OpenChange(cross(cur), demoID, "add a log view", "", "eve/demo", "refs/heads/logview", oid("c"), "refs/heads/main") uassert.Equal(t, int64(0), changeID) // A maintainer's approval satisfies the default policy; the merge moves the // ref and lands in the same log as every other move. testing.SetRealm(testing.NewUserRealm(bob)) ReviewChange(cross(cur), demoID, changeID, "approve", "lgtm") testing.SetRealm(testing.NewUserRealm(bob)) MergeChange(cross(cur), demoID, changeID, oid("b"), oid("d"), "merge change 0") uassert.Equal(t, oid("d"), RefOID(demoID, "refs/heads/main")) r := f.Repo(demoID) uassert.Equal(t, 3, r.LogSize()) uassert.Equal(t, fg.KindMerge, r.LogEntryAt(2).Kind) uassert.Equal(t, bob.String(), r.LogEntryAt(2).Actor.String(), "the log records who merged, not who proposed") ok, _ := r.VerifyLog() uassert.True(t, ok, "the digest chain verifies end to end") // Issue triage: the maintainer closes what the reporter opened. testing.SetRealm(testing.NewUserRealm(bob)) CloseIssue(cross(cur), demoID, issueID) uassert.Equal(t, 0, r.OpenIssueCount()) } // TestHeightsComeFromTheChain checks the realm stamps entries with the block it // ran in. SkipHeights is relative and there is no absolute height getter, so the // assertion is on the delta. func TestHeightsComeFromTheChain(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) CreateRepo(cross(cur), heightsID, "", "") testing.SetRealm(testing.NewUserRealm(alice)) SetRef(cross(cur), heightsID, "refs/heads/main", "", oid("a"), "") testing.SkipHeights(7) testing.SetRealm(testing.NewUserRealm(alice)) SetRef(cross(cur), heightsID, "refs/heads/main", oid("a"), oid("b"), "") r := f.Repo(heightsID) first := r.LogEntryAt(0).Height second := r.LogEntryAt(1).Height uassert.Equal(t, int64(7), second-first, "seven blocks passed between the two writes") } // TestUnknownRepoAborts: every write resolves the repo first, so a typo fails // loudly instead of creating something. func TestUnknownRepoAborts(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "repo not found", func() { SetRef(cross(cur), ghostID, "refs/heads/main", "", oid("a"), "") }) uassert.Equal(t, "", RefOID(ghostID, "refs/heads/main")) uassert.Equal(t, "", LogHead(ghostID)) uassert.False(t, HasRepo(ghostID)) } // TestRoleParsing: the role argument is a string on the wire, so a typo must // abort rather than silently grant nothing. func TestRoleParsing(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) CreateRepo(cross(cur), rolesID, "", "") testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "invalid role", func() { SetMember(cross(cur), rolesID, bob, "commiter") }) testing.SetRealm(testing.NewUserRealm(alice)) SetMember(cross(cur), rolesID, bob, "maintainer") uassert.Equal(t, "maintainer", f.Repo(rolesID).RoleOf(bob).String()) } func TestSplitList(t *testing.T) { cases := []struct { in string want int }{ {"", 0}, {" ", 0}, {"a", 1}, {"a,b", 2}, {" a , b ,, c ", 3}, } for _, tc := range cases { uassert.Equal(t, tc.want, len(splitList(tc.in)), tc.in) } } // TestNamespaceOwnership: a namespace is an r/sys/users name you hold or your // own address, and nothing else. No name is registered in a unit test, so the // name path is exercised through its rejection. func TestNamespaceOwnership(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(alice)) CreateRepo(cross(cur), demoID, "under alice's own address", "") uassert.True(t, HasRepo(demoID)) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "namespace is not yours", func() { CreateRepo(cross(cur), bob.String()+"/squat", "", "") }, "an address namespace belongs to that address") testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "namespace is not yours", func() { CreateRepo(cross(cur), "moul/forge", "", "") }, "an unregistered name belongs to nobody") testing.SetRealm(testing.NewUserRealm(bob)) uassert.AbortsContains(t, cur, "namespace is not yours", func() { Fork(cross(cur), demoID, alice.String()+"/fork") }, "a fork lands in the forker's namespace, not the parent's") testing.SetRealm(testing.NewUserRealm(bob)) Fork(cross(cur), demoID, bob.String()+"/fork") uassert.True(t, HasRepo(bob.String()+"/fork")) testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsContains(t, cur, "invalid repo id", func() { CreateRepo(cross(cur), "nonamespace", "", "") }) }
  8. #8gnomod.toml
  9. #9module = "gno.land/r/moul/forge/v0" gno = "0.9" private = true
  10. #10helpers_test.gno
  11. #11package forge import ( "strings" fg "gno.land/p/moul/forge/v0" "gno.land/p/nt/testutils/v0" ) // Deterministic, checksum-valid test addresses. var ( alice = testutils.TestAddress("alice") // owner bob = testutils.TestAddress("bob") // maintainer carol = testutils.TestAddress("carol") // writer eve = testutils.TestAddress("eve") // stranger ) // Repo ids used by the crossing tests. The realm only lets an address create // under a namespace it owns, and no user name is registered in a unit test, so // these are all address namespaces. var ( demoID = alice.String() + "/demo" heightsID = alice.String() + "/heights" rolesID = alice.String() + "/roles" ghostID = alice.String() + "/ghost" ) func oid(c string) string { return strings.Repeat(c, 40) } // reset restores realm state to empty. Realm globals persist for the whole test // binary and examples run after every Test, so anything that pins Render output // must reset and re-seed first. func reset() { f = fg.New() } // seedFixture builds one deterministic repo straight through the library: no // chain context needed, which is what lets an Example use it. It can use a name // namespace because namespace ownership is a realm rule (it needs a chain to // resolve a name); the library validates the shape and nothing more. func seedFixture() { reset() r, err := f.CreateRepo(alice, 100, "moul/forge", "an on chain software forge", "") if err != nil { panic(err) } if err := r.SetMember(alice, bob, fg.RoleMaintainer); err != nil { panic(err) } if err := r.SetMember(alice, carol, fg.RoleWriter); err != nil { panic(err) } if err := r.SetMirrors(alice, []string{"https://github.com/moul/gno-contracts.git"}); err != nil { panic(err) } if _, err := r.SetRef(carol, 101, "refs/heads/main", "", oid("a"), "initial import"); err != nil { panic(err) } if _, err := r.SetRef(carol, 102, "refs/heads/main", oid("a"), oid("b"), "add the log"); err != nil { panic(err) } if _, err := r.OpenIssue(eve, 103, "force pushes are invisible", "A maintainer can rewrite a branch and nothing records it.", []string{"bug"}); err != nil { panic(err) } if _, err := r.CommentIssue(bob, 104, 0, "that is what the log is for"); err != nil { panic(err) } c, err := r.OpenChange(carol, 105, "record every ref move", "closes 0", "", "refs/heads/feat", oid("c"), "refs/heads/main") if err != nil { panic(err) } if err := r.ReviewChange(bob, 106, c.ID, fg.VerdictApprove, "lgtm"); err != nil { panic(err) } }
  12. #12render.gno
  13. #13package forge import ( "chain/runtime" "chain/runtime/unsafe" "strconv" "strings" fg "gno.land/p/moul/forge/v0" "gno.land/p/moul/md/v0" "gno.land/p/moul/realmpath/v0" "gno.land/p/moul/txlink/v0" ) // pageSize bounds every listing: a Render that walks unbounded state is a // Render that eventually stops rendering. const pageSize = 20 // Render routes gnoweb paths: // // / the forge: every repo // /<ns>/<name> repo overview // /<ns>/<name>/log the full reference log (?page=N) // /<ns>/<name>/issues issues (?page=N) // /<ns>/<name>/issues/<id> one issue and its replies // /<ns>/<name>/changes change requests (?page=N) // /<ns>/<name>/changes/<id> one change, its reviews and its replies // /help what this realm is and how to call it func Render(path string) string { req := realmpath.Parse(path) parts := req.PathParts() page := pageOf(req) switch { case len(parts) == 0 || parts[0] == "": return renderHome() case len(parts) == 1 && parts[0] == "help": return renderHelp() case len(parts) < 2: return notFound("no such page") } id := parts[0] + "/" + parts[1] r := f.Repo(id) if r == nil { return notFound("no repo " + md.InlineCode(id)) } switch { case len(parts) == 2: return renderRepo(r) case len(parts) == 3 && parts[2] == "log": return renderLog(r, page) case len(parts) == 3 && parts[2] == "issues": return renderIssues(r, page) case len(parts) == 3 && parts[2] == "changes": return renderChanges(r, page) case len(parts) == 4 && parts[2] == "issues": return renderIssue(r, parts[3]) case len(parts) == 4 && parts[2] == "changes": return renderChange(r, parts[3]) } return notFound("no such page") } func renderHome() string { var b strings.Builder b.WriteString(md.H1("Forge")) b.WriteString("\nAn on-chain software forge. The objects stay in git; the chain keeps the part a forge is trusted for: which object a ref points at, in what order, on whose authority, and what was reviewed before it moved.\n") b.WriteString("\n**Repos:** " + strconv.Itoa(f.Size()) + "\n") if f.Size() == 0 { b.WriteString("\nNothing here yet. " + link("Create the first repo", txlink.Call("CreateRepo")) + "\n") } else { b.WriteString("\n| repo | refs | issues | changes | log |\n") b.WriteString("| --- | ---: | ---: | ---: | ---: |\n") f.IterateRepos(0, pageSize, func(r *fg.Repo) bool { b.WriteString("| " + link(r.ID, repoURL(r.ID)) + " | " + strconv.Itoa(r.RefCount()) + " | " + strconv.Itoa(r.OpenIssueCount()) + "/" + strconv.Itoa(r.IssueCount()) + " | " + strconv.Itoa(r.OpenChangeCount()) + "/" + strconv.Itoa(r.ChangeCount()) + " | " + strconv.Itoa(r.LogSize()) + " |\n") return false }) } b.WriteString("\n" + link("Create a repo", txlink.Call("CreateRepo")) + " · " + link("How it works", base()+":help") + "\n") return b.String() } func renderRepo(r *fg.Repo) string { var b strings.Builder b.WriteString(md.H1(r.ID)) if r.Description != "" { b.WriteString("\n" + md.EscapeText(r.Description) + "\n") } if r.Archived { b.WriteString("\n**Archived.** No further writes are accepted.\n") } facts := []string{ "**Default ref:** " + md.InlineCode(r.DefaultRef) + " → " + oidCode(refOID(r, r.DefaultRef)), "**Merge policy:** " + strconv.Itoa(r.RequiredApprovals) + " writer approval(s), self-approval " + onOff(r.AllowSelfApproval), "**Members:** " + strconv.Itoa(r.MemberCount()), "**Log head:** " + md.InlineCode(shortDigest(r.LogHead())), } if r.ParentID != "" { facts = append(facts, "**Forked from:** "+link(r.ParentID, repoURL(r.ParentID))) } if len(r.Mirrors) > 0 { facts = append(facts, "**Fetch from:** "+md.InlineCode(r.Mirrors[0])+mirrorRest(r.Mirrors)) } else { facts = append(facts, "**Fetch from:** no mirror declared: the objects are wherever the maintainers keep them") } b.WriteString("\n" + md.BulletList(facts)) b.WriteString("\n" + md.H2("Refs")) if r.RefCount() == 0 { b.WriteString("\nNo ref has ever been recorded.\n") } else { var refs []string r.IterateRefs(func(ref *fg.Ref) bool { refs = append(refs, md.InlineCode(ref.Name)+" → "+oidCode(ref.OID)+" · block "+strconv.FormatInt(ref.UpdatedAt, 10)+" · "+userLink(ref.UpdatedBy)) return false }) b.WriteString("\n" + md.BulletList(refs)) } b.WriteString("\n" + md.H2("Recent log")) b.WriteString("\n" + logList(r, 0, 5)) b.WriteString("\n" + link("Full log, "+strconv.Itoa(r.LogSize())+" entries", repoURL(r.ID)+"/log") + "\n") b.WriteString("\n" + md.H2("Open change requests")) b.WriteString("\n" + changeList(r, 0, 5, true)) b.WriteString("\n" + link("All changes, "+strconv.Itoa(r.ChangeCount())+" total", repoURL(r.ID)+"/changes") + " · " + link("Propose a change", txlink.Call("OpenChange", "repoID", r.ID)) + "\n") b.WriteString("\n" + md.H2("Open issues")) b.WriteString("\n" + issueList(r, 0, 5, true)) b.WriteString("\n" + link("All issues, "+strconv.Itoa(r.IssueCount())+" total", repoURL(r.ID)+"/issues") + " · " + link("Open an issue", txlink.Call("OpenIssue", "repoID", r.ID)) + "\n") return b.String() } func renderLog(r *fg.Repo, page int) string { var b strings.Builder b.WriteString(md.H1(r.ID + ": reference log")) b.WriteString("\nAppend-only and hash-chained: every entry commits to the one before it, so pinning the head digest anywhere off chain pins this whole history.\n") b.WriteString("\n**Head:** " + md.InlineCode(shortDigest(r.LogHead())) + " · **Entries:** " + strconv.Itoa(r.LogSize()) + "\n") b.WriteString("\n" + logList(r, page*pageSize, pageSize)) b.WriteString("\n" + pager(r.LogSize(), page, repoURL(r.ID)+"/log")) return b.String() } func renderIssues(r *fg.Repo, page int) string { var b strings.Builder b.WriteString(md.H1(r.ID + ": issues")) b.WriteString("\n**Open:** " + strconv.Itoa(r.OpenIssueCount()) + " / " + strconv.Itoa(r.IssueCount()) + "\n") b.WriteString("\n" + issueList(r, page*pageSize, pageSize, false)) b.WriteString("\n" + pager(r.IssueCount(), page, repoURL(r.ID)+"/issues")) b.WriteString("\n" + link("Open an issue", txlink.Call("OpenIssue", "repoID", r.ID)) + "\n") return b.String() } func renderIssue(r *fg.Repo, raw string) string { id, err := strconv.ParseInt(raw, 10, 64) if err != nil { return notFound("bad issue id") } i := r.Issue(id) if i == nil { return notFound("no issue " + raw) } var b strings.Builder b.WriteString(md.H1("#" + raw + " " + md.EscapeText(i.Title))) b.WriteString("\n" + state(i.Open, "open", "closed") + " · opened at block " + strconv.FormatInt(i.CreatedAt, 10) + " by " + userLink(i.Author) + "\n") if len(i.Labels) > 0 { b.WriteString("\n**Labels:** " + codeList(i.Labels) + "\n") } if i.Body != "" { b.WriteString("\n" + md.EscapeText(i.Body) + "\n") } b.WriteString("\n" + md.H2("Replies ("+strconv.Itoa(i.CommentCount())+")")) if i.CommentCount() == 0 { b.WriteString("\nNone yet.\n") } else { var items []string i.IterateComments(0, pageSize, func(c *fg.Comment) bool { items = append(items, userLink(c.Author)+" at block "+strconv.FormatInt(c.CreatedAt, 10)+": "+md.EscapeText(c.Body)) return false }) b.WriteString("\n" + md.BulletList(items)) } b.WriteString("\n" + link("Reply", txlink.Call("CommentIssue", "repoID", r.ID, "issueID", raw)) + " · " + link("Close", txlink.Call("CloseIssue", "repoID", r.ID, "issueID", raw)) + " · " + link("Back to issues", repoURL(r.ID)+"/issues") + "\n") return b.String() } func renderChanges(r *fg.Repo, page int) string { var b strings.Builder b.WriteString(md.H1(r.ID + ": change requests")) b.WriteString("\n**Open:** " + strconv.Itoa(r.OpenChangeCount()) + " / " + strconv.Itoa(r.ChangeCount()) + "\n") b.WriteString("\n" + changeList(r, page*pageSize, pageSize, false)) b.WriteString("\n" + pager(r.ChangeCount(), page, repoURL(r.ID)+"/changes")) b.WriteString("\n" + link("Propose a change", txlink.Call("OpenChange", "repoID", r.ID)) + "\n") return b.String() } func renderChange(r *fg.Repo, raw string) string { id, err := strconv.ParseInt(raw, 10, 64) if err != nil { return notFound("bad change id") } c := r.Change(id) if c == nil { return notFound("no change " + raw) } var b strings.Builder b.WriteString(md.H1("!" + raw + " " + md.EscapeText(c.Title))) b.WriteString("\n**" + c.State + "** · opened at block " + strconv.FormatInt(c.CreatedAt, 10) + " by " + userLink(c.Author) + "\n") src := "this repo" if c.SourceRepo != "" { src = md.InlineCode(c.SourceRepo) } facts := []string{ "**Head:** " + oidCode(c.HeadOID) + " (from " + src + refSuffix(c.SourceRef) + ")", "**Target:** " + md.InlineCode(c.TargetRef) + " → " + oidCode(refOID(r, c.TargetRef)), "**Approvals:** " + strconv.Itoa(r.CountApprovals(c)) + " of " + strconv.Itoa(r.RequiredApprovals) + " required", "**Blocking:** " + strconv.Itoa(r.CountBlocking(c)), } if c.State == fg.StateMerged { facts = append(facts, "**Merged:** "+oidCode(c.MergedOID)+" at block "+strconv.FormatInt(c.MergedAt, 10)+" by "+userLink(c.MergedBy)) } b.WriteString("\n" + md.BulletList(facts)) if c.Body != "" { b.WriteString("\n" + md.EscapeText(c.Body) + "\n") } b.WriteString("\n" + md.H2("Reviews ("+strconv.Itoa(c.ReviewCount())+")")) if c.ReviewCount() == 0 { b.WriteString("\nNone yet.\n") } else { var items []string c.IterateReviews(func(rv *fg.Review) bool { line := userLink(rv.Reviewer) + ": **" + rv.Verdict + "** on " + oidCode(rv.OID) if c.Stale(rv) { line += " (stale: the head moved since)" } if rv.Body != "" { line += ": " + md.EscapeText(rv.Body) } items = append(items, line) return false }) b.WriteString("\n" + md.BulletList(items)) } b.WriteString("\n" + md.H2("Replies ("+strconv.Itoa(c.CommentCount())+")")) if c.CommentCount() == 0 { b.WriteString("\nNone yet.\n") } else { var items []string c.IterateComments(0, pageSize, func(cm *fg.Comment) bool { items = append(items, userLink(cm.Author)+" at block "+strconv.FormatInt(cm.CreatedAt, 10)+": "+md.EscapeText(cm.Body)) return false }) b.WriteString("\n" + md.BulletList(items)) } b.WriteString("\n" + link("Review", txlink.Call("ReviewChange", "repoID", r.ID, "changeID", raw, "verdict", "approve")) + " · " + link("Reply", txlink.Call("CommentChange", "repoID", r.ID, "changeID", raw)) + " · " + link("Merge", txlink.Call("MergeChange", "repoID", r.ID, "changeID", raw, "expectedTargetOID", refOID(r, c.TargetRef))) + " · " + link("Back to changes", repoURL(r.ID)+"/changes") + "\n") return b.String() } func renderHelp() string { return md.H1("Forge: how it works") + ` A forge is trusted for three things git does not do by itself: saying which object a branch points at, saying who may move it, and recording that a human reviewed the move. Those three are what lives here. The objects do not: they stay in git, behind whatever mirror the repo declares. ## The reference log Every ref move is one entry in an append-only, hash-chained log. An entry names the ref, the object it left, the object it reached, the actor, the block and the kind: create, update, force, delete or merge: and commits to the digest of the entry before it. Pin the head digest anywhere off chain and the entire history becomes falsifiable. Moves are compare-and-swap: the caller states the tip it expected, and a stale expectation aborts instead of overwriting. That is git's --force-with-lease, except the lease is held by consensus rather than by the server you push to. A move that skips the discipline is not forbidden, it is recorded as a force. The chain has no objects, so it cannot check that a new tip descends from the old one. It does not pretend to. Ordering, attribution and policy are on chain; ancestry is verified by a client that has the repo. ## Namespaces A repo id is "namespace/name". A namespace is either a name you hold in r/sys/users or your own address, so "g1.../forge" works with nothing registered and "moul/forge" needs the name. Nobody can claim a namespace they do not own, and a realm passes the same test a user does, by its address. ## Roles reader < writer < maintainer < admin < owner. Writers move refs, maintainers force and merge, admins manage members and policy. A role can be held by another realm, so a repo owned by a DAO is a repo whose merge button is a vote. ## Reviews Anyone may open an issue or a change request, and anyone may review one: the spam gate is that you pay for your own bytes. Only a writer's approval counts toward the merge policy, and an approval names the object it reviewed: push a new head and it stops counting, with nothing to remember to dismiss. ## Calling it ` + md.CodeBlock(`gnokey maketx call -pkgpath gno.land/r/moul/forge/v0 \ -func SetRef -send "" -gas-fee 1000000ugnot -gas-wanted 3000000 \ -args "moul/forge" -args "refs/heads/main" \ -args "<expected-oid>" -args "<new-oid>" -args "ship it" \ -broadcast -chainid <chain> -remote <rpc> <key>`) + ` Read paths are free: ` + md.InlineCode("vm/qeval") + ` on ` + md.InlineCode("RefOID") + `, ` + md.InlineCode("LogHead") + ` or ` + md.InlineCode("HasRepo") + `, or just browse the routes above. ` } // --------------------------------------------------------------------------- // Fragments // --------------------------------------------------------------------------- func logList(r *fg.Repo, offset, count int) string { if r.LogSize() == 0 { return "No entry yet.\n" } var items []string r.IterateLogReverse(offset, count, func(e *fg.LogEntry) bool { line := "`#" + strconv.FormatInt(e.Seq, 10) + "` **" + e.Kind + "** " + md.InlineCode(e.Ref) + " " + oidCode(e.OldOID) + " → " + oidCode(e.NewOID) + " · block " + strconv.FormatInt(e.Height, 10) + " · " + userLink(e.Actor) if e.Kind == fg.KindMerge { line += " · change " + link("!"+strconv.FormatInt(e.ChangeID, 10), changeURL(r.ID, e.ChangeID)) } if e.Note != "" { line += " · " + md.EscapeText(e.Note) } items = append(items, line) return false }) if len(items) == 0 { return "Nothing on this page.\n" } return md.BulletList(items) } func issueList(r *fg.Repo, offset, count int, openOnly bool) string { var items []string r.IterateIssues(offset, count, func(i *fg.Issue) bool { if openOnly && !i.Open { return false } items = append(items, link("#"+strconv.FormatInt(i.ID, 10)+" "+i.Title, issueURL(r.ID, i.ID))+ " · "+state(i.Open, "open", "closed")+" · "+userLink(i.Author)+ " · "+strconv.Itoa(i.CommentCount())+" replies") return false }) if len(items) == 0 { return "None.\n" } return md.BulletList(items) } func changeList(r *fg.Repo, offset, count int, openOnly bool) string { var items []string r.IterateChanges(offset, count, func(c *fg.Change) bool { if openOnly && c.State != fg.StateOpen { return false } items = append(items, link("!"+strconv.FormatInt(c.ID, 10)+" "+c.Title, changeURL(r.ID, c.ID))+ " · **"+c.State+"** · "+md.InlineCode(c.TargetRef)+ " · "+strconv.Itoa(r.CountApprovals(c))+"/"+strconv.Itoa(r.RequiredApprovals)+" approvals") return false }) if len(items) == 0 { return "None.\n" } return md.BulletList(items) } func pager(total, page int, path string) string { if total <= pageSize { return "" } out := "Page " + strconv.Itoa(page+1) + " of " + strconv.Itoa((total+pageSize-1)/pageSize) + " · " if page > 0 { out += link("previous", path+"?page="+strconv.Itoa(page)) + " " } if (page+1)*pageSize < total { out += link("next", path+"?page="+strconv.Itoa(page+2)) } return out + "\n" } // --------------------------------------------------------------------------- // Small helpers // --------------------------------------------------------------------------- func base() string { return strings.TrimPrefix(unsafe.CurrentRealm().PkgPath(), runtime.ChainDomain()) } func repoURL(id string) string { return base() + ":" + id } func issueURL(id string, n int64) string { return repoURL(id) + "/issues/" + strconv.FormatInt(n, 10) } func changeURL(id string, n int64) string { return repoURL(id) + "/changes/" + strconv.FormatInt(n, 10) } // link builds a markdown link. Internal targets are realm paths this file // built, so only the text needs escaping. func link(text, url string) string { return "[" + md.EscapeText(text) + "](" + url + ")" } func userLink(a address) string { if l := md.UserLink(a.String()); l != "" { return l } return md.InlineCode(a.String()) } func notFound(why string) string { return md.H1("Not found") + "\n" + why + ".\n\n" + link("Back to the forge", base()) + "\n" } func state(ok bool, yes, no string) string { if ok { return "**" + yes + "**" } return "**" + no + "**" } func onOff(b bool) string { if b { return "allowed" } return "off" } func refOID(r *fg.Repo, name string) string { if ref := r.Ref(name); ref != nil { return ref.OID } return "" } // oidCode shows the short object id, code-spanned. An empty id renders as // "(none)": that is a ref being created or deleted, not a zero object. func oidCode(oid string) string { if oid == "" { return "(none)" } if len(oid) > 12 { return md.InlineCode(oid[:12]) } return md.InlineCode(oid) } func shortDigest(d string) string { if d == "" { return "(empty log)" } if len(d) > 16 { return d[:16] } return d } func mirrorRest(mirrors []string) string { if len(mirrors) < 2 { return "" } return " (+" + strconv.Itoa(len(mirrors)-1) + " more)" } func refSuffix(ref string) string { if ref == "" { return "" } return " " + md.InlineCode(ref) } func codeList(items []string) string { out := "" for i, it := range items { if i > 0 { out += " " } out += md.InlineCode(it) } return out } func pageOf(req *realmpath.Request) int { n, err := strconv.Atoi(req.Query.Get("page")) if err != nil || n < 1 { return 0 } return n - 1 } func itoa(n int64) string { return strconv.FormatInt(n, 10) }
  14. #14render_test.gno
  15. #15package forge // Every route is pinned by an example: a realm's Render is its whole public // surface, and output that varies between identical calls is a consensus bug. // Examples run after every Test, so each one re-seeds the state it renders. // ExampleRender pins the forge home page. func ExampleRender() { seedFixture() print(Render("")) // Output: // # Forge // // An on-chain software forge. The objects stay in git; the chain keeps the part a forge is trusted for: which object a ref points at, in what order, on whose authority, and what was reviewed before it moved. // // **Repos:** 1 // // | repo | refs | issues | changes | log | // | --- | ---: | ---: | ---: | ---: | // | [moul/forge](/r/moul/forge/v0:moul/forge) | 1 | 1/1 | 1/1 | 2 | // // [Create a repo](/r/moul/forge/v0$help&func=CreateRepo) · [How it works](/r/moul/forge/v0:help) } // ExampleRenderRepo pins a repo overview. func ExampleRenderRepo() { seedFixture() print(Render("moul/forge")) // Output: // # moul/forge // // an on chain software forge // // - **Default ref:** `refs/heads/main` → `bbbbbbbbbbbb` // - **Merge policy:** 1 writer approval(s), self-approval off // - **Members:** 3 // - **Log head:** `07ca3613cc68e1dc` // - **Fetch from:** `https://github.com/moul/gno-contracts.git` // // ## Refs // // - `refs/heads/main` → `bbbbbbbbbbbb` · block 102 · [g1vdshymmvta047h6lta047h6lta047h6l2asz94](/u/g1vdshymmvta047h6lta047h6lta047h6l2asz94) // // ## Recent log // // - `#1` **update** `refs/heads/main` `aaaaaaaaaaaa` → `bbbbbbbbbbbb` · block 102 · [g1vdshymmvta047h6lta047h6lta047h6l2asz94](/u/g1vdshymmvta047h6lta047h6lta047h6l2asz94) · add the log // - `#0` **create** `refs/heads/main` (none) → `aaaaaaaaaaaa` · block 101 · [g1vdshymmvta047h6lta047h6lta047h6l2asz94](/u/g1vdshymmvta047h6lta047h6lta047h6l2asz94) · initial import // // [Full log, 2 entries](/r/moul/forge/v0:moul/forge/log) // // ## Open change requests // // - [\!0 record every ref move](/r/moul/forge/v0:moul/forge/changes/0) · **open** · `refs/heads/main` · 1/1 approvals // // [All changes, 1 total](/r/moul/forge/v0:moul/forge/changes) · [Propose a change](/r/moul/forge/v0$help&func=OpenChange&repoID=moul%2Fforge) // // ## Open issues // // - [\#0 force pushes are invisible](/r/moul/forge/v0:moul/forge/issues/0) · **open** · [g1v4mx2h6lta047h6lta047h6lta047h6lslgc76](/u/g1v4mx2h6lta047h6lta047h6lta047h6lslgc76) · 1 replies // // [All issues, 1 total](/r/moul/forge/v0:moul/forge/issues) · [Open an issue](/r/moul/forge/v0$help&func=OpenIssue&repoID=moul%2Fforge) } // ExampleRenderLog pins the reference log. func ExampleRenderLog() { seedFixture() print(Render("moul/forge/log")) // Output: // # moul/forge: reference log // // Append-only and hash-chained: every entry commits to the one before it, so pinning the head digest anywhere off chain pins this whole history. // // **Head:** `07ca3613cc68e1dc` · **Entries:** 2 // // - `#1` **update** `refs/heads/main` `aaaaaaaaaaaa` → `bbbbbbbbbbbb` · block 102 · [g1vdshymmvta047h6lta047h6lta047h6l2asz94](/u/g1vdshymmvta047h6lta047h6lta047h6l2asz94) · add the log // - `#0` **create** `refs/heads/main` (none) → `aaaaaaaaaaaa` · block 101 · [g1vdshymmvta047h6lta047h6lta047h6l2asz94](/u/g1vdshymmvta047h6lta047h6lta047h6l2asz94) · initial import } // ExampleRenderIssue pins one issue. func ExampleRenderIssue() { seedFixture() print(Render("moul/forge/issues/0")) // Output: // # #0 force pushes are invisible // // **open** · opened at block 103 by [g1v4mx2h6lta047h6lta047h6lta047h6lslgc76](/u/g1v4mx2h6lta047h6lta047h6lta047h6lslgc76) // // **Labels:** `bug` // // A maintainer can rewrite a branch and nothing records it\. // // ## Replies (1) // // - [g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu](/u/g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu) at block 104: that is what the log is for // // [Reply](/r/moul/forge/v0$help&func=CommentIssue&issueID=0&repoID=moul%2Fforge) · [Close](/r/moul/forge/v0$help&func=CloseIssue&issueID=0&repoID=moul%2Fforge) · [Back to issues](/r/moul/forge/v0:moul/forge/issues) } // ExampleRenderChange pins one change request. func ExampleRenderChange() { seedFixture() print(Render("moul/forge/changes/0")) // Output: // # !0 record every ref move // // **open** · opened at block 105 by [g1vdshymmvta047h6lta047h6lta047h6l2asz94](/u/g1vdshymmvta047h6lta047h6lta047h6l2asz94) // // - **Head:** `cccccccccccc` (from this repo `refs/heads/feat`) // - **Target:** `refs/heads/main` → `bbbbbbbbbbbb` // - **Approvals:** 1 of 1 required // - **Blocking:** 0 // // closes 0 // // ## Reviews (1) // // - [g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu](/u/g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu): **approve** on `cccccccccccc`: lgtm // // ## Replies (0) // // None yet. // // [Review](/r/moul/forge/v0$help&func=ReviewChange&changeID=0&repoID=moul%2Fforge&verdict=approve) · [Reply](/r/moul/forge/v0$help&func=CommentChange&changeID=0&repoID=moul%2Fforge) · [Merge](/r/moul/forge/v0$help&func=MergeChange&changeID=0&expectedTargetOID=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&repoID=moul%2Fforge) · [Back to changes](/r/moul/forge/v0:moul/forge/changes) } // ExampleRenderNotFound pins an unknown repo. func ExampleRenderNotFound() { seedFixture() print(Render("moul/nope")) // Output: // # Not found // // no repo `moul/nope`. // // [Back to the forge](/r/moul/forge/v0) }
#21AddPackagegno.land/r/moul/gns/v024 arguments
Attached funds
45000000ugnot

Arguments · 24

  1. #1gns
  2. #2README.md
  3. #3# GNS — Gno Name Service GNS is an **ENS-equivalent naming system expressed as a single Gno-native realm**. It provides the capabilities mainstream ENS users rely on — registration, renewal, expiry & grace, forward/reverse resolution, primary names, typed and arbitrary records, subnames with policies, delegated operators, pagination and events — but implements all of it through **one realm, one ownership model, one record model, one authorization function and one registration lifecycle**, rather than a collection of emulated ENS contracts. Package path: `gno.land/r/moul/gns/v0` · possible future target: `gno.land/r/gnoland/gns`. Built and tested against **gno 0.9 / master** (`chain`, `chain/banker`, `chain/runtime`, `gno.land/p/nt/avl/v0`). ## What is and isn't compatible with ENS GNS deliberately does **not** aim for byte-for-byte ENS/Ethereum compatibility. **Equivalent user capabilities** (different implementation): | ENS capability | GNS | |---|---| | Register / renew second-level name | `Register`, `Renew` | | Expiration + grace period | stored expiry + configurable grace | | Commit–reveal | `Commit` + `Register` (see preimage below) | | Owner / transfer | `OwnerOf`, `Transfer` | | Resolver records | built-in typed records (no pluggable resolver) | | Multichain addresses | `SetCoinAddress` keyed by coin type | | Text / content hash / pubkey / ABI / interface | typed setters/getters | | Arbitrary records | `SetRecord` with reverse-DNS namespaces | | Reverse resolution + primary name | `SetPrimaryName` / `PrimaryName` (forward-verified) | | Subnames + subname registrar | hierarchical names + `RegistrationPolicy` | | Wrapped / emancipated names | explicit `ControlPolicy` flags + `LockPolicy` | | Delegated managers | `SetOperator` with per-permission grants | | Multicall | not in-realm — loop a typed setter from one `gnokey maketx run` | | Wildcard resolution | `Resolve(..., NearestAncestor)` | | Events | append-only `Event` log + native `chain.Emit` | **Explicitly NOT included** (Ethereum-specific or out of scope for v1): DNSSEC import, DNS registrar, CCIP-Read, L2 resolution, NFT/ERC-721 ownership, Ethereum ABI compatibility, Unicode/emoji names, governance DAO, auctions, secondary marketplace, and arbitrary custom-resolver execution. ## Names - Second-level names are stored **string-native and suffix-free**: `alice`, not `alice.gno`. Display clients may append a `.gno` suffix; it is presentation only. - Hierarchy is `label.parent` (`wallet.alice`, `prod.api.company`). - Labels: lowercase `a–z`, digits `0–9`, and `-` (not leading/trailing). 1–63 bytes per label, ≤255 bytes and ≤16 labels total. Input is lowercased; **non-ASCII is rejected**. All canonicalization happens in `Normalize`. ## Lifecycle `Available → Committed → Active → Grace → Expired → (recycled)`, plus `Deleted` for removed subnames and `Reserved` for admin-held names. - **Active**: `now < ExpiresAt`. Owner and permitted operators may mutate. - **Grace**: `ExpiresAt ≤ now < GraceEndsAt`. Only renewal by the existing owner; not registrable by others. - **Expired**: `now ≥ GraceEndsAt`. Registrable again; old state is cleared on re-registration and `Generation` is incremented so clients can detect the replacement. Subnames carry `ParentGeneration` and do **not** silently survive a recycled parent. ## Commit–reveal The commitment binds the reveal so observers can neither copy nor front-run it: ``` commitment = sha256hex( name | owner | duration | secret | recordsHash | policyRevision ) ``` where `|` is `"|"`, `owner` is the bech32 string, integers are base-10, and `name` is the **normalized** name. Use the on-chain helper `MakeCommitment(...)` to compute it identically to what `Register` recomputes at reveal. ## Pricing Deterministic and boring — no oracle, no USD, no auction: ``` price = duration × BasePricePerSecond × lengthMultiplier(label) ``` Default length multipliers: 1→100, 2→25, 3→5, 4→2, 5+→1. `Price(name, duration)` returns a quote; `Register` always recomputes from state and rejects a stale `policyRevision`. ## Authorization Every mutation funnels through one internal `authorize(caller, name, permission)` with a fixed authority order: 1. **Admin** — emergency/protocol operations only; **never** routine power over user names or records (admin cannot confiscate). 2. **Direct owner** of an active name. 3. **Active operator** holding the matching permission. 4. **Parent authority**, only where the child `ControlPolicy` allows it. ## Gno-specific deviations from the spec The spec is written with Ethereum/Go idioms; these are the deliberate, gno-correct adaptations: - **Mutations panic, they don't return `error`.** In gno only a panic/abort reverts state, so state-changing crossing functions (those taking `cur realm`) panic with a **stable machine-readable error code** (`unauthorized`, `name_unavailable`, `commitment_missing`, …). Read/quote functions return `(value, ok)` or `(value, error)` normally. - **`avl.Tree` instead of Go maps** for every enumerable collection, so all listing APIs (`NamesByOwner`, `Subnames`, `TextKeys`, `CoinTypes`, `Operators`, `EventsAfter`) are ordered, bounded, and cursor-based — there is no unbounded "return everything" query. - **String-native storage** rather than namehashes (hashing is used only for commitments and event digests). ## Public API (summary) Read: `Normalize`, `Status`, `Exists`, `OwnerOf`, `GetName`, `Resolve`, `Address`, `CoinAddress`, `Text`, `ContentHash`, `PublicKey`, `ABI`, `Interface`, `Record`, `PrimaryName`, `Price`, `MakeCommitment`, `CommitmentStatus`, `NamesByOwner`, `Subnames`, `TextKeys`, `CoinTypes`, `Operators`, `EventsAfter`, `EventsForName`, `Render`. Mutations (crossing): `Commit`, `Register`, `Renew`, `Transfer`, `CreateSubname`, `DeleteSubname`, `SetRegistrationPolicy`, `LockPolicy`, `SetOperator`, `RemoveOperator`, `SetPrimaryName`, `ClearPrimaryName`, and the typed record setters (`SetAddress`, `SetText`, `SetCoinAddress`, `SetContentHash`, `SetPublicKey`, `SetABI`, `SetInterface`, `SetRecord`, `SetTTL`). Admin (two-step transfer): `SetPaused`, `SetRegistrationOpen`, `SetPricing`, `SetTreasury`, `ReserveName`, `SetLimits`, `TransferAdmin`, `AcceptAdmin`. ## Render explorer `Render(path)` serves a read-only Markdown explorer: ``` / overview + stats /name/<name> owner, status, expiry, records, subnames /address/<g1...> verified primary name + owned names /available/<name> availability + price /events recent events /help API summary ``` ## Building & testing ```sh export GNOROOT=/path/to/gnolang/gno # a gno master checkout gno lint . gno test . ``` The test suite covers the spec's critical invariants: single effective owner, expired owners lose authority, grace names aren't re-registrable, parents can't exceed child policy, permanent policies only tighten, forward-verified primary names, single-use commitments, deterministic overflow-safe pricing, generation recycling without stale-record leakage, bounded enumeration, and admin non-confiscation. > **Testing note.** Because this realm is developed outside the gno examples > module, it uses local assertion helpers instead of `gno.land/p/nt/uassert` > (whose working-tree copy fails to preprocess for external packages), and it > unit-tests rejection paths against the internal error-returning helpers > (`authorize`, `priceFor`, `available`, `mergeRestrictive`, …) rather than by > catching crossing-boundary aborts. End-to-end abort/`// Error:` filetests can > be added once the realm lives in-tree. ## Client caching Reverse resolution is forward-verified on-chain, so clients and indexers may safely cache the **user ↔ address** mapping: - **positive match** (an address has a verified primary name, or a name resolves to an address): cache for up to **1 hour**; - **negative result** (no primary name / no match): cache for only **1 minute**, so a freshly-set name becomes visible quickly. ## Status This is a v1 implementation of the [GNS design spec][spec]. It is staged for review; it is not deployed. [spec]: https://gist.github.com/moul/1c160b2cb9ee080714b3d1933ddea60a <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:** ![gno.land/r/moul/gns/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/gns/v0/deps.png) > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
  4. #4asserts_test.gno
  5. #5package gns import ( "testing" ) // Local assertion helpers. // // We deliberately avoid gno.land/p/nt/uassert here: when this realm is compiled // as an EXTERNAL package (outside the gno examples module) the working-tree copy // of uassert/v0 fails to preprocess with the pinned gno binary. These helpers // depend only on the standard testing package. // // GNS mutations PANIC with stable error values (see the error_* set) to revert // state, per gno semantics. A recover() inside this same realm package cannot // catch a crossing-boundary abort (only a pure p/ package frame can). Rejection // invariants are therefore unit-tested against the internal, non-crossing // helpers that produce the errors (authorize, priceFor, available, ...), and a // few end-to-end abort behaviours are covered by filetests. func eqStr(t *testing.T, want, got, ctx string) { t.Helper() if want != got { t.Errorf("%s: want %q, got %q", ctx, want, got) } } func eqInt(t *testing.T, want, got int64, ctx string) { t.Helper() if want != got { t.Errorf("%s: want %d, got %d", ctx, want, got) } } func isTrue(t *testing.T, v bool, ctx string) { t.Helper() if !v { t.Errorf("%s: want true", ctx) } } func isFalse(t *testing.T, v bool, ctx string) { t.Helper() if v { t.Errorf("%s: want false", ctx) } } func noErr(t *testing.T, err error, ctx string) { t.Helper() if err != nil { t.Errorf("%s: unexpected error %v", ctx, err) } } func isErr(t *testing.T, err error, ctx string) { t.Helper() if err == nil { t.Errorf("%s: expected error, got nil", ctx) } } // errIs asserts err matches want by stable code (Error() string). func errIs(t *testing.T, err, want error, ctx string) { t.Helper() if err == nil { t.Errorf("%s: expected error %v, got nil", ctx, want) return } if err.Error() != want.Error() { t.Errorf("%s: expected %q, got %q", ctx, want.Error(), err.Error()) } }
  6. #6gnomod.toml
  7. #7module = "gno.land/r/moul/gns/v0" gno = "0.9" private = true
  8. #8gns.gno
  9. #9// Package gns implements GNS (Gno Name Service): an ENS-equivalent naming // system expressed as a single Gno-native realm. // // The design goal is NOT byte-for-byte ENS compatibility. Instead it provides // the same user-facing capabilities (registration, renewal, expiry/grace, // forward & reverse resolution, primary names, typed + arbitrary records, // subnames with policies, delegated operators, pagination and // events) through ONE realm, ONE ownership model, ONE record model, ONE // authorization function and ONE registration lifecycle. // // Key deviations from a naive port of the spec, forced by gno semantics: // // - State-mutating exported functions are "crossing" functions: they take // `cur realm` as the first parameter and PANIC (abort) on failure rather // than returning an error, because in gno only a panic/abort reverts state. // Failures panic with a stable machine-readable code (see the error_* set). // - Persistent, enumerable collections use avl.Tree (ordered, paginatable) // instead of Go maps, so every listing API is bounded and cursor-based. // - Names are stored string-native (canonical "label.parent"), not namehashed. // // See README.md for the full compatibility statement. package gns import ( "chain" "chain/banker" "chain/runtime" "chain/runtime/unsafe" "crypto/sha256" "encoding/hex" "errors" "strconv" "strings" "time" "gno.land/p/nt/avl/v0" "gno.land/p/nt/ufmt/v0" ) // --------------------------------------------------------------------------- // 2. Constants // --------------------------------------------------------------------------- const ( maxLabelLen = 63 maxNameLen = 255 maxDepth = 16 sep = "|" // index-key separator; never appears in names or bech32 addresses ) // Registration-policy modes. const ( ModeClosed = "closed" ModeOwner = "owner" ModeOpen = "open" ModeAllowlist = "allowlist" ModePaid = "paid" ) // Reserved record namespaces (cannot be used by the generic SetRecord API). var reservedNamespaces = map[string]bool{ "gno": true, "addr": true, "text": true, "content": true, "abi": true, "interface": true, "system": true, } // Stable machine-readable error codes. Messages MAY add context, but client // logic should depend on these codes. var ( errInvalidName = errors.New("invalid_name") errInvalidLabel = errors.New("invalid_label") errNameUnavailable = errors.New("name_unavailable") errNameReserved = errors.New("name_reserved") errNameExpired = errors.New("name_expired") errNameInGrace = errors.New("name_in_grace") errUnauthorized = errors.New("unauthorized") errCommitmentMissing = errors.New("commitment_missing") errCommitmentTooNew = errors.New("commitment_too_new") errCommitmentExpired = errors.New("commitment_expired") errCommitmentMismatch = errors.New("commitment_mismatch") errPriceChanged = errors.New("price_changed") errInsufficientPay = errors.New("insufficient_payment") errDurationTooShort = errors.New("duration_too_short") errDurationTooLong = errors.New("duration_too_long") errRecordTooLarge = errors.New("record_too_large") errRecordLimit = errors.New("record_limit_reached") errOperatorLimit = errors.New("operator_limit_reached") errPolicyLocked = errors.New("policy_locked") errParentInvalid = errors.New("parent_invalid") errPaused = errors.New("paused") errNotFound = errors.New("not_found") errNotUser = errors.New("not_user") errSpoofedRealm = errors.New("spoofed_realm") errEmptyAddress = errors.New("empty_address") errRegistrationClosed = errors.New("registration_closed") errBadRequest = errors.New("bad_request") ) // --------------------------------------------------------------------------- // 3. Public types // --------------------------------------------------------------------------- // Permission enumerates the delegable operator capabilities. type Permission int const ( PermManageRecords Permission = iota PermManageOperators PermManageSubnames PermRenew PermTransfer PermManagePolicy ) // Permissions is an operator grant. ExpiresAt == 0 means no expiry. type Permissions struct { ManageRecords bool ManageOperators bool ManageSubnames bool Renew bool Transfer bool ManagePolicy bool ExpiresAt int64 } func (p Permissions) has(perm Permission) bool { switch perm { case PermManageRecords: return p.ManageRecords case PermManageOperators: return p.ManageOperators case PermManageSubnames: return p.ManageSubnames case PermRenew: return p.Renew case PermTransfer: return p.Transfer case PermManagePolicy: return p.ManagePolicy } return false } // ControlPolicy holds the explicit, readable ownership/parent control flags // that replace ENS Name Wrapper fuses. type ControlPolicy struct { OwnerCanTransfer bool OwnerCanCreateSubnames bool RecordsMutable bool ParentCanReclaim bool ParentCanTransfer bool ParentCanDelete bool ParentCanChangePolicy bool // Permanent means the policy can only become MORE restrictive. It does not // make records immutable by itself. Permanent bool } // RegistrationPolicy governs how subnames of a name may be created. The // realm-global config drives second-level registration. type RegistrationPolicy struct { Mode string // closed | owner | open | allowlist | paid MinDuration int64 MaxDuration int64 PricePerSecond int64 PaymentDenom string Allowlist *avl.Tree // address string -> bool DefaultControlPolicy ControlPolicy } // Records is the built-in resolver state for a single name. type Records struct { NativeAddress string ContentHash []byte PublicKey []byte Addresses *avl.Tree // coinType (decimal string) -> []byte Text *avl.Tree // key -> string ABIs *avl.Tree // contentType -> []byte Interfaces *avl.Tree // interfaceID -> string Arbitrary *avl.Tree // "namespace/key" -> []byte Count int // number of stored entries, for MaxRecordsPerName enforcement } func newRecords() *Records { return &Records{ Addresses: avl.NewTree(), Text: avl.NewTree(), ABIs: avl.NewTree(), Interfaces: avl.NewTree(), Arbitrary: avl.NewTree(), } } // Name is the single object the whole realm operates on. type Name struct { Canonical string Owner address CreatedAt int64 UpdatedAt int64 ExpiresAt int64 // 0 == permanent subname (follows parent validity) GraceEndsAt int64 Parent string Label string Depth uint8 TTL uint64 Generation uint64 ParentGeneration uint64 RegistrationPolicy RegistrationPolicy ControlPolicy ControlPolicy Records *Records Operators *avl.Tree // address string -> Permissions OperatorCount int Revision uint64 Reserved bool Deleted bool } // NameView is the read-only projection returned by GetName. type NameView struct { Canonical string Owner string Status string CreatedAt int64 UpdatedAt int64 ExpiresAt int64 GraceEndsAt int64 Parent string Label string Depth uint8 TTL uint64 Generation uint64 Revision uint64 Reserved bool NativeAddr string } // Config is the realm-global configuration. type Config struct { Admin address PendingAdmin address RegistrationOpen bool MinCommitAge int64 MaxCommitAge int64 MinRegistrationDuration int64 MaxRegistrationDuration int64 GracePeriod int64 BasePricePerSecond int64 PremiumByLength map[uint8]int64 PaymentDenom string Treasury address MaxTextValueBytes uint32 MaxBinaryValueBytes uint32 MaxRecordsPerName uint16 MaxOperatorsPerName uint16 PolicyRevision uint64 // bumped whenever pricing/registration rules change Paused bool } // PricingConfig is the admin-settable pricing surface. type PricingConfig struct { BasePricePerSecond int64 PremiumByLength map[uint8]int64 PaymentDenom string } // PriceQuote is returned by Price. type PriceQuote struct { Amount int64 Denom string ValidUntil int64 Revision uint64 } // RegisterRequest is the reveal payload for Register. // // The commitment the client submits via Commit MUST equal // sha256hex(Name|Owner|Duration|Secret|RecordsHash|PolicyRevision) using the // same field values. RecordsHash is an opaque client-computed hex digest of // the intended initial records; it binds the reveal so a front-runner cannot // change records. NativeAddress/SetPrimary are optional conveniences applied // after creation. type RegisterRequest struct { Name string Owner address Duration int64 Secret string RecordsHash string PolicyRevision uint64 NativeAddress string SetPrimary bool } // RegistrationResult is returned by Register. type RegistrationResult struct { Canonical string Owner string ExpiresAt int64 Generation uint64 Paid int64 Refunded int64 } // RenewalResult is returned by Renew. type RenewalResult struct { Canonical string ExpiresAt int64 Paid int64 } // SubnameOptions configures CreateSubname. type SubnameOptions struct { Duration int64 // 0 == permanent (follows parent) ControlPolicy ControlPolicy NativeAddress string } // RecordQuery selects which record Resolve should return. type RecordQuery struct { Kind string // "address" | "text" | "coin" | "content" | "pubkey" | "abi" | "interface" | "record" Key1 string // text key / coin type / abi content type / interface id / namespace Key2 string // arbitrary record key (with namespace in Key1) } // ResolveMode selects exact vs inherited resolution. type ResolveMode int const ( Exact ResolveMode = iota NearestAncestor ) // ResolveResult is returned by Resolve. type ResolveResult struct { Found bool Requested string SourceName string Value []byte Revision uint64 ExpiresAt int64 } // Event is an append-only change record for indexers. type Event struct { ID uint64 Height int64 Timestamp int64 Type string Name string Actor string Owner string Target string Revision uint64 Key string OldDigest string NewDigest string } // Event types. const ( EvNameRegistered = "NameRegistered" EvNameRenewed = "NameRenewed" EvNameTransferred = "NameTransferred" EvNameExpired = "NameExpired" EvNameDeleted = "NameDeleted" EvSubnameCreated = "SubnameCreated" EvPolicyChanged = "PolicyChanged" EvOperatorChanged = "OperatorChanged" EvRecordChanged = "RecordChanged" EvPrimaryNameChange = "PrimaryNameChanged" EvConfigChanged = "ConfigChanged" EvPaused = "Paused" EvUnpaused = "Unpaused" ) // Paged result types (gno avoids generics; concrete types keep it simple). type StringPage struct { Items []string Next string } type OperatorView struct { Address string Permissions Permissions } type OperatorPage struct { Items []OperatorView Next string } type EventPage struct { Items []Event Next string } // NameStatus mirrors the lifecycle states. type NameStatus string const ( StatusAvailable NameStatus = "Available" StatusCommitted NameStatus = "Committed" StatusActive NameStatus = "Active" StatusGrace NameStatus = "Grace" StatusExpired NameStatus = "Expired" StatusDeleted NameStatus = "Deleted" StatusReserved NameStatus = "Reserved" ) // CommitmentView is the read projection of a pending commitment. type CommitmentView struct { Exists bool Committer string CreatedAt int64 ReadyAt int64 ExpiresAt int64 } // commitment is the stored commit record. type commitment struct { Committer address CreatedAt int64 } // --------------------------------------------------------------------------- // 4. Persistent state // --------------------------------------------------------------------------- var ( config Config names = avl.NewTree() // canonical -> *Name commitments = avl.NewTree() // commitment hex -> *commitment reverse = avl.NewTree() // address string -> canonical (primary name) events = avl.NewTree() // zero-padded id -> *Event byOwner = avl.NewTree() // "owner|canonical" -> canonical byParent = avl.NewTree() // "parent|canonical" -> canonical nextEventID uint64 ) // --------------------------------------------------------------------------- // 5. Initialization // --------------------------------------------------------------------------- func init() { deployer := unsafe.OriginCaller() config = Config{ Admin: deployer, RegistrationOpen: true, MinCommitAge: 60, // 1 minute MaxCommitAge: 24 * 3600, // 1 day MinRegistrationDuration: 28 * 24 * 3600, // 28 days MaxRegistrationDuration: 10 * 365 * 24 * 3600, // 10 years GracePeriod: 90 * 24 * 3600, // 90 days BasePricePerSecond: 1, // 1 ugnot / second (deterministic, boring) PremiumByLength: map[uint8]int64{ 1: 100, 2: 25, 3: 5, 4: 2, }, PaymentDenom: "ugnot", Treasury: deployer, MaxTextValueBytes: 4096, MaxBinaryValueBytes: 8192, MaxRecordsPerName: 128, MaxOperatorsPerName: 32, PolicyRevision: 1, Paused: false, } } // --------------------------------------------------------------------------- // 6. Normalization // --------------------------------------------------------------------------- // Normalize canonicalizes a name: lowercases ASCII, validates every label, and // enforces length/depth limits. Non-ASCII input is rejected outright. func Normalize(name string) (string, error) { if name == "" { return "", errInvalidName } if len(name) > maxNameLen { return "", errInvalidName } lower := strings.ToLower(name) // reject non-ASCII (ToLower only folds ASCII deterministically for us; any // byte >= 0x80 is disallowed) for i := 0; i < len(lower); i++ { if lower[i] >= 0x80 { return "", errInvalidName } } labels := strings.Split(lower, ".") if len(labels) > maxDepth { return "", errInvalidName } for _, l := range labels { if err := validateLabel(l); err != nil { return "", err } } return lower, nil } func validateLabel(l string) error { n := len(l) if n < 1 || n > maxLabelLen { return errInvalidLabel } for i := 0; i < n; i++ { c := l[i] isDigit := c >= '0' && c <= '9' isAlpha := c >= 'a' && c <= 'z' isHyphen := c == '-' if !isDigit && !isAlpha && !isHyphen { return errInvalidLabel } if isHyphen && (i == 0 || i == n-1) { return errInvalidLabel // no leading/trailing hyphen } } return nil } func mustNormalize(name string) string { c, err := Normalize(name) if err != nil { panic(err) } return c } // splitLabel returns (label, parent) for a canonical name. func splitLabel(canonical string) (string, string) { i := strings.Index(canonical, ".") if i < 0 { return canonical, "" } return canonical[:i], canonical[i+1:] } func depthOf(canonical string) uint8 { return uint8(strings.Count(canonical, ".") + 1) } // --------------------------------------------------------------------------- // 7. Hashing // --------------------------------------------------------------------------- // MakeCommitment is the public helper clients use to derive the commitment // hex to pass to Commit. It normalizes the name first so the value matches what // Register recomputes at reveal. Returns an error if the name is invalid. func MakeCommitment(name string, owner address, duration int64, secret, recordsHash string, policyRevision uint64) (string, error) { canonical, err := Normalize(name) if err != nil { return "", err } return computeCommitment(canonical, owner, duration, secret, recordsHash, policyRevision), nil } // computeCommitment derives the canonical commitment hex string. Clients MUST // compute it identically (see RegisterRequest docs). func computeCommitment(name string, owner address, duration int64, secret, recordsHash string, policyRev uint64) string { preimage := name + sep + owner.String() + sep + strconv.FormatInt(duration, 10) + sep + secret + sep + recordsHash + sep + strconv.FormatUint(policyRev, 10) sum := sha256.Sum256([]byte(preimage)) return hex.EncodeToString(sum[:]) } // digest returns a short hex digest of a byte value, for event payloads (never // store full record values in events). func digest(b []byte) string { if len(b) == 0 { return "" } sum := sha256.Sum256(b) return hex.EncodeToString(sum[:])[:16] } func digestStr(s string) string { return digest([]byte(s)) } // --------------------------------------------------------------------------- // 8. Time and lifecycle // --------------------------------------------------------------------------- func now() int64 { return time.Now().Unix() } func height() int64 { return runtime.ChainHeight() } // statusOf computes the lifecycle status of a (possibly nil) name. func statusOf(n *Name) NameStatus { if n == nil { return StatusAvailable } if n.Deleted { return StatusDeleted } if n.Reserved && n.Owner == (address("")) { return StatusReserved } if !parentChainValid(n) { return StatusExpired } t := now() if n.ExpiresAt == 0 { // permanent subname; valid while ancestors valid return StatusActive } if t < n.ExpiresAt { return StatusActive } if t < n.GraceEndsAt { return StatusGrace } return StatusExpired } func isActive(n *Name) bool { return statusOf(n) == StatusActive } // parentChainValid verifies every ancestor exists, is active, and matches the // stored ParentGeneration (recycling safety, invariant 16 & recycling). func parentChainValid(n *Name) bool { if n.Parent == "" { return true } p := getRaw(n.Parent) if p == nil || p.Deleted { return false } if n.ParentGeneration != p.Generation { return false } // parent must itself be active (not grace/expired) and its own chain valid if p.ExpiresAt != 0 { t := now() if t >= p.ExpiresAt { return false } } return parentChainValid(p) } // available reports whether a name may be registered now. func available(canonical string) bool { n := getRaw(canonical) if n == nil { return true } if n.Reserved { return false } switch statusOf(n) { case StatusExpired, StatusDeleted: return true default: return false } } // --------------------------------------------------------------------------- // 9. Pricing // --------------------------------------------------------------------------- func lengthMultiplier(label string) int64 { l := uint8(len(label)) if m, ok := config.PremiumByLength[l]; ok { return m } return 1 } // priceFor computes the deterministic, overflow-checked price. func priceFor(canonical string, duration int64) (int64, error) { if duration <= 0 { return 0, errDurationTooShort } label, _ := splitLabel(canonical) mult := lengthMultiplier(label) base := config.BasePricePerSecond // price = duration * base * mult, checked for overflow at each step. p := duration var err error if p, err = mulChecked(p, base); err != nil { return 0, err } if p, err = mulChecked(p, mult); err != nil { return 0, err } return p, nil } func mulChecked(a, b int64) (int64, error) { if a == 0 || b == 0 { return 0, nil } c := a * b if c/b != a || c < 0 { return 0, errRecordTooLarge // reuse as overflow marker; documented } return c, nil } // Price returns a price quote for registering/renewing name for duration. func Price(name string, duration int64) (PriceQuote, error) { canonical, err := Normalize(name) if err != nil { return PriceQuote{}, err } amt, err := priceFor(canonical, duration) if err != nil { return PriceQuote{}, err } return PriceQuote{ Amount: amt, Denom: config.PaymentDenom, ValidUntil: now() + config.MaxCommitAge, Revision: config.PolicyRevision, }, nil } // --------------------------------------------------------------------------- // 10. Authorization // --------------------------------------------------------------------------- // caller authenticates a crossing frame and returns the immediate caller. func caller(cur realm) address { if !cur.IsCurrent() { panic(errSpoofedRealm) } return cur.Previous().Address() } // callerUser authenticates and requires an end-user (EOA) caller. func callerUser(cur realm) address { if !cur.IsCurrent() { panic(errSpoofedRealm) } prev := cur.Previous() if !prev.IsUser() { panic(errNotUser) } return prev.Address() } // authorize is the single gate for all name mutations. Authority order: // 1. admin — NOT here (admin has no routine power over user names); // 2. direct owner of an active name; // 3. active operator with the matching permission; // 4. parent authority, when the child policy allows. func authorize(callerAddr address, n *Name, perm Permission) error { if n == nil { return errNotFound } // (2) direct owner, but only while active (invariant 2). if n.Owner == callerAddr && isActive(n) { return nil } // (3) operator if op, ok := getOperator(n, callerAddr); ok && isActive(n) { if op.has(perm) && (op.ExpiresAt == 0 || op.ExpiresAt > now()) { return nil } } // (4) parent authority if parentAuthorized(callerAddr, n, perm) { return nil } return errUnauthorized } func mustAuthorize(callerAddr address, n *Name, perm Permission) { if err := authorize(callerAddr, n, perm); err != nil { panic(err) } } // parentAuthorized checks whether callerAddr controls the parent AND the // child's policy grants the parent that specific power. func parentAuthorized(callerAddr address, n *Name, perm Permission) bool { if n.Parent == "" { return false } p := getRaw(n.Parent) if p == nil || !isActive(p) { return false } // caller must control the parent (owner or ManageSubnames operator) controls := p.Owner == callerAddr if !controls { if op, ok := getOperator(p, callerAddr); ok && op.ManageSubnames { controls = true } } if !controls { return false } cp := n.ControlPolicy switch perm { case PermTransfer: return cp.ParentCanTransfer || cp.ParentCanReclaim case PermManagePolicy: return cp.ParentCanChangePolicy } return false } // --------------------------------------------------------------------------- // 11. Registration // --------------------------------------------------------------------------- // Commit stores a registration commitment. The commitment hides the intended // name; only its hash is recorded together with the committer and timestamp. func Commit(cur realm, commit string) { requireNotPaused() c := callerUser(cur) if commit == "" { panic(errBadRequest) } if existing, ok := getCommitment(commit); ok { // reject only if the existing commitment is still within its usable // window; stale ones may be overwritten. if now()-existing.CreatedAt <= config.MaxCommitAge { panic(errCommitmentMismatch) } } commitments.Set(commit, &commitment{Committer: c, CreatedAt: now()}) } // Register reveals and consumes a commitment to create a second-level name. func Register(cur realm, request RegisterRequest) RegistrationResult { requireNotPaused() c := callerUser(cur) if !config.RegistrationOpen { panic(errRegistrationClosed) } canonical, err := Normalize(request.Name) if err != nil { panic(err) } // second-level only (no dots) via Register; subnames use CreateSubname. if strings.Contains(canonical, ".") { panic(errInvalidName) } if request.PolicyRevision != config.PolicyRevision { panic(errPriceChanged) } if !request.Owner.IsValid() { panic(errEmptyAddress) } // commitment checks key := computeCommitment(canonical, request.Owner, request.Duration, request.Secret, request.RecordsHash, request.PolicyRevision) cm, ok := getCommitment(key) if !ok { panic(errCommitmentMissing) } if cm.Committer != c { panic(errUnauthorized) } age := now() - cm.CreatedAt if age < config.MinCommitAge { panic(errCommitmentTooNew) } if age > config.MaxCommitAge { panic(errCommitmentExpired) } // availability + duration if !available(canonical) { existing := getRaw(canonical) switch statusOf(existing) { case StatusReserved: panic(errNameReserved) case StatusGrace: panic(errNameInGrace) default: panic(errNameUnavailable) } } if request.Duration < config.MinRegistrationDuration { panic(errDurationTooShort) } if request.Duration > config.MaxRegistrationDuration { panic(errDurationTooLong) } price, err := priceFor(canonical, request.Duration) if err != nil { panic(err) } paid, refunded := collectPayment(cur, price) // build/recycle the name prev := getRaw(canonical) var gen uint64 = 1 if prev != nil { gen = prev.Generation + 1 // recycle: increment generation } t := now() label, parent := splitLabel(canonical) n := &Name{ Canonical: canonical, Owner: request.Owner, CreatedAt: t, UpdatedAt: t, ExpiresAt: t + request.Duration, Parent: parent, Label: label, Depth: depthOf(canonical), Generation: gen, Revision: 1, Records: newRecords(), Operators: avl.NewTree(), ControlPolicy: ControlPolicy{ OwnerCanTransfer: true, OwnerCanCreateSubnames: true, RecordsMutable: true, }, RegistrationPolicy: RegistrationPolicy{Mode: ModeClosed}, } n.GraceEndsAt = n.ExpiresAt + config.GracePeriod // clear any stale owner index from a previous generation before storing. if prev != nil { byOwner.Remove(ownerKey(prev.Owner, canonical)) } putName(n) // optional convenience records if request.NativeAddress != "" { setNativeAddressInternal(n, request.NativeAddress) } if request.SetPrimary && request.NativeAddress != "" && address(request.NativeAddress) == c { reverse.Set(c.String(), canonical) emit(EvPrimaryNameChange, canonical, c, n.Owner, "") } commitments.Remove(key) emitOwner(EvNameRegistered, n) return RegistrationResult{ Canonical: canonical, Owner: n.Owner.String(), ExpiresAt: n.ExpiresAt, Generation: n.Generation, Paid: paid, Refunded: refunded, } } // collectPayment reads the attached ugnot, requires it to cover price, and // forwards price to the treasury while refunding any overpayment. func collectPayment(cur realm, price int64) (paid, refunded int64) { if price <= 0 { return 0, 0 } if !cur.Previous().IsUserCall() { panic(errNotUser) } sent := unsafe.OriginSend() got := sent.AmountOf(config.PaymentDenom) if got < price { panic(errInsufficientPay) } bk := banker.NewBanker(banker.BankerTypeRealmSend, cur) self := cur.Address() // forward the price to the treasury if config.Treasury != self { bk.SendCoins(self, config.Treasury, coins(config.PaymentDenom, price)) } // refund the remainder to the caller over := got - price if over > 0 { bk.SendCoins(self, cur.Previous().Address(), coins(config.PaymentDenom, over)) } return price, over } // --------------------------------------------------------------------------- // 12. Renewal and expiry // --------------------------------------------------------------------------- // Renew extends a name's expiry. Anyone may pay to renew (a socially useful // property: third parties can prevent expiry but gain no authority). Renewal // is allowed while Active or in Grace, never once fully Expired. func Renew(cur realm, name string, duration int64) RenewalResult { requireNotPaused() _ = caller(cur) // authenticate frame; no authority needed to sponsor renewal canonical := mustNormalize(name) n := getRaw(canonical) if n == nil || n.Deleted { panic(errNotFound) } st := statusOf(n) if st != StatusActive && st != StatusGrace { panic(errNameExpired) } if n.ExpiresAt == 0 { panic(errBadRequest) // permanent subname has no independent expiry } if duration <= 0 { panic(errDurationTooShort) } // new expiry base: max(now, current expiry) so grace renewals extend from // the original expiry, active renewals from current expiry. base := n.ExpiresAt if st == StatusGrace { // during grace, extend from now to avoid free grace time abuse base = n.ExpiresAt } newExpiry := base + duration // enforce the maximum expiry horizon (invariant 11). maxHorizon := now() + config.MaxRegistrationDuration if newExpiry > maxHorizon { panic(errDurationTooLong) } price, err := priceFor(canonical, duration) if err != nil { panic(err) } paid, _ := collectPayment(cur, price) n.ExpiresAt = newExpiry n.GraceEndsAt = newExpiry + config.GracePeriod n.UpdatedAt = now() n.Revision++ putName(n) emitOwner(EvNameRenewed, n) return RenewalResult{Canonical: canonical, ExpiresAt: n.ExpiresAt, Paid: paid} } // --------------------------------------------------------------------------- // 13. Ownership // --------------------------------------------------------------------------- // Transfer moves ownership of a name. func Transfer(cur realm, name string, newOwner address, clearOperators, clearRecords bool) { requireNotPaused() c := caller(cur) canonical := mustNormalize(name) n := getRaw(canonical) if n == nil { panic(errNotFound) } if !newOwner.IsValid() { panic(errEmptyAddress) } if newOwner == n.Owner { panic(errBadRequest) // self-transfer rejected (cleaner than no-op) } // owner path additionally requires the policy to allow transfer. if c == n.Owner && isActive(n) { if !n.ControlPolicy.OwnerCanTransfer { panic(errPolicyLocked) } } else { mustAuthorize(c, n, PermTransfer) } old := n.Owner byOwner.Remove(ownerKey(old, canonical)) n.Owner = newOwner n.Revision++ n.UpdatedAt = now() if clearOperators { n.Operators = avl.NewTree() n.OperatorCount = 0 } if clearRecords { n.Records = newRecords() } putName(n) // invalidate reverse mappings that no longer pass forward verification. invalidateReverseFor(old, canonical) emit(EvNameTransferred, canonical, c, newOwner, old.String()) } // --------------------------------------------------------------------------- // 14. Subnames and policy // --------------------------------------------------------------------------- // CreateSubname creates label.parent according to the parent registration // policy. func CreateSubname(cur realm, parent string, label string, owner address, options SubnameOptions) { requireNotPaused() c := callerUser(cur) pcanon := mustNormalize(parent) if err := validateLabel(strings.ToLower(label)); err != nil { panic(err) } label = strings.ToLower(label) canonical := label + "." + pcanon if len(canonical) > maxNameLen { panic(errInvalidName) } if depthOf(canonical) > maxDepth { panic(errInvalidName) } p := getRaw(pcanon) if p == nil || !isActive(p) { panic(errParentInvalid) } if !owner.IsValid() { panic(errEmptyAddress) } if !available(canonical) { panic(errNameUnavailable) } pol := p.RegistrationPolicy // authorize + charge according to policy mode. switch pol.Mode { case ModeClosed, "": panic(errRegistrationClosed) case ModeOwner: mustAuthorize(c, p, PermManageSubnames) case ModeOpen: // anyone case ModeAllowlist: if pol.Allowlist == nil || !allowlistHas(pol.Allowlist, c) { panic(errUnauthorized) } case ModePaid: charge := int64(0) if pol.PricePerSecond > 0 && options.Duration > 0 { var err error if charge, err = mulChecked(pol.PricePerSecond, options.Duration); err != nil { panic(err) } } denom := pol.PaymentDenom if denom == "" { denom = config.PaymentDenom } collectPaymentDenom(cur, charge, denom, p.Owner) default: panic(errBadRequest) } t := now() prev := getRaw(canonical) var gen uint64 = 1 if prev != nil { gen = prev.Generation + 1 byOwner.Remove(ownerKey(prev.Owner, canonical)) } cpol := options.ControlPolicy if (cpol == ControlPolicy{}) { cpol = pol.DefaultControlPolicy } n := &Name{ Canonical: canonical, Owner: owner, CreatedAt: t, UpdatedAt: t, Parent: pcanon, Label: label, Depth: depthOf(canonical), Generation: gen, ParentGeneration: p.Generation, Revision: 1, Records: newRecords(), Operators: avl.NewTree(), ControlPolicy: cpol, RegistrationPolicy: RegistrationPolicy{Mode: ModeClosed}, } if options.Duration > 0 { n.ExpiresAt = t + options.Duration n.GraceEndsAt = n.ExpiresAt + config.GracePeriod } // else permanent (ExpiresAt == 0) putName(n) if options.NativeAddress != "" { setNativeAddressInternal(n, options.NativeAddress) } emit(EvSubnameCreated, canonical, c, owner, pcanon) } // DeleteSubname removes a subname. Callable by the owner, or by the parent when // ParentCanDelete is set. func DeleteSubname(cur realm, name string) { requireNotPaused() c := caller(cur) canonical := mustNormalize(name) n := getRaw(canonical) if n == nil || n.Deleted { panic(errNotFound) } if n.Parent == "" { panic(errBadRequest) // not a subname } authorized := false if c == n.Owner && isActive(n) { authorized = true } else if p := getRaw(n.Parent); p != nil && isActive(p) && n.ControlPolicy.ParentCanDelete { if p.Owner == c { authorized = true } else if op, ok := getOperator(p, c); ok && op.ManageSubnames { authorized = true } } if !authorized { panic(errUnauthorized) } deleteName(n) emit(EvNameDeleted, canonical, c, n.Owner, "") } // SetRegistrationPolicy sets the subname-issuance policy for a name. func SetRegistrationPolicy(cur realm, name string, policy RegistrationPolicy) { requireNotPaused() c := caller(cur) canonical := mustNormalize(name) n := getRaw(canonical) if n == nil { panic(errNotFound) } if c == n.Owner && isActive(n) { if !n.ControlPolicy.OwnerCanCreateSubnames && policy.Mode != ModeClosed { panic(errPolicyLocked) } } else { mustAuthorize(c, n, PermManagePolicy) } n.RegistrationPolicy = policy n.UpdatedAt = now() n.Revision++ putName(n) emit(EvPolicyChanged, canonical, c, n.Owner, "registration") } // LockPolicy makes a name's control policy strictly more restrictive // (emancipation). Flags may only move true->false; once Permanent, no field // may be relaxed. Only the owner may lock. func LockPolicy(cur realm, name string, restrictions ControlPolicy) { requireNotPaused() c := caller(cur) canonical := mustNormalize(name) n := getRaw(canonical) if n == nil { panic(errNotFound) } if c != n.Owner || !isActive(n) { panic(errUnauthorized) } cur0 := n.ControlPolicy if cur0.Permanent && !restrictions.Permanent { panic(errPolicyLocked) } next := mergeRestrictive(cur0, restrictions) n.ControlPolicy = next n.UpdatedAt = now() n.Revision++ putName(n) emit(EvPolicyChanged, canonical, c, n.Owner, "control") } // mergeRestrictive returns a policy where each boolean can only go from // permissive (true) to restrictive (false); Permanent can only be turned on. func mergeRestrictive(cur, req ControlPolicy) ControlPolicy { andRestrict := func(cur, req bool) bool { return cur && req } return ControlPolicy{ OwnerCanTransfer: andRestrict(cur.OwnerCanTransfer, req.OwnerCanTransfer), OwnerCanCreateSubnames: andRestrict(cur.OwnerCanCreateSubnames, req.OwnerCanCreateSubnames), RecordsMutable: andRestrict(cur.RecordsMutable, req.RecordsMutable), ParentCanReclaim: andRestrict(cur.ParentCanReclaim, req.ParentCanReclaim), ParentCanTransfer: andRestrict(cur.ParentCanTransfer, req.ParentCanTransfer), ParentCanDelete: andRestrict(cur.ParentCanDelete, req.ParentCanDelete), ParentCanChangePolicy: andRestrict(cur.ParentCanChangePolicy, req.ParentCanChangePolicy), Permanent: cur.Permanent || req.Permanent, } } // SetOperator grants (or updates) an operator's permissions on a name. func SetOperator(cur realm, name string, operator address, permissions Permissions) { requireNotPaused() c := caller(cur) canonical := mustNormalize(name) n := getRaw(canonical) if n == nil { panic(errNotFound) } mustAuthorizeOwnerOrOp(c, n, PermManageOperators) if !operator.IsValid() { panic(errEmptyAddress) } _, existed := getOperator(n, operator) if !existed { if int(config.MaxOperatorsPerName) > 0 && n.OperatorCount >= int(config.MaxOperatorsPerName) { panic(errOperatorLimit) } n.OperatorCount++ } n.Operators.Set(operator.String(), permissions) n.UpdatedAt = now() n.Revision++ putName(n) emit(EvOperatorChanged, canonical, c, operator, "set") } // RemoveOperator revokes an operator. func RemoveOperator(cur realm, name string, operator address) { requireNotPaused() c := caller(cur) canonical := mustNormalize(name) n := getRaw(canonical) if n == nil { panic(errNotFound) } mustAuthorizeOwnerOrOp(c, n, PermManageOperators) if _, removed := n.Operators.Remove(operator.String()); removed { n.OperatorCount-- n.UpdatedAt = now() n.Revision++ putName(n) emit(EvOperatorChanged, canonical, c, operator, "remove") } } // mustAuthorizeOwnerOrOp allows the active owner directly or an operator with // the given permission (used for record/operator management). func mustAuthorizeOwnerOrOp(c address, n *Name, perm Permission) { if c == n.Owner && isActive(n) { return } if op, ok := getOperator(n, c); ok && isActive(n) && op.has(perm) && (op.ExpiresAt == 0 || op.ExpiresAt > now()) { return } panic(errUnauthorized) } // --------------------------------------------------------------------------- // 15. Records // --------------------------------------------------------------------------- func requireRecordsMutable(n *Name) { if !n.ControlPolicy.RecordsMutable { panic(errPolicyLocked) } // records may not be mutated during grace (spec: normal updates disabled in // grace, except clearing reverse). if statusOf(n) != StatusActive { panic(errNameExpired) } } func bumpRecords(n *Name) { n.UpdatedAt = now() n.Revision++ putName(n) } func (n *Name) recordCountGuard(added int) { if config.MaxRecordsPerName > 0 && n.Records.Count+added > int(config.MaxRecordsPerName) { panic(errRecordLimit) } } // recordMutation is the shared preamble for every typed record setter: pause // gate, caller authentication, name resolution, record-management authority, // and records-mutable check. Returns the name and the caller address. func recordMutation(cur realm, name string) (*Name, address) { requireNotPaused() c := caller(cur) n := mustName(name) mustAuthorizeOwnerOrOp(c, n, PermManageRecords) requireRecordsMutable(n) return n, c } // SetAddress sets the native address record. func SetAddress(cur realm, name string, addr string) { n, c := recordMutation(cur, name) if addr != "" && !address(addr).IsValid() { panic(errEmptyAddress) } setNativeAddressInternal(n, addr) bumpRecords(n) emitRecord(EvRecordChanged, n, c, "addr", digestStr(addr)) } func setNativeAddressInternal(n *Name, addr string) { n.Records.NativeAddress = addr } // Address returns the native address record. func Address(name string) (string, bool) { n := resolvableName(name) if n == nil || n.Records.NativeAddress == "" { return "", false } return n.Records.NativeAddress, true } // SetCoinAddress sets a multichain address for coinType (decimal string). func SetCoinAddress(cur realm, name string, coinType string, value []byte) { n, c := recordMutation(cur, name) guardBinary(value) if !n.Records.Addresses.Has(coinType) { n.recordCountGuard(1) n.Records.Count++ } n.Records.Addresses.Set(coinType, value) bumpRecords(n) emitRecord(EvRecordChanged, n, c, "coin:"+coinType, digest(value)) } // CoinAddress returns a multichain address. func CoinAddress(name string, coinType string) ([]byte, bool) { n := resolvableName(name) if n == nil { return nil, false } if v, ok := treeGet(n.Records.Addresses, coinType); ok { return v.([]byte), true } return nil, false } // SetText sets a text record; empty value deletes it (physical removal). func SetText(cur realm, name string, key string, value string) { n, c := recordMutation(cur, name) if key == "" { panic(errBadRequest) } if value == "" { if _, removed := n.Records.Text.Remove(key); removed { n.Records.Count-- bumpRecords(n) emitRecord(EvRecordChanged, n, c, "text:"+key, "") } return } if uint32(len(value)) > config.MaxTextValueBytes { panic(errRecordTooLarge) } if !n.Records.Text.Has(key) { n.recordCountGuard(1) n.Records.Count++ } n.Records.Text.Set(key, value) bumpRecords(n) emitRecord(EvRecordChanged, n, c, "text:"+key, digestStr(value)) } // Text returns a text record. func Text(name string, key string) (string, bool) { n := resolvableName(name) if n == nil { return "", false } if v, ok := treeGet(n.Records.Text, key); ok { return v.(string), true } return "", false } // SetContentHash sets the content hash record. func SetContentHash(cur realm, name string, value []byte) { n, c := recordMutation(cur, name) guardBinary(value) n.Records.ContentHash = value bumpRecords(n) emitRecord(EvRecordChanged, n, c, "content", digest(value)) } // ContentHash returns the content hash record. func ContentHash(name string) ([]byte, bool) { n := resolvableName(name) if n == nil || len(n.Records.ContentHash) == 0 { return nil, false } return n.Records.ContentHash, true } // SetPublicKey sets the public key record. func SetPublicKey(cur realm, name string, value []byte) { n, c := recordMutation(cur, name) guardBinary(value) n.Records.PublicKey = value bumpRecords(n) emitRecord(EvRecordChanged, n, c, "pubkey", digest(value)) } // PublicKey returns the public key record. func PublicKey(name string) ([]byte, bool) { n := resolvableName(name) if n == nil || len(n.Records.PublicKey) == 0 { return nil, false } return n.Records.PublicKey, true } // SetABI sets an ABI blob by content type. func SetABI(cur realm, name string, contentType string, value []byte) { n, c := recordMutation(cur, name) guardBinary(value) if !n.Records.ABIs.Has(contentType) { n.recordCountGuard(1) n.Records.Count++ } n.Records.ABIs.Set(contentType, value) bumpRecords(n) emitRecord(EvRecordChanged, n, c, "abi:"+contentType, digest(value)) } // ABI returns an ABI blob. func ABI(name string, contentType string) ([]byte, bool) { n := resolvableName(name) if n == nil { return nil, false } if v, ok := treeGet(n.Records.ABIs, contentType); ok { return v.([]byte), true } return nil, false } // SetInterface sets an interface target by interface ID. func SetInterface(cur realm, name string, interfaceID string, target string) { n, c := recordMutation(cur, name) if !n.Records.Interfaces.Has(interfaceID) { n.recordCountGuard(1) n.Records.Count++ } n.Records.Interfaces.Set(interfaceID, target) bumpRecords(n) emitRecord(EvRecordChanged, n, c, "interface:"+interfaceID, digestStr(target)) } // Interface returns an interface target. func Interface(name string, interfaceID string) (string, bool) { n := resolvableName(name) if n == nil { return "", false } if v, ok := treeGet(n.Records.Interfaces, interfaceID); ok { return v.(string), true } return "", false } // SetRecord sets an arbitrary namespaced record. Reserved namespaces are // rejected; use the typed setters for those. func SetRecord(cur realm, name string, namespace string, key string, value []byte) { n, c := recordMutation(cur, name) if namespace == "" || key == "" { panic(errBadRequest) } if reservedNamespaces[namespace] { panic(errBadRequest) } guardBinary(value) k := namespace + "/" + key if !n.Records.Arbitrary.Has(k) { n.recordCountGuard(1) n.Records.Count++ } n.Records.Arbitrary.Set(k, value) bumpRecords(n) emitRecord(EvRecordChanged, n, c, "record:"+k, digest(value)) } // Record returns an arbitrary namespaced record. func Record(name string, namespace string, key string) ([]byte, bool) { n := resolvableName(name) if n == nil { return nil, false } if v, ok := treeGet(n.Records.Arbitrary, namespace+"/"+key); ok { return v.([]byte), true } return nil, false } // SetTTL sets the name's TTL metadata. func SetTTL(cur realm, name string, ttl uint64) { n, c := recordMutation(cur, name) n.TTL = ttl bumpRecords(n) emitRecord(EvRecordChanged, n, c, "ttl", strconv.FormatUint(ttl, 10)) } func guardBinary(value []byte) { if config.MaxBinaryValueBytes > 0 && uint32(len(value)) > config.MaxBinaryValueBytes { panic(errRecordTooLarge) } } // Resolve returns a record either at the exact name or from the nearest valid // ancestor (wildcard-style). Inheritance is explicit, never implicit in the // primitive getters. func Resolve(name string, query RecordQuery, mode ResolveMode) ResolveResult { canonical, err := Normalize(name) if err != nil { return ResolveResult{Requested: name} } cur := canonical for { n := resolvableName(cur) if n != nil { if val, ok := lookupRecord(n, query); ok { return ResolveResult{ Found: true, Requested: canonical, SourceName: cur, Value: val, Revision: n.Revision, ExpiresAt: n.ExpiresAt, } } } if mode == Exact { break } _, parent := splitLabel(cur) if parent == "" { break } cur = parent } return ResolveResult{Requested: canonical} } func lookupRecord(n *Name, q RecordQuery) ([]byte, bool) { switch q.Kind { case "address": if n.Records.NativeAddress != "" { return []byte(n.Records.NativeAddress), true } case "text": if v, ok := treeGet(n.Records.Text, q.Key1); ok { return []byte(v.(string)), true } case "coin": if v, ok := treeGet(n.Records.Addresses, q.Key1); ok { return v.([]byte), true } case "content": if len(n.Records.ContentHash) > 0 { return n.Records.ContentHash, true } case "pubkey": if len(n.Records.PublicKey) > 0 { return n.Records.PublicKey, true } case "abi": if v, ok := treeGet(n.Records.ABIs, q.Key1); ok { return v.([]byte), true } case "interface": if v, ok := treeGet(n.Records.Interfaces, q.Key1); ok { return []byte(v.(string)), true } case "record": if v, ok := treeGet(n.Records.Arbitrary, q.Key1+"/"+q.Key2); ok { return v.([]byte), true } } return nil, false } // --------------------------------------------------------------------------- // 16. Reverse resolution and primary names // --------------------------------------------------------------------------- // SetPrimaryName sets the caller's primary (reverse) name. The name must be // active and forward-resolve (Address) to the caller. func SetPrimaryName(cur realm, name string) { requireNotPaused() c := callerUser(cur) canonical := mustNormalize(name) n := getRaw(canonical) if n == nil || !isActive(n) { panic(errNotFound) } if n.Records.NativeAddress != c.String() { panic(errUnauthorized) } reverse.Set(c.String(), canonical) emit(EvPrimaryNameChange, canonical, c, n.Owner, "") } // PrimaryName returns the verified primary name for an address, checking that // (1) a reverse record exists, (2) the name is active, and (3) forward // resolution still matches. Any failure returns not-found. func PrimaryName(addr string) (string, bool) { v, ok := treeGet(reverse, addr) if !ok { return "", false } canonical := v.(string) n := getRaw(canonical) if n == nil || !isActive(n) { return "", false } if n.Records.NativeAddress != addr { return "", false } return canonical, true } // ClearPrimaryName clears the caller's reverse record. func ClearPrimaryName(cur realm) { c := caller(cur) // allowed even when paused / during grace if _, removed := reverse.Remove(c.String()); removed { emit(EvPrimaryNameChange, "", c, "", "cleared") } } // invalidateReverseFor lazily clears a reverse record if the just-changed name // no longer forward-verifies for the old owner. PrimaryName also re-verifies, // so this is best-effort cleanup. func invalidateReverseFor(oldOwner address, canonical string) { if v, ok := treeGet(reverse, oldOwner.String()); ok && v.(string) == canonical { n := getRaw(canonical) if n == nil || n.Records.NativeAddress != oldOwner.String() { reverse.Remove(oldOwner.String()) } } } // --------------------------------------------------------------------------- // 17. Enumeration (bounded, cursor-based) // --------------------------------------------------------------------------- func capLimit(limit uint16) int { const hardCap = 200 if limit == 0 || int(limit) > hardCap { return hardCap } return int(limit) } // NamesByOwner lists canonical names owned by owner. func NamesByOwner(owner string, cursor string, limit uint16) StringPage { prefix := owner + sep start := prefix if cursor != "" { start = prefix + cursor } max := capLimit(limit) items := []string{} next := "" byOwner.Iterate(start, prefixEnd(prefix), func(k string, v any) bool { if cursor != "" && k <= prefix+cursor { return false } if len(items) == max { next = strings.TrimPrefix(k, prefix) return true } items = append(items, v.(string)) return false }) return StringPage{Items: items, Next: next} } // Subnames lists direct subnames of parent. func Subnames(parent string, cursor string, limit uint16) StringPage { pcanon, err := Normalize(parent) if err != nil { return StringPage{} } prefix := pcanon + sep start := prefix if cursor != "" { start = prefix + cursor } max := capLimit(limit) items := []string{} next := "" byParent.Iterate(start, prefixEnd(prefix), func(k string, v any) bool { if cursor != "" && k <= prefix+cursor { return false } if len(items) == max { next = strings.TrimPrefix(k, prefix) return true } items = append(items, v.(string)) return false }) return StringPage{Items: items, Next: next} } // TextKeys lists text-record keys for a name. func TextKeys(name string, cursor string, limit uint16) StringPage { n := mustName(name) return treeKeys(n.Records.Text, cursor, limit) } // CoinTypes lists multichain coin types set for a name. func CoinTypes(name string, cursor string, limit uint16) StringPage { n := mustName(name) return treeKeys(n.Records.Addresses, cursor, limit) } // Operators lists operators and their permissions for a name. func Operators(name string, cursor string, limit uint16) OperatorPage { n := mustName(name) max := capLimit(limit) items := []OperatorView{} next := "" n.Operators.Iterate(cursor, "", func(k string, v any) bool { if cursor != "" && k <= cursor { return false } if len(items) == max { next = k return true } items = append(items, OperatorView{Address: k, Permissions: v.(Permissions)}) return false }) return OperatorPage{Items: items, Next: next} } func treeKeys(tree *avl.Tree, cursor string, limit uint16) StringPage { max := capLimit(limit) items := []string{} next := "" tree.Iterate(cursor, "", func(k string, v any) bool { if cursor != "" && k <= cursor { return false } if len(items) == max { next = k return true } items = append(items, k) return false }) return StringPage{Items: items, Next: next} } // --------------------------------------------------------------------------- // 18. Events // --------------------------------------------------------------------------- func eventKey(id uint64) string { // zero-pad to 20 digits for lexicographic ordering. s := strconv.FormatUint(id, 10) return strings.Repeat("0", 20-len(s)) + s } func recordEvent(e *Event) { nextEventID++ e.ID = nextEventID e.Height = height() e.Timestamp = now() events.Set(eventKey(e.ID), e) // also surface as a native gno event for tx-level indexers. chain.Emit(e.Type, "name", e.Name, "actor", e.Actor, "id", strconv.FormatUint(e.ID, 10)) } func emit(typ, name string, actor address, owner interface{}, key string) { ownerStr := "" switch o := owner.(type) { case address: ownerStr = o.String() case string: ownerStr = o } recordEvent(&Event{Type: typ, Name: name, Actor: actor.String(), Owner: ownerStr, Key: key}) } func emitOwner(typ string, n *Name) { recordEvent(&Event{Type: typ, Name: n.Canonical, Actor: n.Owner.String(), Owner: n.Owner.String(), Revision: n.Revision}) } func emitRecord(typ string, n *Name, actor address, key, newDigest string) { recordEvent(&Event{Type: typ, Name: n.Canonical, Actor: actor.String(), Owner: n.Owner.String(), Revision: n.Revision, Key: key, NewDigest: newDigest}) } // EventsAfter returns events with ID strictly greater than id. func EventsAfter(id uint64, limit uint16) EventPage { max := capLimit(limit) items := []Event{} next := "" start := eventKey(id + 1) events.Iterate(start, "", func(k string, v any) bool { if len(items) == max { next = k return true } items = append(items, *v.(*Event)) return false }) return EventPage{Items: items, Next: next} } // EventsForName returns events for a specific name with ID greater than after. func EventsForName(name string, after uint64, limit uint16) EventPage { canonical, err := Normalize(name) if err != nil { return EventPage{} } max := capLimit(limit) items := []Event{} next := "" start := eventKey(after + 1) events.Iterate(start, "", func(k string, v any) bool { e := v.(*Event) if e.Name != canonical { return false } if len(items) == max { next = k return true } items = append(items, *e) return false }) return EventPage{Items: items, Next: next} } // --------------------------------------------------------------------------- // 19. Administration // --------------------------------------------------------------------------- func requireAdmin(cur realm) address { c := caller(cur) if c != config.Admin { panic(errUnauthorized) } return c } func requireNotPaused() { if config.Paused { panic(errPaused) } } // SetPaused toggles the global pause. Paused blocks registration, subname // creation, transfers and record mutation; reads, renewals and primary-name // clearing remain available. It never confiscates or mutates ownership. func SetPaused(cur realm, paused bool) { requireAdmin(cur) config.Paused = paused if paused { emit(EvPaused, "", config.Admin, "", "") } else { emit(EvUnpaused, "", config.Admin, "", "") } } // SetRegistrationOpen toggles whether new second-level registrations are open. func SetRegistrationOpen(cur realm, open bool) { requireAdmin(cur) config.RegistrationOpen = open emit(EvConfigChanged, "", config.Admin, "", "registration_open") } // SetPricing updates future pricing and bumps the policy revision so pending // commitments that priced against the old rules are rejected at reveal. func SetPricing(cur realm, pricing PricingConfig) { requireAdmin(cur) if pricing.BasePricePerSecond < 0 { panic(errBadRequest) } config.BasePricePerSecond = pricing.BasePricePerSecond if pricing.PremiumByLength != nil { config.PremiumByLength = pricing.PremiumByLength } if pricing.PaymentDenom != "" { config.PaymentDenom = pricing.PaymentDenom } config.PolicyRevision++ emit(EvConfigChanged, "", config.Admin, "", "pricing") } // SetTreasury updates the treasury address. func SetTreasury(cur realm, treasury address) { requireAdmin(cur) if !treasury.IsValid() { panic(errEmptyAddress) } config.Treasury = treasury emit(EvConfigChanged, "", config.Admin, "", "treasury") } // ReserveName reserves (or unreserves) an unregistered name so it cannot be // publicly registered. Admin may not reserve an actively-owned name. func ReserveName(cur realm, name string, reserved bool) { requireAdmin(cur) canonical := mustNormalize(name) n := getRaw(canonical) if n != nil && isActive(n) && n.Owner != (address("")) { panic(errNameUnavailable) // cannot confiscate an active name } if n == nil { label, parent := splitLabel(canonical) n = &Name{ Canonical: canonical, Label: label, Parent: parent, Depth: depthOf(canonical), Records: newRecords(), Operators: avl.NewTree(), CreatedAt: now(), } } // If unreserving a bare placeholder (never registered), physically remove // it so the name becomes Available again rather than lingering as an // ExpiresAt==0 node (which statusOf would read as a permanent Active name). if !reserved && !n.Owner.IsValid() { names.Remove(canonical) emit(EvConfigChanged, canonical, config.Admin, "", "reserve") return } n.Reserved = reserved names.Set(canonical, n) emit(EvConfigChanged, canonical, config.Admin, "", "reserve") } // TransferAdmin begins a two-step admin handover. func TransferAdmin(cur realm, next address) { requireAdmin(cur) if !next.IsValid() { panic(errEmptyAddress) } config.PendingAdmin = next emit(EvConfigChanged, "", config.Admin, next, "transfer_admin") } // AcceptAdmin completes the two-step admin handover. func AcceptAdmin(cur realm) { c := caller(cur) if config.PendingAdmin == (address("")) || c != config.PendingAdmin { panic(errUnauthorized) } config.Admin = c config.PendingAdmin = address("") emit(EvConfigChanged, "", c, "", "accept_admin") } // SetLimits updates operational storage/abuse limits (future registrations and // mutations). Existing names are unaffected until next mutation. func SetLimits(cur realm, maxRecords, maxOperators uint16, maxText, maxBinary uint32) { requireAdmin(cur) config.MaxRecordsPerName = maxRecords config.MaxOperatorsPerName = maxOperators config.MaxTextValueBytes = maxText config.MaxBinaryValueBytes = maxBinary emit(EvConfigChanged, "", config.Admin, "", "limits") } // --------------------------------------------------------------------------- // 20. Read API (status/lookup) // --------------------------------------------------------------------------- // Status returns the lifecycle status of a name. func Status(name string) NameStatus { canonical, err := Normalize(name) if err != nil { return StatusAvailable } return statusOf(getRaw(canonical)) } // Exists reports whether a name currently resolves to a live registration. func Exists(name string) bool { canonical, err := Normalize(name) if err != nil { return false } n := getRaw(canonical) if n == nil { return false } switch statusOf(n) { case StatusActive, StatusGrace: return true } return false } // OwnerOf returns the owner of an active/grace name. func OwnerOf(name string) (string, bool) { canonical, err := Normalize(name) if err != nil { return "", false } n := getRaw(canonical) if n == nil { return "", false } switch statusOf(n) { case StatusActive, StatusGrace: return n.Owner.String(), true } return "", false } // GetName returns a read-only view of a name. func GetName(name string) (NameView, bool) { canonical, err := Normalize(name) if err != nil { return NameView{}, false } n := getRaw(canonical) if n == nil { return NameView{}, false } return NameView{ Canonical: n.Canonical, Owner: n.Owner.String(), Status: string(statusOf(n)), CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt, ExpiresAt: n.ExpiresAt, GraceEndsAt: n.GraceEndsAt, Parent: n.Parent, Label: n.Label, Depth: n.Depth, TTL: n.TTL, Generation: n.Generation, Revision: n.Revision, Reserved: n.Reserved, NativeAddr: n.Records.NativeAddress, }, true } // CommitmentStatus returns the state of a pending commitment. func CommitmentStatus(commit string) CommitmentView { cm, ok := getCommitment(commit) if !ok { return CommitmentView{} } return CommitmentView{ Exists: true, Committer: cm.Committer.String(), CreatedAt: cm.CreatedAt, ReadyAt: cm.CreatedAt + config.MinCommitAge, ExpiresAt: cm.CreatedAt + config.MaxCommitAge, } } // --------------------------------------------------------------------------- // 21. Rendering // --------------------------------------------------------------------------- // Render is a human-readable explorer. It never mutates state. func Render(path string) string { path = strings.TrimPrefix(path, "/") switch { case path == "": return renderHome() case strings.HasPrefix(path, "name/"): return renderName(strings.TrimPrefix(path, "name/")) case strings.HasPrefix(path, "address/"): return renderAddress(strings.TrimPrefix(path, "address/")) case strings.HasPrefix(path, "available/"): return renderAvailable(strings.TrimPrefix(path, "available/")) case path == "events": return renderEvents() case path == "help": return renderHelp() default: return "# GNS\n\nUnknown route. See [/help](/r/moul/gns/v0:help).\n" } } func renderHome() string { var b strings.Builder b.WriteString("# GNS — Gno Name Service\n\n") b.WriteString("A single-realm, ENS-equivalent naming system for gno.land.\n\n") b.WriteString(ufmt.Sprintf("- Registered names: **%d**\n", names.Size())) b.WriteString(ufmt.Sprintf("- Events: **%d**\n", int(nextEventID))) b.WriteString(ufmt.Sprintf("- Registration open: **%t**\n", config.RegistrationOpen)) b.WriteString(ufmt.Sprintf("- Paused: **%t**\n\n", config.Paused)) b.WriteString("## Routes\n\n") b.WriteString("- `/name/<name>` — details for a name\n") b.WriteString("- `/address/<g1...>` — primary name + owned names\n") b.WriteString("- `/available/<name>` — availability and price\n") b.WriteString("- `/events` — recent events\n") b.WriteString("- `/help` — public API summary\n") return b.String() } func renderName(name string) string { canonical, err := Normalize(name) if err != nil { return "# " + name + "\n\nInvalid name: " + err.Error() + "\n" } n := getRaw(canonical) if n == nil { return "# " + canonical + "\n\n_Available._ See [/available/" + canonical + "](/r/moul/gns/v0:available/" + canonical + ").\n" } var b strings.Builder b.WriteString("# " + canonical + "\n\n") b.WriteString("Owner: `" + n.Owner.String() + "`\n\n") b.WriteString("Status: " + string(statusOf(n)) + "\n\n") if n.ExpiresAt > 0 { b.WriteString(ufmt.Sprintf("Expires: %s\n\n", time.Unix(n.ExpiresAt, 0).UTC().Format("2006-01-02"))) } else { b.WriteString("Expires: never (permanent subname)\n\n") } b.WriteString(ufmt.Sprintf("Generation: %d · Revision: %d\n\n", int(n.Generation), int(n.Revision))) if n.Records.NativeAddress != "" { b.WriteString("Primary address: `" + n.Records.NativeAddress + "`\n\n") } // text records b.WriteString("## Records\n\n") hasText := false n.Records.Text.Iterate("", "", func(k string, v any) bool { b.WriteString("- " + k + ": " + v.(string) + "\n") hasText = true return false }) if !hasText { b.WriteString("_No text records._\n") } // subnames b.WriteString("\n## Subnames\n\n") subs := Subnames(canonical, "", 50) if len(subs.Items) == 0 { b.WriteString("_None._\n") } else { for _, s := range subs.Items { b.WriteString("- " + s + "\n") } } return b.String() } func renderAddress(addr string) string { var b strings.Builder b.WriteString("# " + addr + "\n\n") if pn, ok := PrimaryName(addr); ok { b.WriteString("Primary name: **" + pn + "**\n\n") } else { b.WriteString("_No verified primary name._\n\n") } b.WriteString("## Owned names\n\n") page := NamesByOwner(addr, "", 50) if len(page.Items) == 0 { b.WriteString("_None._\n") } else { for _, s := range page.Items { b.WriteString("- " + s + "\n") } } return b.String() } func renderAvailable(name string) string { canonical, err := Normalize(name) if err != nil { return "# " + name + "\n\nInvalid: " + err.Error() + "\n" } var b strings.Builder b.WriteString("# " + canonical + "\n\n") if available(canonical) { b.WriteString("**Available.**\n\n") q, _ := Price(canonical, config.MinRegistrationDuration) b.WriteString(ufmt.Sprintf("Price for %d seconds: %d %s\n", int(config.MinRegistrationDuration), int(q.Amount), q.Denom)) } else { b.WriteString("**Not available** (status: " + string(Status(canonical)) + ").\n") } return b.String() } func renderEvents() string { var b strings.Builder b.WriteString("# Recent events\n\n") from := uint64(0) if nextEventID > 20 { from = nextEventID - 20 } page := EventsAfter(from, 20) if len(page.Items) == 0 { b.WriteString("_No events yet._\n") return b.String() } b.WriteString("| ID | Type | Name | Actor |\n| ---: | --- | --- | --- |\n") for _, e := range page.Items { b.WriteString(ufmt.Sprintf("| %d | %s | %s | `%s` |\n", int(e.ID), e.Type, e.Name, e.Actor)) } return b.String() } func renderHelp() string { return "# GNS API\n\n" + "Read: `Normalize`, `Status`, `Exists`, `OwnerOf`, `GetName`, `Resolve`, " + "`Address`, `CoinAddress`, `Text`, `ContentHash`, `PublicKey`, `ABI`, " + "`Interface`, `Record`, `PrimaryName`, `Price`, `CommitmentStatus`, " + "`NamesByOwner`, `Subnames`, `TextKeys`, `CoinTypes`, `Operators`, " + "`EventsAfter`, `EventsForName`.\n\n" + "Mutations (crossing, panic on failure): `Commit`, `Register`, `Renew`, " + "`Transfer`, `CreateSubname`, `DeleteSubname`, `SetRegistrationPolicy`, " + "`LockPolicy`, `SetOperator`, `RemoveOperator`, `SetPrimaryName`, " + "`ClearPrimaryName`, and the typed record setters.\n\n" + "Admin: `SetPaused`, `SetRegistrationOpen`, `SetPricing`, `SetTreasury`, " + "`ReserveName`, `TransferAdmin`, `AcceptAdmin`, `SetLimits`.\n" } // --------------------------------------------------------------------------- // 22. Internal storage helpers // --------------------------------------------------------------------------- func coins(denom string, amount int64) chain.Coins { return chain.NewCoins(chain.NewCoin(denom, amount)) } // treeGet adapts the avl v0 API (Get returns a single value; existence is via // Has) to the (value, ok) idiom used throughout this file. func treeGet(t *avl.Tree, key string) (any, bool) { if !t.Has(key) { return nil, false } return t.Get(key), true } func getRaw(canonical string) *Name { v, ok := treeGet(names, canonical) if !ok { return nil } return v.(*Name) } // resolvableName returns the name only if it is currently Active (records // resolve only for active names). func resolvableName(name string) *Name { canonical, err := Normalize(name) if err != nil { return nil } n := getRaw(canonical) if n == nil || !isActive(n) { return nil } return n } // mustName returns an existing name or panics with not_found. func mustName(name string) *Name { canonical := mustNormalize(name) n := getRaw(canonical) if n == nil { panic(errNotFound) } return n } func putName(n *Name) { names.Set(n.Canonical, n) byOwner.Set(ownerKey(n.Owner, n.Canonical), n.Canonical) if n.Parent != "" { byParent.Set(n.Parent+sep+n.Canonical, n.Canonical) } } func deleteName(n *Name) { n.Deleted = true byOwner.Remove(ownerKey(n.Owner, n.Canonical)) if n.Parent != "" { byParent.Remove(n.Parent + sep + n.Canonical) } names.Remove(n.Canonical) } func ownerKey(owner address, canonical string) string { return owner.String() + sep + canonical } func getCommitment(key string) (*commitment, bool) { v, ok := treeGet(commitments, key) if !ok { return nil, false } return v.(*commitment), true } func getOperator(n *Name, addr address) (Permissions, bool) { if n.Operators == nil { return Permissions{}, false } v, ok := treeGet(n.Operators, addr.String()) if !ok { return Permissions{}, false } return v.(Permissions), true } func allowlistHas(tree *avl.Tree, addr address) bool { v, ok := treeGet(tree, addr.String()) return ok && v.(bool) } func collectPaymentDenom(cur realm, price int64, denom string, treasury address) { if price <= 0 { return } if !cur.Previous().IsUserCall() { panic(errNotUser) } sent := unsafe.OriginSend() got := sent.AmountOf(denom) if got < price { panic(errInsufficientPay) } bk := banker.NewBanker(banker.BankerTypeRealmSend, cur) self := cur.Address() if treasury != self { bk.SendCoins(self, treasury, coins(denom, price)) } if over := got - price; over > 0 { bk.SendCoins(self, cur.Previous().Address(), coins(denom, over)) } } func prefixEnd(prefix string) string { if prefix == "" { return "" } b := []byte(prefix) for i := len(b) - 1; i >= 0; i-- { if b[i] < 0xff { b[i]++ return string(b[:i+1]) } } return "" // prefix is all 0xff; iterate to end }
  10. #10gns_test.gno
  11. #11package gns import ( "testing" ) func TestNormalize(t *testing.T) { reset() cases := []struct { in string out string ok bool }{ {"alice", "alice", true}, {"ALICE", "alice", true}, {"Wallet.Alice", "wallet.alice", true}, {"a", "a", true}, {"a1-b", "a1-b", true}, {"-alice", "", false}, // leading hyphen {"alice-", "", false}, // trailing hyphen {"", "", false}, // empty {"a..b", "", false}, // empty label (double dot) {"al ice", "", false}, // space {"café", "", false}, // non-ASCII {"a_b", "", false}, // underscore not allowed } for _, tc := range cases { got, err := Normalize(tc.in) if tc.ok { noErr(t, err, "normalize "+tc.in) eqStr(t, tc.out, got, "normalize "+tc.in) } else { isErr(t, err, "expected error for "+tc.in) } } } func TestNormalizeDepthAndLength(t *testing.T) { reset() deep := "a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a" // 17 labels > maxDepth _, err := Normalize(deep) isErr(t, err, "excessive depth rejected") long := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // 64 > 63 _, err = Normalize(long) isErr(t, err, "excessive label length rejected") } func TestStatusAndOwnerLifecycle(cur realm, t *testing.T) { reset() eqStr(t, string(StatusAvailable), string(Status("alice")), "initial status") isFalse(t, Exists("alice"), "not exists initially") doRegister(cur, "alice", alice, 1000) eqStr(t, string(StatusActive), string(Status("alice")), "post-register status") isTrue(t, Exists("alice"), "exists after register") owner, ok := OwnerOf("alice") isTrue(t, ok, "ownerOf ok") eqStr(t, alice.String(), owner, "owner is alice") view, ok := GetName("alice") isTrue(t, ok, "getName ok") eqInt(t, 1, int64(view.Generation), "generation 1") eqStr(t, "alice", view.Canonical, "canonical") // advance past expiry, into grace (expiry ≈ +1000s, grace ends ≈ +1100s) testing.SkipHeights(210) // +1050s eqStr(t, string(StatusGrace), string(Status("alice")), "grace status") _, ok = OwnerOf("alice") isTrue(t, ok, "owner known during grace") // advance well past grace -> Expired testing.SkipHeights(200) // +1000s more, far beyond grace end eqStr(t, string(StatusExpired), string(Status("alice")), "expired status") isFalse(t, Exists("alice"), "not exists after grace") } func TestExpiredOwnerLosesAuthority(cur realm, t *testing.T) { reset() doRegister(cur, "alice", alice, 1000) testing.SkipHeights(300) // beyond grace eqStr(t, string(StatusExpired), string(Status("alice")), "expired") // Invariant 2: an expired name grants no owner mutation authority. Tested // against the single authorization gate directly (mutations funnel through // it, then panic on the returned error). n := getRaw("alice") isTrue(t, n != nil, "raw name present") errIs(t, authorize(alice, n, PermManageRecords), errUnauthorized, "expired owner authorize") errIs(t, authorize(alice, n, PermTransfer), errUnauthorized, "expired owner transfer") }
  12. #12helpers_test.gno
  13. #13package gns import ( "testing" "gno.land/p/nt/avl/v0" "gno.land/p/nt/testutils/v0" ) // Deterministic, checksum-valid test addresses. var ( admin = testutils.TestAddress("admin") alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") carol = testutils.TestAddress("carol") dave = testutils.TestAddress("dave") ) // reset re-initializes all package state to a deterministic, test-friendly // configuration. Durations are small so lifecycle tests can advance time with // modest SkipHeights calls. Pricing is free by default (BasePricePerSecond=0) // so most functional tests need not attach payment; payment tests override it. func reset() { names = avl.NewTree() commitments = avl.NewTree() reverse = avl.NewTree() events = avl.NewTree() byOwner = avl.NewTree() byParent = avl.NewTree() nextEventID = 0 config = Config{ Admin: admin, Treasury: admin, RegistrationOpen: true, MinCommitAge: 10, MaxCommitAge: 1000, MinRegistrationDuration: 100, MaxRegistrationDuration: 10_000_000, GracePeriod: 100, BasePricePerSecond: 0, PremiumByLength: map[uint8]int64{1: 100, 2: 25, 3: 5, 4: 2}, PaymentDenom: "ugnot", MaxTextValueBytes: 4096, MaxBinaryValueBytes: 8192, MaxRecordsPerName: 128, MaxOperatorsPerName: 32, PolicyRevision: 1, } } // newTestName builds a standalone active *Name for unit-testing pure helpers // (authorize, statusOf, ...) without going through the registration flow. func newTestName(canonical string, owner address, cp ControlPolicy) *Name { label, parent := splitLabel(canonical) return &Name{ Canonical: canonical, Owner: owner, ExpiresAt: now() + 1_000_000, Parent: parent, Label: label, Depth: depthOf(canonical), Generation: 1, Revision: 1, Records: newRecords(), Operators: avl.NewTree(), ControlPolicy: cp, } } // doRegister runs the full commit→reveal flow for a second-level name using the // free (zero-price) path. Caller identity is set to owner. func doRegister(cur realm, name string, owner address, duration int64) { secret := "sec-" + name canonical, err := Normalize(name) if err != nil { panic(err) } c := computeCommitment(canonical, owner, duration, secret, "", config.PolicyRevision) testing.SetRealm(testing.NewUserRealm(owner)) Commit(cross(cur), c) testing.SkipHeights(3) // +15s > MinCommitAge (10s) testing.SetRealm(testing.NewUserRealm(owner)) Register(cross(cur), RegisterRequest{ Name: name, Owner: owner, Duration: duration, Secret: secret, PolicyRevision: config.PolicyRevision, }) }
  14. #14policy_test.gno
  15. #15package gns import ( "testing" ) func TestOperatorDelegation(cur realm, t *testing.T) { reset() doRegister(cur, "alice", alice, 100000) // alice grants bob record management only (not transfer) testing.SetRealm(testing.NewUserRealm(alice)) SetOperator(cross(cur), "alice", bob, Permissions{ManageRecords: true}) // bob can set a text record testing.SetRealm(testing.NewUserRealm(bob)) SetText(cross(cur), "alice", "url", "https://bob-managed.example") v, ok := Text("alice", "url") isTrue(t, ok, "operator set record") eqStr(t, "https://bob-managed.example", v, "operator record value") // invariant: operator without Transfer cannot transfer n := getRaw("alice") errIs(t, authorize(bob, n, PermTransfer), errUnauthorized, "record operator lacks transfer") noErr(t, authorize(bob, n, PermManageRecords), "record operator has records perm") } func TestRemoveOperator(cur realm, t *testing.T) { reset() doRegister(cur, "alice", alice, 100000) testing.SetRealm(testing.NewUserRealm(alice)) SetOperator(cross(cur), "alice", bob, Permissions{ManageRecords: true}) n := getRaw("alice") eqInt(t, 1, int64(n.OperatorCount), "one operator") testing.SetRealm(testing.NewUserRealm(alice)) RemoveOperator(cross(cur), "alice", bob) n = getRaw("alice") eqInt(t, 0, int64(n.OperatorCount), "operator removed") errIs(t, authorize(bob, n, PermManageRecords), errUnauthorized, "removed operator loses perm") } func TestTransferHappy(cur realm, t *testing.T) { reset() doRegister(cur, "alice", alice, 100000) r0 := getRaw("alice").Revision testing.SetRealm(testing.NewUserRealm(alice)) Transfer(cross(cur), "alice", bob, true, false) owner, ok := OwnerOf("alice") isTrue(t, ok, "still exists") eqStr(t, bob.String(), owner, "owner now bob") isTrue(t, getRaw("alice").Revision > r0, "revision bumped on transfer (invariant 12 side-effect)") } func TestAuthorizeOrder(cur realm, t *testing.T) { reset() doRegister(cur, "alice", alice, 100000) n := getRaw("alice") // direct owner (active) authorized noErr(t, authorize(alice, n, PermManageRecords), "owner authorized") // unrelated user rejected errIs(t, authorize(carol, n, PermManageRecords), errUnauthorized, "stranger rejected") // admin has NO routine authority over user names (invariant 18) errIs(t, authorize(admin, n, PermTransfer), errUnauthorized, "admin cannot transfer user name") } func TestNonTransferablePolicyPure(t *testing.T) { reset() // A name whose policy forbids owner transfer: the Transfer owner-branch // checks OwnerCanTransfer and rejects with policy_locked. We assert the // policy flag drives that decision. n := newTestName("locked", alice, ControlPolicy{OwnerCanTransfer: false}) isFalse(t, n.ControlPolicy.OwnerCanTransfer, "policy forbids transfer") // and no operator/parent path grants transfer either errIs(t, authorize(bob, n, PermTransfer), errUnauthorized, "no delegated transfer") }
  16. #16records_test.gno
  17. #17package gns import ( "testing" ) func TestTypedRecords(cur realm, t *testing.T) { reset() doRegister(cur, "alice", alice, 100000) testing.SetRealm(testing.NewUserRealm(alice)) SetAddress(cross(cur), "alice", alice.String()) SetText(cross(cur), "alice", "com.github", "moul") SetCoinAddress(cross(cur), "alice", "60", []byte{0xde, 0xad}) SetContentHash(cross(cur), "alice", []byte{0x12, 0x20}) SetPublicKey(cross(cur), "alice", []byte{0x01, 0x02}) SetABI(cross(cur), "alice", "application/json", []byte("{}")) SetInterface(cross(cur), "alice", "0x1", "gno.land/r/demo/foo") SetRecord(cross(cur), "alice", "com.example", "game", []byte("hi")) addr, ok := Address("alice") isTrue(t, ok, "address set") eqStr(t, alice.String(), addr, "address value") txt, ok := Text("alice", "com.github") isTrue(t, ok, "text set") eqStr(t, "moul", txt, "text value") coin, ok := CoinAddress("alice", "60") isTrue(t, ok, "coin set") eqInt(t, 2, int64(len(coin)), "coin len") _, ok = ContentHash("alice") isTrue(t, ok, "contenthash set") _, ok = PublicKey("alice") isTrue(t, ok, "pubkey set") _, ok = ABI("alice", "application/json") isTrue(t, ok, "abi set") iface, ok := Interface("alice", "0x1") isTrue(t, ok, "interface set") eqStr(t, "gno.land/r/demo/foo", iface, "interface value") rec, ok := Record("alice", "com.example", "game") isTrue(t, ok, "record set") eqStr(t, "hi", string(rec), "record value") } func TestTextDeletePhysical(cur realm, t *testing.T) { reset() doRegister(cur, "alice", alice, 100000) testing.SetRealm(testing.NewUserRealm(alice)) SetText(cross(cur), "alice", "url", "https://example.com") _, ok := Text("alice", "url") isTrue(t, ok, "url present") // empty value physically removes it testing.SetRealm(testing.NewUserRealm(alice)) SetText(cross(cur), "alice", "url", "") _, ok = Text("alice", "url") isFalse(t, ok, "url removed") n := getRaw("alice") eqInt(t, 0, int64(n.Records.Count), "record count back to zero") } func TestRevisionBumps(cur realm, t *testing.T) { reset() doRegister(cur, "alice", alice, 100000) r0 := getRaw("alice").Revision testing.SetRealm(testing.NewUserRealm(alice)) SetText(cross(cur), "alice", "a", "1") r1 := getRaw("alice").Revision isTrue(t, r1 > r0, "revision increments on record change") } func TestResolveExactAndAncestor(cur realm, t *testing.T) { reset() doRegister(cur, "company", alice, 100000) // open subname policy so we can create a child testing.SetRealm(testing.NewUserRealm(alice)) SetRegistrationPolicy(cross(cur), "company", RegistrationPolicy{Mode: ModeOpen}) testing.SetRealm(testing.NewUserRealm(alice)) CreateSubname(cross(cur), "company", "api", alice, SubnameOptions{Duration: 100000}) // text only on the parent testing.SetRealm(testing.NewUserRealm(alice)) SetText(cross(cur), "company", "url", "https://company.example") // exact on child: not found exact := Resolve("api.company", RecordQuery{Kind: "text", Key1: "url"}, Exact) isFalse(t, exact.Found, "exact child has no url") // nearest ancestor: inherits from company inh := Resolve("api.company", RecordQuery{Kind: "text", Key1: "url"}, NearestAncestor) isTrue(t, inh.Found, "ancestor url found") eqStr(t, "company", inh.SourceName, "source is parent") eqStr(t, "https://company.example", string(inh.Value), "inherited value") } // Multiple record updates are done by calling the typed setters repeatedly // (from a client, via a single `gnokey maketx run` that calls a helper N times); // GNS does not need an in-realm batch entry point. func TestSetTTLAndMultipleRecords(cur realm, t *testing.T) { reset() doRegister(cur, "alice", alice, 100000) testing.SetRealm(testing.NewUserRealm(alice)) SetText(cross(cur), "alice", "email", "a@b.co") testing.SetRealm(testing.NewUserRealm(alice)) SetText(cross(cur), "alice", "url", "https://x.example") testing.SetRealm(testing.NewUserRealm(alice)) SetTTL(cross(cur), "alice", 3600) v, ok := Text("alice", "email") isTrue(t, ok, "email set") eqStr(t, "a@b.co", v, "email value") v, ok = Text("alice", "url") isTrue(t, ok, "url set") eqStr(t, "https://x.example", v, "url value") n := getRaw("alice") eqInt(t, 3600, int64(n.TTL), "ttl set") } func TestReservedNamespaceRejectedPure(t *testing.T) { reset() // the reserved-namespace guard is a pure lookup used by SetRecord. isTrue(t, reservedNamespaces["gno"], "gno reserved") isTrue(t, reservedNamespaces["system"], "system reserved") isFalse(t, reservedNamespaces["com.example"], "app namespace allowed") }
  18. #18registration_test.gno
  19. #19package gns import ( "chain" "testing" ) func TestCommitRevealFlow(cur realm, t *testing.T) { reset() secret := "hunter2" dur := int64(1000) commit, err := MakeCommitment("alice", alice, dur, secret, "", config.PolicyRevision) noErr(t, err, "make commitment") testing.SetRealm(testing.NewUserRealm(alice)) Commit(cross(cur), commit) // commitment visible, not yet ready cs := CommitmentStatus(commit) isTrue(t, cs.Exists, "commitment exists") eqStr(t, alice.String(), cs.Committer, "committer") testing.SkipHeights(3) // pass MinCommitAge testing.SetRealm(testing.NewUserRealm(alice)) res := Register(cross(cur), RegisterRequest{ Name: "alice", Owner: alice, Duration: dur, Secret: secret, PolicyRevision: config.PolicyRevision, }) eqStr(t, "alice", res.Canonical, "result canonical") eqInt(t, 1, int64(res.Generation), "generation") // commitment consumed exactly once (invariant 7) isFalse(t, CommitmentStatus(commit).Exists, "commitment consumed") // second reveal with same commitment can't find it _, ok := getCommitment(commit) isFalse(t, ok, "commitment removed") } func TestPricingDeterministic(t *testing.T) { reset() config.BasePricePerSecond = 10 // 5+ char label -> multiplier 1 p, err := priceFor("alice", 100) noErr(t, err, "price alice") eqInt(t, 1000, p, "price 5-char") // 1 char -> multiplier 100 p, err = priceFor("a", 100) noErr(t, err, "price a") eqInt(t, 100000, p, "price 1-char") // 3 char -> multiplier 5 p, err = priceFor("abc", 100) noErr(t, err, "price abc") eqInt(t, 5000, p, "price 3-char") // invariant 10: overflow-safe _, err = priceFor("a", 1<<62) isErr(t, err, "overflow rejected") // zero/negative duration _, err = priceFor("alice", 0) errIs(t, err, errDurationTooShort, "zero duration") } func TestGenerationRecycle(cur realm, t *testing.T) { reset() doRegister(cur, "alice", alice, 1000) // put a record, then let it expire testing.SetRealm(testing.NewUserRealm(alice)) SetText(cross(cur), "alice", "url", "https://old.example") testing.SkipHeights(500) // beyond grace // re-register by a different owner doRegister(cur, "alice", bob, 1000) view, ok := GetName("alice") isTrue(t, ok, "exists after recycle") eqInt(t, 2, int64(view.Generation), "generation incremented (invariant 20)") eqStr(t, bob.String(), view.Owner, "new owner") // invariant 19: old records do not leak _, ok = Text("alice", "url") isFalse(t, ok, "old record cleared on recycle") } func TestReservedNames(cur realm, t *testing.T) { reset() testing.SetRealm(testing.NewUserRealm(admin)) ReserveName(cross(cur), "gno", true) // invariant 17: reserved names cannot be publicly registered isFalse(t, available("gno"), "reserved not available") eqStr(t, string(StatusReserved), string(Status("gno")), "status reserved") // unreserve testing.SetRealm(testing.NewUserRealm(admin)) ReserveName(cross(cur), "gno", false) isTrue(t, available("gno"), "available after unreserve") } func TestRegisterWithPayment(cur realm, t *testing.T) { reset() config.BasePricePerSecond = 1 config.Treasury = cur.Address() // realm keeps funds; no banker forward needed dur := int64(1000) // price = 1000 * 1 * 1 (5+ chars) secret := "s" commit, _ := MakeCommitment("alice", alice, dur, secret, "", config.PolicyRevision) testing.SetRealm(testing.NewUserRealm(alice)) Commit(cross(cur), commit) testing.SkipHeights(3) testing.IssueCoins(cur.Address(), chain.Coins{{Denom: "ugnot", Amount: 1000}}) testing.SetOriginSend(chain.Coins{{Denom: "ugnot", Amount: 1000}}) testing.SetRealm(testing.NewUserRealm(alice)) res := Register(cross(cur), RegisterRequest{ Name: "alice", Owner: alice, Duration: dur, Secret: secret, PolicyRevision: config.PolicyRevision, }) eqInt(t, 1000, res.Paid, "paid exact price") eqInt(t, 0, res.Refunded, "no refund") } func TestPriceQuote(t *testing.T) { reset() config.BasePricePerSecond = 2 q, err := Price("bob", 500) // 3-char -> mult 5; 500*2*5 = 5000 noErr(t, err, "quote") eqInt(t, 5000, q.Amount, "quote amount") eqStr(t, "ugnot", q.Denom, "quote denom") eqInt(t, int64(config.PolicyRevision), int64(q.Revision), "quote revision") }
  20. #20render_test.gno
  21. #21package gns import ( "strings" "testing" ) func TestRenderRoutes(cur realm, t *testing.T) { reset() doRegister(cur, "alice", alice, 100000) testing.SetRealm(testing.NewUserRealm(alice)) SetText(cross(cur), "alice", "com.github", "moul") home := Render("") isTrue(t, strings.Contains(home, "GNS"), "home mentions GNS") isTrue(t, strings.Contains(home, "Registered names"), "home shows stats") name := Render("name/alice") isTrue(t, strings.Contains(name, "alice"), "name page has name") isTrue(t, strings.Contains(name, alice.String()), "name page shows owner") isTrue(t, strings.Contains(name, "com.github"), "name page shows records") avail := Render("available/freeone") isTrue(t, strings.Contains(avail, "Available"), "available page") help := Render("help") isTrue(t, strings.Contains(help, "Register"), "help lists API") events := Render("events") isTrue(t, strings.Contains(events, "events"), "events page") } func TestRenderNoMutation(cur realm, t *testing.T) { reset() doRegister(cur, "alice", alice, 100000) before := names.Size() _ = Render("name/alice") _ = Render("") _ = Render("available/whatever") after := names.Size() eqInt(t, int64(before), int64(after), "Render does not mutate state") } func TestEnumerationBounded(cur realm, t *testing.T) { reset() doRegister(cur, "one", alice, 100000) doRegister(cur, "two", alice, 100000) doRegister(cur, "three", alice, 100000) page := NamesByOwner(alice.String(), "", 2) eqInt(t, 2, int64(len(page.Items)), "page limited to 2") isTrue(t, page.Next != "", "cursor for next page") // invariant 15: hard cap even when a huge limit is requested. page2 := NamesByOwner(alice.String(), "", 65535) isTrue(t, len(page2.Items) <= 200, "hard cap applied") }
  22. #22reverse_test.gno
  23. #23package gns import ( "testing" ) func TestPrimaryNameForwardVerified(cur realm, t *testing.T) { reset() doRegister(cur, "alice", alice, 100000) // forward address must point to the caller before SetPrimaryName succeeds testing.SetRealm(testing.NewUserRealm(alice)) SetAddress(cross(cur), "alice", alice.String()) testing.SetRealm(testing.NewUserRealm(alice)) SetPrimaryName(cross(cur), "alice") name, ok := PrimaryName(alice.String()) isTrue(t, ok, "primary resolves") eqStr(t, "alice", name, "primary is alice") } func TestPrimaryNameForwardMismatch(cur realm, t *testing.T) { reset() doRegister(cur, "alice", alice, 100000) testing.SetRealm(testing.NewUserRealm(alice)) SetAddress(cross(cur), "alice", alice.String()) testing.SetRealm(testing.NewUserRealm(alice)) SetPrimaryName(cross(cur), "alice") // change the forward address away from alice -> primary must stop resolving testing.SetRealm(testing.NewUserRealm(alice)) SetAddress(cross(cur), "alice", bob.String()) // invariant 6: primary returned only when forward resolution matches _, ok := PrimaryName(alice.String()) isFalse(t, ok, "stale reverse not returned") } func TestPrimaryNameExpired(cur realm, t *testing.T) { reset() doRegister(cur, "alice", alice, 1000) testing.SetRealm(testing.NewUserRealm(alice)) SetAddress(cross(cur), "alice", alice.String()) testing.SetRealm(testing.NewUserRealm(alice)) SetPrimaryName(cross(cur), "alice") testing.SkipHeights(500) // expire the name _, ok := PrimaryName(alice.String()) isFalse(t, ok, "expired name yields no primary") } func TestClearPrimaryName(cur realm, t *testing.T) { reset() doRegister(cur, "alice", alice, 100000) testing.SetRealm(testing.NewUserRealm(alice)) SetAddress(cross(cur), "alice", alice.String()) testing.SetRealm(testing.NewUserRealm(alice)) SetPrimaryName(cross(cur), "alice") _, ok := PrimaryName(alice.String()) isTrue(t, ok, "primary set") testing.SetRealm(testing.NewUserRealm(alice)) ClearPrimaryName(cross(cur)) _, ok = PrimaryName(alice.String()) isFalse(t, ok, "primary cleared") } func TestTransferInvalidatesReverse(cur realm, t *testing.T) { reset() doRegister(cur, "alice", alice, 100000) testing.SetRealm(testing.NewUserRealm(alice)) SetAddress(cross(cur), "alice", alice.String()) testing.SetRealm(testing.NewUserRealm(alice)) SetPrimaryName(cross(cur), "alice") // transfer to bob, clearing records -> forward no longer points to alice testing.SetRealm(testing.NewUserRealm(alice)) Transfer(cross(cur), "alice", bob, true, true) _, ok := PrimaryName(alice.String()) isFalse(t, ok, "reverse invalidated after transfer+record clear") }
  24. #24security_test.gno

Result log

msg:0,success:true,log:,events:[]
msg:1,success:true,log:,events:[]
msg:2,success:true,log:,events:[]
msg:3,success:true,log:,events:[]
msg:4,success:true,log:,events:[]
msg:5,success:true,log:,events:[]
msg:6,success:true,log:,events:[]
msg:7,success:true,log:,events:[]
msg:8,success:true,log:,events:[]
msg:9,success:true,log:,events:[]
msg:10,success:true,log:,events:[]
msg:11,success:true,log:,events:[]
msg:12,success:true,log:,events:[]
msg:13,success:true,log:,events:[]
msg:14,success:true,log:,events:[]
msg:15,success:true,log:,events:[]
msg:16,success:true,log:,events:[]
msg:17,success:true,log:,events:[]
msg:18,success:true,log:,events:[]
msg:19,success:true,log:,events:[]
msg:20,success:true,log:,events:[]

← Back to block 272,406