Transaction

2F9C928C6C4BE6…321B224DEEAB

Block 592,215 · index 0 · indexed

Summary

Hash
2F9C928C6C4BE6FFF1600EB52ABB806812940F0BC85C854B7C62321B224DEEAB
Block
592,215
Size
352762 bytes
Gas used
398,204,480 / 1,067,107,600
Fee
3201322ugnot
Status
success

Messages

#1AddPackagegno.land/p/moul/kit/index/v09 arguments
Attached funds
12000000ugnot

Arguments · 9

  1. #1index
  2. #2README.md
  3. #3# index The secondary index every realm with a store was writing by hand: a lookup from a key a human typed to the [`store.ID`](../store)s that carry it. ```go import ( "gno.land/p/moul/kit/index/v0" "gno.land/p/moul/kit/store/v0" ) var notes = store.Named("note") var byTag index.Index // the zero value is an empty, usable index func Post(cur realm, tag, body string) int64 { id := notes.Add(&note{Tag: tag, Body: body}) if err := byTag.Add(tag, id); err != nil { panic(err) // the store write is not committed either: same frame } return int64(id) } func Render(path string) string { for _, e := range byTag.Page(1, 20) { // keys ascending, one page _, _ = e.Key, e.IDs } return "" } ``` Fifth package of the `p/moul/kit/*` layer, after `ui`, `store`, `num` and `tally`. Live demo: [`r/moul/x/kitindexdemo`](../../../../r/moul/x/kitindexdemo). ## What it is not **Not a multi-index record store.** [`p/moul/collection`](../../collection) is that, and measured at n = 1,000 an update there costs **964,833 gas against the 508,977** that created the record, because `Update` removes and re-adds every index entry for every index whether or not the indexed value changed. Two containers written side by side cost a third of that, and the realm can see what it is paying for. **Not a bound.** An index grows with the store it indexes. The realm owns the bound, because only the realm knows what it is willing to pay a storage deposit for. ## The decisions worth knowing **Backed by a B+ tree at fanout 32, not `avl`, and not 128.** A wider node is cheaper to store and to insert into, measured against gno master `1fc4c140e` on 2026-09-29, n = 1,000, string values: | backing | bytes/entry | gas/insert | |---|--:|--:| | `bptree` fanout 128 | 592 | 152,975 | | `bptree` fanout 32 | 671 | 162,161 | | `avl` | 2,029 | 417,811 | But an index's keys are removed whenever its records move, and a removal rewrites every later value in its leaf. Measured on a real node against gno master `3cc494ec4` on 2026-10-02: removing the first key of a fanout-128 leaf holding 120 cost 35.3M gas, the last 8.0M, about 230k per value shifted (its values are `*entry`, a pointer). At 32 one removal rewrites about 45 values at most (its own leaf, and a neighbour's when the leaf underflows and borrows), so it is the cheaper default for something that moves. **The ids under a key are kept ascending, not in insertion order.** So `Lookup` depends on the *set* of ids and not on the order they arrived in: two realms that indexed the same records in a different order render identically. A `Render` whose output depends on insertion history is a determinism bug, and this is where it would have come from. **`Lookup` and `Each` hand out a copy.** Returning the stored slice would make every caller a writer: an assignment into it corrupts the index with no write path having been called. A realm that passes the result onward is passing its own data, not a handle to ours. **The `Index` itself is a handle.** Its state sits behind one pointer, so a copy taken after the first write (or of anything `Unique()` returned) is the same index, and `Len` can never disagree with `Keys` about what it holds. ## API | | | |---|---| | `New()`, `Unique()` | many ids per key, or at most one. The zero value is `New()` | | `Add(key, id) error` | idempotent for the same pair; `ErrEmptyKey`, `ErrZeroID`, `ErrDuplicateKey` | | `Remove(key, id) bool`, `RemoveKey(key) int` | a key whose last id goes is removed with it | | `Lookup(key) []store.ID`, `First(key) (store.ID, bool)` | ascending; a copy | | `Has`, `Count(key)`, `Keys()`, `Len()`, `IsUnique()` | `Keys` counts keys, `Len` counts pairs, and the difference is the fan-out | | `Each(fn) bool` | ascending by key; true means stop, matching `bptree` and `kit/store`. `fn` must not write to the index | | `Page(page, size) []Entry`, `Pages(size) int` | 1-based, every out-of-range page is nil (`kit/store`'s `Page` returns an empty slice instead), `Pages` is never 0 | `Add` returns an error rather than aborting, because a `p/` package does not know the caller's policy. In a realm the handling is almost always `panic(err)`: every one of the three is a bug in the realm rather than a value a user chose. <!-- 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/index/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/p/moul/kit/index/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/index/v0" gno = "0.9"
  6. #6index.gno
  7. #7// Package index is the secondary index every realm with a store was writing by // hand: a lookup from a key a human typed to the [store.ID]s that carry it. // // A realm keeps its records in a [gno.land/p/moul/kit/store] and answers // "entry 7". The moment it also has to answer "every entry tagged gno" it needs // a second container, and the correctness of the pair is entirely in the // discipline of writing both in the same function. This package owns the second // container and as much of that discipline as a package can. // // var notes = store.Named("note") // var byTag index.Index // the zero value is an empty, usable index // // func Post(cur realm, tag, body string) int64 { // id := notes.Add(&note{Tag: tag, Body: body}) // if err := byTag.Add(tag, id); err != nil { // panic(err) // the store write is not committed either: same frame // } // return int64(id) // } // // It is NOT a multi-index record store. [gno.land/p/moul/collection] is that, // and at n = 1,000 an update there costs 964,833 gas against the 508,977 that // created the record, because it re-indexes every index whether or not the // indexed value changed. Two containers written side by side cost a third of // that and the realm can see what it is paying for. // // Live demo: r/moul/x/kitindexdemo. package index import ( "errors" "gno.land/p/moul/kit/store/v0" "gno.land/p/nt/bptree/v0" ) // Fanout is the B+ tree fanout this package uses: 32, because an index's keys // are removed as its records move. // // A removal shifts every later value in its leaf and rewrites each one, and an // index's values are pointers: measured on a real node against gno master // 3cc494ec4 on 2026-10-02, removing the first key of a fanout-128 leaf holding // 120 cost 35.3M gas against 8.0M for the last, about 230k per value shifted. // Fanout 32 bounds what one removal rewrites to about 45 values: its own // leaf, and a neighbour's when the leaf underflows and borrows. What it costs, measured against gno master // 1fc4c140e on 2026-09-29 at n = 1,000 with string values: 671 bytes per entry // and 162,161 gas per insert, against 592 and 152,975 at fanout 128, and avl's // 2,029 and 417,811. const Fanout = 32 // Errors an Add can return. A /p/ package returns errors and lets the realm // decide whether they are fatal; every one of these is a programming error in // the realm rather than a value a user chose, so panicking on them is the // normal handling. var ( // ErrEmptyKey is returned for "". An empty key sorts before every real one // and is almost always an unset field rather than a deliberate bucket, so // it is refused rather than silently indexed. ErrEmptyKey = errors.New("index: the key is empty") // ErrZeroID is returned for store.ID(0), which [store.Store] never assigns, // so indexing it can only produce a lookup that cannot hit. ErrZeroID = errors.New("index: the id is zero") // ErrDuplicateKey is returned by a [Unique] index asked to attach a second // id to a key that already has one. ErrDuplicateKey = errors.New("index: the key is already taken") ) // Index maps a key to the ids carrying it, ordered by key. // // The zero value is an empty, usable, non-unique index: a realm declares // `var byTag index.Index` and writes to it, exactly as it does with a // [store.Store]. Use [Unique] when at most one id may carry a key. // // An Index is a handle, like the [store.Store] it sits next to: copies taken // after the first write, or of anything [Unique] returned, are the same index, // and a write through one is seen through all of them. A copy of a zero value // that was never written is a separate empty index. type Index struct { s *state } // state is everything an Index holds, behind one pointer so that a copy of the // handle cannot split the tree from the counter that describes it. type state struct { tree *bptree.BPTree unique bool pairs int } // New returns an empty index where a key may carry many ids. It is the zero // value, spelled out for a caller who prefers a constructor. func New() *Index { return &Index{} } // Unique returns an empty index where a key carries at most one id, and a // second [Index.Add] to the same key fails with [ErrDuplicateKey] instead of // appending. // // Unique is a constructor rather than a field or a setter because flipping it // on a populated index would have to either reject the duplicates already in it // or silently keep them, and both are worse than not offering the operation. func Unique() *Index { return &Index{s: &state{tree: bptree.NewBPTreeN(Fanout), unique: true}} } // entry is the value stored at a key: the ids carrying it, ascending. // // A struct rather than a bare []store.ID so that a later field (a count, a // timestamp) does not change the stored shape, and so the slice itself is never // handed out by accident. type entry struct { ids []store.ID } // init materialises the state. Called by every mutating method, so the zero // value works without a constructor. The tree is created with the state, so a // non-nil state always has one. func (ix *Index) init() { if ix.s == nil { ix.s = &state{tree: bptree.NewBPTreeN(Fanout)} } } // Add attaches id to key. // // Adding a pair that is already present is a no-op and returns nil, so a realm // re-running a write is not punished for it. The ids under a key are kept // ascending, which makes [Index.Lookup] depend on the set of ids and not on the // order they arrived in: two realms that indexed the same records in a // different order render identically, and a Render whose output depends on // insertion history is the determinism bug this avoids. func (ix *Index) Add(key string, id store.ID) error { if key == "" { return ErrEmptyKey } if id == 0 { return ErrZeroID } ix.init() e, _ := ix.s.tree.Get(key).(*entry) if e == nil { ix.s.tree.Set(key, &entry{ids: []store.ID{id}}) ix.s.pairs++ return nil } pos, found := e.find(id) if found { return nil } if ix.s.unique { return ErrDuplicateKey } e.ids = append(e.ids, 0) copy(e.ids[pos+1:], e.ids[pos:]) e.ids[pos] = id ix.s.tree.Set(key, e) ix.s.pairs++ return nil } // Remove detaches id from key and reports whether the pair was there. A key // whose last id is removed is removed with it, so [Index.Keys] never counts an // empty bucket. func (ix *Index) Remove(key string, id store.ID) bool { if ix.s == nil { return false } e, _ := ix.s.tree.Get(key).(*entry) if e == nil { return false } pos, found := e.find(id) if !found { return false } ix.s.pairs-- if len(e.ids) == 1 { ix.s.tree.Remove(key) return true } // A fresh slice, not append(ids[:pos], ids[pos+1:]...). // // In gno a slice is ONE persisted object and the storage deposit comes back // when that object is dropped, at no other time: shortening in place keeps // the peak allocation charged until the whole key goes, so a key that grew // to 1,000 ids and shrank to 1 would still be paying for 1,000. Allocating // the exact size drops the old array and refunds the difference. Same O(n) // as the memmove it replaces. trimmed := make([]store.ID, 0, len(e.ids)-1) trimmed = append(trimmed, e.ids[:pos]...) trimmed = append(trimmed, e.ids[pos+1:]...) e.ids = trimmed ix.s.tree.Set(key, e) return true } // RemoveKey detaches every id from key and reports how many it detached. func (ix *Index) RemoveKey(key string) int { if ix.s == nil { return 0 } v, removed := ix.s.tree.Remove(key) if !removed { return 0 } n := len(v.(*entry).ids) ix.s.pairs -= n return n } // Lookup returns the ids carrying key, ascending, or nil. // // The slice is a COPY. Handing out the stored one would make every caller a // writer: an append past its length would be invisible to the index, and an // assignment into it would corrupt the index with no write path having been // called. A realm that returns this value onward is returning its own data, // not a handle to ours. func (ix *Index) Lookup(key string) []store.ID { if ix.s == nil { return nil } e, _ := ix.s.tree.Get(key).(*entry) if e == nil { return nil } out := make([]store.ID, len(e.ids)) copy(out, e.ids) return out } // First returns the lowest id carrying key. On a [Unique] index it is the only // one, which is the usual reason to call it. func (ix *Index) First(key string) (store.ID, bool) { if ix.s == nil { return 0, false } e, _ := ix.s.tree.Get(key).(*entry) if e == nil || len(e.ids) == 0 { return 0, false } return e.ids[0], true } // Has reports whether any id carries key. func (ix *Index) Has(key string) bool { if ix.s == nil { return false } return ix.s.tree.Has(key) } // Count returns how many ids carry key. func (ix *Index) Count(key string) int { if ix.s == nil { return 0 } e, _ := ix.s.tree.Get(key).(*entry) if e == nil { return 0 } return len(e.ids) } // Keys returns the number of distinct keys. func (ix *Index) Keys() int { if ix.s == nil { return 0 } return ix.s.tree.Size() } // Len returns the number of (key, id) pairs, which is what the index actually // costs. It is NOT [Index.Keys], and the two differ by exactly the amount of // fan-out a realm has. func (ix *Index) Len() int { if ix.s == nil { return 0 } return ix.s.pairs } // IsUnique reports whether this index was built by [Unique]. func (ix *Index) IsUnique() bool { return ix.s != nil && ix.s.unique } // Each visits every key in ascending order with a copy of its ids, and stops // when fn returns true. It reports whether it stopped early. // // True means stop, matching bptree.IterCbFn and kit/store's EachUntil exactly, // so a callback moved between them keeps its meaning. // // fn must not call [Index.Add], [Index.Remove] or [Index.RemoveKey] on this // index. The walk is the B+ tree's own, which holds a position inside a leaf: // removing the key being visited shifts the next one into that slot and the // walk steps over it, and inserting one ahead of it is visited twice. Collect // what to change, then change it after Each returns. func (ix *Index) Each(fn func(key string, ids []store.ID) bool) bool { if ix.s == nil { return false } return ix.s.tree.Iterate("", "", func(k string, v any) bool { e := v.(*entry) ids := make([]store.ID, len(e.ids)) copy(ids, e.ids) return fn(k, ids) }) } // Page returns page number page of size keys, ascending, each with a copy of // its ids. // // Pages are 1-based, and every out-of-range page (zero, negative, past the end, // any page of an empty index) returns nil rather than panicking, because the // page number usually arrives from a Render path and is user input. This is // NOT [store.Store.Page], which returns an empty non-nil slice past the end; // test with len, not with nil, when code handles both. func (ix *Index) Page(page, size int) []Entry { if ix.s == nil || page < 1 || size < 1 { return nil } // The offset is computed in a way that cannot wrap, because // bptree.IterateByOffset normalises a NEGATIVE offset to 0: an overflowed // (page-1)*size would silently hand back page 1 for a page past the end, // which is the opposite of this method's contract. // // page <= 1+(keys-1)/size bounds (page-1)*size by keys-1, so the product // cannot overflow, and it also rejects the page that starts exactly at the // end, which would otherwise come back as an empty non-nil slice while // every later page is nil. keys := ix.s.tree.Size() if keys == 0 || page > 1+(keys-1)/size { return nil } //gnovet:ignore page-offset-overflow bounded by keys-1 on the line above offset := (page - 1) * size // The capacity follows what exists, not what was asked for, so Page(1, // maxInt) over three keys reserves three slots and not two billion. room := keys - offset if room > size { room = size } out := make([]Entry, 0, room) ix.s.tree.IterateByOffset(offset, size, func(k string, v any) bool { e := v.(*entry) ids := make([]store.ID, len(e.ids)) copy(ids, e.ids) out = append(out, Entry{Key: k, IDs: ids}) return false }) return out } // Pages returns how many pages of the given size the keys fill, at least 1 so // a page picker always has something to render. func (ix *Index) Pages(size int) int { if size < 1 { return 1 } n := ix.Keys() if n == 0 { return 1 } // 1 + (n-1)/size rather than (n+size-1)/size: the second overflows when // size is near the integer limit, wraps negative, and divides to 0, which // contradicts the "at least 1" this method promises and divides a page // picker by zero. return 1 + (n-1)/size } // Entry is one key and the ids carrying it, as returned by [Index.Page]. type Entry struct { Key string IDs []store.ID } // find returns the position id occupies, or would occupy, in the ascending ids, // and whether it is already there. Binary search: a key with many ids is the // case this package exists for. func (e *entry) find(id store.ID) (int, bool) { lo, hi := 0, len(e.ids) for lo < hi { mid := int(uint(lo+hi) >> 1) switch { case e.ids[mid] == id: return mid, true case e.ids[mid] < id: lo = mid + 1 default: hi = mid } } return lo, false }
  8. #8index_test.gno
  9. #9package index import ( "testing" "gno.land/p/moul/kit/store/v0" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) func ids(ns ...uint64) []store.ID { out := make([]store.ID, len(ns)) for i, n := range ns { out[i] = store.ID(n) } return out } func equalIDs(t *testing.T, want, got []store.ID) { t.Helper() urequire.Equal(t, len(want), len(got)) for i := range want { uassert.Equal(t, want[i].String(), got[i].String()) } } func TestZeroValueIsUsable(t *testing.T) { var ix Index uassert.Equal(t, 0, ix.Keys()) uassert.Equal(t, 0, ix.Len()) uassert.False(t, ix.Has("gno")) uassert.Equal(t, 0, ix.Count("gno")) uassert.Equal(t, 0, len(ix.Lookup("gno"))) _, ok := ix.First("gno") uassert.False(t, ok) uassert.False(t, ix.Remove("gno", 1)) uassert.Equal(t, 0, ix.RemoveKey("gno")) uassert.False(t, ix.Each(func(string, []store.ID) bool { return true })) urequire.NoError(t, ix.Add("gno", 1)) uassert.Equal(t, 1, ix.Keys()) } // The whole point of sorting the ids: Lookup must depend on the SET of ids, not // on the order they arrived in, or two realms that indexed the same records in // a different order render differently. func TestLookupDoesNotDependOnInsertionOrder(t *testing.T) { forward, reverse := New(), New() for _, n := range []uint64{3, 1, 2} { urequire.NoError(t, forward.Add("gno", store.ID(n))) } for _, n := range []uint64{2, 1, 3} { urequire.NoError(t, reverse.Add("gno", store.ID(n))) } equalIDs(t, ids(1, 2, 3), forward.Lookup("gno")) equalIDs(t, ids(1, 2, 3), reverse.Lookup("gno")) } func TestAddIsIdempotentForTheSamePair(t *testing.T) { ix := New() urequire.NoError(t, ix.Add("gno", 7)) urequire.NoError(t, ix.Add("gno", 7)) equalIDs(t, ids(7), ix.Lookup("gno")) uassert.Equal(t, 1, ix.Len()) uassert.Equal(t, 1, ix.Keys()) } func TestAddRefusals(t *testing.T) { for _, tc := range []struct { name string key string id store.ID want error }{ {"empty key", "", 1, ErrEmptyKey}, {"zero id", "gno", 0, ErrZeroID}, } { t.Run(tc.name, func(t *testing.T) { ix := New() err := ix.Add(tc.key, tc.id) urequire.ErrorIs(t, err, tc.want) uassert.Equal(t, 0, ix.Len()) }) } } func TestUniqueRefusesASecondID(t *testing.T) { ix := Unique() uassert.True(t, ix.IsUnique()) urequire.NoError(t, ix.Add("moul", 1)) err := ix.Add("moul", 2) urequire.ErrorIs(t, err, ErrDuplicateKey) equalIDs(t, ids(1), ix.Lookup("moul")) uassert.Equal(t, 1, ix.Len()) // Re-adding the SAME pair is still a no-op, not a duplicate: a realm // re-running a write must not be punished for it. urequire.NoError(t, ix.Add("moul", 1)) uassert.Equal(t, 1, ix.Len()) } // Keys and Len answer different questions and the difference is the fan-out, // which is what the index actually costs. func TestKeysAndLenDiffer(t *testing.T) { ix := New() urequire.NoError(t, ix.Add("gno", 1)) urequire.NoError(t, ix.Add("gno", 2)) urequire.NoError(t, ix.Add("go", 3)) uassert.Equal(t, 2, ix.Keys()) uassert.Equal(t, 3, ix.Len()) uassert.Equal(t, 2, ix.Count("gno")) uassert.Equal(t, 1, ix.Count("go")) uassert.Equal(t, 0, ix.Count("rust")) } func TestRemoveDropsTheKeyWithItsLastID(t *testing.T) { ix := New() urequire.NoError(t, ix.Add("gno", 1)) urequire.NoError(t, ix.Add("gno", 2)) uassert.True(t, ix.Remove("gno", 1)) uassert.True(t, ix.Has("gno")) equalIDs(t, ids(2), ix.Lookup("gno")) uassert.True(t, ix.Remove("gno", 2)) uassert.False(t, ix.Has("gno")) uassert.Equal(t, 0, ix.Keys()) uassert.Equal(t, 0, ix.Len()) uassert.False(t, ix.Remove("gno", 2)) } func TestRemoveKey(t *testing.T) { ix := New() urequire.NoError(t, ix.Add("gno", 1)) urequire.NoError(t, ix.Add("gno", 2)) urequire.NoError(t, ix.Add("go", 3)) uassert.Equal(t, 2, ix.RemoveKey("gno")) uassert.Equal(t, 1, ix.Keys()) uassert.Equal(t, 1, ix.Len()) uassert.Equal(t, 0, ix.RemoveKey("gno")) } // A returned slice that is the stored one makes every caller a writer: an // assignment into it corrupts the index with no write path having been called. func TestLookupHandsOutACopy(t *testing.T) { ix := New() urequire.NoError(t, ix.Add("gno", 1)) urequire.NoError(t, ix.Add("gno", 2)) got := ix.Lookup("gno") got[0] = store.ID(999) equalIDs(t, ids(1, 2), ix.Lookup("gno")) } func TestEachIsAscendingAndStops(t *testing.T) { ix := New() for _, k := range []string{"go", "gno", "rust"} { urequire.NoError(t, ix.Add(k, 1)) } var seen []string uassert.False(t, ix.Each(func(k string, _ []store.ID) bool { seen = append(seen, k) return false })) urequire.Equal(t, 3, len(seen)) uassert.Equal(t, "gno", seen[0]) uassert.Equal(t, "go", seen[1]) uassert.Equal(t, "rust", seen[2]) var first string uassert.True(t, ix.Each(func(k string, _ []store.ID) bool { first = k return true })) uassert.Equal(t, "gno", first) } func TestEachHandsOutACopy(t *testing.T) { ix := New() urequire.NoError(t, ix.Add("gno", 1)) ix.Each(func(_ string, got []store.ID) bool { got[0] = store.ID(999) return true }) equalIDs(t, ids(1), ix.Lookup("gno")) } func TestPage(t *testing.T) { ix := New() for _, k := range []string{"a", "b", "c", "d", "e"} { urequire.NoError(t, ix.Add(k, 1)) } for _, tc := range []struct { name string page int size int want []string }{ {"first", 1, 2, []string{"a", "b"}}, {"second", 2, 2, []string{"c", "d"}}, {"last, short", 3, 2, []string{"e"}}, {"past the end", 4, 2, nil}, {"page zero", 0, 2, nil}, {"size zero", 1, 0, nil}, {"negative page", -1, 2, nil}, } { t.Run(tc.name, func(t *testing.T) { got := ix.Page(tc.page, tc.size) urequire.Equal(t, len(tc.want), len(got)) for i := range tc.want { uassert.Equal(t, tc.want[i], got[i].Key) } }) } } // Pages is what a page picker divides by, so it is never 0. func TestPagesIsNeverZero(t *testing.T) { ix := New() uassert.Equal(t, 1, ix.Pages(10)) uassert.Equal(t, 1, ix.Pages(0)) uassert.Equal(t, 1, ix.Pages(-1)) for _, k := range []string{"a", "b", "c"} { urequire.NoError(t, ix.Add(k, 1)) } uassert.Equal(t, 2, ix.Pages(2)) uassert.Equal(t, 1, ix.Pages(3)) uassert.Equal(t, 3, ix.Pages(1)) } func TestFirst(t *testing.T) { ix := New() urequire.NoError(t, ix.Add("gno", 9)) urequire.NoError(t, ix.Add("gno", 4)) got, ok := ix.First("gno") urequire.True(t, ok) uassert.Equal(t, "4", got.String()) _, ok = ix.First("missing") uassert.False(t, ok) } // Many ids under one key is the case this package exists for, so the binary // search has to hold at a size where a linear scan would still look fine. func TestManyIDsUnderOneKey(t *testing.T) { ix := New() for n := uint64(64); n >= 1; n-- { urequire.NoError(t, ix.Add("gno", store.ID(n))) } uassert.Equal(t, 64, ix.Count("gno")) uassert.Equal(t, 1, ix.Keys()) got := ix.Lookup("gno") urequire.Equal(t, 64, len(got)) for i, id := range got { uassert.Equal(t, store.ID(i+1).String(), id.String()) } } // maxInt is the largest int, which is where unchecked arithmetic in Page and // Pages goes wrong. const maxInt = int(^uint(0) >> 1) // A removed id must drop the old backing array, not merely shorten it. // // In gno a slice is ONE persisted object, and the deposit comes back when the // object is dropped and at no other time (EFFECTIVE_GNO.md section 2.12). // Shortening in place keeps the peak allocation charged until the whole key // goes, so a key that grew to 1,000 ids and shrank to 1 would still be paying // for 1,000. // // Not observable through the API, so this reads the entry directly: same // package, and the invariant is worth more than the encapsulation. func TestRemoveReleasesTheBackingArray(t *testing.T) { ix := New() for n := uint64(1); n <= 16; n++ { urequire.NoError(t, ix.Add("gno", store.ID(n))) } for n := uint64(1); n <= 15; n++ { uassert.True(t, ix.Remove("gno", store.ID(n))) } e, _ := ix.s.tree.Get("gno").(*entry) urequire.True(t, e != nil) uassert.Equal(t, 1, len(e.ids)) uassert.Equal(t, 1, cap(e.ids), "the shrunk bucket still owns its peak allocation, so the deposit never comes back") } // Page's contract is that a page past the end is nil. Unchecked // (page-1)*size wraps negative at the limit, and bptree.IterateByOffset // normalises a negative offset to 0, so the caller silently gets page 1. func TestPageDoesNotWrapAtTheIntegerLimit(t *testing.T) { ix := New() for _, k := range []string{"a", "b", "c"} { urequire.NoError(t, ix.Add(k, 1)) } uassert.Equal(t, 0, len(ix.Page(maxInt, 2)), "a page past the end is nil, including when the offset would overflow") uassert.Equal(t, 0, len(ix.Page(maxInt/2+2, 2))) } // A huge size must not reserve a huge slice for three keys. func TestPageCapsItsAllocationToWhatExists(t *testing.T) { ix := New() for _, k := range []string{"a", "b", "c"} { urequire.NoError(t, ix.Add(k, 1)) } got := ix.Page(1, maxInt) urequire.Equal(t, 3, len(got)) uassert.True(t, cap(got) <= 3, "the capacity follows the keys, not the requested size") } // Pages documents "at least 1". The ceiling n+size-1 wraps when size is the // limit, and the division then returns 0. func TestPagesNeverReturnsZeroAtTheIntegerLimit(t *testing.T) { ix := New() urequire.NoError(t, ix.Add("a", 1)) urequire.NoError(t, ix.Add("b", 1)) uassert.Equal(t, 1, ix.Pages(maxInt)) } // An Index is a handle. A copy taken after the first write must see every // later write through either copy, or Len (a counter) and Keys (the tree) // disagree about what the index holds. func TestCopiesAfterTheFirstWriteShareOneIndex(t *testing.T) { var a Index urequire.NoError(t, a.Add("a", 1)) b := a urequire.NoError(t, b.Add("b", 2)) uassert.Equal(t, 2, a.Keys()) uassert.Equal(t, 2, a.Len(), "the copy wrote a pair the original does not count") uassert.True(t, b.Remove("a", 1)) uassert.Equal(t, 1, a.Len()) u := *Unique() v := u urequire.NoError(t, v.Add("k", 1)) uassert.ErrorIs(t, u.Add("k", 2), ErrDuplicateKey) uassert.Equal(t, 1, u.Len()) } // Every out-of-range page is nil, including the one that starts exactly at // the end and the first page of an index that has been emptied. func TestPagePastTheEndIsAlwaysNil(t *testing.T) { ix := New() for _, k := range []string{"a", "b", "c", "d"} { urequire.NoError(t, ix.Add(k, 1)) } uassert.True(t, ix.Page(2, 2) != nil) uassert.True(t, ix.Page(3, 2) == nil, "a page starting exactly at the end is past the end") uassert.True(t, ix.Page(4, 2) == nil) for _, k := range []string{"a", "b", "c", "d"} { uassert.Equal(t, 1, ix.RemoveKey(k)) } uassert.True(t, ix.Page(1, 2) == nil, "an emptied index has no first page") var zero Index uassert.True(t, zero.Page(1, 2) == nil) }
#2AddPackagegno.land/p/moul/vendor/nt/ufmt/v217 arguments
Attached funds
21000000ugnot

Arguments · 17

  1. #1ufmt
  2. #2README.md
  3. #3# p/moul/vendor/nt/ufmt (v2 preview) **This is ufmt v2, published here before its real path exists.** The expected home is [`gno.land/p/nt/ufmt/v2`](/p/nt/ufmt/v2), which is not published yet. Until it is, import: ```go import "gno.land/p/moul/vendor/nt/ufmt/v2" ``` When `gno.land/p/nt/ufmt/v2` goes live, change the import and nothing else. This path is then frozen: it keeps working, it never gets another fix. ## Why [`p/nt/ufmt/v0`](/p/nt/ufmt/v0) knows a handful of verbs and no flags, and it fails quietly. `Sprintf("0x%08X", pc)` prints `0x(unhandled verb: %X)`, `Sprintf("%08x", w)` prints `(unhandled)`, and `Sprintf("%03d", 7)` prints `7`. [`r/moul/x/vm/riscvdemo`](/r/moul/x/vm/riscvdemo/v0) shipped a register dump that was nothing but those. v2 is Go's `fmt`, as far as gno can follow it: | | | |---|---| | flags | `+` `-` `#` ` ` `0` | | width, precision | `%8d`, `%.2f`, `%*d`, `%.*f`, `%.3s` in runes | | argument indexes | `%[2]d %[1]s`, `%[1]*[2]d` | | integers | `%b %c %d %o %O %q %x %X %U`, every int and uint type | | floats | `%b %e %E %f %F %g %G %x %X`, float32 and float64 | | strings, `[]byte` | `%s %q %x %X`, `% x`, `%#q` | | methods | `Formatter`, `GoStringer`, `error`, `Stringer`, in Go's order | | errors | `Errorf` with `%w`, one or several, seen by `errors.Is` | | composites | slices of the basic types, `[]any`, `map[string]T` sorted by key | A mistake is printed, never panicked, with Go's strings: `%!d(string=hi)`, `%!d(MISSING)`, `%!(EXTRA int=1)`, `%!(NOVERB)`. A `Render` cannot be taken down by a format string. ## How it is tested `gofmt_test.gno` is 210 cases whose expected output was produced by running each one through Go's `fmt.Sprintf` (go1.27.1), so a failure there is, by construction, a place v2 and Go disagree. It also costs less gas than v0 on the calls realms make most: about 211k against 254k per `Sprintf("id %d is %s (%t)", ...)`, measured with `gno test`. ## What it cannot do gno has no `reflect`. A struct, a pointer, or a named type without a `String` method prints `(unhandled)`, and `%T` names only the types it knows. Give the type a `String` method. `%p` is not supported. ## Migrating from v0 Mostly a change of import, with these differences, all of them toward Go: - `Sprint` adds a space only between two operands that are both non-strings. - a float prints in its shortest form from `%v` and `Sprint` (`3.14`, not `3.140000`), and `%e` defaults to 6 digits. - `%v` of a `[]byte` is `[104 105]`; use `%s` for the text. - a missing or extra argument is written into the output instead of panicking. - `error` wins over `String` when a value has both. <!-- 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. #4doc.gno
  5. #5// Package ufmt is a preview of gno.land/p/nt/ufmt/v2, published here before // that path exists. // // This is v2 of ufmt. It is not yet published at its expected path, // gno.land/p/nt/ufmt/v2. Until it is, import it from // gno.land/p/moul/vendor/nt/ufmt/v2; once gno.land/p/nt/ufmt/v2 is live, // switch the import and nothing else, because the API is meant to be the same. // This path is then frozen and never updated again. // // v0 (gno.land/p/nt/ufmt/v0) supports a handful of verbs and no flags, so // "%08x", "%X" and "%-10s" print "(unhandled)" or drop their padding without a // word. v2 follows Go's fmt instead: the same flags, widths, precisions and // verbs, and the same error strings in place of panics. Where it cannot follow // Go, because gno has no reflect, it says so below. // // Supported, as in Go's fmt: // // flags '+' '-' '#' ' ' '0' // width %8d, %*d, and a negative * width left-justifies // precision %.2f, %.*f, %.3s (in runes) // indexes %[2]d %[1]s, %[1]*[2]d // general %v %+v %#v %T %% // bool %t // integer %b %c %d %o %O %q %x %X %U, every int and uint type // float %b %e %E %f %F %g %G %x %X, float32 and float64 // string %s %q %x %X, and []byte the same way // errors %w in Errorf, unwrapped by errors.Unwrap and errors.Is // methods Formatter, GoStringer, error, Stringer, in Go's order // composite []T of the basic types, []any, []error, and map[string]T // with its keys sorted, as Go prints them // // Errors are written into the output, never panicked: "%!d(string=hi)" for a // wrong type, "%!d(MISSING)" for a missing argument, "%!(EXTRA int=1)" for an // unused one, "%!(NOVERB)" for a trailing '%'. // // What gno cannot do, and what v2 prints instead: // // - no reflect, so a struct, a pointer, a named type without a String // method, or a slice or map of anything other than the types listed above // prints "(unhandled)", and %T names only the types it knows ("unknown" // otherwise). Give the type a String method. // - %p is not supported. // // What changed from v0, so a migration is a review and not a find-and-replace: // // - Sprint adds a space only between two operands when neither is a string, // as Go does; v0 put a space between every pair. // - %v of a float, and Sprint of one, is the shortest representation ("3.14"), // as Go does; v0 printed "%f" ("3.140000") from Sprint. // - %e defaults to 6 digits after the point, as Go does; v0 used 2. // - %v of a []byte prints "[104 105]", as Go does; v0 wrote the raw bytes. // Use %s for the string. // - a missing or extra argument, or a trailing '%', is written into the // output instead of panicking. // - an error is preferred to a String method when a value has both. package ufmt
  6. #6format.gno
  7. #7package ufmt import ( "strconv" "unicode/utf8" ) // This file is the low level: padding, integers, floats, strings. It is a port // of format.go in Go's fmt, kept close enough to read side by side. const ( ldigits = "0123456789abcdefx" udigits = "0123456789ABCDEFX" ) // clearFlags resets everything a verb's specifier sets. func (p *printer) clearFlags() { p.plus, p.minus, p.sharp, p.space, p.zero = false, false, false, false, false p.plusV, p.sharpV = false, false p.wid, p.prec = 0, 0 p.widPresent, p.precPresent = false, false } // writePadding writes n pad bytes: zeros when the 0 flag is set, else spaces. func (p *printer) writePadding(n int) { if n <= 0 { return } c := byte(' ') if p.zero { c = '0' } for i := 0; i < n; i++ { p.buf = append(p.buf, c) } } // pad writes b, padded to the width in runes, on the side the - flag says. func (p *printer) pad(b []byte) { if !p.widPresent || p.wid == 0 { p.buf = append(p.buf, b...) return } width := p.wid - utf8.RuneCount(b) if !p.minus { p.writePadding(width) p.buf = append(p.buf, b...) } else { p.buf = append(p.buf, b...) p.writePadding(width) } } // padString is pad for a string. func (p *printer) padString(s string) { if !p.widPresent || p.wid == 0 { p.buf = append(p.buf, s...) return } width := p.wid - utf8.RuneCountInString(s) if !p.minus { p.writePadding(width) p.buf = append(p.buf, s...) } else { p.buf = append(p.buf, s...) p.writePadding(width) } } // padNoZero pads with spaces whatever the 0 flag says. func (p *printer) padNoZero(b []byte) { oldZero := p.zero p.zero = false p.pad(b) p.zero = oldZero } func (p *printer) fmtBoolean(v bool) { if v { p.padString("true") } else { p.padString("false") } } // fmtInteger formats u, the magnitude, in base, with a '-' when negative. func (p *printer) fmtInteger(u uint64, negative bool, base int, verb rune, digits string) { // Fast path: no flags, width or precision, lowercase digits. if !p.widPresent && !p.precPresent && !p.plus && !p.space && !p.sharp && verb != 'O' && (base != 16 || digits[10] == 'a') { if negative { p.buf = append(p.buf, '-') } p.buf = strconv.AppendUint(p.buf, u, base) return } prec := 0 if p.precPresent { prec = p.prec // %.0d of zero prints nothing but its padding. if prec == 0 && u == 0 { oldZero := p.zero p.zero = false p.writePadding(p.wid) p.zero = oldZero return } } else if p.zero && !p.minus && p.widPresent { // Zero padding is a precision in disguise, minus room for the sign. prec = p.wid if negative || p.plus || p.space { prec-- } } size := 68 // 64 binary digits, a sign and a 0b/0x/0o prefix if prec+4 > size { size = prec + 4 } buf := make([]byte, size) i := size switch base { case 10: for u >= 10 { i-- next := u / 10 buf[i] = byte('0' + u - next*10) u = next } case 16: for u >= 16 { i-- buf[i] = digits[u&0xF] u >>= 4 } case 8: for u >= 8 { i-- buf[i] = byte('0' + u&7) u >>= 3 } case 2: for u >= 2 { i-- buf[i] = byte('0' + u&1) u >>= 1 } } i-- buf[i] = digits[u] for i > 0 && prec > size-i { i-- buf[i] = '0' } if p.sharp { switch base { case 2: i-- buf[i] = 'b' i-- buf[i] = '0' case 8: if buf[i] != '0' { i-- buf[i] = '0' } case 16: i-- buf[i] = digits[16] i-- buf[i] = '0' } } if verb == 'O' { i-- buf[i] = 'o' i-- buf[i] = '0' } if negative { i-- buf[i] = '-' } else if p.plus { i-- buf[i] = '+' } else if p.space { i-- buf[i] = ' ' } // The zeros, if any, are already in buf: pad the rest with spaces. p.padNoZero(buf[i:]) } // fmtUnicode formats u as "U+0078", or "U+0078 'x'" with the # flag. func (p *printer) fmtUnicode(u uint64) { prec := 4 if p.precPresent && p.prec > 4 { prec = p.prec } hex := strconv.FormatUint(u, 16) b := []byte("U+") for n := len(hex); n < prec; n++ { b = append(b, '0') } for i := 0; i < len(hex); i++ { c := hex[i] if c >= 'a' { c -= 'a' - 'A' } b = append(b, c) } if p.sharp && u <= utf8.MaxRune && strconv.IsPrint(rune(u)) { b = append(b, ' ', '\'') b = utf8.AppendRune(b, rune(u)) b = append(b, '\'') } p.padNoZero(b) } // fmtC formats u as the rune it encodes, or U+FFFD when it encodes none. func (p *printer) fmtC(u uint64) { r := rune(utf8.RuneError) if u <= utf8.MaxRune { r = rune(u) } p.pad(utf8.AppendRune(nil, r)) } // fmtQc formats u as a single-quoted rune literal. func (p *printer) fmtQc(u uint64) { r := rune(utf8.RuneError) if u <= utf8.MaxRune { r = rune(u) } if p.plus { p.pad(strconv.AppendQuoteRuneToASCII(nil, r)) } else { p.pad(strconv.AppendQuoteRune(nil, r)) } } // fmtFloat formats v, with prec digits (-1 for the shortest) unless the verb // set its own precision. func (p *printer) fmtFloat(v float64, size int, verb rune, prec int) { if p.precPresent { prec = p.prec } // num[0] is reserved for the sign. num := strconv.AppendFloat([]byte{0}, v, byte(verb), prec, size) if num[1] == '-' || num[1] == '+' { num = num[1:] } else { num[0] = '+' } if p.space && num[0] == '+' && !p.plus { num[0] = ' ' } // Infinities and NaN do not look like numbers, so they are never zero padded. if num[1] == 'I' || num[1] == 'N' { oldZero := p.zero p.zero = false if num[1] == 'N' && !p.space && !p.plus { num = num[1:] } p.pad(num) p.zero = oldZero return } // The # flag keeps the decimal point, and for %g the trailing zeros. if p.sharp && verb != 'b' { digits := 0 if verb == 'v' || verb == 'g' || verb == 'G' || verb == 'x' { digits = prec if digits == -1 { digits = 6 } } var tail []byte hasDecimalPoint := false sawNonzeroDigit := false for i := 1; i < len(num); i++ { c := num[i] if c == '.' { hasDecimalPoint = true continue } if c == 'p' || c == 'P' || ((c == 'e' || c == 'E') && verb != 'x' && verb != 'X') { tail = append(tail, num[i:]...) num = num[:i] break } if c != '0' { sawNonzeroDigit = true } if sawNonzeroDigit { digits-- } } if !hasDecimalPoint { // A lone leading zero counts as one significant digit. if len(num) == 2 && num[1] == '0' { digits-- } num = append(num, '.') } for digits > 0 { num = append(num, '0') digits-- } num = append(num, tail...) } if p.plus || num[0] != '+' { // Zero padding goes after the sign. if p.zero && !p.minus && p.widPresent && p.wid > len(num) { p.buf = append(p.buf, num[0]) p.writePadding(p.wid - len(num)) p.buf = append(p.buf, num[1:]...) return } p.pad(num) return } p.pad(num[1:]) } // truncate cuts s to the precision, counted in runes. func (p *printer) truncate(s string) string { if p.precPresent { n := p.prec for i := range s { n-- if n < 0 { return s[:i] } } } return s } func (p *printer) fmtS(s string) { p.padString(p.truncate(s)) } // fmtSx formats s as hex, two digits a byte. The space flag separates bytes, // and # prefixes 0x, once or before each byte when spaced. func (p *printer) fmtSx(s string, digits string) { length := len(s) if p.precPresent && p.prec < length { length = p.prec } width := 2 * length if width > 0 { if p.space { if p.sharp { width *= 2 } width += length - 1 } else if p.sharp { width += 2 } } else { if p.widPresent { p.writePadding(p.wid) } return } if p.widPresent && p.wid > width && !p.minus { p.writePadding(p.wid - width) } if p.sharp { p.buf = append(p.buf, '0', digits[16]) } for i := 0; i < length; i++ { if p.space && i > 0 { p.buf = append(p.buf, ' ') if p.sharp { p.buf = append(p.buf, '0', digits[16]) } } c := s[i] p.buf = append(p.buf, digits[c>>4], digits[c&0xF]) } if p.widPresent && p.wid > width && p.minus { p.writePadding(p.wid - width) } } // fmtQ formats s as a double-quoted Go string, backquoted with the # flag when // it can be, and ASCII only with the + flag. func (p *printer) fmtQ(s string) { s = p.truncate(s) if p.sharp && strconv.CanBackquote(s) { p.padString("`" + s + "`") return } if p.plus { p.pad(strconv.AppendQuoteToASCII(nil, s)) } else { p.pad(strconv.AppendQuote(nil, s)) } }
  8. #8gnomod.toml
  9. #9module = "gno.land/p/moul/vendor/nt/ufmt/v2" gno = "0.9"
  10. #10gofmt_test.gno
  11. #11package ufmt import ( "math" "testing" ) // goCases were generated by running each row through Go's fmt.Sprintf // (go1.27.1), so a row that fails here is a place v2 differs from Go. var goCases = []struct { format string args []any want string }{ {"%d", []any{7}, "7"}, {"%03d", []any{7}, "007"}, {"%08X", []any{uint32(0xdead)}, "0000DEAD"}, {"0x%08X", []any{uint32(0x10074)}, "0x00010074"}, {"%08x", []any{uint32(0x00a00513)}, "00a00513"}, {"%x", []any{uint32(255)}, "ff"}, {"%X", []any{-255}, "-FF"}, {"%#x", []any{255}, "0xff"}, {"%#X", []any{255}, "0XFF"}, {"%#08x", []any{255}, "0x000000ff"}, {"%#o", []any{8}, "010"}, {"%O", []any{8}, "0o10"}, {"%b", []any{5}, "101"}, {"%#b", []any{5}, "0b101"}, {"%o", []any{-8}, "-10"}, {"%5d", []any{42}, " 42"}, {"%-5d|", []any{42}, "42 |"}, {"%+d", []any{42}, "+42"}, {"% d", []any{42}, " 42"}, {"%+05d", []any{42}, "+0042"}, {"%-05d|", []any{42}, "42 |"}, {"%.3d", []any{7}, "007"}, {"%8.3d", []any{-7}, " -007"}, {"%.0d", []any{0}, ""}, {"%5.0d|", []any{0}, " |"}, {"%d", []any{int64(-9223372036854775808)}, "-9223372036854775808"}, {"%x", []any{int64(-9223372036854775808)}, "-8000000000000000"}, {"%d", []any{uint64(18446744073709551615)}, "18446744073709551615"}, {"%x", []any{uint64(18446744073709551615)}, "ffffffffffffffff"}, {"%d", []any{int8(-128)}, "-128"}, {"%d", []any{uint8(200)}, "200"}, {"%x", []any{int16(-1)}, "-1"}, {"%d", []any{"hi"}, "%!d(string=hi)"}, {"%s", []any{42}, "%!s(int=42)"}, {"%c", []any{'x'}, "x"}, {"%c", []any{0x1F600}, "😀"}, {"%q", []any{'x'}, "'x'"}, {"%+q", []any{'é'}, "'\\u00e9'"}, {"%U", []any{0x1F600}, "U+1F600"}, {"%#U", []any{'x'}, "U+0078 'x'"}, {"%U", []any{1}, "U+0001"}, {"%.6U", []any{1}, "U+000001"}, {"%c", []any{-1}, "�"}, {"%s", []any{"hello"}, "hello"}, {"%10s|", []any{"hello"}, " hello|"}, {"%-10s|", []any{"hello"}, "hello |"}, {"%.2s", []any{"hello"}, "he"}, {"%.2s", []any{"héllo"}, "hé"}, {"%5.1s|", []any{"héllo"}, " h|"}, {"%05s", []any{"ab"}, "000ab"}, {"%q", []any{"hi\n"}, "\"hi\\n\""}, {"%+q", []any{"héllo"}, "\"h\\u00e9llo\""}, {"%#q", []any{"back`tick"}, "\"back`tick\""}, {"%#q", []any{"plain"}, "`plain`"}, {"%x", []any{"hello"}, "68656c6c6f"}, {"%X", []any{"hello"}, "68656C6C6F"}, {"% x", []any{"hello"}, "68 65 6c 6c 6f"}, {"%# x", []any{"hi"}, "0x68 0x69"}, {"%#x", []any{"hi"}, "0x6869"}, {"%10x|", []any{"hi"}, " 6869|"}, {"%-10x|", []any{"hi"}, "6869 |"}, {"%x", []any{""}, ""}, {"%5x|", []any{""}, " |"}, {"%.1x", []any{"hi"}, "68"}, {"%s", []any{[]byte("bytes")}, "bytes"}, {"%x", []any{[]byte{1, 171}}, "01ab"}, {"%X", []any{[]byte{1, 171}}, "01AB"}, {"%v", []any{[]byte{1, 2}}, "[1 2]"}, {"%d", []any{[]byte{1, 2}}, "[1 2]"}, {"%#v", []any{[]byte{1, 2}}, "[]byte{0x1, 0x2}"}, {"%q", []any{[]byte("q")}, "\"q\""}, {"%b", []any{[]byte{3}}, "[11]"}, {"%t", []any{true}, "true"}, {"%v", []any{false}, "false"}, {"%5t|", []any{true}, " true|"}, {"%d", []any{true}, "%!d(bool=true)"}, {"%f", []any{3.14159}, "3.141590"}, {"%.2f", []any{3.14159}, "3.14"}, {"%8.3f|", []any{3.14159}, " 3.142|"}, {"%-8.3f|", []any{3.14159}, "3.142 |"}, {"%08.3f", []any{-3.14159}, "-003.142"}, {"%+.1f", []any{2.0}, "+2.0"}, {"% .1f", []any{2.0}, " 2.0"}, {"%e", []any{1234.5678}, "1.234568e+03"}, {"%E", []any{1234.5678}, "1.234568E+03"}, {"%.2e", []any{1234.5678}, "1.23e+03"}, {"%g", []any{1234.5678}, "1234.5678"}, {"%g", []any{1e21}, "1e+21"}, {"%g", []any{0.00001}, "1e-05"}, {"%G", []any{1e-10}, "1E-10"}, {"%v", []any{3.14}, "3.14"}, {"%v", []any{1e6}, "1e+06"}, {"%v", []any{100000.0}, "100000"}, {"%v", []any{float32(0.1)}, "0.1"}, {"%F", []any{1.5}, "1.500000"}, {"%#g", []any{1.0}, "1.00000"}, {"%#.3g", []any{1.0}, "1.00"}, {"%#f", []any{1.0}, "1.000000"}, {"%#.0f", []any{1.0}, "1."}, {"%#.0e", []any{1.0}, "1.e+00"}, {"%x", []any{1.0}, "0x1p+00"}, {"%X", []any{1.0}, "0X1P+00"}, {"%b", []any{1.0}, "4503599627370496p-52"}, {"%.3x", []any{3.14159}, "0x1.922p+01"}, {"%f", []any{math.Inf(1)}, "+Inf"}, {"%+f", []any{math.Inf(1)}, "+Inf"}, {"%08f", []any{math.Inf(-1)}, " -Inf"}, {"%f", []any{math.NaN()}, "NaN"}, {"%+f", []any{math.NaN()}, "+NaN"}, {"%010.2f", []any{12.5}, "0000012.50"}, {"%s", []any{1.5}, "%!s(float64=1.5)"}, {"%v", []any{nil}, "<nil>"}, {"%d", []any{nil}, "%!d(<nil>)"}, {"%s", []any{nil}, "%!s(<nil>)"}, {"%T", []any{nil}, "<nil>"}, {"%T", []any{42}, "int"}, {"%T", []any{"s"}, "string"}, {"%T", []any{[]byte("x")}, "[]uint8"}, {"%T", []any{3.0}, "float64"}, {"%T", []any{int8(1)}, "int8"}, {"%T", []any{[]string{"a"}}, "[]string"}, {"%T", []any{map[string]int{}}, "map[string]int"}, {"%5T|", []any{1}, " int|"}, {"%v", []any{[]string{"a", "b"}}, "[a b]"}, {"%q", []any{[]string{"a", "b"}}, "[\"a\" \"b\"]"}, {"%#v", []any{[]string{"a", "b"}}, "[]string{\"a\", \"b\"}"}, {"%#v", []any{[]string(nil)}, "[]string(nil)"}, {"%v", []any{[]int{1, 2, 3}}, "[1 2 3]"}, {"%3d", []any{[]int{1, 2}}, "[ 1 2]"}, {"%x", []any{[]int{10, 255}}, "[a ff]"}, {"%d", []any{[]string{"a"}}, "[%!d(string=a)]"}, {"%v", []any{[]any{1, "a", nil, true}}, "[1 a <nil> true]"}, {"%#v", []any{[]any{1, "a", nil}}, "[]interface {}{1, \"a\", interface {}(nil)}"}, {"%v", []any{[]float64{1.5, 2}}, "[1.5 2]"}, {"%v", []any{map[string]int{"b": 2, "a": 1, "c": 3}}, "map[a:1 b:2 c:3]"}, {"%#v", []any{map[string]int{"b": 2, "a": 1}}, "map[string]int{\"a\":1, \"b\":2}"}, {"%v", []any{map[string]string{"k": "v"}}, "map[k:v]"}, {"%v", []any{map[string]any{"x": nil, "y": 1}}, "map[x:<nil> y:1]"}, {"%v", []any{map[string]bool{}}, "map[]"}, {"%#v", []any{"str"}, "\"str\""}, {"%#v", []any{42}, "42"}, {"%#v", []any{uint(42)}, "0x2a"}, {"%#v", []any{3.0}, "3"}, {"%#v", []any{true}, "true"}, {"%#v", []any{nil}, "<nil>"}, {"%+v", []any{42}, "42"}, {"%+v", []any{"x"}, "x"}, {"%%", []any{}, "%"}, {"100%%", []any{}, "100%"}, {"%5%", []any{}, "%"}, {"%", []any{}, "%!(NOVERB)"}, {"abc%", []any{}, "abc%!(NOVERB)"}, {"%!", []any{}, "%!!(MISSING)"}, {"%z", []any{1}, "%!z(int=1)"}, {"%d", []any{}, "%!d(MISSING)"}, {"%d %d", []any{1}, "1 %!d(MISSING)"}, {"%d", []any{1, 2}, "1%!(EXTRA int=2)"}, {"%s", []any{"a", 3, nil}, "a%!(EXTRA int=3, <nil>)"}, {"%[2]d %[1]d", []any{1, 2}, "2 1"}, {"%[1]d %[1]x", []any{255}, "255 ff"}, {"%[3]d", []any{1, 2}, "%!d(BADINDEX)"}, {"%[0]d", []any{1}, "%!d(BADINDEX)"}, {"%[x]d", []any{1}, "%!d(BADINDEX)"}, {"%[1]*d|", []any{5, 42}, " 42|"}, {"%[2]*[1]d|", []any{42, 6}, " 42|"}, {"%*d|", []any{5, 42}, " 42|"}, {"%-*d|", []any{5, 42}, "42 |"}, {"%*d|", []any{-5, 42}, "42 |"}, {"%.*f", []any{2, 3.14159}, "3.14"}, {"%.*f", []any{-1, 3.14159}, "%!(BADPREC)3.141590"}, {"%*d", []any{"x", 1}, "%!(BADWIDTH)1"}, {"%.*d", []any{"x", 1}, "%!(BADPREC)1"}, {"%*.*f|", []any{8, 2, 3.14159}, " 3.14|"}, {"%d %s", []any{1}, "1 %!s(MISSING)"}, {"%[2]d", []any{1}, "%!d(BADINDEX)"}, {"%5.2v|", []any{3.14159}, " 3.1|"}, {"%v %v", []any{"a", "b"}, "a b"}, {"%-+5d|", []any{3}, "+3 |"}, {"%+-5d|", []any{3}, "+3 |"}, {"%0-5d|", []any{3}, "3 |"}, {"%x", []any{-1}, "-1"}, {"%#v", []any{[]byte(nil)}, "[]byte(nil)"}, {"%v", []any{[]byte{}}, "[]"}, {"%v", []any{[]string{}}, "[]"}, {"%d", []any{[]any{1, "x"}}, "[1 %!d(string=x)]"}, {"%v", []any{int32(-5)}, "-5"}, {"%c", []any{65}, "A"}, {"%x", []any{"\xff\x00"}, "ff00"}, {"%s", []any{"日本語"}, "日本語"}, {"%6s|", []any{"日本語"}, " 日本語|"}, {"%-6s|", []any{"日本語"}, "日本語 |"}, {"%q", []any{"日本語"}, "\"日本語\""}, {"%+q", []any{"日本語"}, "\"\\u65e5\\u672c\\u8a9e\""}, {"%x", []any{"日本"}, "e697a5e69cac"}, {"%v", []any{"\x00"}, "\x00"}, {"%q", []any{'\n'}, "'\\n'"}, {"%q", []any{0x110000}, "'�'"}, {"%U", []any{-1}, "U+FFFFFFFFFFFFFFFF"}, {"%10.4f|", []any{1234.56789}, " 1234.5679|"}, {"%-10.1e|", []any{1234.56789}, "1.2e+03 |"}, {"%+e", []any{0.0}, "+0.000000e+00"}, {"%g", []any{-0.0}, "0"}, {"%.3g", []any{123456.0}, "1.23e+05"}, {"%.10g", []any{1.0 / 3}, "0.3333333333"}, {"%e", []any{float32(1.0) / 3}, "3.333333e-01"}, {"%v", []any{float32(16777216.0)}, "1.6777216e+07"}, {"%v", []any{1e100}, "1e+100"}, {"%v", []any{123456789.0}, "1.23456789e+08"}, {"%v", []any{0.000001}, "1e-06"}, {"%v", []any{0.0000001}, "1e-07"}, } func TestSprintfMatchesGo(t *testing.T) { for _, c := range goCases { if got := Sprintf(c.format, c.args...); got != c.want { t.Errorf("Sprintf(%q, %v) = %q, want %q", c.format, c.args, got, c.want) } } }
  12. #12methods_test.gno
  13. #13package ufmt import ( "errors" "strings" "testing" ) type str struct{ s string } func (v str) String() string { return v.s } type errT struct{} func (errT) Error() string { return "boom" } func (errT) String() string { return "stringer" } type goStr struct{} func (goStr) GoString() string { return "goStr{}" } func (goStr) String() string { return "plain" } type formatter struct{} func (formatter) Format(f State, verb rune) { w, wok := f.Width() p, pok := f.Precision() Fprintf(f, "F[%c w=%d/%t p=%d/%t minus=%t plus=%t sharp=%t]", verb, w, wok, p, pok, f.Flag('-'), f.Flag('+'), f.Flag('#')) } type panicky struct{} func (panicky) String() string { panic("nope") } type opaque struct{ n int } // Every want below is what Go's fmt prints for the same call (go1.27.1), // except where a comment says gno cannot see what Go sees. func TestMethods(t *testing.T) { cases := []struct{ got, want string }{ {Sprintf("%v|%s|%q|%x|%10s|%-6v|", str{"hi"}, str{"hi"}, str{"hi"}, str{"hi"}, str{"hi"}, str{"hi"}), `hi|hi|"hi"|6869| hi|hi |`}, {Sprintf("%v %s", errT{}, errT{}), "boom boom"}, // Error wins over String {Sprintf("%#v %v", goStr{}, goStr{}), "goStr{} plain"}, {Sprintf("%v|%-8.3s|%+#x", formatter{}, formatter{}, formatter{}), "F[v w=0/false p=0/false minus=false plus=false sharp=false]|" + "F[s w=8/true p=3/true minus=true plus=false sharp=false]|" + "F[x w=0/false p=0/false minus=false plus=true sharp=true]"}, {Sprintf("%v", panicky{}), "%!v(PANIC=String method: nope)"}, {Sprintf("%v", []any{str{"a"}, errT{}}), "[a boom]"}, {Sprintf("%s", []error{errors.New("base"), errT{}}), "[base boom]"}, {Sprint("a", 1, 2, "b", "c", 3.5, true, nil), "a1 2bc3.5 true <nil>"}, {Sprint(str{"x"}, str{"y"}), "x y"}, {Sprintln("a", 1, 2, "b"), "a 1 2 b\n"}, // Go reflects into the struct and prints "{%!d(string=hi)}" and // "{7}"; gno has no reflect, so v2 says so instead of guessing. {Sprintf("%d", str{"hi"}), "%!d(unhandled)"}, {Sprintf("%v", opaque{7}), "(unhandled)"}, {Sprintf("%T", opaque{7}), "unknown"}, // Go prints "%!w(*errors.errorString=&{base})": the type is not // nameable here either. {Sprintf("%w", errors.New("base")), "%!w(unhandled)"}, } for i, c := range cases { if c.got != c.want { t.Errorf("case %d: got %q, want %q", i, c.got, c.want) } } } func TestErrorf(t *testing.T) { base := errors.New("base") e := Errorf("wrap: %w", base) if e.Error() != "wrap: base" || !errors.Is(e, base) || errors.Unwrap(e) != base { t.Errorf("single %%w: %q, Is=%t", e.Error(), errors.Is(e, base)) } e2 := Errorf("two: %w and %w", base, errT{}) if e2.Error() != "two: base and boom" || !errors.Is(e2, base) || errors.Unwrap(e2) != nil { t.Errorf("two %%w: %q, Is=%t", e2.Error(), errors.Is(e2, base)) } if got := Errorf("%w", "notanerror").Error(); got != "%!w(string=notanerror)" { t.Errorf("non-error %%w: %q", got) } plain := Errorf("plain %d", 1) if plain.Error() != "plain 1" || errors.Unwrap(plain) != nil { t.Errorf("plain: %q", plain.Error()) } } func TestWriters(t *testing.T) { var sb strings.Builder n, err := Fprintf(&sb, "%04d|", 7) Fprint(&sb, "a", 1) Fprintln(&sb, "!", 2) if sb.String() != "0007|a1! 2\n" || n != 5 || err != nil { t.Errorf("Fprint*: %q n=%d err=%v", sb.String(), n, err) } b := Appendf([]byte("x="), "%x", 255) b = Append(b, " ", true) b = Appendln(b, "", 1) if string(b) != "x=ff true 1\n" { t.Errorf("Append*: %q", string(b)) } } // The register dump that v0 could not print: "0x(unhandled verb: %X)". func TestRegisterDump(t *testing.T) { got := Sprintf("0x%08X %08x", uint32(0x1004), uint32(0xa00513)) if got != "0x00001004 00a00513" { t.Errorf("got %q", got) } }
  14. #14print.gno
  15. #15package ufmt import ( "sort" "unicode/utf8" ) // This file is the high level: parsing the format, and choosing how each // operand prints. It is a port of print.go in Go's fmt, minus reflect. const ( commaSpace = ", " nilAngle = "<nil>" nilParen = "(nil)" unhandled = "(unhandled)" percentBang = "%!" missing = "(MISSING)" badIndex = "(BADINDEX)" extra = "%!(EXTRA " badWidth = "%!(BADWIDTH)" badPrec = "%!(BADPREC)" noVerb = "%!(NOVERB)" ) // printer holds the output and the state of the verb being printed. type printer struct { buf []byte // The flags, width and precision of the current verb. plus, minus, sharp, space, zero bool plusV, sharpV bool // %+v and %#v wid, prec int widPresent, precPresent bool arg any // the operand being printed, for error messages erroring bool // printing an error message: do not call methods reordered bool // an explicit index was used goodArgNum bool // the last index was valid wrapErrs bool // Errorf: %w is allowed wrapped []int } func newPrinter() *printer { return &printer{} } // Write implements State, so a Formatter can write. func (p *printer) Write(b []byte) (int, error) { p.buf = append(p.buf, b...) return len(b), nil } // WriteString lets a Formatter write a string without a conversion. func (p *printer) WriteString(s string) (int, error) { p.buf = append(p.buf, s...) return len(s), nil } // Width implements State. func (p *printer) Width() (int, bool) { return p.wid, p.widPresent } // Precision implements State. func (p *printer) Precision() (int, bool) { return p.prec, p.precPresent } // Flag implements State. func (p *printer) Flag(c int) bool { switch c { case '-': return p.minus case '+': return p.plus || p.plusV case '#': return p.sharp || p.sharpV case ' ': return p.space case '0': return p.zero } return false } func (p *printer) writeString(s string) { p.buf = append(p.buf, s...) } func (p *printer) writeByte(c byte) { p.buf = append(p.buf, c) } func (p *printer) writeRune(r rune) { p.buf = utf8.AppendRune(p.buf, r) } // doPrint is Sprint: %v, with a space between two operands neither of which is // a string. func (p *printer) doPrint(a []any) { prevString := false for argNum, arg := range a { _, isString := arg.(string) if argNum > 0 && !isString && !prevString { p.writeByte(' ') } p.printArg(arg, 'v') prevString = isString } } // doPrintln is Sprintln: %v, always a space between operands, then a newline. func (p *printer) doPrintln(a []any) { for argNum, arg := range a { if argNum > 0 { p.writeByte(' ') } p.printArg(arg, 'v') } p.writeByte('\n') } // parsenum reads a decimal number at s[start:end]. func parsenum(s string, start, end int) (num int, isnum bool, newi int) { if start >= end { return 0, false, end } for newi = start; newi < end && '0' <= s[newi] && s[newi] <= '9'; newi++ { if num > 1e6 { return 0, false, end // absurdly large: refuse rather than allocate } num = num*10 + int(s[newi]-'0') isnum = true } return } // intFromArg reads a * width or precision from a[argNum]. func intFromArg(a []any, argNum int) (num int, isInt bool, newArgNum int) { newArgNum = argNum if argNum >= len(a) { return } isInt = true switch v := a[argNum].(type) { case int: num = v case int8: num = int(v) case int16: num = int(v) case int32: num = int(v) case int64: num = int(v) case uint: num = int(v) case uint8: num = int(v) case uint16: num = int(v) case uint32: num = int(v) case uint64: num = int(v) default: isInt = false } newArgNum = argNum + 1 if num > 1e6 || num < -1e6 { num = 0 isInt = false } return } // argNumber reads an explicit index, "[3]", at format[i:]. func (p *printer) argNumber(argNum int, format string, i int, numArgs int) (newArgNum, newi int, found bool) { if len(format) <= i || format[i] != '[' { return argNum, i, false } p.reordered = true for j := 1; j < len(format)-i; j++ { if format[i+j] == ']' { width, ok, newi := parsenum(format, i+1, i+j) if !ok || newi != i+j { p.goodArgNum = false return argNum, i + j + 1, true } index := width - 1 if index >= 0 && index < numArgs { return index, i + j + 1, true } p.goodArgNum = false return argNum, i + j + 1, true } } p.goodArgNum = false return argNum, i + 1, false } // doPrintf is Sprintf. func (p *printer) doPrintf(format string, a []any) { end := len(format) argNum := 0 afterIndex := false // the previous item was an index like [3] p.reordered = false for i := 0; i < end; { p.goodArgNum = true lasti := i for i < end && format[i] != '%' { i++ } if i > lasti { p.writeString(format[lasti:i]) } if i >= end { break } i++ // skip '%' p.clearFlags() // Fast path: a bare lowercase verb, "%d" or "%s", is most of what any // realm prints, and skips the specifier parsing entirely. if i < end && 'a' <= format[i] && format[i] <= 'z' && argNum < len(a) { verb := rune(format[i]) if verb == 'w' { p.wrapped = append(p.wrapped, argNum) } p.printArg(a[argNum], verb) argNum++ i++ continue } flags: for ; i < end; i++ { switch format[i] { case '#': p.sharp = true case '0': p.zero = !p.minus // zero padding only ever goes on the left case '+': p.plus = true case '-': p.minus = true p.zero = false case ' ': p.space = true default: break flags } } argNum, i, afterIndex = p.argNumber(argNum, format, i, len(a)) // Width. if i < end && format[i] == '*' { i++ p.wid, p.widPresent, argNum = intFromArg(a, argNum) if !p.widPresent { p.writeString(badWidth) } if p.wid < 0 { p.wid = -p.wid p.minus = true p.zero = false } afterIndex = false } else { p.wid, p.widPresent, i = parsenum(format, i, end) if afterIndex && p.widPresent { // "%[3]2d" p.goodArgNum = false } } // Precision. if i+1 < end && format[i] == '.' { i++ if afterIndex { // "%[3].2d" p.goodArgNum = false } argNum, i, afterIndex = p.argNumber(argNum, format, i, len(a)) if i < end && format[i] == '*' { i++ p.prec, p.precPresent, argNum = intFromArg(a, argNum) if p.prec < 0 { p.prec = 0 p.precPresent = false } if !p.precPresent { p.writeString(badPrec) } afterIndex = false } else { p.prec, p.precPresent, i = parsenum(format, i, end) if !p.precPresent { p.prec = 0 p.precPresent = true } } } if !afterIndex { argNum, i, afterIndex = p.argNumber(argNum, format, i, len(a)) } if i >= end { p.writeString(noVerb) break } verb, size := rune(format[i]), 1 if verb >= utf8.RuneSelf { verb, size = utf8.DecodeRuneInString(format[i:]) } i += size switch { case verb == '%': // takes no operand, ignores width and precision p.writeByte('%') case !p.goodArgNum: p.badArgNum(verb) case argNum >= len(a): p.missingArg(verb) default: if verb == 'w' { p.wrapped = append(p.wrapped, argNum) } if verb == 'v' || verb == 'w' { p.sharpV, p.sharp = p.sharp, false p.plusV, p.plus = p.plus, false } p.printArg(a[argNum], verb) argNum++ } } // Report unused operands, unless an index made the count meaningless. if !p.reordered && argNum < len(a) { p.clearFlags() p.writeString(extra) for i, arg := range a[argNum:] { if i > 0 { p.writeString(commaSpace) } if arg == nil { p.writeString(nilAngle) continue } p.writeString(typeName(arg)) p.writeByte('=') p.printArg(arg, 'v') } p.writeByte(')') } } func (p *printer) badArgNum(verb rune) { p.writeString(percentBang) p.writeRune(verb) p.writeString(badIndex) } func (p *printer) missingArg(verb rune) { p.writeString(percentBang) p.writeRune(verb) p.writeString(missing) } // badVerb writes "%!d(string=hi)": the verb, the operand's type and its value. func (p *printer) badVerb(verb rune) { p.erroring = true p.writeString(percentBang) p.writeRune(verb) p.writeByte('(') switch { case p.arg == nil: p.writeString(nilAngle) default: if t := typeName(p.arg); t != "unknown" { p.writeString(t) p.writeByte('=') p.printArg(p.arg, 'v') } else { p.writeString("unhandled") } } p.writeByte(')') p.erroring = false } // printArg prints one operand. func (p *printer) printArg(arg any, verb rune) { p.arg = arg if arg == nil { switch verb { case 'T', 'v': p.padString(nilAngle) default: p.badVerb(verb) } return } switch verb { case 'T': p.fmtS(typeName(arg)) return case 'p': p.badVerb(verb) return } // The basic types first, exactly as Go: a named type never matches these, // so its methods are still found below. switch f := arg.(type) { case bool: p.fmtBool(f, verb) case float32: p.fmtFloatVerb(float64(f), 32, verb) case float64: p.fmtFloatVerb(f, 64, verb) case int: p.fmtSigned(int64(f), verb) case int8: p.fmtSigned(int64(f), verb) case int16: p.fmtSigned(int64(f), verb) case int32: p.fmtSigned(int64(f), verb) case int64: p.fmtSigned(f, verb) case uint: p.fmtUnsigned(uint64(f), verb) case uint8: p.fmtUnsigned(uint64(f), verb) case uint16: p.fmtUnsigned(uint64(f), verb) case uint32: p.fmtUnsigned(uint64(f), verb) case uint64: p.fmtUnsigned(f, verb) case string: p.fmtString(f, verb) case []byte: p.fmtBytes(f, verb) default: if !p.handleMethods(verb) { p.printComposite(arg, verb) } } } // handleMethods prints arg with its own Format, GoString, Error or String // method, in Go's order, and reports whether it did. func (p *printer) handleMethods(verb rune) (handled bool) { if p.erroring { return false } if verb == 'w' { // %w is for Errorf, and for an error. if _, ok := p.arg.(error); !ok || !p.wrapErrs { p.badVerb(verb) return true } verb = 'v' } if f, ok := p.arg.(Formatter); ok { handled = true // stays true if the method panics defer p.catchPanic(p.arg, verb, "Format") f.Format(p, verb) return } if p.sharpV { if g, ok := p.arg.(GoStringer); ok { handled = true defer p.catchPanic(p.arg, verb, "GoString") p.fmtS(g.GoString()) return } return false } switch verb { case 'v', 's', 'x', 'X', 'q': switch v := p.arg.(type) { case error: handled = true defer p.catchPanic(p.arg, verb, "Error") p.fmtString(v.Error(), verb) return case Stringer: handled = true defer p.catchPanic(p.arg, verb, "String") p.fmtString(v.String(), verb) return } } return false } // catchPanic turns a panicking method into "%!v(PANIC=String method: msg)". func (p *printer) catchPanic(arg any, verb rune, method string) { r := recover() if r == nil { return } if p.erroring { panic(r) // the method panicked again while printing its own panic } p.clearFlags() p.writeString(percentBang) p.writeRune(verb) p.writeString("(PANIC=") p.writeString(method) p.writeString(" method: ") p.erroring = true p.printArg(r, 'v') p.erroring = false p.writeByte(')') } func (p *printer) fmtBool(v bool, verb rune) { switch verb { case 't', 'v': p.fmtBoolean(v) default: p.badVerb(verb) } } // fmtSigned prints a signed integer. The magnitude of math.MinInt64 does not // fit an int64, so it is computed without ever negating v itself. func (p *printer) fmtSigned(v int64, verb rune) { if v >= 0 { p.fmtIntegerVerb(uint64(v), false, true, verb) return } mag := uint64(-(v + 1)) + 1 p.fmtIntegerVerb(mag, true, true, verb) } func (p *printer) fmtUnsigned(u uint64, verb rune) { p.fmtIntegerVerb(u, false, false, verb) } func (p *printer) fmtIntegerVerb(u uint64, negative, isSigned bool, verb rune) { switch verb { case 'v': if p.sharpV && !isSigned { p.fmt0x64(u, true) } else { p.fmtInteger(u, negative, 10, verb, ldigits) } case 'd': p.fmtInteger(u, negative, 10, verb, ldigits) case 'b': p.fmtInteger(u, negative, 2, verb, ldigits) case 'o', 'O': p.fmtInteger(u, negative, 8, verb, ldigits) case 'x': p.fmtInteger(u, negative, 16, verb, ldigits) case 'X': p.fmtInteger(u, negative, 16, verb, udigits) case 'c', 'q', 'U': if negative { u = ^u + 1 // back to two's complement, as Go sees it: never a rune } switch verb { case 'c': p.fmtC(u) case 'q': p.fmtQc(u) default: p.fmtUnicode(u) } default: p.badVerb(verb) } } // fmt0x64 prints u in hex with a 0x prefix (leading0x) or without one. func (p *printer) fmt0x64(u uint64, leading0x bool) { sharp := p.sharp p.sharp = leading0x p.fmtInteger(u, false, 16, 'v', ldigits) p.sharp = sharp } func (p *printer) fmtFloatVerb(v float64, size int, verb rune) { switch verb { case 'v': p.fmtFloat(v, size, 'g', -1) case 'b', 'g', 'G', 'x', 'X': p.fmtFloat(v, size, verb, -1) case 'f', 'e', 'E': p.fmtFloat(v, size, verb, 6) case 'F': p.fmtFloat(v, size, 'f', 6) default: p.badVerb(verb) } } func (p *printer) fmtString(v string, verb rune) { switch verb { case 'v': if p.sharpV { p.fmtQ(v) } else { p.fmtS(v) } case 's': p.fmtS(v) case 'x': p.fmtSx(v, ldigits) case 'X': p.fmtSx(v, udigits) case 'q': p.fmtQ(v) default: p.badVerb(verb) } } func (p *printer) fmtBytes(v []byte, verb rune) { switch verb { case 'v', 'd': if p.sharpV { p.writeString("[]byte") if v == nil { p.writeString(nilParen) return } p.writeByte('{') for i, c := range v { if i > 0 { p.writeString(commaSpace) } p.fmt0x64(uint64(c), true) } p.writeByte('}') return } p.writeByte('[') for i, c := range v { if i > 0 { p.writeByte(' ') } p.fmtInteger(uint64(c), false, 10, verb, ldigits) } p.writeByte(']') case 's': p.fmtS(string(v)) case 'x': p.fmtSx(string(v), ldigits) case 'X': p.fmtSx(string(v), udigits) case 'q': p.fmtQ(string(v)) default: // Every other verb applies to each byte, as for any slice. p.writeByte('[') for i, c := range v { if i > 0 { p.writeByte(' ') } p.fmtUnsigned(uint64(c), verb) } p.writeByte(']') } } // printComposite prints the slices and maps gno can see without reflect. func (p *printer) printComposite(arg any, verb rune) { switch v := arg.(type) { case []any: p.printSlice("[]interface {}", v == nil, len(v), func(i int) any { return v[i] }, verb) case []string: p.printSlice("[]string", v == nil, len(v), func(i int) any { return v[i] }, verb) case []error: p.printSlice("[]error", v == nil, len(v), func(i int) any { return v[i] }, verb) case []bool: p.printSlice("[]bool", v == nil, len(v), func(i int) any { return v[i] }, verb) case []int: p.printSlice("[]int", v == nil, len(v), func(i int) any { return v[i] }, verb) case []int8: p.printSlice("[]int8", v == nil, len(v), func(i int) any { return v[i] }, verb) case []int16: p.printSlice("[]int16", v == nil, len(v), func(i int) any { return v[i] }, verb) case []int32: p.printSlice("[]int32", v == nil, len(v), func(i int) any { return v[i] }, verb) case []int64: p.printSlice("[]int64", v == nil, len(v), func(i int) any { return v[i] }, verb) case []uint: p.printSlice("[]uint", v == nil, len(v), func(i int) any { return v[i] }, verb) case []uint16: p.printSlice("[]uint16", v == nil, len(v), func(i int) any { return v[i] }, verb) case []uint32: p.printSlice("[]uint32", v == nil, len(v), func(i int) any { return v[i] }, verb) case []uint64: p.printSlice("[]uint64", v == nil, len(v), func(i int) any { return v[i] }, verb) case []float32: p.printSlice("[]float32", v == nil, len(v), func(i int) any { return v[i] }, verb) case []float64: p.printSlice("[]float64", v == nil, len(v), func(i int) any { return v[i] }, verb) case map[string]string: keys := sortedKeys(len(v), func(add func(string)) { for k := range v { add(k) } }) p.printMap("map[string]string", v == nil, keys, func(k string) any { return v[k] }, verb) case map[string]any: keys := sortedKeys(len(v), func(add func(string)) { for k := range v { add(k) } }) p.printMap("map[string]interface {}", v == nil, keys, func(k string) any { return v[k] }, verb) case map[string]int: keys := sortedKeys(len(v), func(add func(string)) { for k := range v { add(k) } }) p.printMap("map[string]int", v == nil, keys, func(k string) any { return v[k] }, verb) case map[string]bool: keys := sortedKeys(len(v), func(add func(string)) { for k := range v { add(k) } }) p.printMap("map[string]bool", v == nil, keys, func(k string) any { return v[k] }, verb) default: if verb == 'v' { p.padString(unhandled) } else { p.badVerb(verb) } } } // printSlice prints "[a b]", or "[]T{a, b}" for %#v, each element with verb. func (p *printer) printSlice(typ string, isNil bool, n int, at func(int) any, verb rune) { if p.sharpV { p.writeString(typ) if isNil { p.writeString(nilParen) return } p.writeByte('{') for i := 0; i < n; i++ { if i > 0 { p.writeString(commaSpace) } p.printElem(at(i), verb) } p.writeByte('}') return } p.writeByte('[') for i := 0; i < n; i++ { if i > 0 { p.writeByte(' ') } p.printElem(at(i), verb) } p.writeByte(']') } // printMap prints "map[a:1 b:2]", or "map[string]int{"a":1, "b":2}" for %#v, // keys sorted as Go sorts them. func (p *printer) printMap(typ string, isNil bool, keys []string, at func(string) any, verb rune) { if p.sharpV { p.writeString(typ) if isNil { p.writeString(nilParen) return } p.writeByte('{') } else { p.writeString("map[") } for i, k := range keys { if i > 0 { if p.sharpV { p.writeString(commaSpace) } else { p.writeByte(' ') } } p.printElem(k, verb) p.writeByte(':') p.printElem(at(k), verb) } if p.sharpV { p.writeByte('}') } else { p.writeByte(']') } } // printElem prints one element of a composite, keeping the outer operand for // error messages. A nil element prints "<nil>" whatever the verb, as in Go. func (p *printer) printElem(v any, verb rune) { outer := p.arg if v == nil { if p.sharpV { p.writeString("interface {}(nil)") } else { p.padString(nilAngle) } } else { p.printArg(v, verb) } p.arg = outer } func sortedKeys(n int, each func(add func(string))) []string { keys := make([]string, 0, n) each(func(k string) { keys = append(keys, k) }) sort.Strings(keys) return keys } // typeName is %T for the types gno can name without reflect. func typeName(v any) string { switch v.(type) { case bool: return "bool" case int: return "int" case int8: return "int8" case int16: return "int16" case int32: return "int32" case int64: return "int64" case uint: return "uint" case uint8: return "uint8" case uint16: return "uint16" case uint32: return "uint32" case uint64: return "uint64" case float32: return "float32" case float64: return "float64" case string: return "string" case []byte: return "[]uint8" case []any: return "[]interface {}" case []string: return "[]string" case []error: return "[]error" case []bool: return "[]bool" case []int: return "[]int" case []int8: return "[]int8" case []int16: return "[]int16" case []int32: return "[]int32" case []int64: return "[]int64" case []uint: return "[]uint" case []uint16: return "[]uint16" case []uint32: return "[]uint32" case []uint64: return "[]uint64" case []float32: return "[]float32" case []float64: return "[]float64" case map[string]string: return "map[string]string" case map[string]any: return "map[string]interface {}" case map[string]int: return "map[string]int" case map[string]bool: return "map[string]bool" } return "unknown" }
  16. #16ufmt.gno
  17. #17package ufmt import ( "io" "strings" ) // State is what a Formatter's Format method is handed: the flags, width and // precision of the verb being printed, and somewhere to write. type State interface { // Write emits formatted output. Write(b []byte) (n int, err error) // Width returns the width and whether one was set. Width() (wid int, ok bool) // Precision returns the precision and whether one was set. Precision() (prec int, ok bool) // Flag reports whether the flag c, a character, was set. Flag(c int) bool } // Formatter is implemented by a value that formats itself. Format is called // for every verb, and its output replaces what ufmt would have printed. type Formatter interface { Format(f State, verb rune) } // Stringer is implemented by a value that has a natural string form. It is // used for %v, %s, %q, %x and %X, and by Print. type Stringer interface { String() string } // GoStringer is implemented by a value that has a Go-syntax form, used for %#v. type GoStringer interface { GoString() string } // Sprintf formats according to a format specifier and returns the string. func Sprintf(format string, a ...any) string { p := newPrinter() p.doPrintf(format, a) return string(p.buf) } // Appendf formats according to a format specifier and appends the result to b. func Appendf(b []byte, format string, a ...any) []byte { p := newPrinter() p.doPrintf(format, a) return append(b, p.buf...) } // Fprintf formats according to a format specifier and writes to w. func Fprintf(w io.Writer, format string, a ...any) (n int, err error) { p := newPrinter() p.doPrintf(format, a) return w.Write(p.buf) } // Printf formats according to a format specifier and prints the result with // the print builtin, which is what a test or a filetest captures. func Printf(format string, a ...any) (n int, err error) { return printOut(Sprintf(format, a...)) } // Sprint formats its operands with %v and returns the string. A space is added // between two operands when neither is a string. func Sprint(a ...any) string { p := newPrinter() p.doPrint(a) return string(p.buf) } // Append formats its operands as Sprint does and appends the result to b. func Append(b []byte, a ...any) []byte { p := newPrinter() p.doPrint(a) return append(b, p.buf...) } // Fprint formats its operands as Sprint does and writes to w. func Fprint(w io.Writer, a ...any) (n int, err error) { p := newPrinter() p.doPrint(a) return w.Write(p.buf) } // Print formats its operands as Sprint does and prints the result. func Print(a ...any) (n int, err error) { return printOut(Sprint(a...)) } // Sprintln formats its operands with %v, always separated by a space, and // appends a newline. func Sprintln(a ...any) string { p := newPrinter() p.doPrintln(a) return string(p.buf) } // Appendln formats its operands as Sprintln does and appends the result to b. func Appendln(b []byte, a ...any) []byte { p := newPrinter() p.doPrintln(a) return append(b, p.buf...) } // Fprintln formats its operands as Sprintln does and writes to w. func Fprintln(w io.Writer, a ...any) (n int, err error) { p := newPrinter() p.doPrintln(a) return w.Write(p.buf) } // Println formats its operands as Sprintln does and prints the result. func Println(a ...any) (n int, err error) { return printOut(Sprintln(a...)) } func printOut(s string) (int, error) { var sb strings.Builder n, err := sb.WriteString(s) print(sb.String()) return n, err } // Errorf formats according to a format specifier and returns the string as an // error. Each %w operand must be an error; it is printed as %v and returned by // the error's Unwrap method, so errors.Is and errors.Unwrap can find it. One %w // gives an Unwrap() error, several give an Unwrap() []error, as in Go. func Errorf(format string, a ...any) error { p := newPrinter() p.wrapErrs = true p.doPrintf(format, a) s := string(p.buf) switch len(p.wrapped) { case 0: return &errorString{s} case 1: w := &wrapError{msg: s} w.err, _ = a[p.wrapped[0]].(error) return w default: if p.reordered { sortInts(p.wrapped) } var errs []error for i, argNum := range p.wrapped { if i > 0 && p.wrapped[i-1] == argNum { continue } if e, ok := a[argNum].(error); ok { errs = append(errs, e) } } return &wrapErrors{s, errs} } } type errorString struct { s string } func (e *errorString) Error() string { return e.s } type wrapError struct { msg string err error } func (e *wrapError) Error() string { return e.msg } func (e *wrapError) Unwrap() error { return e.err } type wrapErrors struct { msg string errs []error } func (e *wrapErrors) Error() string { return e.msg } func (e *wrapErrors) Unwrap() []error { return e.errs } // sortInts is an insertion sort: the slice holds one entry per %w. func sortInts(a []int) { for i := 1; i < len(a); i++ { for j := i; j > 0 && a[j] < a[j-1]; j-- { a[j], a[j-1] = a[j-1], a[j] } } }
#3AddPackagegno.land/p/moul/x/social/coin/v09 arguments
Attached funds
6000000ugnot

Arguments · 9

  1. #1coin
  2. #2README.md
  3. #3# `gno.land/p/moul/x/social/coin/v0` **A GRC20 that refuses to exist until its mint rule, its sink and its buyer are written down**: `New`, `Earn`, `Spend`, `Supply`, `Earned`, `Spent`, `Render`. ```go import "gno.land/p/moul/x/social/coin/v0" var points = coin.New("Thread Points", "THREAD", 0, 0, coin.Policy{ Mint: "1 per distinct address that replies to your thread", Sink: "burned to pin a thread to the top of its page", Buyer: "anyone who wants placement and has not earned it", }, 0, cur) points.Earn(author, 1) // the mint rule, with exactly one call site points.Spend(buyer, 10) // the sink, which is the only thing that removes supply ``` The three strings are the whole point. `New` panics on a blank one, so the declaration happens before the first unit exists rather than in a README written afterwards, and `Render` prints all three on the issuing realm's own page, where being wrong about them is visible to the people holding the token. **A mint rule with no sink is a scoreboard with a price.** Supply only grows, the number is the product, and a holder has no reason to buy one from an earner. Nothing in a package can check that a declared sink is real, which is exactly why it is a declaration and not a validation: the cheap failure is forgetting to decide, not lying. `Earned` and `Spent` are kept separately from the supply because a sink that never fires is invisible in the supply alone. A flat line reads the same as no sink at all; two counters say which it is. `Earn` of a non-positive amount is a no-op rather than an abort. A mint rule that computes zero means nothing new happened, and that should not fail the transaction that discovered it. `Spend` is the opposite and aborts naming both numbers, because its caller is a user about to be told they cannot afford something. Everything else is the embedded [`p/nt/grc20`](https://gno.land/p/nt/grc20/v0): transfers, approvals, balances and events are an ordinary GRC20, so wallets and indexers need to know nothing about this package. `Token()` hands it over for the realm to expose and to register with a token registry. The trailing `_ int, rlm realm` on `New` is the shape a pure package has to use to reach the caller's frame: a `p/` may not declare a crossing function, so the realm token is threaded as a later parameter, exactly as grc20's own tellers do. **Live user:** [`r/moul/x/social/threads`](https://github.com/moul/gno-contracts/tree/main/r/moul/x/social/threads). <!-- 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/social/coin/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/p/moul/x/social/coin/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. #4coin.gno
  5. #5// Package coin is a GRC20 that cannot exist until its author has answered the // three questions a social app's token usually dodges: what mints it, what // burns it, and who has a reason to buy it. // // # Why a package and not a convention // // Every app in the x/social family is asked the same question, "and can it // have a token", and the honest answer is only yes when all three of those // have an answer. A token with a mint rule and no sink is a scoreboard with a // price: supply grows, nothing consumes it, and the number on the leaderboard // is the whole product. That is the shape a chain-wide measurement keeps // finding, and it is cheap to avoid, so [New] refuses a [Policy] with a blank // field rather than letting the omission ship. // // The three strings are not validated beyond being non-empty, because no // package can check that a sink is real. They are a declaration, rendered on // the realm's own page by [Coin.Render], where being wrong is visible. // // # The model // // earned minted by the app, for the behaviour the app wants more of // spent burned by the app, for the thing holders actually want // traded a plain GRC20 transfer, which is what makes the sink a market // // Earning and spending are the app's business and go through [Coin.Earn] and // [Coin.Spend], which keep running totals so a reader can see the two halves // against each other. Transfers, approvals and balances are the embedded // [grc20.Token]'s, unchanged, so wallets and indexers see an ordinary GRC20. // // # Usage // // A realm holds one Coin and never exports the ledger: // // var points = coin.New("Thread Points", "THREAD", 0, 0, coin.Policy{ // Mint: "1 per distinct address that replies to your thread", // Sink: "burned to pin a thread to the top of its page", // Buyer: "anyone who wants placement and has not earned it", // }, 0, cur) // // The trailing `_ int, rlm realm` is the shape a pure package has to use to // reach the caller's frame: a p/ package may not declare a crossing function, // so the realm token is threaded as a later parameter, exactly as grc20's own // tellers do. package coin import ( "gno.land/p/moul/kit/num/v0" "gno.land/p/moul/kit/ui/v0" "gno.land/p/moul/md/v0" "gno.land/p/nt/grc20/v0" "gno.land/p/nt/seqid/v0" "gno.land/p/nt/ufmt/v0" ) // Policy is the three declarations a Coin cannot be created without. // // Each one is a sentence for a human: it is rendered on the issuing realm's // page and nothing branches on it. type Policy struct { // Mint says what behaviour earns the token, precisely enough that a // reader can work out whether they can farm it. Mint string // Sink says what destroys it. "Nothing" is not an answer; if there is // no sink, there is no reason for the token to exist. Sink string // Buyer says who wants it badly enough to acquire it from someone who // earned it. This is the one most often left blank, and the one that // decides whether the other two matter. Buyer string } // Valid reports whether every field is filled in. func (p Policy) Valid() bool { return p.Mint != "" && p.Sink != "" && p.Buyer != "" } // Coin is a GRC20 plus the policy it was issued under and the running totals // of the two flows the policy describes. type Coin struct { tok *grc20.Token led *grc20.PrivateLedger policy Policy earned int64 // cumulative minted spent int64 // cumulative burned } // New issues the token. It panics when the policy is incomplete, which is the // whole point of the package: the declaration happens before the first unit // exists, not in a README written afterwards. // // name, symbol, decimals and id are grc20's own; id distinguishes two tokens // issued by the same realm. func New(name, symbol string, decimals int, id seqid.ID, policy Policy, _ int, rlm realm) *Coin { if !policy.Valid() { panic("coin: a token needs a mint rule, a sink and a buyer; one of the three is empty") } tok, led := grc20.NewToken(name, symbol, decimals, id, rlm) return &Coin{tok: tok, led: led, policy: policy} } // Token returns the GRC20 itself, for the realm to expose as its read API and // to register with a token registry. func (c *Coin) Token() *grc20.Token { return c.tok } // CallerTeller is the teller that acts as the user who called the realm: it // moves their own balance and nothing else. // // It is exposed, where the ledger is not, because transfer and approve are // what make the sink a market. Mint and burn stay behind [Coin.Earn] and // [Coin.Spend] so the mint rule keeps exactly one call site. func (c *Coin) CallerTeller() grc20.Teller { return c.led.CallerTeller() } // Policy returns the declarations the token was issued under. func (c *Coin) Policy() Policy { return c.policy } // Earn mints amount to addr. The realm calls it from the one place its mint // rule is implemented, so that rule has exactly one call site. // // A non-positive amount is a no-op rather than an abort: a mint rule that // computes zero (nothing new happened) is a normal outcome and should not // fail the transaction that discovered it. func (c *Coin) Earn(to address, amount int64) { if amount <= 0 { return } if err := c.led.Mint(to, amount); err != nil { panic("coin: mint: " + err.Error()) } c.earned += amount } // Spend burns amount from addr, which is how the sink consumes supply. // // It aborts when the balance is short, naming both numbers, because the // caller is a user who is about to be told they cannot afford something. func (c *Coin) Spend(from address, amount int64) { if amount <= 0 { panic("coin: spend: amount must be positive") } if bal := c.tok.BalanceOf(from); bal < amount { panic(ufmt.Sprintf("coin: balance %d is short of %d %s", bal, amount, c.tok.GetSymbol())) } if err := c.led.Burn(from, amount); err != nil { panic("coin: burn: " + err.Error()) } c.spent += amount } // BalanceOf is the holder's balance. func (c *Coin) BalanceOf(addr address) int64 { return c.tok.BalanceOf(addr) } // Supply is what exists right now, that is, earned minus spent. func (c *Coin) Supply() int64 { return c.tok.TotalSupply() } // Earned and Spent are the cumulative flows. Their difference is [Coin.Supply] // and they are kept separately because a sink that never fires is invisible in // the supply alone: a flat line reads the same as no sink at all. func (c *Coin) Earned() int64 { return c.earned } // Spent is the cumulative amount burned through [Coin.Spend]. func (c *Coin) Spent() int64 { return c.spent } // Holders is how many addresses the ledger knows about. func (c *Coin) Holders() int { return c.tok.KnownAccounts() } // Render is the block a realm puts on its own page: the three declarations, // then the two flows against each other. // // The policy strings are escaped: they are constants in practice, but a realm // could build one from configuration, and this package cannot tell. func (c *Coin) Render() string { out := md.H3(ui.Inline(c.tok.GetName()) + " (" + ui.Inline(c.tok.GetSymbol()) + ")") t := ui.NewTable("", "") t.Row("earns", ui.Cell(c.policy.Mint)) t.Row("burns", ui.Cell(c.policy.Sink)) t.Row("bought by", ui.Cell(c.policy.Buyer)) out += t.String() s := ui.NewTable("supply", "earned", "burned", "holders") s.Row( num.Dec(c.Supply(), c.tok.GetDecimals()), num.Dec(c.earned, c.tok.GetDecimals()), num.Dec(c.spent, c.tok.GetDecimals()), ufmt.Sprintf("%d", c.Holders()), ) out += s.String() return out }
  6. #6coin_test.gno
  7. #7package coin import ( "strings" "testing" "gno.land/p/nt/seqid/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) var fullPolicy = Policy{ Mint: "1 per distinct replier", Sink: "burned to pin a thread", Buyer: "anyone who wants placement", } // newTestCoin builds a Coin from a test. The same-realm cross promotes the // test's EOA-origin cur into a code realm frame, which is what grc20.NewToken // requires: it is the shape grc20's own tests use (vendor/.../token_test.gno). func newTestCoin(t *testing.T, policy Policy, id seqid.ID, rlm realm) (c *Coin) { t.Helper() func(cur realm) { c = New("Test Points", "TEST", 0, id, policy, 0, cur) }(cross(rlm)) return } func TestPolicyValid(t *testing.T) { tests := []struct { name string p Policy want bool }{ {"complete", fullPolicy, true}, {"no mint rule", Policy{Sink: "s", Buyer: "b"}, false}, {"no sink", Policy{Mint: "m", Buyer: "b"}, false}, {"no buyer", Policy{Mint: "m", Sink: "s"}, false}, {"empty", Policy{}, false}, } for _, tt := range tests { uassert.Equal(t, tt.want, tt.p.Valid(), tt.name) } } // New's refusal is asserted with AbortsContains and not PanicsContains: the // constructor runs behind a same-realm cross (the frame grc20.NewToken // requires), and a panic raised across a crossing call is an abort that no // recover() in the caller can catch. func TestNewRefusesAnIncompletePolicy(cur realm, t *testing.T) { uassert.AbortsContains(t, cur, "mint rule, a sink and a buyer", func() { newTestCoin(t, Policy{Mint: "m", Sink: "s"}, 0, cur) }) } func TestEarnAndSpendMoveSupplyInOppositeDirections(cur realm, t *testing.T) { c := newTestCoin(t, fullPolicy, 1, cur) alice := testutils.TestAddress("alice") c.Earn(alice, 10) uassert.Equal(t, int64(10), c.BalanceOf(alice)) uassert.Equal(t, int64(10), c.Supply()) c.Spend(alice, 4) uassert.Equal(t, int64(6), c.BalanceOf(alice)) uassert.Equal(t, int64(6), c.Supply()) // The flows stay visible after the fact, which the supply alone cannot // show: 6 in supply could be 6 earned and nothing burned. uassert.Equal(t, int64(10), c.Earned()) uassert.Equal(t, int64(4), c.Spent()) } func TestEarnOfNothingIsANoOp(cur realm, t *testing.T) { c := newTestCoin(t, fullPolicy, 2, cur) alice := testutils.TestAddress("alice") c.Earn(alice, 0) c.Earn(alice, -5) uassert.Equal(t, int64(0), c.Supply()) uassert.Equal(t, int64(0), c.Earned()) uassert.Equal(t, 0, c.Holders()) } func TestSpendNamesBothNumbersWhenShort(cur realm, t *testing.T) { c := newTestCoin(t, fullPolicy, 3, cur) alice := testutils.TestAddress("alice") c.Earn(alice, 2) uassert.PanicsContains(t, cur, "balance 2 is short of 5", func() { c.Spend(alice, 5) }) uassert.PanicsContains(t, cur, "must be positive", func() { c.Spend(alice, 0) }) uassert.Equal(t, int64(2), c.BalanceOf(alice)) } func TestRenderCarriesTheThreeDeclarations(cur realm, t *testing.T) { c := newTestCoin(t, fullPolicy, 4, cur) c.Earn(testutils.TestAddress("alice"), 7) c.Spend(testutils.TestAddress("alice"), 3) out := c.Render() for _, want := range []string{ "Test Points", "TEST", fullPolicy.Mint, fullPolicy.Sink, fullPolicy.Buyer, } { uassert.True(t, strings.Contains(out, want), "render is missing "+want) } // 4 in supply, 7 earned, 3 burned, 1 holder. uassert.True(t, strings.Contains(out, "| 4 | 7 | 3 | 1 |"), "render is missing the flow row: "+out) } func TestTokenIsAnOrdinaryGRC20(cur realm, t *testing.T) { c := newTestCoin(t, fullPolicy, 5, cur) alice := testutils.TestAddress("alice") c.Earn(alice, 3) tok := c.Token() urequire.False(t, tok == nil, "token should not be nil") uassert.Equal(t, "TEST", tok.GetSymbol()) uassert.Equal(t, int64(3), tok.TotalSupply()) uassert.Equal(t, int64(3), tok.BalanceOf(alice)) }
  8. #8gnomod.toml
  9. #9module = "gno.land/p/moul/x/social/coin/v0" gno = "0.9"
#4AddPackagegno.land/p/moul/x/social/crew/v09 arguments
Attached funds
18000000ugnot

Arguments · 9

  1. #1crew
  2. #2README.md
  3. #3# crew Small-group coordination as a product rather than as a framework: three to fifteen people with a shared pot, a way to decide, and a way to leave with their share. The pure engine behind [`r/moul/x/social/crews`](https://github.com/moul/gno-contracts/tree/main/r/moul/x/social/crews), which is the chain wiring. The targets are real and already exist: a validator set, a working group, a hackathon team, a five-person company. `p/moul/grants` and daokit are the framework layer; this is the thing you can create in one transaction. ## The API ```go cs := crew.New() id, err := cs.Create("validators", founder, amount, height) // founder gets amount/InitialPricePerShare shares shares, err := cs.Join(id, who, amount) // minted at the CURRENT per-share value err := cs.Fund(id, amount) // mints nothing, every share is worth more pid, err := cs.Propose(id, who, "ship v1", height) // any member, open for VoteBlocks err := cs.Vote(pid, who, true, height) // weighted by shares held right now passed, err := cs.Close(pid, height) // anyone, after the deadline shares, amount, err := cs.Ragequit(id, who) // burn the shares, be credited the slice amount, err := cs.Withdraw(who) // collect, zeroed before it returns ``` Reads: `Get`, `Proposal`, `Len`, `ProposalCount`, `List`, `CreditOf`, `TotalOwed`, and on a `*Crew`: `SharesOf`, `IsMember`, `MemberCount`, `Members`, `Proposals`, `ValuePerShare`, `PricePerShare`. It returns errors and declares no crossing function. The caller, the height and the amount all arrive as plain arguments, and the realm decides what to abort on. ## Shares are the token A share is the vote weight and the claim on the treasury at the same moment, minted by paying in and burned by leaving. There is no second asset to issue, distribute, or fail to make meaningful, and the exit price is the same number that votes: a member outvoted on everything can still leave with their part of what the crew built, and nobody has to agree to let them. ## The trap it avoids: which way the division rounds Both divisions round **down**, so both round in the crew's favour. | | | |---|---| | joining | `amount * totalShares / treasury`, so a late joiner buys at what a share is worth **now**. Rounding up would hand them a sliver of value the existing members built. | | ragequitting | `shares * treasury / totalShares`, so a leaver takes no more than their slice and the remainder stays with the people who stayed. | Both go through `xmath.MulDiv`, which computes through a 128-bit intermediate and refuses rather than returning a wrong number: the naive `a*b/c` wraps to a plausible-looking figure, which is how a payout split leaks money without anything failing. The dust that accrues is never stranded. The last member out holds every outstanding share, so their division is exact and the treasury empties to the last ugnot. `TestEveryoneRagequittingEmptiesTheTreasury` pins it on a treasury of 3002 over three shares, which divides by nothing. ## What v0 does not do A proposal is **advisory text**. Passing one records that the crew agreed by share weight and executes nothing: it moves no coins, changes no membership and binds no code. Executing a payout is the next step, and it is what turns this into a treasury contract rather than a notice board. A vote keeps the weight it was cast with. A member who votes and then ragequits leaves their weight behind in the tally, because unwinding it would mean reweighing every ballot on every share change. There is no quorum. A crew where one member votes and the rest ignore it passes the proposal, which is exactly as advisory as the text it carries. A tie fails. ## Two write-time rules worth knowing `ValidName` refuses a pipe. A name is rendered as the title of a link inside a table cell, `md.Link` escapes with the inline-text escaper, and that escaper deliberately leaves `|` alone because a pipe is markdown-inert outside a table. Wrapping the title in `ui.Cell` on top would double-escape and render the backslashes, so the character is refused at write time instead. `ValidText` allows a pipe, because free prose legitimately contains one, and the render escapes it with `ui.Cell`. A validator and an escaper protect against different mistakes. <!-- 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/social/crew/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/p/moul/x/social/crew/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. #4crew.gno
  5. #5// Package crew is small-group coordination as a product rather than as a // framework: three to fifteen people with a shared pot, a way to decide, and a // way to leave with their share. // // # Why not a DAO framework // // A framework asks you to pick a governance module, a voting strategy and a // treasury adapter before anybody has put in a single ugnot. The targets here // are real and already exist: a validator set, a working group, a hackathon // team, a five-person company. They want the thing you can create in one // transaction. [gno.land/p/moul/grants] and daokit are the framework layer, and // this package is deliberately underneath them: one call to open a crew, one to // join it, one to leave with what your shares are worth. // // # The model // // Crews every crew, plus the credit ledger every payout goes through // Crew a name, a creation height, members with shares, a treasury, proposals // Proposal advisory text with a deadline and a share-weighted tally // // Shares are the whole design. They are the vote weight and the claim on the // treasury at the same time, minted by [Crews.Join] and burned by // [Crews.Ragequit], so leaving is priced by the same number that decides. There // is no separate token to issue, distribute or forget to make meaningful. // // # Rounding, which is the part that has to be right // // Every division here rounds DOWN, and both of them therefore round in the // crew's favour: // // - joining mints amount * totalShares / treasury, so a late joiner buys // exactly what they paid for at the CURRENT per-share value and never a // share more. Rounding up would hand them a sliver of value the existing // members built, which is the whole reason a flat price is wrong here. // - ragequitting credits shares * treasury / totalShares, so a leaver takes // no more than their slice and the remainder stays with the people who // stayed. // // The dust that accrues from both is never stranded: the last member to // ragequit holds every outstanding share, so their MulDiv is exact and the // treasury empties to the last ugnot. [Crews.Ragequit] has a test for that. // // # What v0 deliberately does not do // // A proposal is advisory text. Passing one executes nothing, moves nothing and // binds nothing; it records that the crew agreed by share weight at a point in // time. Executing a payout is the obvious next step and the one that turns this // into a treasury contract rather than a notice board. // // A vote is weighed at the moment it is cast. A member who votes and then // ragequits leaves their weight behind in the tally, because unwinding it would // mean re-weighing every ballot on every share change. // // # Errors, not aborts // // This is a p/, so it returns errors and declares no crossing function: the // caller, the height and the amount all arrive as plain arguments and the realm // that wires it to the chain decides what to abort on. package crew import ( "errors" "strings" "gno.land/p/moul/kit/store/v0" "gno.land/p/moul/xmath/v1" ) const ( // MaxMembers is the upper end of "three to fifteen people". It is a // product constraint and not a technical one: above it the share math // still works and the thing stops being a crew. MaxMembers = 15 // InitialPricePerShare is what a share costs in ugnot before the crew // has a treasury to price against: at creation, and again if every // member has left. 1000 ugnot makes 1 GNOT worth 1000 shares, which // keeps the integer arithmetic far away from both zero and overflow. InitialPricePerShare = int64(1000) // VoteBlocks is how long a proposal stays open, in blocks. VoteBlocks = int64(1000) // MaxNameLen and MaxTextLen bound what one call can make the crew's // members pay a storage deposit on. MaxNameLen = 60 MaxTextLen = 500 // ExcerptLen is how much of a proposal a listing shows. ExcerptLen = 60 ) // maxInt64 is the largest int64, spelled out because the overflow guards // compare against it directly. const maxInt64 = int64(9223372036854775807) // The errors a caller can get back. var ( ErrBadName = errors.New("crew: name is empty, too long, or has control characters") ErrBadText = errors.New("crew: text is empty, too long, or has control characters") ErrBadAmount = errors.New("crew: amount must be positive") ErrNoCrew = errors.New("crew: no such crew") ErrNoProposal = errors.New("crew: no such proposal") ErrNotMember = errors.New("crew: not a member of this crew") ErrIsMember = errors.New("crew: already a member of this crew") ErrFull = errors.New("crew: the crew is full") ErrNoShares = errors.New("crew: the amount sent buys no whole share") ErrVoteClosed = errors.New("crew: the proposal is no longer open") ErrVoteOpen = errors.New("crew: the proposal is still open") ErrNothing = errors.New("crew: nothing to withdraw") ErrWouldExceed = errors.New("crew: the amount would overflow the ledger") ) // Crew is one group: who is in it, what it holds, and what it is deciding. type Crew struct { Name string CreatedAt int64 // block height Treasury int64 // ugnot, accounted here and held at the realm's address // TotalShares is every share outstanding. It is the denominator of both // the join price and the ragequit payout, so it is maintained here // rather than summed over the members on demand. TotalShares int64 shares map[string]int64 // address -> shares held order []address // members in join order, so output never iterates a map proposals []store.ID } // SharesOf is how many shares who holds, zero when they hold none. func (c *Crew) SharesOf(who address) int64 { if c == nil { return 0 } return c.shares[who.String()] } // IsMember reports whether who holds any share. func (c *Crew) IsMember(who address) bool { return c.SharesOf(who) > 0 } // MemberCount is how many addresses hold shares. func (c *Crew) MemberCount() int { if c == nil { return 0 } return len(c.order) } // Members returns the members in join order. The slice is a copy, so a caller // rendering it cannot reorder the crew. func (c *Crew) Members() []address { if c == nil { return nil } out := make([]address, len(c.order)) copy(out, c.order) return out } // Proposals returns this crew's proposal ids, oldest first, as a copy. func (c *Crew) Proposals() []store.ID { if c == nil { return nil } out := make([]store.ID, len(c.proposals)) copy(out, c.proposals) return out } // ValuePerShare is what one share is worth in ugnot right now, rounded down. // // It is a display figure and nothing computes against it: [Crews.Join] and // [Crews.Ragequit] divide by the real totals, so a crew whose treasury is // smaller than its share count still prices both correctly while this reads 0. func (c *Crew) ValuePerShare() int64 { if c == nil || c.TotalShares == 0 { return 0 } return c.Treasury / c.TotalShares } // PricePerShare is what the next share costs a joiner, rounded down, which is // [Crew.ValuePerShare] except on a crew nobody holds a share in. func (c *Crew) PricePerShare() int64 { if c == nil || c.TotalShares == 0 || c.Treasury == 0 { return InitialPricePerShare } return c.Treasury / c.TotalShares } // ballot is one member's vote: which way, and what it weighed when cast. type ballot struct { yes bool weight int64 } // Proposal is a question put to a crew. v0 proposals are advisory text and // execute nothing: see the package doc. type Proposal struct { CrewID store.ID Author address Text string OpenedAt int64 Deadline int64 // OpenedAt + VoteBlocks, exclusive // Yes and No are the running share-weighted tally, maintained on every // vote so reading it never walks the ballots. Yes int64 No int64 Closed bool Passed bool votes map[string]ballot } // Open reports whether the proposal still accepts votes at height now. func (p *Proposal) Open(now int64) bool { return p != nil && !p.Closed && now < p.Deadline } // VoteOf reports how who voted and what it weighed, and whether they voted at // all. func (p *Proposal) VoteOf(who address) (yes bool, weight int64, voted bool) { if p == nil { return false, 0, false } b, ok := p.votes[who.String()] return b.yes, b.weight, ok } // Voters is how many members have cast a ballot. func (p *Proposal) Voters() int { if p == nil { return 0 } return len(p.votes) } // Crews holds every crew, every proposal, and the credit ledger each payout // goes through. type Crews struct { crews *store.Store props *store.Store credit map[string]int64 // address -> ugnot owed owed int64 // the sum of the above, which the holder must reserve } // New returns an empty Crews. func New() *Crews { return &Crews{ crews: store.Named("crew"), props: store.Named("proposal"), credit: map[string]int64{}, } } // Create opens a crew with founder as its only member, their shares bought out // of amount at [InitialPricePerShare]. // // The remainder below one whole share stays in the treasury rather than being // refunded, which is the same direction every other division here rounds. func (cs *Crews) Create(name string, founder address, amount, at int64) (store.ID, error) { if !ValidName(name) { return 0, ErrBadName } if amount <= 0 { return 0, ErrBadAmount } shares := amount / InitialPricePerShare if shares < 1 { return 0, ErrNoShares } c := &Crew{ Name: name, CreatedAt: at, Treasury: amount, TotalShares: shares, shares: map[string]int64{founder.String(): shares}, order: []address{founder}, } return cs.crews.Add(c), nil } // Join mints who shares at the crew's CURRENT per-share value and adds amount // to its treasury. // // amount * totalShares / treasury, rounded down. That is the whole anti-dilution // rule: a crew that turned 10 GNOT into 20 sells the next share for what a share // is worth now, not for what the founders paid, and the rounding remainder stays // with the crew rather than with the joiner. func (cs *Crews) Join(id store.ID, who address, amount int64) (int64, error) { c, ok := cs.Get(id) if !ok { return 0, ErrNoCrew } if amount <= 0 { return 0, ErrBadAmount } if c.IsMember(who) { return 0, ErrIsMember } if len(c.order) >= MaxMembers { return 0, ErrFull } if c.Treasury > maxInt64-amount { return 0, ErrWouldExceed } var shares int64 if c.TotalShares == 0 || c.Treasury == 0 { // Nobody holds a share, so there is no value to price against and // the crew is back at its opening price. shares = amount / InitialPricePerShare } else { shares = xmath.MulDiv(amount, c.TotalShares, c.Treasury) } if shares < 1 { return 0, ErrNoShares } if c.TotalShares > maxInt64-shares { return 0, ErrWouldExceed } c.shares[who.String()] = shares c.order = append(c.order, who) c.TotalShares += shares c.Treasury += amount return shares, nil } // Fund adds amount to a crew's treasury and mints nothing. // // It is what makes the join price mean anything: a crew whose treasury only // ever moves with its share count prices every joiner identically forever, and // the anti-dilution rule in [Crews.Join] would be decorative. Revenue, a grant // and a member topping the pot up all arrive this way, and every existing share // is worth more afterwards. func (cs *Crews) Fund(id store.ID, amount int64) error { c, ok := cs.Get(id) if !ok { return ErrNoCrew } if amount <= 0 { return ErrBadAmount } if c.Treasury > maxInt64-amount { return ErrWouldExceed } c.Treasury += amount return nil } // Propose opens an advisory question, open for [VoteBlocks] blocks. Any member // may propose. func (cs *Crews) Propose(id store.ID, who address, text string, at int64) (store.ID, error) { c, ok := cs.Get(id) if !ok { return 0, ErrNoCrew } if !c.IsMember(who) { return 0, ErrNotMember } if !ValidText(text) { return 0, ErrBadText } p := &Proposal{ CrewID: id, Author: who, Text: text, OpenedAt: at, Deadline: at + VoteBlocks, votes: map[string]ballot{}, } pid := cs.props.Add(p) c.proposals = append(c.proposals, pid) return pid, nil } // Vote records who's ballot, weighted by the shares they hold right now. // // One ballot per member, changeable while the proposal is open: a second call // replaces the first, tally and weight both, so changing your mind after buying // more shares counts the shares you now hold. func (cs *Crews) Vote(pid store.ID, who address, yes bool, at int64) error { p, ok := cs.Proposal(pid) if !ok { return ErrNoProposal } if !p.Open(at) { return ErrVoteClosed } c, ok := cs.Get(p.CrewID) if !ok { return ErrNoCrew } weight := c.SharesOf(who) if weight <= 0 { return ErrNotMember } key := who.String() if prev, voted := p.votes[key]; voted { if prev.yes { p.Yes -= prev.weight } else { p.No -= prev.weight } } p.votes[key] = ballot{yes: yes, weight: weight} if yes { p.Yes += weight } else { p.No += weight } return nil } // Close records a proposal as passed or failed by share weight, once its // deadline has gone by. Anyone may call it: closing is bookkeeping, not // authority, and a proposal nobody closes is simply never recorded. // // A tie fails. There is no quorum in v0: a crew where one member votes and the // rest ignore it passes the proposal, which is exactly as advisory as the text // it carries. func (cs *Crews) Close(pid store.ID, at int64) (bool, error) { p, ok := cs.Proposal(pid) if !ok { return false, ErrNoProposal } if p.Closed { return p.Passed, ErrVoteClosed } if at < p.Deadline { return false, ErrVoteOpen } p.Closed = true p.Passed = p.Yes > p.No return p.Passed, nil } // Ragequit burns every share who holds, credits them their pro-rata slice of // the treasury and removes them from the crew. // // shares * treasury / totalShares, rounded down, so the remainder stays with // the members who stayed. This is what makes a share mean something: the exit // is priced by the same number that votes, and nobody has to agree to let you // out. // // The payout is credited, never sent. The caller moves the coins after this // returns, which is the ordering the pull-payment pattern exists for. func (cs *Crews) Ragequit(id store.ID, who address) (shares, amount int64, err error) { c, ok := cs.Get(id) if !ok { return 0, 0, ErrNoCrew } shares = c.SharesOf(who) if shares <= 0 { return 0, 0, ErrNotMember } amount = xmath.MulDiv(shares, c.Treasury, c.TotalShares) c.TotalShares -= shares c.Treasury -= amount delete(c.shares, who.String()) c.order = dropAddress(c.order, who) if amount > 0 { if err := cs.credits(who, amount); err != nil { return 0, 0, err } } return shares, amount, nil } // credits adds to an address's withdrawable balance. func (cs *Crews) credits(who address, amount int64) error { key := who.String() if cs.credit[key] > maxInt64-amount || cs.owed > maxInt64-amount { return ErrWouldExceed } cs.credit[key] += amount cs.owed += amount return nil } // Withdraw zeroes who's credit and returns what they were owed. // // The balance is gone from the ledger before this returns, so the caller can // move the coins afterwards and a reentrant call finds nothing: effects, then // interactions. func (cs *Crews) Withdraw(who address) (int64, error) { key := who.String() amount, ok := cs.credit[key] if !ok || amount == 0 { return 0, ErrNothing } delete(cs.credit, key) cs.owed -= amount return amount, nil } // CreditOf is what who can withdraw right now. func (cs *Crews) CreditOf(who address) int64 { return cs.credit[who.String()] } // TotalOwed is the sum of every outstanding credit, which is what the holding // realm must keep in reserve on top of every crew's treasury. func (cs *Crews) TotalOwed() int64 { return cs.owed } // Get returns a crew by id. func (cs *Crews) Get(id store.ID) (*Crew, bool) { v, ok := cs.crews.Get(id) if !ok { return nil, false } return v.(*Crew), true } // Proposal returns a proposal by id. func (cs *Crews) Proposal(pid store.ID) (*Proposal, bool) { v, ok := cs.props.Get(pid) if !ok { return nil, false } return v.(*Proposal), true } // Len is how many crews exist. func (cs *Crews) Len() int { return cs.crews.Len() } // ProposalCount is how many proposals exist, across every crew. func (cs *Crews) ProposalCount() int { return cs.props.Len() } // Listing is one row of [Crews.List]: the crew and the id a link needs. type Listing struct { ID store.ID Crew *Crew } // List returns up to limit crews, newest first. limit below 1 returns nothing. func (cs *Crews) List(limit int) []Listing { var out []Listing for _, e := range cs.crews.PageReverse(1, limit) { out = append(out, Listing{ID: e.ID, Crew: e.Value.(*Crew)}) } return out } // ValidName reports whether name can be stored: non-empty after trimming, // within [MaxNameLen], free of control characters including newlines, and // free of the pipe character. // // The pipe is the one restriction that is not obvious, and it is here because // a name is rendered as the TITLE of a link inside a table cell. md.Link // escapes its title with the inline-text escaper, which deliberately leaves // "|" alone because a pipe is markdown-inert outside a table, and wrapping the // title in ui.Cell on top of that would double-escape and render the // backslashes. Refusing the character at write time is the only place left // where the fix is one rule rather than one exception per call site. // // A proposal's text has no such restriction: [ValidText] allows a pipe and the // render escapes it with ui.Cell, because free prose legitimately contains one // and a name does not. func ValidName(name string) bool { if len(name) > MaxNameLen || strings.TrimSpace(name) == "" { return false } for i := 0; i < len(name); i++ { c := name[i] if c < 0x20 || c == 0x7f || c == '|' { return false } } return true } // ValidText reports whether a proposal body can be stored: non-empty after // trimming, within [MaxTextLen], and free of control characters other than // newline and tab. // // Control characters are refused rather than stripped because the text is shown // back to its author, and silently rewriting what somebody wrote is worse than // telling them it was refused. Everything else is allowed and escaped at render // time: a validator and an escaper protect against different mistakes. func ValidText(text string) bool { if len(text) > MaxTextLen || strings.TrimSpace(text) == "" { return false } for i := 0; i < len(text); i++ { c := text[i] if c < 0x20 && c != '\n' && c != '\t' || c == 0x7f { return false } } return true } // dropAddress returns addrs without who, in a freshly allocated slice. // // It allocates rather than shortening in place: a slice is one persisted object // and the storage deposit only comes back when that object is dropped, so // append(s[:i], s[i+1:]...) would keep the peak allocation charged forever. func dropAddress(addrs []address, who address) []address { out := make([]address, 0, len(addrs)) for _, a := range addrs { if a != who { out = append(out, a) } } return out }
  6. #6crew_test.gno
  7. #7package crew import ( "strconv" "strings" "testing" "gno.land/p/moul/kit/store/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) var ( alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") carol = testutils.TestAddress("carol") dave = testutils.TestAddress("dave") ) // join is Join with the minted share count dropped, for the cases that only // care that it worked. func join(t *testing.T, cs *Crews, id store.ID, who address, amount int64) { t.Helper() _, err := cs.Join(id, who, amount) urequire.NoError(t, err) } // tenGNOT is the amount every founder in these tests puts in, chosen so the // share count is a round 10,000 and a halved or doubled price is still exact. const tenGNOT = int64(10000000) func TestValidName(t *testing.T) { tests := []struct { name string want bool }{ {"validators", true}, {"The Hackathon Team", true}, {"a", true}, {strings.Repeat("x", MaxNameLen), true}, {"", false}, {" ", false}, {strings.Repeat("x", MaxNameLen+1), false}, {"two\nlines", false}, {"a\tb", false}, {"a\x7fb", false}, {"a | pipe", false}, } for _, tt := range tests { uassert.Equal(t, tt.want, ValidName(tt.name), tt.name) } } func TestValidText(t *testing.T) { tests := []struct { name string text string want bool }{ {"plain", "ship v1 before the conference", true}, {"newlines and tabs are content", "a\nb\tc", true}, {"markdown is allowed and escaped later", "[x](y) | z", true}, {"empty", "", false}, {"blank", " \n ", false}, {"too long", strings.Repeat("x", MaxTextLen+1), false}, {"control character", "a\x01b", false}, {"delete character", "a\x7fb", false}, } for _, tt := range tests { uassert.Equal(t, tt.want, ValidText(tt.text), tt.name) } } func TestCreateMintsAtTheOpeningPrice(t *testing.T) { cs := New() id, err := cs.Create("validators", alice, tenGNOT, 100) urequire.NoError(t, err) c, ok := cs.Get(id) urequire.True(t, ok, "the crew exists") uassert.Equal(t, "validators", c.Name) uassert.Equal(t, int64(100), c.CreatedAt) uassert.Equal(t, tenGNOT/InitialPricePerShare, c.TotalShares) uassert.Equal(t, tenGNOT, c.Treasury) uassert.Equal(t, c.TotalShares, c.SharesOf(alice)) uassert.Equal(t, 1, c.MemberCount()) uassert.Equal(t, InitialPricePerShare, c.ValuePerShare()) uassert.Equal(t, 1, cs.Len()) } // The remainder below one whole share stays in the treasury, which is the same // direction every other division here rounds. func TestCreateKeepsTheRemainder(t *testing.T) { cs := New() id, err := cs.Create("crew", alice, 2999, 100) urequire.NoError(t, err) c, _ := cs.Get(id) uassert.Equal(t, int64(2), c.TotalShares, "2999 ugnot buys two whole shares") uassert.Equal(t, int64(2999), c.Treasury, "and the 999 left over is the crew's") } func TestCreateRefusals(t *testing.T) { tests := []struct { label string name string amount int64 want error }{ {"empty name", "", tenGNOT, ErrBadName}, {"name with a newline", "two\nlines", tenGNOT, ErrBadName}, {"name with a pipe", "a | pipe", tenGNOT, ErrBadName}, {"nothing sent", "crew", 0, ErrBadAmount}, {"negative", "crew", -1, ErrBadAmount}, {"under one share", "crew", InitialPricePerShare - 1, ErrNoShares}, } for _, tt := range tests { cs := New() _, err := cs.Create(tt.name, alice, tt.amount, 100) uassert.ErrorIs(t, err, tt.want, tt.label) uassert.Equal(t, 0, cs.Len(), tt.label+": nothing was stored") } } // A second joiner at the same per-share value gets the same shares. This is the // baseline the anti-dilution rule is measured against. func TestASecondJoinerAtTheSamePriceGetsTheSameShares(t *testing.T) { cs := New() id, _ := cs.Create("crew", alice, tenGNOT, 100) got, err := cs.Join(id, bob, tenGNOT) urequire.NoError(t, err) c, _ := cs.Get(id) uassert.Equal(t, c.SharesOf(alice), got, "same money, same shares") uassert.Equal(t, tenGNOT/InitialPricePerShare, got) uassert.Equal(t, 2*tenGNOT, c.Treasury) uassert.Equal(t, 2*(tenGNOT/InitialPricePerShare), c.TotalShares) uassert.Equal(t, InitialPricePerShare, c.ValuePerShare(), "the value per share did not move") uassert.Equal(t, 2, c.MemberCount()) } // A joiner arriving after the treasury grew gets FEWER shares for the same // money, because they are buying at what a share is worth now. That is the // whole point: a late joiner cannot buy into value the existing members built. func TestAJoinerAfterTheTreasuryGrewGetsFewer(t *testing.T) { cs := New() id, _ := cs.Create("crew", alice, tenGNOT, 100) urequire.NoError(t, cs.Fund(id, tenGNOT)) // the crew doubled its money c, _ := cs.Get(id) urequire.Equal(t, 2*InitialPricePerShare, c.ValuePerShare()) got, err := cs.Join(id, bob, tenGNOT) urequire.NoError(t, err) uassert.Equal(t, c.SharesOf(alice)/2, got, "the same money buys half as much") uassert.Equal(t, 2*InitialPricePerShare, c.ValuePerShare(), "and joining did not move the value for anybody else") } // Rounding down on the way in means a joiner never gets a share they did not // fully pay for, and the fractional remainder accrues to the crew. func TestJoinRoundsInTheCrewsFavour(t *testing.T) { cs := New() id, _ := cs.Create("crew", alice, 1000, 100) // 1 share, treasury 1000 urequire.NoError(t, cs.Fund(id, 500)) // treasury 1500, still 1 share // 1499 ugnot is 0.999 of a share. Rounding up would hand bob a whole // one and half of alice's money with it. _, err := cs.Join(id, bob, 1499) uassert.ErrorIs(t, err, ErrNoShares) got, err := cs.Join(id, bob, 2999) urequire.NoError(t, err) uassert.Equal(t, int64(1), got, "1.999 shares' worth buys one share") c, _ := cs.Get(id) uassert.Equal(t, int64(4499), c.Treasury, "the 1499 ugnot of remainder stayed") uassert.Equal(t, int64(2), c.TotalShares) } func TestJoinRefusals(t *testing.T) { cs := New() id, _ := cs.Create("crew", alice, tenGNOT, 100) _, err := cs.Join(999, bob, tenGNOT) uassert.ErrorIs(t, err, ErrNoCrew) _, err = cs.Join(id, alice, tenGNOT) uassert.ErrorIs(t, err, ErrIsMember, "a member cannot buy in twice") _, err = cs.Join(id, bob, 0) uassert.ErrorIs(t, err, ErrBadAmount) c, _ := cs.Get(id) uassert.Equal(t, 1, c.MemberCount(), "no refusal changed the roster") uassert.Equal(t, tenGNOT, c.Treasury) } // Fifteen people is the product constraint, and the sixteenth is refused rather // than quietly allowed. func TestACrewIsFullAtMaxMembers(t *testing.T) { cs := New() id, _ := cs.Create("crew", alice, tenGNOT, 100) for i := 1; i < MaxMembers; i++ { who := testutils.TestAddress("m" + strconv.Itoa(i)) _, err := cs.Join(id, who, tenGNOT) urequire.NoError(t, err) } c, _ := cs.Get(id) urequire.Equal(t, MaxMembers, c.MemberCount()) _, err := cs.Join(id, testutils.TestAddress("one too many"), tenGNOT) uassert.ErrorIs(t, err, ErrFull) uassert.Equal(t, MaxMembers, c.MemberCount()) } func TestProposeAndTallyByShareWeight(t *testing.T) { cs := New() id, _ := cs.Create("crew", alice, tenGNOT, 100) // alice: 10000 shares cs.Join(id, bob, tenGNOT/2) // bob: 5000 shares pid, err := cs.Propose(id, bob, "ship v1 before the conference", 200) urequire.NoError(t, err) p, ok := cs.Proposal(pid) urequire.True(t, ok, "the proposal exists") uassert.Equal(t, uint64(id), uint64(p.CrewID)) uassert.Equal(t, int64(200+VoteBlocks), p.Deadline) uassert.True(t, p.Open(200)) uassert.False(t, p.Open(200+VoteBlocks), "the deadline is exclusive") urequire.NoError(t, cs.Vote(pid, bob, true, 210)) urequire.NoError(t, cs.Vote(pid, alice, false, 211)) uassert.Equal(t, int64(5000), p.Yes) uassert.Equal(t, int64(10000), p.No, "a bigger holder outvotes a smaller one") uassert.Equal(t, 2, p.Voters()) c, _ := cs.Get(id) uassert.Equal(t, 1, len(c.Proposals())) } // One ballot per member, replaced in place while the proposal is open, and // reweighed from the shares held at the moment it is recast. func TestAVoteIsChangeableWhileOpen(t *testing.T) { cs := New() id, _ := cs.Create("crew", alice, tenGNOT, 100) pid, _ := cs.Propose(id, alice, "a question", 200) p, _ := cs.Proposal(pid) urequire.NoError(t, cs.Vote(pid, alice, true, 210)) uassert.Equal(t, int64(10000), p.Yes) uassert.Equal(t, int64(0), p.No) urequire.NoError(t, cs.Vote(pid, alice, false, 211)) uassert.Equal(t, int64(0), p.Yes, "the old ballot is removed, not added to") uassert.Equal(t, int64(10000), p.No) uassert.Equal(t, 1, p.Voters(), "still one voter") yes, weight, voted := p.VoteOf(alice) uassert.True(t, voted) uassert.False(t, yes) uassert.Equal(t, int64(10000), weight) } func TestProposeAndVoteRefusals(t *testing.T) { cs := New() id, _ := cs.Create("crew", alice, tenGNOT, 100) _, err := cs.Propose(999, alice, "text", 200) uassert.ErrorIs(t, err, ErrNoCrew) _, err = cs.Propose(id, bob, "text", 200) uassert.ErrorIs(t, err, ErrNotMember, "only a member proposes") _, err = cs.Propose(id, alice, " ", 200) uassert.ErrorIs(t, err, ErrBadText) pid, _ := cs.Propose(id, alice, "a question", 200) uassert.ErrorIs(t, cs.Vote(999, alice, true, 210), ErrNoProposal) uassert.ErrorIs(t, cs.Vote(pid, bob, true, 210), ErrNotMember) uassert.ErrorIs(t, cs.Vote(pid, alice, true, 200+VoteBlocks), ErrVoteClosed, "the deadline is exclusive") } func TestCloseRecordsTheResult(t *testing.T) { tests := []struct { label string aliceVotes bool bobVotes bool want bool }{ {"the bigger holder carries it", true, false, true}, {"and blocks it", false, true, false}, {"unanimous yes", true, true, true}, {"unanimous no", false, false, false}, } for _, tt := range tests { cs := New() id, _ := cs.Create("crew", alice, tenGNOT, 100) // 10000 shares cs.Join(id, bob, tenGNOT/2) // 5000 shares pid, _ := cs.Propose(id, alice, "a question", 200) urequire.NoError(t, cs.Vote(pid, alice, tt.aliceVotes, 210)) urequire.NoError(t, cs.Vote(pid, bob, tt.bobVotes, 211)) passed, err := cs.Close(pid, 200+VoteBlocks) urequire.NoError(t, err) uassert.Equal(t, tt.want, passed, tt.label) p, _ := cs.Proposal(pid) uassert.True(t, p.Closed, tt.label) uassert.Equal(t, tt.want, p.Passed, tt.label) } } // A tie fails, and a proposal nobody voted on is a tie at zero. func TestATieFails(t *testing.T) { cs := New() id, _ := cs.Create("crew", alice, tenGNOT, 100) cs.Join(id, bob, tenGNOT) pid, _ := cs.Propose(id, alice, "a question", 200) urequire.NoError(t, cs.Vote(pid, alice, true, 210)) urequire.NoError(t, cs.Vote(pid, bob, false, 211)) passed, err := cs.Close(pid, 200+VoteBlocks) urequire.NoError(t, err) uassert.False(t, passed, "equal weight on both sides is not agreement") silent, _ := cs.Propose(id, alice, "nobody cares", 200) passed, err = cs.Close(silent, 200+VoteBlocks) urequire.NoError(t, err) uassert.False(t, passed, "0 to 0 is not agreement either") } func TestCloseRefusals(t *testing.T) { cs := New() id, _ := cs.Create("crew", alice, tenGNOT, 100) pid, _ := cs.Propose(id, alice, "a question", 200) _, err := cs.Close(999, 5000) uassert.ErrorIs(t, err, ErrNoProposal) _, err = cs.Close(pid, 200+VoteBlocks-1) uassert.ErrorIs(t, err, ErrVoteOpen, "one block early is early") _, err = cs.Close(pid, 200+VoteBlocks) urequire.NoError(t, err) _, err = cs.Close(pid, 200+VoteBlocks) uassert.ErrorIs(t, err, ErrVoteClosed, "closing twice does not re-tally") } // A vote keeps the weight it was cast with. Ragequitting afterwards does not // unwind it, which is a v0 caveat stated in the package doc rather than a bug. func TestAVoteKeepsTheWeightItWasCastWith(t *testing.T) { cs := New() id, _ := cs.Create("crew", alice, tenGNOT, 100) cs.Join(id, bob, tenGNOT) pid, _ := cs.Propose(id, alice, "a question", 200) urequire.NoError(t, cs.Vote(pid, bob, true, 210)) _, _, err := cs.Ragequit(id, bob) urequire.NoError(t, err) p, _ := cs.Proposal(pid) uassert.Equal(t, int64(10000), p.Yes, "bob's ballot stayed behind") uassert.ErrorIs(t, cs.Vote(pid, bob, false, 220), ErrNotMember, "but he cannot touch it again") } func TestRagequitTakesAProRataSlice(t *testing.T) { cs := New() id, _ := cs.Create("crew", alice, tenGNOT, 100) cs.Join(id, bob, tenGNOT) urequire.NoError(t, cs.Fund(id, 2*tenGNOT)) // the crew doubled its money shares, amount, err := cs.Ragequit(id, bob) urequire.NoError(t, err) uassert.Equal(t, tenGNOT/InitialPricePerShare, shares, "every share is burned") uassert.Equal(t, 2*tenGNOT, amount, "half of a treasury worth four, for half the shares") c, _ := cs.Get(id) uassert.Equal(t, 1, c.MemberCount()) uassert.Equal(t, int64(0), c.SharesOf(bob)) uassert.False(t, c.IsMember(bob)) uassert.Equal(t, 2*tenGNOT, c.Treasury) uassert.Equal(t, tenGNOT/InitialPricePerShare, c.TotalShares) uassert.Equal(t, 2*tenGNOT, cs.CreditOf(bob), "credited, never sent") uassert.Equal(t, 2*tenGNOT, cs.TotalOwed()) _, _, err = cs.Ragequit(id, bob) uassert.ErrorIs(t, err, ErrNotMember, "there is nothing left to burn") _, _, err = cs.Ragequit(999, alice) uassert.ErrorIs(t, err, ErrNoCrew) } // A one-member crew ragequitting takes everything: their shares ARE the total, // so the division is exact whatever the numbers are. func TestAOneMemberCrewTakesEverything(t *testing.T) { cs := New() id, _ := cs.Create("crew", alice, 5001, 100) // 5 shares, a treasury of 5001 shares, amount, err := cs.Ragequit(id, alice) urequire.NoError(t, err) uassert.Equal(t, int64(5), shares) uassert.Equal(t, int64(5001), amount, "including the ugnot that bought no share") c, _ := cs.Get(id) uassert.Equal(t, int64(0), c.Treasury) uassert.Equal(t, int64(0), c.TotalShares) uassert.Equal(t, 0, c.MemberCount()) } // Everyone ragequitting empties the treasury to the last ugnot, even when no // single division is exact: the dust each leaver rounds away belongs to whoever // is still in, and the last one out holds every share. func TestEveryoneRagequittingEmptiesTheTreasury(t *testing.T) { cs := New() id, _ := cs.Create("crew", alice, 1000, 100) join(t, cs, id, bob, 1000) join(t, cs, id, carol, 1000) urequire.NoError(t, cs.Fund(id, 2)) // 3002 ugnot over 3 shares, divides by nothing c, _ := cs.Get(id) urequire.Equal(t, int64(3002), c.Treasury) urequire.Equal(t, int64(3), c.TotalShares) var paid int64 for _, who := range []address{alice, bob, carol} { _, amount, err := cs.Ragequit(id, who) urequire.NoError(t, err) paid += amount } uassert.Equal(t, int64(3002), paid, "every ugnot left with somebody") uassert.Equal(t, int64(0), c.Treasury, "to the last one") uassert.Equal(t, int64(0), c.TotalShares) uassert.Equal(t, 0, c.MemberCount()) uassert.Equal(t, int64(3002), cs.TotalOwed()) // The dust did not land evenly, which is the point: the people who // stayed longest absorbed it. uassert.Equal(t, int64(1000), cs.CreditOf(alice), "first out, rounded down hardest") uassert.Equal(t, int64(1001), cs.CreditOf(bob)) uassert.Equal(t, int64(1001), cs.CreditOf(carol)) } // An emptied crew is not a dead one. With no shares outstanding there is no // value to price against, so it reopens at the price it opened at. func TestRejoiningAnEmptiedCrewIsBackAtTheOpeningPrice(t *testing.T) { cs := New() id, _ := cs.Create("crew", alice, tenGNOT, 100) _, _, err := cs.Ragequit(id, alice) urequire.NoError(t, err) c, _ := cs.Get(id) urequire.Equal(t, int64(0), c.TotalShares) uassert.Equal(t, int64(0), c.ValuePerShare(), "nothing is worth anything yet") uassert.Equal(t, InitialPricePerShare, c.PricePerShare(), "but a share still has a price") got, err := cs.Join(id, dave, tenGNOT) urequire.NoError(t, err) uassert.Equal(t, tenGNOT/InitialPricePerShare, got) uassert.Equal(t, tenGNOT, c.Treasury) } func TestWithdrawZeroesTheCreditBeforeItReturns(t *testing.T) { cs := New() id, _ := cs.Create("crew", alice, tenGNOT, 100) cs.Join(id, bob, tenGNOT) _, owed, _ := cs.Ragequit(id, bob) urequire.True(t, owed > 0, "bob is owed something") got, err := cs.Withdraw(bob) urequire.NoError(t, err) uassert.Equal(t, owed, got) uassert.Equal(t, int64(0), cs.CreditOf(bob), "zeroed, so a reentrant call finds nothing") uassert.Equal(t, int64(0), cs.TotalOwed()) _, err = cs.Withdraw(bob) uassert.ErrorIs(t, err, ErrNothing) _, err = cs.Withdraw(carol) uassert.ErrorIs(t, err, ErrNothing) } // Credits accumulate across crews, because one ledger serves all of them. func TestCreditsAccumulateAcrossCrews(t *testing.T) { cs := New() first, _ := cs.Create("first", alice, tenGNOT, 100) second, _ := cs.Create("second", bob, tenGNOT, 100) join(t, cs, second, alice, tenGNOT) _, a, _ := cs.Ragequit(first, alice) _, b, _ := cs.Ragequit(second, alice) uassert.Equal(t, a+b, cs.CreditOf(alice), "one withdrawal collects both") uassert.Equal(t, 2, cs.Len()) } func TestListIsNewestFirst(t *testing.T) { cs := New() first, _ := cs.Create("first", alice, tenGNOT, 100) second, _ := cs.Create("second", bob, tenGNOT, 101) third, _ := cs.Create("third", carol, tenGNOT, 102) got := cs.List(10) urequire.Equal(t, 3, len(got)) uassert.Equal(t, uint64(third), uint64(got[0].ID)) uassert.Equal(t, uint64(second), uint64(got[1].ID)) uassert.Equal(t, uint64(first), uint64(got[2].ID)) uassert.Equal(t, "third", got[0].Crew.Name) uassert.Equal(t, 1, len(cs.List(1))) uassert.Equal(t, 0, len(cs.List(0))) } // Members comes back in join order and as a copy, so a Render can never // reshuffle the roster it is reading. func TestMembersAreAnOrderedCopy(t *testing.T) { cs := New() id, _ := cs.Create("crew", alice, tenGNOT, 100) cs.Join(id, bob, tenGNOT) cs.Join(id, carol, tenGNOT) c, _ := cs.Get(id) got := c.Members() urequire.Equal(t, 3, len(got)) uassert.Equal(t, alice.String(), got[0].String()) uassert.Equal(t, bob.String(), got[1].String()) uassert.Equal(t, carol.String(), got[2].String()) got[0] = dave uassert.Equal(t, alice.String(), c.Members()[0].String(), "the caller got a copy") // Ragequitting removes the leaver and keeps the rest in order. cs.Ragequit(id, bob) got = c.Members() urequire.Equal(t, 2, len(got)) uassert.Equal(t, alice.String(), got[0].String()) uassert.Equal(t, carol.String(), got[1].String()) } func TestFundRefusals(t *testing.T) { cs := New() id, _ := cs.Create("crew", alice, tenGNOT, 100) uassert.ErrorIs(t, cs.Fund(999, 100), ErrNoCrew) uassert.ErrorIs(t, cs.Fund(id, 0), ErrBadAmount) uassert.ErrorIs(t, cs.Fund(id, -1), ErrBadAmount) c, _ := cs.Get(id) uassert.Equal(t, tenGNOT, c.Treasury, "no refusal moved the treasury") uassert.Equal(t, 0, cs.ProposalCount()) }
  8. #8gnomod.toml
  9. #9module = "gno.land/p/moul/x/social/crew/v0" gno = "0.9"
Attached funds
15000000ugnot

Arguments · 9

  1. #1curated
  2. #2README.md
  3. #3# `gno.land/p/moul/x/social/curated/v0` **The engine behind a list where being on it costs something**: `New`, `Apply`, `Challenge`, `Vote`, `Resolve`, `Unlist`, `Withdraw`, plus the reads a page needs. ```go r := curated.New(1_000_000, 1000) // deposit, challenge window r.Apply("gnoswap", "/r/gnoswap", "the DEX", owner, paid, height) r.Challenge("gnoswap", challenger, bond, height) // bond matches the deposit r.Vote("gnoswap", voter, false, height) r.Resolve("gnoswap", height+1001) // credits the winner ``` **A list anybody can write to for free is a list nobody can read**, because the cheapest way to be on it is to be on it a thousand times. A deposit does not make an entry good. It makes a bad entry expensive to leave standing, because somebody who disagrees can put the same amount at risk and take yours. The list is worth reading in proportion to what it would cost to pollute it. **This package moves no coins.** Every payout is a credit in an internal ledger the payee collects with `Withdraw`, which zeroes it before returning the amount; the realm sends afterwards. A registry that looped over winners and sent to each one would fail entirely when one of them could not be paid. Three rules worth knowing before you read the code: - **A tie keeps the entry.** The incumbent wins ties, so a challenge that convinces nobody costs the challenger their bond. A challenge has to be worth making, which means losing one has to hurt. - **An owner may not challenge their own entry.** It is free for them (a kept entry credits the owner the bond, which would be their own money back) and it would make the entry immune to a real challenge for the whole window. - **A removed key is free to apply for again.** Burning the name forever punishes the name rather than the entry. **Votes are one address, one vote, and that is sybil-prone**: a hundred addresses are cheap and nothing here can tell them apart. Gating who counts is the job of a vouch graph, `r/moul/x/social/vouch` in this family, and wiring the two together is the first real upgrade this package wants. Voters are also paid nothing in v0, which is the second. `Locked()` plus `Owed()` is the solvency invariant: what the registry is holding as deposits and bonds, plus what it owes people who have not collected yet, should equal the realm's balance. The realm's test suite asserts exactly that against the chain at every step of a full round. **Live realm:** [`r/moul/x/social/curated`](https://github.com/moul/gno-contracts/tree/main/r/moul/x/social/curated), which carries the reasoning for why the bonds are GNOT and not a token of its own. <!-- 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. #4curated.gno
  5. #5// Package curated is the engine behind a curated list where being on the list // costs something: anyone may list an entry by locking a deposit, anyone may // challenge an entry by matching that deposit with a bond, and the loser of the // challenge pays the winner. // // # Why a deposit // // A list anybody can write to for free is a list nobody can read, because the // cheapest way to be on it is to be on it a thousand times. A deposit does not // make an entry good; it makes a bad entry expensive to leave standing, because // somebody who disagrees can put the same amount at risk and take yours. The // list is worth reading in proportion to what it would cost to pollute it. // // # The model // // Registry every entry, plus the credit ledger the payouts land in // Entry a key, a URL, a description, an owner, the deposit, the height // it was listed at, and one of three states // Challenge a challenger, a matching bond, a deadline, and the votes // // A key is unique while it is on the list ([ValidKey] bounds it to a // slug), and a removed key is free again: a challenge that wins removes an // entry, it does not burn the name forever. // // # The money, and why nothing is ever sent from here // // This package moves no coins. Every payout is a credit in an internal ledger // that the payee collects with [Registry.Withdraw], which is the pull-payment // shape: a realm that loops over winners and sends to each one fails entirely // when one of them cannot be paid, and hands a griefer a cheap denial of // service. [Registry.Locked] plus [Registry.Owed] is what the holding realm // must have at its address, and a realm can assert exactly that. // // # Voting is sybil-prone, deliberately and visibly // // [Registry.Vote] is one address, one vote, unweighted. An address is free, so // a challenge outcome is a poll of whoever bothered to make keys, not of // anybody in particular. That is not a gap this package can close: deciding who // counts as a person is a different problem with a different realm behind it, // r/moul/x/social/vouch, a sibling in this family. Until a vote is // gated on a vouched identity, read a resolution as "nobody with a stake // objected enough", not as a verdict. // // # What v0 does not do, in the order it should be fixed // // 1. Voters are paid nothing. Voting costs gas and returns nothing, so the // only addresses with a reason to vote are the two with money on the // outcome. A share of the loser's stake for the winning side is the // standard answer and it is the first thing to add. // 2. There is no application period: [Registry.Apply] lists immediately, so a // bad entry is visible until somebody challenges it. // 3. A challenge cannot be withdrawn, and a vote cannot be changed. package curated import ( "errors" "strings" ) const ( // MaxKeyLen is the longest key accepted. A key is a name people type and // link to, not a payload. MaxKeyLen = 64 // MaxURLLen and MaxDescLen bound what one entry can lock up of somebody // else's storage deposit. MaxURLLen = 240 MaxDescLen = 240 // MaxEntries bounds the registry so a listing stays predictable in gas. MaxEntries = 4096 // MaxPayees bounds the credit ledger for the same reason. MaxPayees = 4096 ) const maxInt64 = int64(9223372036854775807) // The errors a caller can get back. A p/ returns them; the realm decides to // abort. var ( ErrBadKey = errors.New("curated: not a key: 1 to 64 bytes of a-z 0-9 - _ . starting alphanumeric") ErrBadURL = errors.New("curated: url is empty, too long, or has a space or a control character") ErrBadDescription = errors.New("curated: description is empty, too long, or not a single line") ErrTaken = errors.New("curated: that key is already on the list") ErrNoEntry = errors.New("curated: no such entry") ErrWrongDeposit = errors.New("curated: the deposit must be paid exactly") ErrWrongBond = errors.New("curated: the bond must match the entry's deposit") ErrChallenged = errors.New("curated: that entry is under challenge") ErrSelfChallenge = errors.New("curated: an owner cannot challenge their own entry") ErrNoChallenge = errors.New("curated: that entry is not under challenge") ErrVotingClosed = errors.New("curated: the challenge deadline has passed") ErrAlreadyVoted = errors.New("curated: one address, one vote") ErrTooEarly = errors.New("curated: the challenge is still open") ErrNotOwner = errors.New("curated: only the entry's owner can do that") ErrNothingOwed = errors.New("curated: nothing to withdraw") ErrFull = errors.New("curated: the registry is full") ErrOverflow = errors.New("curated: the credit would overflow") ErrBadAmount = errors.New("curated: amount must be positive") ) // State is where an entry stands. type State uint8 const ( // StateListed is on the list and unchallenged. StateListed State = iota // StateChallenged is on the list with a challenge open against it. It // still renders: a challenge is an objection, not a verdict. StateChallenged // StateRemoved is off the list, either lost to a challenge or taken down // by its own owner. The key is free for anyone to apply for again. StateRemoved ) // String names the state for a reader and for an event. func (s State) String() string { switch s { case StateListed: return "listed" case StateChallenged: return "challenged" case StateRemoved: return "removed" default: return "unknown" } } // Challenge is an open objection to one entry. type Challenge struct { Challenger address Bond int64 Deadline int64 // block height the voting stops at, exclusive // Keep and Remove are the unweighted vote counts. See the package doc on // what they are and are not worth. Keep int64 Remove int64 // voters is the set of addresses that have voted, so one address votes // once. It is unexported: a caller reads [Challenge.Voters]. voters map[string]bool } // Voters is how many distinct addresses have voted. func (c *Challenge) Voters() int { if c == nil { return 0 } return len(c.voters) } // HasVoted reports whether who has already voted in this challenge. func (c *Challenge) HasVoted(who address) bool { if c == nil { return false } return c.voters[who.String()] } // Open reports whether votes are still being taken at height now. func (c *Challenge) Open(now int64) bool { return c != nil && now < c.Deadline } // Entry is one row of the list. type Entry struct { Key string URL string Description string Owner address Deposit int64 // what the owner locked to list it At int64 // the block height it was listed at State State // challenge is the open objection, or nil. It is cleared on resolution: // the outcome is in the entry's state and in the ledger, and keeping a // resolved challenge would be a second place to read it from. challenge *Challenge } // Live reports whether the entry is on the list, challenged or not. func (e *Entry) Live() bool { return e != nil && e.State != StateRemoved } // Registry is the whole list: the entries, and the credits waiting to be // withdrawn. type Registry struct { deposit int64 challengeBlocks int64 entries map[string]*Entry order []string // keys in the order they were first listed credits map[string]int64 owed int64 locked int64 } // New returns an empty registry where listing costs deposit and a challenge // runs for challengeBlocks blocks. // // Both are fixed for the life of the registry. An entry remembers the deposit // it actually paid, so a future registry that can reprice itself still charges // a challenger what the owner of that entry risked, and not today's number. func New(deposit, challengeBlocks int64) *Registry { return &Registry{ deposit: deposit, challengeBlocks: challengeBlocks, entries: map[string]*Entry{}, credits: map[string]int64{}, } } // Deposit is what listing costs. func (r *Registry) Deposit() int64 { return r.deposit } // ChallengeBlocks is how long a challenge takes to resolve. func (r *Registry) ChallengeBlocks() int64 { return r.challengeBlocks } // Apply lists an entry immediately, in exchange for exactly the deposit. // // There is no application period in v0: the entry is on the list the moment the // deposit is paid, and the check on it is that anybody can challenge it. // // A key whose entry was removed is free again, and applying for it writes a // fresh entry in the same position in the listing order. func (r *Registry) Apply(key, url, description string, owner address, paid, now int64) error { if !ValidKey(key) { return ErrBadKey } if !ValidURL(url) { return ErrBadURL } if !ValidDescription(description) { return ErrBadDescription } if paid != r.deposit { return ErrWrongDeposit } old, seen := r.entries[key] if seen && old.Live() { return ErrTaken } if !seen { if len(r.order) >= MaxEntries { return ErrFull } r.order = append(r.order, key) } r.entries[key] = &Entry{ Key: key, URL: url, Description: description, Owner: owner, Deposit: paid, At: now, State: StateListed, } r.locked += paid return nil } // BondFor is what challenging key would cost, and whether it can be challenged // at all. // // The realm reads this BEFORE it reads the envelope, so a caller who attaches // coins to a challenge of something unchallengeable is refused on the entry and // not on the amount. func (r *Registry) BondFor(key string) (int64, bool) { e, ok := r.entries[key] if !ok || e.State != StateListed { return 0, false } return e.Deposit, true } // Challenge opens an objection to an entry, against a bond equal to that // entry's deposit, and sets the deadline at now + ChallengeBlocks. // // An owner may not challenge their own entry. It would cost nothing (a kept // entry credits its owner the bond, which here is their own) and it would make // the entry immune to a real challenge for the whole window. func (r *Registry) Challenge(key string, challenger address, bond, now int64) error { e, ok := r.entries[key] if !ok || !e.Live() { return ErrNoEntry } if e.State == StateChallenged { return ErrChallenged } if challenger == e.Owner { return ErrSelfChallenge } if bond != e.Deposit { return ErrWrongBond } e.State = StateChallenged e.challenge = &Challenge{ Challenger: challenger, Bond: bond, Deadline: now + r.challengeBlocks, voters: map[string]bool{}, } r.locked += bond return nil } // Vote records one address's opinion on an open challenge: keep the entry, or // remove it. // // One address, one vote, unweighted, and it cannot be changed. Anyone may vote, // including the owner and the challenger, because excluding them would only // move their vote to another address they control. See the package doc: this is // sybil-prone on purpose rather than by oversight, and gating it is the job of // the vouch realm. func (r *Registry) Vote(key string, voter address, keep bool, now int64) error { e, ok := r.entries[key] if !ok { return ErrNoEntry } c := e.challenge if e.State != StateChallenged || c == nil { return ErrNoChallenge } if !c.Open(now) { return ErrVotingClosed } k := voter.String() if c.voters[k] { return ErrAlreadyVoted } c.voters[k] = true if keep { c.Keep++ } else { c.Remove++ } return nil } // Outcome is what a resolution decided and who it paid. type Outcome struct { Key string Kept bool Winner address Amount int64 // credited to the winner Keep int64 Remove int64 } // Resolve closes a challenge whose deadline has passed. Anyone may call it: the // two parties both have a reason to and neither can stall the other. // // A majority of keep votes keeps the entry and credits its owner the // challenger's bond. Otherwise the entry is removed and the challenger is // credited the bond plus the deposit. // // A TIE KEEPS THE ENTRY, including the tie of nobody voting at all. The // incumbent paid first and is already at risk, so the burden is on the // challenger to produce a reason; if ties went the other way, a challenge that // convinced nobody would still win, and listing anything would be pointless. // The cost of that choice is the one a challenger signs up for: being wrong // costs the bond. func (r *Registry) Resolve(key string, now int64) (Outcome, error) { e, ok := r.entries[key] if !ok { return Outcome{}, ErrNoEntry } c := e.challenge if e.State != StateChallenged || c == nil { return Outcome{}, ErrNoChallenge } if c.Open(now) { return Outcome{}, ErrTooEarly } out := Outcome{Key: key, Kept: c.Keep >= c.Remove, Keep: c.Keep, Remove: c.Remove} if out.Kept { out.Winner, out.Amount = e.Owner, c.Bond } else { out.Winner, out.Amount = c.Challenger, c.Bond+e.Deposit } // Credit first: it is the only step that can fail, and a half-applied // resolution would leave an entry with no challenge and nobody paid. if err := r.credit(out.Winner, out.Amount); err != nil { return Outcome{}, err } if out.Kept { e.State = StateListed r.locked -= c.Bond } else { e.State = StateRemoved r.locked -= c.Bond + e.Deposit } e.challenge = nil return out, nil } // Unlist takes an owner's own entry down and credits them the deposit back. // // It is refused while a challenge is open, which is the whole point of the // bond: an owner who could walk away mid-challenge would be risking nothing. func (r *Registry) Unlist(key string, owner address) error { e, ok := r.entries[key] if !ok || !e.Live() { return ErrNoEntry } if e.Owner != owner { return ErrNotOwner } if e.State == StateChallenged { return ErrChallenged } if err := r.credit(owner, e.Deposit); err != nil { return err } e.State = StateRemoved r.locked -= e.Deposit return nil } // Withdraw zeroes who's credit and returns what they were owed. // // The holding realm sends the coins AFTER this returns. That ordering is the // pattern: the credit is already gone from the ledger when control passes to // the recipient, so a reentrant withdrawal finds ErrNothingOwed. func (r *Registry) Withdraw(who address) (int64, error) { k := who.String() amount := r.credits[k] if amount <= 0 { return 0, ErrNothingOwed } delete(r.credits, k) // effects before interactions r.owed -= amount return amount, nil } // credit records that who is owed amount more. func (r *Registry) credit(who address, amount int64) error { if amount <= 0 { return ErrBadAmount } k := who.String() have, seen := r.credits[k] if !seen && len(r.credits) >= MaxPayees { return ErrFull } if have > maxInt64-amount || r.owed > maxInt64-amount { return ErrOverflow } r.credits[k] = have + amount r.owed += amount return nil } // Get returns an entry by key, whatever its state, and whether it ever existed. func (r *Registry) Get(key string) (*Entry, bool) { e, ok := r.entries[key] return e, ok } // IsListed reports whether key is on the list right now. A challenged entry is // still listed. func (r *Registry) IsListed(key string) bool { e, ok := r.entries[key] return ok && e.Live() } // Count is how many entries are on the list right now. func (r *Registry) Count() int { n := 0 for _, key := range r.order { if r.entries[key].Live() { n++ } } return n } // Records is how many keys the registry has ever held, removed ones included. func (r *Registry) Records() int { return len(r.order) } // Listed returns every entry on the list, oldest first. // // The order comes from a slice and not from iterating the map, so it is a // stable sequence a Render can be pinned against rather than an insertion order // that a delete-and-re-add would reshuffle. func (r *Registry) Listed() []*Entry { out := []*Entry{} for _, key := range r.order { if e := r.entries[key]; e.Live() { out = append(out, e) } } return out } // Challenged returns every entry with a challenge open against it, oldest // listing first. func (r *Registry) Challenged() []*Entry { out := []*Entry{} for _, key := range r.order { if e := r.entries[key]; e.State == StateChallenged { out = append(out, e) } } return out } // ChallengeOf returns the open challenge against key, if there is one. func (r *Registry) ChallengeOf(key string) (*Challenge, bool) { e, ok := r.entries[key] if !ok || e.challenge == nil { return nil, false } return e.challenge, true } // CreditOf is what who can withdraw right now. func (r *Registry) CreditOf(who address) int64 { return r.credits[who.String()] } // Owed is every credit not yet withdrawn. func (r *Registry) Owed() int64 { return r.owed } // Locked is the deposits behind live entries plus the bonds behind open // challenges. // // Locked plus [Registry.Owed] is what the holding realm must have at its // address: every ugnot it ever took is either still backing something or // already assigned to somebody. A realm can assert that equality against its // own balance, and this package's tests do. func (r *Registry) Locked() int64 { return r.locked } // ValidKey reports whether key is usable: 1 to [MaxKeyLen] bytes of lowercase // ASCII letters, digits, '-', '_' and '.', starting with a letter or a digit. // // The leading-alphanumeric rule is what stops "." and "..", and what stops a // key that reads as punctuation in the list it is shown in. The charset is // narrow so a key can be typed, linked and compared without surprises; it is // still escaped at render time, because a validator and an escaper protect // against different mistakes. func ValidKey(key string) bool { if key == "" || len(key) > MaxKeyLen { return false } if c := key[0]; !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9') { return false } for i := 0; i < len(key); i++ { c := key[i] switch { case c >= 'a' && c <= 'z', c >= '0' && c <= '9': case c == '-' || c == '_' || c == '.': default: return false } } return true } // ValidURL reports whether url can be stored: non-empty, within [MaxURLLen], // and free of spaces and control characters. // // No scheme is required, because a list of on-chain things is the obvious use // and those have no host. A renderer treats a schemeless URL as a path // relative to the chain's web root, so an on-chain target is written // "/r/moul/home" and an off-chain one carries its own "https://". // // Nothing here checks that the target exists: a deposit backs the claim that // the entry is worth listing, not the claim that it resolves. func ValidURL(url string) bool { if url == "" || len(url) > MaxURLLen { return false } for i := 0; i < len(url); i++ { if c := url[i]; c <= 0x20 || c == 0x7f { return false } } return true } // ValidDescription reports whether description can be stored: non-empty after // trimming, within [MaxDescLen], and a single line. // // Newlines and tabs are refused rather than stripped, because a description is // shown back to the person who wrote it and silently rewriting it is worse than // telling them it was refused. It lives in a table cell, which a newline would // break out of and an escaper would then have to repair. func ValidDescription(description string) bool { if len(description) > MaxDescLen || strings.TrimSpace(description) == "" { return false } for i := 0; i < len(description); i++ { if c := description[i]; c < 0x20 || c == 0x7f { return false } } return true }
  6. #6curated_test.gno
  7. #7package curated import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) const ( deposit = int64(1000) window = int64(100) ) var ( alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") carol = testutils.TestAddress("carol") dave = testutils.TestAddress("dave") ) // list returns a registry holding one entry owned by alice, listed at height // 100, which is the starting point of most cases below. func list(t *testing.T) *Registry { t.Helper() r := New(deposit, window) urequire.NoError(t, r.Apply("gnoswap", "https://gnoswap.io", "an AMM", alice, deposit, 100)) return r } func TestValidKey(t *testing.T) { tests := []struct { key string want bool }{ {"gnoswap", true}, {"a", true}, {"0", true}, {"gno-swap_v2.1", true}, {strings.Repeat("x", MaxKeyLen), true}, {"", false}, {strings.Repeat("x", MaxKeyLen+1), false}, {"GnoSwap", false}, {"gno swap", false}, {"gno/swap", false}, {"gno:swap", false}, {".", false}, {"..", false}, {"-leading", false}, {"_leading", false}, {"émoji", false}, } for _, tt := range tests { uassert.Equal(t, tt.want, ValidKey(tt.key), tt.key) } } func TestValidURL(t *testing.T) { tests := []struct { name string url string want bool }{ {"https", "https://gnoswap.io/pools", true}, {"an on-chain path, which has no scheme to carry", "/r/moul/home", true}, {"query and fragment", "https://x.io/a?b=c#d", true}, {"a pipe is escaped at render time, not refused", "https://x.io/a|b", true}, {"empty", "", false}, {"too long", "https://x.io/" + strings.Repeat("x", MaxURLLen), false}, {"a space", "https://x.io/a b", false}, {"a newline", "https://x.io/a\nb", false}, {"a control character", "https://x.io/a\x01b", false}, } for _, tt := range tests { uassert.Equal(t, tt.want, ValidURL(tt.url), tt.name) } } func TestValidDescription(t *testing.T) { tests := []struct { name string desc string want bool }{ {"plain", "an AMM on gno.land", true}, {"markdown is allowed and escaped later", "[x](y) | z", true}, {"empty", "", false}, {"blank", " ", false}, {"too long", strings.Repeat("x", MaxDescLen+1), false}, {"a newline would break its own table cell", "a\nb", false}, {"a tab, same reason", "a\tb", false}, } for _, tt := range tests { uassert.Equal(t, tt.want, ValidDescription(tt.desc), tt.name) } } func TestApplyRefusesWhatItCannotStore(t *testing.T) { tests := []struct { name string key string url string desc string paid int64 want error }{ {"bad key", "Gnoswap", "https://x.io", "an AMM", deposit, ErrBadKey}, {"bad url", "gnoswap", "", "an AMM", deposit, ErrBadURL}, {"bad description", "gnoswap", "https://x.io", " ", deposit, ErrBadDescription}, {"underpaid", "gnoswap", "https://x.io", "an AMM", deposit - 1, ErrWrongDeposit}, {"overpaid", "gnoswap", "https://x.io", "an AMM", deposit + 1, ErrWrongDeposit}, {"nothing attached", "gnoswap", "https://x.io", "an AMM", 0, ErrWrongDeposit}, } for _, tt := range tests { r := New(deposit, window) err := r.Apply(tt.key, tt.url, tt.desc, alice, tt.paid, 100) uassert.ErrorIs(t, err, tt.want, tt.name) uassert.Equal(t, 0, r.Count(), tt.name+": nothing was listed") uassert.Equal(t, int64(0), r.Locked(), tt.name+": nothing was locked") } } func TestApplyListsImmediately(t *testing.T) { r := list(t) e, ok := r.Get("gnoswap") urequire.True(t, ok, "the entry exists") uassert.Equal(t, "https://gnoswap.io", e.URL) uassert.Equal(t, "an AMM", e.Description) uassert.Equal(t, alice.String(), e.Owner.String()) uassert.Equal(t, deposit, e.Deposit) uassert.Equal(t, int64(100), e.At) uassert.Equal(t, "listed", e.State.String()) uassert.True(t, r.IsListed("gnoswap")) uassert.False(t, r.IsListed("nothing")) uassert.Equal(t, 1, r.Count()) uassert.Equal(t, 1, r.Records()) uassert.Equal(t, deposit, r.Locked()) uassert.Equal(t, int64(0), r.Owed()) } // A key is unique while it is on the list, and free again once it is off it: a // challenge removes an entry, it does not burn the name. func TestAKeyIsTakenUntilItIsRemoved(t *testing.T) { r := list(t) uassert.ErrorIs(t, r.Apply("gnoswap", "https://other.io", "mine now", bob, deposit, 101), ErrTaken) urequire.NoError(t, r.Unlist("gnoswap", alice)) uassert.False(t, r.IsListed("gnoswap")) urequire.NoError(t, r.Apply("gnoswap", "https://other.io", "mine now", bob, deposit, 102)) e, _ := r.Get("gnoswap") uassert.Equal(t, bob.String(), e.Owner.String()) uassert.Equal(t, int64(102), e.At, "a re-application is a fresh entry") uassert.Equal(t, 1, r.Count()) uassert.Equal(t, 1, r.Records(), "and it reuses its place in the listing order") } func TestListedIsOldestFirstAndSkipsTheRemoved(t *testing.T) { r := New(deposit, window) urequire.NoError(t, r.Apply("one", "https://1.io", "first", alice, deposit, 100)) urequire.NoError(t, r.Apply("two", "https://2.io", "second", alice, deposit, 101)) urequire.NoError(t, r.Apply("three", "https://3.io", "third", alice, deposit, 102)) got := r.Listed() urequire.Equal(t, 3, len(got)) uassert.Equal(t, "one", got[0].Key) uassert.Equal(t, "two", got[1].Key) uassert.Equal(t, "three", got[2].Key) urequire.NoError(t, r.Unlist("two", alice)) got = r.Listed() urequire.Equal(t, 2, len(got)) uassert.Equal(t, "one", got[0].Key) uassert.Equal(t, "three", got[1].Key) uassert.Equal(t, 0, len(r.Challenged())) } func TestChallengeRefusals(t *testing.T) { tests := []struct { name string key string who address bond int64 want error }{ {"no such entry", "nothing", bob, deposit, ErrNoEntry}, {"the bond must match", "gnoswap", bob, deposit - 1, ErrWrongBond}, {"and may not exceed it either", "gnoswap", bob, deposit + 1, ErrWrongBond}, {"an owner cannot shield their own entry", "gnoswap", alice, deposit, ErrSelfChallenge}, } for _, tt := range tests { r := list(t) uassert.ErrorIs(t, r.Challenge(tt.key, tt.who, tt.bond, 200), tt.want, tt.name) uassert.Equal(t, deposit, r.Locked(), tt.name+": no bond was taken") _, open := r.ChallengeOf("gnoswap") uassert.False(t, open, tt.name+": no challenge was opened") } } func TestChallengeOpensOnceAndSetsTheDeadline(t *testing.T) { r := list(t) bond, ok := r.BondFor("gnoswap") urequire.True(t, ok, "it is challengeable") uassert.Equal(t, deposit, bond, "the bond matches that entry's own deposit") urequire.NoError(t, r.Challenge("gnoswap", bob, bond, 200)) c, open := r.ChallengeOf("gnoswap") urequire.True(t, open, "the challenge is readable") uassert.Equal(t, bob.String(), c.Challenger.String()) uassert.Equal(t, int64(200+window), c.Deadline) uassert.True(t, c.Open(200+window-1)) uassert.False(t, c.Open(200+window), "the deadline is exclusive") uassert.Equal(t, "challenged", mustGet(t, r, "gnoswap").State.String()) uassert.True(t, r.IsListed("gnoswap"), "a challenge is an objection, not a verdict") uassert.Equal(t, 2*deposit, r.Locked(), "the deposit and the bond") // A second challenge would be a second bond on one entry. uassert.ErrorIs(t, r.Challenge("gnoswap", carol, deposit, 201), ErrChallenged) _, ok = r.BondFor("gnoswap") uassert.False(t, ok, "and BondFor says so before any money is read") } func TestVoteIsOneAddressOneVoteWhileTheChallengeIsOpen(t *testing.T) { r := list(t) uassert.ErrorIs(t, r.Vote("gnoswap", carol, true, 200), ErrNoChallenge) uassert.ErrorIs(t, r.Vote("nothing", carol, true, 200), ErrNoEntry) urequire.NoError(t, r.Challenge("gnoswap", bob, deposit, 200)) urequire.NoError(t, r.Vote("gnoswap", carol, true, 210)) uassert.ErrorIs(t, r.Vote("gnoswap", carol, false, 211), ErrAlreadyVoted) uassert.ErrorIs(t, r.Vote("gnoswap", carol, true, 212), ErrAlreadyVoted) urequire.NoError(t, r.Vote("gnoswap", dave, false, 213)) c, _ := r.ChallengeOf("gnoswap") uassert.Equal(t, int64(1), c.Keep) uassert.Equal(t, int64(1), c.Remove) uassert.Equal(t, 2, c.Voters()) uassert.True(t, c.HasVoted(carol)) uassert.False(t, c.HasVoted(alice)) // Past the deadline the poll is shut, and so is the deadline itself. uassert.ErrorIs(t, r.Vote("gnoswap", alice, true, 200+window), ErrVotingClosed) uassert.Equal(t, 2, c.Voters()) } // The outcome table: who wins, and what the loser pays. A tie keeps the entry. func TestResolvePaysTheWinnerAndTiesKeep(t *testing.T) { tests := []struct { name string keep []address remove []address wantKept bool wantWinner address wantAmount int64 }{ {"nobody voted, the incumbent keeps it", nil, nil, true, alice, deposit}, {"a tie keeps it", []address{carol}, []address{dave}, true, alice, deposit}, {"a majority to keep", []address{carol, dave}, nil, true, alice, deposit}, {"a majority to remove", nil, []address{carol, dave}, false, bob, 2 * deposit}, } for _, tt := range tests { r := list(t) urequire.NoError(t, r.Challenge("gnoswap", bob, deposit, 200)) for _, who := range tt.keep { urequire.NoError(t, r.Vote("gnoswap", who, true, 210)) } for _, who := range tt.remove { urequire.NoError(t, r.Vote("gnoswap", who, false, 210)) } out, err := r.Resolve("gnoswap", 200+window) urequire.NoError(t, err, tt.name) uassert.Equal(t, tt.wantKept, out.Kept, tt.name) uassert.Equal(t, tt.wantWinner.String(), out.Winner.String(), tt.name) uassert.Equal(t, tt.wantAmount, out.Amount, tt.name) uassert.Equal(t, tt.wantAmount, r.CreditOf(tt.wantWinner), tt.name+": credited, not sent") uassert.Equal(t, tt.wantKept, r.IsListed("gnoswap"), tt.name) // Whatever happened, every ugnot is either still backing the entry or // assigned to somebody. uassert.Equal(t, 2*deposit, r.Locked()+r.Owed(), tt.name+": the books balance") // The challenge is gone either way, so nothing resolves twice. _, open := r.ChallengeOf("gnoswap") uassert.False(t, open, tt.name) _, err = r.Resolve("gnoswap", 200+window) uassert.ErrorIs(t, err, ErrNoChallenge, tt.name) } } func TestResolveRefusesBeforeTheDeadline(t *testing.T) { r := list(t) _, err := r.Resolve("gnoswap", 200) uassert.ErrorIs(t, err, ErrNoChallenge, "an unchallenged entry has nothing to resolve") _, err = r.Resolve("nothing", 200) uassert.ErrorIs(t, err, ErrNoEntry) urequire.NoError(t, r.Challenge("gnoswap", bob, deposit, 200)) _, err = r.Resolve("gnoswap", 200+window-1) uassert.ErrorIs(t, err, ErrTooEarly) uassert.Equal(t, int64(0), r.Owed(), "nothing was paid out early") uassert.Equal(t, "challenged", mustGet(t, r, "gnoswap").State.String()) } // Losing a challenge frees the key, and the deposit that backed it has already // gone to the challenger, so re-listing it costs a fresh deposit. func TestAnEntryThatLosesIsRemovedAndItsDepositIsGone(t *testing.T) { r := list(t) urequire.NoError(t, r.Challenge("gnoswap", bob, deposit, 200)) urequire.NoError(t, r.Vote("gnoswap", carol, false, 210)) _, err := r.Resolve("gnoswap", 200+window) urequire.NoError(t, err) uassert.Equal(t, "removed", mustGet(t, r, "gnoswap").State.String()) uassert.Equal(t, 0, r.Count()) uassert.Equal(t, int64(0), r.Locked()) uassert.Equal(t, 2*deposit, r.Owed()) uassert.Equal(t, int64(0), r.CreditOf(alice), "the owner lost the deposit") uassert.ErrorIs(t, r.Unlist("gnoswap", alice), ErrNoEntry, "there is nothing left to take down") } func TestUnlistIsTheOwnersAndNotWhileChallenged(t *testing.T) { r := list(t) uassert.ErrorIs(t, r.Unlist("nothing", alice), ErrNoEntry) uassert.ErrorIs(t, r.Unlist("gnoswap", bob), ErrNotOwner) urequire.NoError(t, r.Challenge("gnoswap", bob, deposit, 200)) uassert.ErrorIs(t, r.Unlist("gnoswap", alice), ErrChallenged, "an owner who could walk away mid-challenge would be risking nothing") uassert.Equal(t, int64(0), r.CreditOf(alice)) // Once the challenge is behind it, the entry is the owner's to take down. urequire.NoError(t, r.Vote("gnoswap", carol, true, 210)) _, err := r.Resolve("gnoswap", 200+window) urequire.NoError(t, err) urequire.NoError(t, r.Unlist("gnoswap", alice)) uassert.Equal(t, 2*deposit, r.CreditOf(alice), "the bond she won, plus her own deposit back") uassert.Equal(t, int64(0), r.Locked()) } func TestWithdrawZeroesTheCreditBeforeItIsPaid(t *testing.T) { r := list(t) urequire.NoError(t, r.Unlist("gnoswap", alice)) uassert.Equal(t, deposit, r.Owed()) _, err := r.Withdraw(bob) uassert.ErrorIs(t, err, ErrNothingOwed, "bob is owed nothing") got, err := r.Withdraw(alice) urequire.NoError(t, err) uassert.Equal(t, deposit, got) uassert.Equal(t, int64(0), r.CreditOf(alice)) uassert.Equal(t, int64(0), r.Owed()) // A second call, which is what a reentrant one would be, finds nothing. _, err = r.Withdraw(alice) uassert.ErrorIs(t, err, ErrNothingOwed) } func TestCreditsAccumulateAcrossEntries(t *testing.T) { r := list(t) urequire.NoError(t, r.Apply("gnoscan", "https://gnoscan.io", "an explorer", alice, deposit, 101)) urequire.NoError(t, r.Unlist("gnoswap", alice)) urequire.NoError(t, r.Unlist("gnoscan", alice)) uassert.Equal(t, 2*deposit, r.CreditOf(alice), "crediting twice owes the sum") got, err := r.Withdraw(alice) urequire.NoError(t, err) uassert.Equal(t, 2*deposit, got, "and one withdrawal collects both") } // Everything the registry ever took in is either locked behind something live // or owed to somebody, at every step of a full round. func TestTheBooksBalanceAtEveryStep(t *testing.T) { r := New(deposit, window) paidIn := int64(0) check := func(step string) { t.Helper() uassert.Equal(t, paidIn, r.Locked()+r.Owed(), step) } check("empty") urequire.NoError(t, r.Apply("one", "https://1.io", "first", alice, deposit, 100)) paidIn += deposit check("after a listing") urequire.NoError(t, r.Challenge("one", bob, deposit, 200)) paidIn += deposit check("after a challenge") urequire.NoError(t, r.Vote("one", carol, false, 210)) check("after a vote") _, err := r.Resolve("one", 200+window) urequire.NoError(t, err) check("after a resolution") _, err = r.Withdraw(bob) urequire.NoError(t, err) paidIn -= 2 * deposit check("after the winner collected") uassert.Equal(t, int64(0), r.Locked()+r.Owed(), "and the realm owes nothing") } func mustGet(t *testing.T, r *Registry, key string) *Entry { t.Helper() e, ok := r.Get(key) urequire.True(t, ok, "entry "+key+" exists") return e }
  8. #8gnomod.toml
  9. #9module = "gno.land/p/moul/x/social/curated/v0" gno = "0.9"
#6AddPackagegno.land/p/moul/x/social/patron/v09 arguments
Attached funds
15000000ugnot

Arguments · 9

  1. #1patron
  2. #2README.md
  3. #3# `gno.land/p/moul/x/social/patron/v0` **The engine behind recurring support for a builder**: a plan somebody subscribes to, period after period, paid in ugnot. `NewRegistry`, `Open`, `Subscribe`, `Close`, `Reopen`, `Withdraw`, plus the reads a page needs. ```go r := patron.NewRegistry() id, _ := r.Open(creator, "monthly support", "what you get", 1_000_000, 43_200) pay, _ := r.Subscribe(id, supporter, 2_500_000, height) // 2 periods, 500000 change plan, _ := r.Get(id) plan.IsActive(supporter, height) // true until pay.PaidThrough r.Withdraw(creator) // the earnings r.Withdraw(supporter) // the change ``` **There is no cron on chain, so a renewal is a pull and not a push.** Nothing here can charge anybody, and nothing could: a native coin cannot be pulled at all, since a banker may only spend its own realm's address. A supporter renews by signing another payment. That is the one sentence a reader arriving from web2 needs, because the thing they are picturing, a standing mandate on a card, does not exist anywhere on this chain. **What it adds over a tip jar.** [`r/moul/x/daily/tipjar`](https://github.com/moul/gno-contracts/tree/main/r/moul/x/daily/tipjar) is the one-shot version and is already live: one payment, a leaderboard, done. The recurrence is the whole difference here. A plan carries a price per period and a period measured in **blocks**, a payment buys whole periods of it, and the registry answers "is this address active right now" at any height. It is the one piece of the `x/social` family that produces a recurring write rather than a one-off. **The period arithmetic is the part that has to be right**, so it is stated rather than left to the reader: - A payment buys `floor(sent / price)` **whole** periods and refuses anything short of one. Rounding is **down**, and the remainder under one period is credited back to the supporter rather than kept. Keeping it would be a fee nobody agreed to, and a silent fee is the thing a subscription realm must not have. - The extension starts from **whichever is later, now or the current `paidThrough`**. Renewing early therefore adds a whole period on top of what is left instead of discarding it; renewing after a lapse starts from now, because the gap was never paid for. - `IsActive` is strict: an address paid through height `h` is active at `h-1` and not at `h`. One rule at both edges, so two periods never overlap by a block. - `Subscribe` computes the extension through [`xmath.MulDiv`](https://github.com/moul/gno-contracts/tree/main/p/moul/xmath). The division there is exact by construction, so nothing is rounded a second time; what it buys is the 128-bit intermediate, because `spent * periodBlocks` overflows an `int64` for a plan priced in whole GNOT with a period measured in months, and the naive product wraps to a plausible-looking height rather than an obviously wrong one. **Earnings are credited at the moment of payment, not streamed.** The creator can withdraw the whole price the instant it arrives, so a supporter who stops being active is **not** refunded and no part of a paid period ever comes back. That is a real limitation, and it is v0 on purpose: escrowed streaming, where the creator claims only what has elapsed and the supporter cancels and reclaims the rest, needs a claim schedule and a refund path. That is a larger realm than this one, not a flag on it, and it is the v1. **The trap it avoids: money leaves by pull, never by a push loop.** Nothing here moves a coin. `Subscribe` credits a [`pullpayment`](https://github.com/moul/gno-contracts/tree/main/p/moul/x/daily/pullpayment) ledger, and `Withdraw` zeroes a credit and reports what was owed so the holding realm transfers afterwards, with the balance already gone when control leaves. A realm that looped over payees instead would fail entirely on one unpayable address and hand a griefer a cheap denial of service. **A title and a description are attacker-controlled markdown.** `ValidTitle` and `ValidDescription` bound them and refuse control characters, which is a different protection from escaping and not a substitute for it. One note for whoever renders them: `md.Link` escapes its text with the *inline* escaper, and the inline escaper does not touch a pipe, so a caller's title carried inside a link inside a table cell still opens a column. In a table, the link text has to be something the realm owns and the title gets `ui.Cell`. ## v0 ships no token, and that is the answer rather than a gap A creator coin minted per period paid is the easy half. The sink is not: what a supporter would redeem it for is a promise the creator makes off chain, and a token whose only sink is a promise is a scoreboard with a price. It would also compete with the thing that already works here, which is that a period is paid in ugnot and either active or not. What would change the answer is a **redeem the creator can be held to on chain**: a queue position the realm enforces, an allocation it hands out, an access gate another realm checks before it lets somebody in. Any of those turns the coin into a claim rather than a souvenir, and at that point the mint rule, the sink and the buyer can all be named. Until one of them exists, the condition is the deliverable. The sibling package `gno.land/p/moul/x/social/coin/v0` is where that gets enforced: a GRC20 that refuses to exist until its mint rule, its sink and its buyer are declared. This package does not import it, and will not until there is something true to declare. **Live realm:** [`r/moul/x/social/patron`](https://github.com/moul/gno-contracts/tree/main/r/moul/x/social/patron) · render it at [`/r/moul/x/social/patron/v0`](https://gno.land/r/moul/x/social/patron/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/social/patron/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/p/moul/x/social/patron/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/social/patron/v0" gno = "0.9"
  6. #6patron.gno
  7. #7// Package patron is the engine behind recurring support for a builder: a plan // somebody subscribes to, period after period, paid in ugnot on chain. // // # There is no cron on chain, so a renewal is a pull and not a push // // Nothing here can charge anybody. The supporter sends another payment and // their paid-through height moves forward; there is no scheduler, no keeper, // and no standing authority over anybody's balance. There is no way to write // one either, because a native coin cannot be pulled at all: a banker may only // spend its own realm's address, so the inbound path is always the holder // signing. A reader arriving from web2 expects the opposite, and that is the // one expectation to unlearn before reading the rest of this package. // // # What this adds over a tip jar // // A tip jar is one payment and then nothing, and the one-shot version is // already live at gno.land/r/moul/x/daily/tipjar. The recurrence is the only // reason this exists: a plan carries a price per period and a period measured // in BLOCKS, a supporter buys whole periods, and anybody can ask at any height // whether a given address is still active. It is the one piece of the x/social // family that produces a recurring write rather than a one-off. // // # The model // // Registry every plan, plus the credit ledger money leaves through // Plan a creator, a title, a description, a price, a period, open or not // Payment what one Subscribe call bought: periods, spent, change, through // // # Renewing early never loses time already paid for // // [Registry.Subscribe] extends from whichever is LATER, now or the supporter's // current paid-through height. Renewing two blocks before a lapse adds a whole // period on top of what is left. Renewing after a lapse starts from now, // because the gap was never paid for and nothing backdates it. // // # The change is credited back, never kept // // A payment buys floor(sent / price) whole periods, and the remainder under // one period is credited to the SUPPORTER's own withdrawable balance. Keeping // it would be a fee nobody agreed to, and a silent fee is the thing a // subscription realm must not have. The supporter takes it back through the // same [Registry.Withdraw] a creator uses. // // # Earnings are credited at the moment of payment, not streamed // // The creator can withdraw the whole price the instant it is paid. A supporter // who stops being active is therefore NOT refunded, and no part of a paid // period is ever returned. That is a real limitation and it is v0 on purpose: // escrowed streaming, where the creator claims only what has elapsed and the // supporter can cancel and reclaim the rest, needs a claim schedule and a // refund path, which is a bigger realm than this one. It is the v1, and it is // not a line that can be bolted onto this one. // // # Money leaves by pull, never by a push loop // // Nothing here moves coins. [Registry.Subscribe] credits a // gno.land/p/moul/x/daily/pullpayment ledger, and [Registry.Withdraw] zeroes a // credit and reports what was owed so the holding realm can transfer after // that call, with the balance already gone when control leaves. A realm that // looped over payees instead would fail entirely on one unpayable address and // hand a griefer a cheap denial of service. // // # A title and a description are attacker-controlled markdown // // Both are free text. [ValidTitle] and [ValidDescription] bound them and // refuse control characters, which is a different protection from escaping and // not a substitute for it: a realm that renders either one escapes it with // ui.Inline in prose or ui.Cell in a table cell. package patron import ( "errors" "strings" "gno.land/p/moul/kit/store/v0" "gno.land/p/moul/x/daily/pullpayment/v0" "gno.land/p/moul/xmath/v1" ) const ( // MaxTitleLen and MaxDescriptionLen bound the two free-text fields. Long // enough to say what the plan is, short enough that opening one cannot // lock an unbounded storage deposit somebody else is paying for. MaxTitleLen = 80 MaxDescriptionLen = 500 // MinPeriodBlocks and MaxPeriodBlocks bound a period both ways. A period // of zero blocks is not a subscription, it is a division by zero wearing // a price tag. The ceiling is about a year at the five second blocks the // test chain runs, past which "recurring" stops meaning anything and the // plan is a one-off with extra steps. MinPeriodBlocks = int64(10) MaxPeriodBlocks = int64(6307200) // MinPricePerPeriod is one ugnot, because a free plan is a tip jar // (gno.land/r/moul/x/daily/tipjar) and not a subscription: at a price of // zero there is nothing to buy a period with and every address would be // active forever. MinPricePerPeriod = int64(1) // MaxPeriodsPerPayment bounds what one payment may buy. It keeps // periods * PeriodBlocks inside an int64 by construction rather than by // hope, and it stops a single send from parking a paid-through height so // far ahead that no later arithmetic on it means anything. MaxPeriodsPerPayment = int64(10000) ) // The errors a caller can get back. A p/ package returns them and the realm // decides to abort. var ( ErrBadTitle = errors.New("patron: title is empty, too long, or has control characters") ErrBadDescription = errors.New("patron: description is too long or has control characters") ErrBadPrice = errors.New("patron: a plan needs a price of at least one ugnot per period") ErrBadPeriod = errors.New("patron: period out of range") ErrNoPlan = errors.New("patron: no such plan") ErrPlanClosed = errors.New("patron: this plan is closed to new subscriptions") ErrNotCreator = errors.New("patron: only the plan's creator can do that") ErrAlreadyOpen = errors.New("patron: the plan is already open") ErrAlreadyClosed = errors.New("patron: the plan is already closed") ErrShortOfOnePeriod = errors.New("patron: the payment does not cover one whole period") ErrTooManyPeriods = errors.New("patron: one payment cannot buy that many periods") ErrNothingToWithdraw = errors.New("patron: nothing to withdraw") ) // Plan is one creator's recurring support plan. type Plan struct { Creator address Title string Description string // PricePerPeriod is what one period costs, in ugnot. PricePerPeriod int64 // PeriodBlocks is how long a period lasts, in blocks. Blocks and not // seconds: height is the clock consensus agrees on, and a block timestamp // is set by proposers and is not something to build a billing cliff out // of at second resolution. PeriodBlocks int64 // Open reports whether the plan takes NEW payments. Closing it never // touches a subscription already paid for, which runs to its own // paid-through height. Open bool // Received is the lifetime ugnot this plan credited to its creator. Received int64 // paidThrough is the first height at which a supporter is no longer // active. A supporter is active while now < paidThrough, so a period // bought at height h ends at h+PeriodBlocks and the holder is inactive // at exactly that height. paidThrough map[string]int64 // supporters is every address that has ever paid, in first-payment // order. It exists so a listing is deterministic without iterating a map // as if insertion order were a sort. supporters []address } // PaidThrough is the height who stops being active at, or zero if they never // paid. func (p *Plan) PaidThrough(who address) int64 { if p == nil { return 0 } return p.paidThrough[who.String()] } // IsActive reports whether who is paid up at height now. // // The comparison is strict: a supporter paid through height h is active at // h-1 and not at h. One rule, applied at both edges, so a period never // overlaps the next one by a block. func (p *Plan) IsActive(who address, now int64) bool { return p.PaidThrough(who) > now } // SupporterCount is how many distinct addresses have ever paid, active or not. func (p *Plan) SupporterCount() int { if p == nil { return 0 } return len(p.supporters) } // Supporters is every address that has ever paid, in first-payment order. // // It returns a copy. Handing out the stored slice would be a live mutation // handle on realm state, which is the cheapest way for a reader to become a // writer. func (p *Plan) Supporters() []address { if p == nil { return nil } out := make([]address, len(p.supporters)) copy(out, p.supporters) return out } // ActiveCount is how many supporters are paid up at height now. func (p *Plan) ActiveCount(now int64) int { if p == nil { return 0 } n := 0 for _, who := range p.supporters { if p.IsActive(who, now) { n++ } } return n } // Payment is what one [Registry.Subscribe] call bought. type Payment struct { // Periods is how many whole periods the payment covered. Periods int64 // Spent is the ugnot those periods cost, credited to the creator. Spent int64 // Change is the remainder under one period, credited back to the // supporter rather than kept. Change int64 // PaidThrough is the supporter's new paid-through height. PaidThrough int64 // NewSupporter reports whether this address had never paid this plan // before, which is the signal a realm wants for an event or a counter. NewSupporter bool } // Registry holds every plan and the credit ledger money leaves through. type Registry struct { plans *store.Store ledger *pullpayment.Ledger // earned is lifetime ugnot credited per creator, which survives a // withdrawal. The ledger only knows what is owed RIGHT NOW, and a page // showing a creator zero the moment they cash out would be telling the // truth about the wrong question. earned map[string]int64 } // NewRegistry returns an empty registry. func NewRegistry() *Registry { return &Registry{ plans: store.Named("plan"), ledger: pullpayment.New(), earned: map[string]int64{}, } } // Open creates a plan and returns its id. Anyone may open one. func (r *Registry) Open(creator address, title, description string, pricePerPeriod, periodBlocks int64) (store.ID, error) { if !ValidTitle(title) { return 0, ErrBadTitle } if !ValidDescription(description) { return 0, ErrBadDescription } if pricePerPeriod < MinPricePerPeriod { return 0, ErrBadPrice } if periodBlocks < MinPeriodBlocks || periodBlocks > MaxPeriodBlocks { return 0, ErrBadPeriod } return r.plans.Add(&Plan{ Creator: creator, Title: title, Description: description, PricePerPeriod: pricePerPeriod, PeriodBlocks: periodBlocks, Open: true, paidThrough: map[string]int64{}, }), nil } // Subscribe buys whole periods on a plan with sent ugnot, at height now. // // It refuses anything short of one period, buys floor(sent / price) of them, // and credits the remainder back to the supporter. The extension starts from // whichever is LATER, now or the supporter's current paid-through height, so // renewing early never discards time already paid for and renewing after a // lapse never backdates the gap. // // The creator is credited at the moment of payment, not as the periods // elapse. See the package doc for why that is v0 and what v1 would have to // carry instead. func (r *Registry) Subscribe(id store.ID, who address, sent, now int64) (Payment, error) { p, ok := r.Get(id) if !ok { return Payment{}, ErrNoPlan } if !p.Open { return Payment{}, ErrPlanClosed } if sent < p.PricePerPeriod { return Payment{}, ErrShortOfOnePeriod } // The one rounding decision in this function: periods is floor, so a // supporter is never sold a period they did not fully fund, and the // remainder is handed back below rather than kept. Neither side keeps a // fraction of a period. periods := sent / p.PricePerPeriod if periods > MaxPeriodsPerPayment { return Payment{}, ErrTooManyPeriods } spent := sent - sent%p.PricePerPeriod change := sent - spent // spent is an exact whole number of periods, so this division leaves no // remainder and there is no second rounding direction to choose. MulDiv // is here for the intermediate: spent * PeriodBlocks overflows an int64 // for a plan priced in whole GNOT with a period measured in months, and // the naive product wraps to a plausible-looking height rather than to an // obvious one. MulDivUp would be identical on an exact division; MulDiv // says plainly that nothing is being rounded up. added := xmath.MulDiv(spent, p.PeriodBlocks, p.PricePerPeriod) from := now if pt := p.PaidThrough(who); pt > from { from = pt } through := from + added key := who.String() // Both credits happen before the plan is touched, and both can fail: the // ledger is bounded and guards its own overflow. A realm calling this // aborts on the error, which reverts everything written in the same // frame, so a half-applied subscription is not reachable from a crossing // call. The ordering is what makes that true for every other caller too. if err := r.ledger.Credit(p.Creator.String(), spent); err != nil { return Payment{}, err } if change > 0 { if err := r.ledger.Credit(key, change); err != nil { return Payment{}, err } } r.earned[p.Creator.String()] += spent _, seen := p.paidThrough[key] if !seen { p.supporters = append(p.supporters, who) } p.paidThrough[key] = through p.Received += spent return Payment{ Periods: periods, Spent: spent, Change: change, PaidThrough: through, NewSupporter: !seen, }, nil } // Close stops a plan taking new subscriptions. Creator only. // // It does not touch anything already paid for: existing supporters run to // their own paid-through height, which is the only behaviour that does not // turn closing a plan into taking money back. func (r *Registry) Close(id store.ID, who address) error { p, err := r.ownPlan(id, who) if err != nil { return err } if !p.Open { return ErrAlreadyClosed } p.Open = false return nil } // Reopen lets a closed plan take subscriptions again. Creator only. func (r *Registry) Reopen(id store.ID, who address) error { p, err := r.ownPlan(id, who) if err != nil { return err } if p.Open { return ErrAlreadyOpen } p.Open = true return nil } // ownPlan is the shared lookup plus authority check, so Close and Reopen // cannot drift apart on who is allowed to call them. func (r *Registry) ownPlan(id store.ID, who address) (*Plan, error) { p, ok := r.Get(id) if !ok { return nil, ErrNoPlan } if p.Creator != who { return nil, ErrNotCreator } return p, nil } // Withdraw zeroes who's credit and reports what they were owed. // // It moves no coins. The holding realm transfers the returned amount AFTER // this call, which is the whole point of the pattern: the credit is already // gone from the ledger when control passes to the payee, so a reentrant call // finds nothing and gets [ErrNothingToWithdraw]. func (r *Registry) Withdraw(who address) (int64, error) { amount, err := r.ledger.Withdraw(who.String()) if err != nil { return 0, ErrNothingToWithdraw } return amount, nil } // CreditOf is what addr can withdraw right now: earnings as a creator, change // as a supporter, or both. func (r *Registry) CreditOf(addr address) int64 { return r.ledger.Balance(addr.String()) } // EarnedBy is the lifetime ugnot credited to creator across every plan, // whether or not it has been withdrawn. func (r *Registry) EarnedBy(creator address) int64 { return r.earned[creator.String()] } // TotalOwed is everything the holding realm must keep in reserve. func (r *Registry) TotalOwed() int64 { return r.ledger.TotalOwed() } // Get returns a plan by id. func (r *Registry) Get(id store.ID) (*Plan, bool) { v, ok := r.plans.Get(id) if !ok { return nil, false } return v.(*Plan), true } // Count is how many plans exist. func (r *Registry) Count() int { return r.plans.Len() } // Listing is one row of [Registry.List]: the plan and the id a link needs. type Listing struct { ID store.ID Plan *Plan } // List returns one page of plans, newest first. func (r *Registry) List(page, size int) []Listing { var out []Listing for _, e := range r.plans.PageReverse(page, size) { out = append(out, Listing{ID: e.ID, Plan: e.Value.(*Plan)}) } return out } // Pages is how many pages of the given size the registry holds. func (r *Registry) Pages(size int) int { return r.plans.Pages(size) } // ValidTitle reports whether title can be stored: non-empty after trimming, // within [MaxTitleLen], and free of control characters including newlines. // // A title is one line by construction, so a newline in one is refused rather // than stripped: silently rewriting what somebody typed is worse than telling // them it was refused. func ValidTitle(title string) bool { if len(title) > MaxTitleLen || strings.TrimSpace(title) == "" { return false } for i := 0; i < len(title); i++ { if c := title[i]; c < 0x20 || c == 0x7f { return false } } return true } // ValidDescription reports whether description can be stored: within // [MaxDescriptionLen] and free of control characters other than newline and // tab. Empty is allowed, because a plan whose title says it all should not // have to invent prose. func ValidDescription(description string) bool { if len(description) > MaxDescriptionLen { return false } for i := 0; i < len(description); i++ { c := description[i] if c < 0x20 && c != '\n' && c != '\t' || c == 0x7f { return false } } return true } // PlanURL is the gnoweb path of one plan on the hosting realm. func PlanURL(realmPath string, id store.ID) string { return RealmURL(realmPath) + ":plan/" + id.String() } // RealmURL is the gnoweb path of a realm given as a package path. // // The chain domain is the first element of a package path and a gnoweb path is // the rest of it, so this is a prefix strip and not a hostname this package // has to know. func RealmURL(realmPath string) string { if i := strings.Index(realmPath, "/"); i >= 0 { return realmPath[i:] } return "/" + realmPath }
  8. #8patron_test.gno
  9. #9package patron import ( "strings" "testing" "gno.land/p/moul/kit/store/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) var ( alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") carol = testutils.TestAddress("carol") ) // plan opens one plan with the given price and period and returns its id, // failing the test rather than returning an error nobody reads. func plan(t *testing.T, r *Registry, price, period int64) store.ID { t.Helper() id, err := r.Open(alice, "a plan", "every period, forever", price, period) urequire.NoError(t, err) return id } // planOf reads a plan back, failing the test when it is not there. func planOf(t *testing.T, r *Registry, id store.ID) *Plan { t.Helper() p, ok := r.Get(id) urequire.True(t, ok, "plan #"+id.String()+" exists") return p } func TestValidTitle(t *testing.T) { tests := []struct { name string title string want bool }{ {"plain", "monthly support", true}, {"markdown is allowed and escaped later", "[x](y) | z", true}, {"at the limit", strings.Repeat("x", MaxTitleLen), true}, {"empty", "", false}, {"blank", " ", false}, {"too long", strings.Repeat("x", MaxTitleLen+1), false}, {"a title is one line", "two\nlines", false}, {"tab is a control character here", "a\tb", false}, {"delete character", "a\x7fb", false}, } for _, tt := range tests { uassert.Equal(t, tt.want, ValidTitle(tt.title), tt.name) } } func TestValidDescription(t *testing.T) { tests := []struct { name string desc string want bool }{ {"empty is fine, the title may say it all", "", true}, {"newlines and tabs are content", "a\nb\tc", true}, {"at the limit", strings.Repeat("x", MaxDescriptionLen), true}, {"too long", strings.Repeat("x", MaxDescriptionLen+1), false}, {"control character", "a\x01b", false}, {"delete character", "a\x7fb", false}, } for _, tt := range tests { uassert.Equal(t, tt.want, ValidDescription(tt.desc), tt.name) } } func TestOpenRejectsWhatItCannotStore(t *testing.T) { tests := []struct { name string title string desc string price int64 period int64 want error }{ {"no title", "", "d", 100, 100, ErrBadTitle}, {"description too long", "t", strings.Repeat("x", MaxDescriptionLen+1), 100, 100, ErrBadDescription}, {"a free plan is a tip jar", "t", "d", 0, 100, ErrBadPrice}, {"a negative price", "t", "d", -1, 100, ErrBadPrice}, {"no period at all", "t", "d", 100, 0, ErrBadPeriod}, {"under the floor", "t", "d", 100, MinPeriodBlocks - 1, ErrBadPeriod}, {"over the ceiling", "t", "d", 100, MaxPeriodBlocks + 1, ErrBadPeriod}, } r := NewRegistry() for _, tt := range tests { _, err := r.Open(alice, tt.title, tt.desc, tt.price, tt.period) uassert.ErrorIs(t, err, tt.want, tt.name) } uassert.Equal(t, 0, r.Count(), "nothing refused was stored") // The edges themselves are accepted. _, err := r.Open(alice, "t", "", MinPricePerPeriod, MinPeriodBlocks) uassert.NoError(t, err) _, err = r.Open(alice, "t", "", MinPricePerPeriod, MaxPeriodBlocks) uassert.NoError(t, err) uassert.Equal(t, 2, r.Count()) } func TestOnePeriodBuysExactlyOnePeriod(t *testing.T) { r := NewRegistry() id := plan(t, r, 1000, 100) pay, err := r.Subscribe(id, bob, 1000, 500) urequire.NoError(t, err) uassert.Equal(t, int64(1), pay.Periods) uassert.Equal(t, int64(1000), pay.Spent) uassert.Equal(t, int64(0), pay.Change) uassert.Equal(t, int64(600), pay.PaidThrough, "500 + one period of 100 blocks") uassert.True(t, pay.NewSupporter) uassert.Equal(t, int64(1000), r.CreditOf(alice), "the creator is credited at payment time") uassert.Equal(t, int64(1000), r.EarnedBy(alice)) uassert.Equal(t, int64(0), r.CreditOf(bob), "an exact payment leaves no change") uassert.Equal(t, int64(1000), r.TotalOwed()) } func TestThreePeriodsAtOnce(t *testing.T) { r := NewRegistry() id := plan(t, r, 1000, 100) pay, err := r.Subscribe(id, bob, 3000, 500) urequire.NoError(t, err) uassert.Equal(t, int64(3), pay.Periods) uassert.Equal(t, int64(3000), pay.Spent) uassert.Equal(t, int64(0), pay.Change) uassert.Equal(t, int64(800), pay.PaidThrough, "three periods of 100 blocks from 500") } // Renewing before the current period ends adds a whole period on top of what // is left, rather than restarting the clock and discarding it. func TestRenewingEarlyExtendsFromPaidThrough(t *testing.T) { r := NewRegistry() id := plan(t, r, 1000, 100) first, err := r.Subscribe(id, bob, 1000, 500) urequire.NoError(t, err) urequire.Equal(t, int64(600), first.PaidThrough) // Two blocks before the lapse, with 98 blocks still paid for. second, err := r.Subscribe(id, bob, 1000, 502) urequire.NoError(t, err) uassert.Equal(t, int64(700), second.PaidThrough, "600 + 100, not 502 + 100") uassert.False(t, second.NewSupporter, "bob was already a supporter") uassert.Equal(t, 1, planOf(t, r, id).SupporterCount(), "renewing is not a second supporter") } // Renewing after a lapse starts from now: the gap was never paid for and // nothing backdates it. func TestRenewingAfterLapseStartsFromNow(t *testing.T) { r := NewRegistry() id := plan(t, r, 1000, 100) _, err := r.Subscribe(id, bob, 1000, 500) urequire.NoError(t, err) // 900 is long past the 600 the first period ran to. second, err := r.Subscribe(id, bob, 1000, 900) urequire.NoError(t, err) uassert.Equal(t, int64(1000), second.PaidThrough, "900 + 100, the gap is not backdated") } // Paying exactly at the paid-through height is the boundary between the two // rules above, and it resolves the same way either way: 600 is not later than // 600, so the extension runs from 600. func TestRenewingAtTheBoundary(t *testing.T) { r := NewRegistry() id := plan(t, r, 1000, 100) _, err := r.Subscribe(id, bob, 1000, 500) urequire.NoError(t, err) second, err := r.Subscribe(id, bob, 1000, 600) urequire.NoError(t, err) uassert.Equal(t, int64(700), second.PaidThrough) } func TestAPaymentShortOfOnePeriodIsRefused(t *testing.T) { r := NewRegistry() id := plan(t, r, 1000, 100) for _, sent := range []int64{0, 1, 999} { _, err := r.Subscribe(id, bob, sent, 500) uassert.ErrorIs(t, err, ErrShortOfOnePeriod) } uassert.Equal(t, int64(0), r.CreditOf(alice), "a refused payment credits nobody") uassert.Equal(t, int64(0), planOf(t, r, id).PaidThrough(bob)) uassert.Equal(t, 0, planOf(t, r, id).SupporterCount()) } // The remainder under one period is the supporter's, not the realm's: keeping // it would be a fee nobody agreed to. func TestChangeIsCreditedBackAndIsWithdrawable(t *testing.T) { r := NewRegistry() id := plan(t, r, 1000, 100) pay, err := r.Subscribe(id, bob, 2500, 500) urequire.NoError(t, err) uassert.Equal(t, int64(2), pay.Periods) uassert.Equal(t, int64(2000), pay.Spent) uassert.Equal(t, int64(500), pay.Change) uassert.Equal(t, int64(700), pay.PaidThrough) uassert.Equal(t, int64(500), r.CreditOf(bob), "the change is the supporter's") uassert.Equal(t, int64(2000), r.CreditOf(alice)) uassert.Equal(t, int64(2500), r.TotalOwed(), "every ugnot that came in is owed to somebody") uassert.Equal(t, int64(0), r.EarnedBy(bob), "change is not earnings") got, err := r.Withdraw(bob) urequire.NoError(t, err) uassert.Equal(t, int64(500), got) uassert.Equal(t, int64(0), r.CreditOf(bob)) uassert.Equal(t, int64(2000), r.TotalOwed()) } // Withdraw zeroes the credit before it reports it, so the second call of a // reentrant pair finds nothing. func TestWithdrawZeroesBeforeItPays(t *testing.T) { r := NewRegistry() id := plan(t, r, 1000, 100) _, err := r.Subscribe(id, bob, 1000, 500) urequire.NoError(t, err) got, err := r.Withdraw(alice) urequire.NoError(t, err) uassert.Equal(t, int64(1000), got) _, err = r.Withdraw(alice) uassert.ErrorIs(t, err, ErrNothingToWithdraw) _, err = r.Withdraw(carol) uassert.ErrorIs(t, err, ErrNothingToWithdraw, "an address owed nothing withdraws nothing") uassert.Equal(t, int64(1000), r.EarnedBy(alice), "earnings outlive the withdrawal") uassert.Equal(t, int64(0), r.CreditOf(alice)) } // IsActive is strict on both sides: active at paidThrough-1, not at // paidThrough. func TestIsActiveFlipsAtTheRightHeight(t *testing.T) { r := NewRegistry() id := plan(t, r, 1000, 100) _, err := r.Subscribe(id, bob, 1000, 500) urequire.NoError(t, err) p := planOf(t, r, id) tests := []struct { name string now int64 want bool }{ {"the block it was bought in", 500, true}, {"one before the end", 599, true}, {"the end itself", 600, false}, {"after", 601, false}, } for _, tt := range tests { uassert.Equal(t, tt.want, p.IsActive(bob, tt.now), tt.name) } uassert.False(t, p.IsActive(carol, 500), "an address that never paid is never active") uassert.Equal(t, int64(600), p.PaidThrough(bob)) uassert.Equal(t, int64(0), p.PaidThrough(carol)) uassert.Equal(t, 1, p.ActiveCount(599)) uassert.Equal(t, 0, p.ActiveCount(600)) } func TestCloseStopsNewMoneyAndNotPaidTime(t *testing.T) { r := NewRegistry() id := plan(t, r, 1000, 100) _, err := r.Subscribe(id, bob, 1000, 500) urequire.NoError(t, err) uassert.ErrorIs(t, r.Close(id, bob), ErrNotCreator) urequire.NoError(t, r.Close(id, alice)) uassert.ErrorIs(t, r.Close(id, alice), ErrAlreadyClosed) _, err = r.Subscribe(id, carol, 1000, 501) uassert.ErrorIs(t, err, ErrPlanClosed) p := planOf(t, r, id) uassert.False(t, p.Open) uassert.True(t, p.IsActive(bob, 599), "a closed plan does not take back a paid period") uassert.ErrorIs(t, r.Reopen(id, bob), ErrNotCreator) urequire.NoError(t, r.Reopen(id, alice)) uassert.ErrorIs(t, r.Reopen(id, alice), ErrAlreadyOpen) _, err = r.Subscribe(id, carol, 1000, 501) uassert.NoError(t, err) } func TestNothingWorksOnAPlanThatIsNotThere(t *testing.T) { r := NewRegistry() _, err := r.Subscribe(4242, bob, 1000, 500) uassert.ErrorIs(t, err, ErrNoPlan) uassert.ErrorIs(t, r.Close(4242, alice), ErrNoPlan) uassert.ErrorIs(t, r.Reopen(4242, alice), ErrNoPlan) _, ok := r.Get(4242) uassert.False(t, ok) } func TestOnePaymentCannotBuyUnboundedTime(t *testing.T) { r := NewRegistry() id := plan(t, r, 1, MaxPeriodBlocks) _, err := r.Subscribe(id, bob, MaxPeriodsPerPayment+1, 500) uassert.ErrorIs(t, err, ErrTooManyPeriods) uassert.Equal(t, int64(0), planOf(t, r, id).PaidThrough(bob)) // The ceiling itself is accepted, and the product is the one place the // naive arithmetic would have wrapped. pay, err := r.Subscribe(id, bob, MaxPeriodsPerPayment, 500) urequire.NoError(t, err) uassert.Equal(t, 500+MaxPeriodsPerPayment*MaxPeriodBlocks, pay.PaidThrough) } func TestSupportersAreDistinctAndInFirstPaymentOrder(t *testing.T) { r := NewRegistry() id := plan(t, r, 1000, 100) _, err := r.Subscribe(id, carol, 1000, 500) urequire.NoError(t, err) _, err = r.Subscribe(id, bob, 1000, 501) urequire.NoError(t, err) _, err = r.Subscribe(id, carol, 1000, 502) urequire.NoError(t, err) p := planOf(t, r, id) got := p.Supporters() urequire.Equal(t, 2, len(got)) uassert.Equal(t, carol.String(), got[0].String(), "first payment wins the first slot") uassert.Equal(t, bob.String(), got[1].String()) uassert.Equal(t, 2, p.SupporterCount()) // The slice handed out is a copy: writing to it cannot reach the plan. got[0] = bob uassert.Equal(t, carol.String(), p.Supporters()[0].String()) } func TestEarningsAccumulateAcrossPlansAndSupporters(t *testing.T) { r := NewRegistry() first := plan(t, r, 1000, 100) second := plan(t, r, 2000, 200) _, err := r.Subscribe(first, bob, 1000, 500) urequire.NoError(t, err) _, err = r.Subscribe(second, carol, 4000, 500) urequire.NoError(t, err) uassert.Equal(t, int64(5000), r.EarnedBy(alice)) uassert.Equal(t, int64(5000), r.CreditOf(alice)) uassert.Equal(t, int64(1000), planOf(t, r, first).Received) uassert.Equal(t, int64(4000), planOf(t, r, second).Received) uassert.Equal(t, 2, r.Count()) } func TestListIsNewestFirst(t *testing.T) { r := NewRegistry() first := plan(t, r, 1000, 100) second := plan(t, r, 1000, 100) third := plan(t, r, 1000, 100) got := r.List(1, 10) urequire.Equal(t, 3, len(got)) uassert.Equal(t, uint64(third), uint64(got[0].ID)) uassert.Equal(t, uint64(second), uint64(got[1].ID)) uassert.Equal(t, uint64(first), uint64(got[2].ID)) uassert.Equal(t, 2, r.Pages(2)) uassert.Equal(t, 0, len(r.List(9, 10))) } func TestURLsStripTheChainDomain(t *testing.T) { uassert.Equal(t, "/r/moul/x/social/patron/v0", RealmURL("gno.land/r/moul/x/social/patron/v0")) uassert.Equal(t, "/r/a/b:plan/7", PlanURL("gno.land/r/a/b", 7)) }
Attached funds
9000000ugnot

Arguments · 9

  1. #1threads
  2. #2README.md
  3. #3# `gno.land/p/moul/x/social/threads/v0` **The engine behind an embeddable discussion block**, keyed on the page it is shown under rather than on the realm that stores it: `NewBoard`, `Post`, `Reply`, `Pin`, `List`, `Recent`, `Block`. ```go b := threads.NewBoard() id, _ := b.Post("gno.land/r/moul/home", author, "worth discussing", height) isNew, _ := b.Reply(id, replier, "it is", height) // isNew is the mint signal b.Pin(id, height+1000) // placement, bought elsewhere threads.Block(realmPath, page, b.List(page, height, 5), b.PageLen(page), height) ``` **A forum is a destination and has to earn its traffic before anybody writes the first post. A block does not.** It is dropped into pages that already have readers, and the discussion attaches to the object it is about: a realm page, a proposal, an address. One realm holds every thread, every host realm ships the same two lines, and no host realm stores anything. That is the shape the web settled on for comments in 2010, and the same one [`p/moul/reactions`](https://github.com/moul/gno-contracts/tree/main/p/moul/reactions) uses for the tally. The two are deliberate neighbours and share a page key format: reactions are a closed palette and need no moderation, text needs some, so a realm can take the cheap one alone. **`Reply` reports whether the replier was new to that thread**, and that boolean is the whole reason the package keeps a replier set. A realm paying an author for attention wants distinct people, not distinct messages, and a thread's own author replying to themselves is never new. The mint rule then has exactly one signal and exactly one call site. **`List` is a partition, not a sort**: pinned first, then the rest, both newest first. No comparator, no tie to break, and two identical calls always produce the same page, which is what a `Render` needs. A pin is an absolute height, so it expires on its own and nothing has to sweep it. `Pin` refuses to move a pin backwards, so a cheap pin cannot cut an expensive one short; what a pin costs is the realm's decision, not this package's. **A body is free text and therefore attacker-controlled markdown.** `ValidBody` bounds it and refuses control characters, which is a different protection from escaping and not a substitute for it: everything rendered here goes through `ui.Inline` or `ui.Cell`, and so must any realm that renders a body itself. Note `Block` truncates with `ui.ShortN` and then hands the result to `md.Link`, which escapes: `ui.Excerpt` there would escape twice and render the backslashes. A page key is a package path, validated by `ValidPage` to the same rule `p/moul/reactions` uses, so a realm embedding both blocks passes one key to both. **Live realm:** [`r/moul/x/social/threads`](https://github.com/moul/gno-contracts/tree/main/r/moul/x/social/threads) · render it at [`/r/moul/x/social/threads/v0`](https://gno.land/r/moul/x/social/threads/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/social/threads/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/p/moul/x/social/threads/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/social/threads/v0" gno = "0.9"
  6. #6threads.gno
  7. #7// Package threads is the engine behind an embeddable discussion block: the // text half of what a reaction bar does, keyed on the PAGE it is shown under // rather than on the realm that stores it. // // # Why not a forum // // A forum is a destination, and a destination has to earn its traffic before // anybody writes the first post. A block does not: it is dropped into pages // that already have readers, and the discussion attaches to the object it is // about. One realm holds every thread, every host realm ships the same two // lines, and no host realm stores anything. // // That is the shape the web settled on for comments in 2010, and the same one // [gno.land/p/moul/reactions] uses for the tally. This package is deliberately // its neighbour: reactions are a closed palette and need no moderation, text // needs some, and the two are separate so a realm can take the cheap one alone. // // # The model // // Board every thread, across every page // Thread a root post: page, author, body, height, pin, replies // Reply an author, a body and a height, and nothing else // // A thread remembers the set of addresses that have replied to it, which is // what lets a realm implement "earned by being replied to" without counting // one person twice. [Board.Reply] reports whether the replier was new, so the // mint rule has exactly one signal and exactly one call site. // // # Ordering, and the pin // // [Board.List] returns pinned threads first, then the rest, both newest first, // which is a partition and not a sort: no comparator, no tie to break, and the // same input always produces the same page. A pin is an absolute height, so it // expires on its own and nothing has to be swept. // // The realm decides what a pin costs. This package only enforces that a pin // cannot be moved backwards, so buying one does not shorten somebody else's. // // # Page keys and bodies // // A page key is a package path, the full one, chain domain included. // [ValidPage] bounds it to a path-shaped lowercase ASCII string of at most // [MaxPageLen] bytes, so a stored key can never be a markdown payload. A body // is bounded by [MaxBodyLen] and rejected when it carries a control character, // but it is otherwise free text and therefore attacker-controlled markdown: // everything here escapes it with ui.Inline or ui.Cell, and so must any realm // that renders one itself. package threads import ( "errors" "strconv" "strings" "gno.land/p/moul/kit/store/v0" "gno.land/p/moul/kit/ui/v0" "gno.land/p/moul/md/v0" ) const ( // MaxPageLen is the longest page key accepted, matching // gno.land/p/moul/reactions so the two blocks agree on what a page is. MaxPageLen = 120 // MaxBodyLen is the longest post or reply accepted, in bytes. Long // enough for a real comment, short enough that one call cannot lock an // unbounded storage deposit somebody else is paying for. MaxBodyLen = 1000 // ExcerptLen is how much of a body a listing shows. ExcerptLen = 60 ) // The errors a caller can get back. A p/ returns them; the realm decides to // abort. var ( ErrBadPage = errors.New("threads: not a page key") ErrBadBody = errors.New("threads: body is empty, too long, or has control characters") ErrNoThread = errors.New("threads: no such thread") ErrPinIsPast = errors.New("threads: a pin cannot be moved backwards") ) // Reply is one answer under a thread. type Reply struct { Author address Body string At int64 // block height } // Thread is a root post and everything under it. type Thread struct { Page string Author address Body string At int64 // block height Replies []Reply // PinnedUntil is the height the thread stops being pinned at. Zero is // never pinned, and a past height is an expired pin: nothing has to // sweep it. PinnedUntil int64 // repliers is the set of addresses that have replied, so a thread can // be scored on people rather than on messages. repliers map[string]bool } // Pinned reports whether the thread is pinned at height now. func (t *Thread) Pinned(now int64) bool { return t != nil && t.PinnedUntil > now } // Repliers is how many distinct addresses have replied. func (t *Thread) Repliers() int { if t == nil { return 0 } return len(t.repliers) } // HasReplied reports whether who has already replied to this thread. func (t *Thread) HasReplied(who address) bool { if t == nil { return false } return t.repliers[who.String()] } // Board holds every thread, indexed by the page it was posted under. type Board struct { threads *store.Store pages map[string][]store.ID // page key -> ids, oldest first } // NewBoard returns an empty board. func NewBoard() *Board { return &Board{threads: store.Named("thread"), pages: map[string][]store.ID{}} } // Post opens a thread on page and returns its id. func (b *Board) Post(page string, author address, body string, at int64) (store.ID, error) { if !ValidPage(page) { return 0, ErrBadPage } if !ValidBody(body) { return 0, ErrBadBody } id := b.threads.Add(&Thread{ Page: page, Author: author, Body: body, At: at, repliers: map[string]bool{}, }) b.pages[page] = append(b.pages[page], id) return id, nil } // Reply appends to a thread and reports whether this author had never replied // to it before. // // That boolean is the mint signal: a realm that pays an author for attention // wants distinct people, not distinct messages, and a thread's own author // replying to themselves is never new. func (b *Board) Reply(id store.ID, author address, body string, at int64) (isNewReplier bool, err error) { t, ok := b.Get(id) if !ok { return false, ErrNoThread } if !ValidBody(body) { return false, ErrBadBody } t.Replies = append(t.Replies, Reply{Author: author, Body: body, At: at}) key := author.String() if author == t.Author || t.repliers[key] { return false, nil } t.repliers[key] = true return true, nil } // Pin keeps a thread at the top of its page until height until. // // It refuses to move a pin backwards, so a cheap pin cannot cut short an // expensive one, and extends from whichever is later: the current pin or now. func (b *Board) Pin(id store.ID, until int64) error { t, ok := b.Get(id) if !ok { return ErrNoThread } if until <= t.PinnedUntil { return ErrPinIsPast } t.PinnedUntil = until return nil } // Get returns a thread by id. func (b *Board) Get(id store.ID) (*Thread, bool) { v, ok := b.threads.Get(id) if !ok { return nil, false } return v.(*Thread), true } // Len is how many threads exist, across every page. func (b *Board) Len() int { return b.threads.Len() } // Pages is how many pages have ever been posted on. func (b *Board) Pages() int { return len(b.pages) } // PageLen is how many threads a page holds. func (b *Board) PageLen(page string) int { return len(b.pages[page]) } // Listing is one row of [Board.List]: the thread and the id a link needs. type Listing struct { ID store.ID Thread *Thread } // List returns up to limit threads on page: pinned first, then the rest, both // newest first. // // It is a partition and not a sort. There is no comparator and no tie to // break, so two identical calls always produce the same page, which is what a // Render needs. limit <= 0 returns everything. func (b *Board) List(page string, now int64, limit int) []Listing { ids := b.pages[page] var pinned, rest []Listing for i := len(ids) - 1; i >= 0; i-- { t, ok := b.Get(ids[i]) if !ok { continue } item := Listing{ID: ids[i], Thread: t} if t.Pinned(now) { pinned = append(pinned, item) } else { rest = append(rest, item) } } out := append(pinned, rest...) if limit > 0 && len(out) > limit { out = out[:limit] } return out } // Recent returns up to limit threads from every page, newest first. It is what // the hosting realm's own homepage shows. func (b *Board) Recent(limit int) []Listing { var out []Listing for _, e := range b.threads.PageReverse(1, limit) { out = append(out, Listing{ID: e.ID, Thread: e.Value.(*Thread)}) } return out } // ValidPage reports whether page is a usable page key: path-shaped, lowercase // ASCII, no leading, trailing or doubled slash, at most [MaxPageLen] bytes. // // Same rule as gno.land/p/moul/reactions, deliberately: a realm embedding both // blocks passes one key to both. func ValidPage(page string) bool { if page == "" || len(page) > MaxPageLen { return false } if strings.HasPrefix(page, "/") || strings.HasSuffix(page, "/") || strings.Contains(page, "//") || !strings.Contains(page, "/") { return false } for i := 0; i < len(page); i++ { c := page[i] switch { case c >= 'a' && c <= 'z', c >= '0' && c <= '9': case c == '/' || c == '.' || c == '-' || c == '_' || c == ':': default: return false } } return true } // ValidBody reports whether body can be stored: non-empty after trimming, // within [MaxBodyLen], and free of control characters. // // Control characters are refused rather than stripped because a body is shown // back to its author: silently rewriting what somebody wrote is worse than // telling them it was refused. Everything else is allowed and escaped at // render time, since a validator and an escaper protect against different // mistakes. func ValidBody(body string) bool { if len(body) > MaxBodyLen || strings.TrimSpace(body) == "" { return false } for i := 0; i < len(body); i++ { c := body[i] if c < 0x20 && c != '\n' && c != '\t' || c == 0x7f { return false } } return true } // Block renders the embeddable widget: the page's threads, then the button // that opens a new one. // // realmPath is the realm that owns Post and Reply, given as the full package // path from its gnomod.toml module line. It is a parameter and not a constant // because this package is the engine and not the deployment. func Block(realmPath, page string, items []Listing, total int, now int64) string { out := "" if len(items) == 0 { out += ui.Empty("No discussion yet.") } else { t := ui.NewTable("", "#", "thread", "by", "replies") for _, it := range items { mark := "" if it.Thread.Pinned(now) { mark = "📌" } t.Row( mark, // The link title is the id and never the body. md.Link // escapes markdown but NOT a pipe (sanitize.InlineText // leaves it; only ui.Cell rewrites it), so a body used as // a link title inside a table opens a column. md.Link("#"+it.ID.String(), ThreadURL(realmPath, it.ID)), // Cut first, escape second: the other order can strand a // trailing backslash that escapes the chrome after it. ui.Cell(ui.ShortN(it.Thread.Body, ExcerptLen, 0)), ui.Addr(it.Thread.Author), strconv.Itoa(len(it.Thread.Replies)), ) } out += t.String() } out += "\n" + ui.ActionIn(realmPath, "💬 Post", "Post", "page", page, "body", "") if total > len(items) { out += " · " + md.Link( strconv.Itoa(total)+" threads", PageURL(realmPath, page), ) } return out + "\n" } // ThreadURL is the gnoweb path of one thread on the hosting realm. func ThreadURL(realmPath string, id store.ID) string { return RealmURL(realmPath) + ":thread/" + id.String() } // PageURL is the gnoweb path of a page's full thread list. func PageURL(realmPath, page string) string { return RealmURL(realmPath) + ":page/" + page } // RealmURL is the gnoweb path of a realm given as a package path. // // The chain domain is the first element of a package path and a gnoweb path is // the rest of it, so this is a prefix strip and not a hostname this package // has to know. func RealmURL(realmPath string) string { if i := strings.Index(realmPath, "/"); i >= 0 { return realmPath[i:] } return "/" + realmPath }
  8. #8threads_test.gno
  9. #9package threads import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) var ( alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") carol = testutils.TestAddress("carol") home = "gno.land/r/moul/home" ) func TestValidPage(t *testing.T) { tests := []struct { page string want bool }{ {"gno.land/r/moul/home", true}, {"gno.land/r/gov/dao:proposal/42", true}, {"a/b", true}, {"", false}, {"nopath", false}, {"/leading", false}, {"trailing/", false}, {"double//slash", false}, {"gno.land/r/Moul/home", false}, {"gno.land/r/moul/home?x=1", false}, {"gno.land/r/moul/" + strings.Repeat("x", MaxPageLen), false}, } for _, tt := range tests { uassert.Equal(t, tt.want, ValidPage(tt.page), tt.page) } } func TestValidBody(t *testing.T) { tests := []struct { name string body string want bool }{ {"plain", "hello", true}, {"newlines and tabs are content", "a\nb\tc", true}, {"markdown is allowed and escaped later", "[x](y) | z", true}, {"empty", "", false}, {"blank", " \n ", false}, {"too long", strings.Repeat("x", MaxBodyLen+1), false}, {"control character", "a\x01b", false}, {"delete character", "a\x7fb", false}, } for _, tt := range tests { uassert.Equal(t, tt.want, ValidBody(tt.body), tt.name) } } func TestPostRejectsWhatItCannotStore(t *testing.T) { b := NewBoard() _, err := b.Post("nopath", alice, "hi", 100) uassert.ErrorIs(t, err, ErrBadPage) _, err = b.Post(home, alice, "", 100) uassert.ErrorIs(t, err, ErrBadBody) uassert.Equal(t, 0, b.Len()) uassert.Equal(t, 0, b.Pages()) } func TestReplyCountsPeopleAndNotMessages(t *testing.T) { b := NewBoard() id, err := b.Post(home, alice, "root", 100) urequire.NoError(t, err) // A new address is the mint signal, exactly once. isNew, err := b.Reply(id, bob, "first", 101) urequire.NoError(t, err) uassert.True(t, isNew, "bob is a new replier") isNew, err = b.Reply(id, bob, "again", 102) urequire.NoError(t, err) uassert.False(t, isNew, "bob replying twice is not two people") // The author replying to their own thread never counts. isNew, err = b.Reply(id, alice, "mine", 103) urequire.NoError(t, err) uassert.False(t, isNew, "the author is not an audience") isNew, err = b.Reply(id, carol, "hello", 104) urequire.NoError(t, err) uassert.True(t, isNew, "carol is a new replier") th, ok := b.Get(id) urequire.True(t, ok, "thread exists") uassert.Equal(t, 4, len(th.Replies)) uassert.Equal(t, 2, th.Repliers()) uassert.True(t, th.HasReplied(bob)) uassert.False(t, th.HasReplied(alice), "the author is never in the replier set") } func TestReplyToNothing(t *testing.T) { b := NewBoard() _, err := b.Reply(42, bob, "hi", 100) uassert.ErrorIs(t, err, ErrNoThread) } func TestPinOnlyEverMovesForward(t *testing.T) { b := NewBoard() id, _ := b.Post(home, alice, "root", 100) urequire.NoError(t, b.Pin(id, 1000)) th, _ := b.Get(id) uassert.True(t, th.Pinned(999)) uassert.False(t, th.Pinned(1000), "the pin is exclusive at its own height") // A cheaper pin cannot cut an expensive one short. uassert.ErrorIs(t, b.Pin(id, 500), ErrPinIsPast) uassert.ErrorIs(t, b.Pin(id, 1000), ErrPinIsPast) uassert.Equal(t, int64(1000), th.PinnedUntil) urequire.NoError(t, b.Pin(id, 1200)) uassert.Equal(t, int64(1200), th.PinnedUntil) } func TestListIsPinnedThenNewest(t *testing.T) { b := NewBoard() first, _ := b.Post(home, alice, "first", 100) second, _ := b.Post(home, bob, "second", 101) third, _ := b.Post(home, carol, "third", 102) // Newest first, nothing pinned. got := b.List(home, 200, 0) urequire.Equal(t, 3, len(got)) uassert.Equal(t, uint64(third), uint64(got[0].ID)) uassert.Equal(t, uint64(second), uint64(got[1].ID)) uassert.Equal(t, uint64(first), uint64(got[2].ID)) // Pinning the oldest moves it to the top and leaves the rest in order. urequire.NoError(t, b.Pin(first, 500)) got = b.List(home, 200, 0) uassert.Equal(t, uint64(first), uint64(got[0].ID)) uassert.Equal(t, uint64(third), uint64(got[1].ID)) uassert.Equal(t, uint64(second), uint64(got[2].ID)) // The pin expires on its own, with nothing to sweep. got = b.List(home, 500, 0) uassert.Equal(t, uint64(third), uint64(got[0].ID)) // limit truncates after the partition, so a pin is never cut off. got = b.List(home, 200, 1) urequire.Equal(t, 1, len(got)) uassert.Equal(t, uint64(first), uint64(got[0].ID)) } func TestPagesAreIndependent(t *testing.T) { b := NewBoard() b.Post(home, alice, "on home", 100) b.Post("gno.land/r/moul/blog", bob, "on the blog", 101) uassert.Equal(t, 2, b.Len()) uassert.Equal(t, 2, b.Pages()) uassert.Equal(t, 1, b.PageLen(home)) uassert.Equal(t, 0, b.PageLen("gno.land/r/nobody/here")) uassert.Equal(t, 0, len(b.List("gno.land/r/nobody/here", 100, 0))) } func TestRecentCrossesPages(t *testing.T) { b := NewBoard() b.Post(home, alice, "one", 100) b.Post("gno.land/r/moul/blog", bob, "two", 101) third, _ := b.Post(home, carol, "three", 102) got := b.Recent(2) urequire.Equal(t, 2, len(got)) uassert.Equal(t, uint64(third), uint64(got[0].ID), "newest first") } func TestBlockEscapesTheBodyItShows(t *testing.T) { b := NewBoard() id, _ := b.Post(home, alice, "a | pipe and [a link](x)", 100) out := Block("gno.land/r/moul/x/social/threads/v0", home, b.List(home, 200, 10), 1, 200) // The assertion has to be on what IS there, not on what is not: an // escaped cell contains the backslash form, and a row that silently lost // the body entirely would satisfy any "does not contain" check. uassert.True(t, strings.Contains(out, `a \| pipe and \[a link\]\(x\)`), "the body is cut and escaped once, pipe included: "+out) uassert.False(t, strings.Contains(out, "| a | pipe"), "a raw pipe would open a column: "+out) uassert.True(t, strings.Contains(out, ThreadURL("gno.land/r/moul/x/social/threads/v0", id))) uassert.True(t, strings.Contains(out, "func=Post"), "the block offers the transaction") } func TestBlockOnAnEmptyPageSaysSo(t *testing.T) { out := Block("gno.land/r/moul/x/social/threads/v0", home, nil, 0, 200) uassert.True(t, strings.Contains(out, "No discussion yet."), out) } func TestURLsStripTheChainDomain(t *testing.T) { uassert.Equal(t, "/r/moul/x/social/threads/v0", RealmURL("gno.land/r/moul/x/social/threads/v0")) uassert.Equal(t, "/r/a/b:thread/7", ThreadURL("gno.land/r/a/b", 7)) uassert.Equal(t, "/r/a/b:page/gno.land/r/moul/home", PageURL("gno.land/r/a/b", home)) }
#8AddPackagegno.land/p/moul/x/social/vouch/v09 arguments
Attached funds
11000000ugnot

Arguments · 9

  1. #1vouch
  2. #2README.md
  3. #3# `gno.land/p/moul/x/social/vouch/v0` **The engine behind a web of trust**: `NewGraph`, `Record`, `Revoke`, `Withdraw`, `ScoreOf`, `IsTrusted`, `VouchedBy`, `VouchesOf`, `Mutual`, `Leaderboard`. ```go g := vouch.NewGraph() g.Record(alice, bob, "worked with them for a year", 1_000_000, height) g.IsTrusted(bob, 2) // the one call another realm makes g.Revoke(alice, bob) // credits the bond back, withdrawn separately ``` **It is a sybil gate, and it exists because the apps around it do not have one.** A realm that mints a point per distinct replier is farmed by two addresses replying to each other; a realm that counts one vote per address is farmed by holding a hundred. Neither can fix that alone, because neither knows anything about the people behind the addresses. This package knows one thing: who was willing to say, on chain and under their own name, that an address is somebody they stand behind. **A score counts people, not transactions.** A vouch is directed and at most one exists per ordered pair, so a second `Record` from the same address to the same target updates the reason and adds to the bond rather than counting twice. An address cannot vouch for itself. A pair vouching for each other both reach a score of one, which is exactly why a real gate asks for two. **The engine counts the bond, it never moves it.** The amount arrives as an argument, `Revoke` moves it into a withdrawal ledger, and the realm holding the coins transfers only after `Withdraw` has zeroed the credit. That ordering is the pull-payment pattern and it is not optional. **There is no slashing in v0, and the missing half is not the accounting.** Slashing needs an arbiter: somebody has to decide that a vouch was a lie. A DAO vote is a popularity contest against whoever is unpopular this month, a challenge market pays whoever is loudest, an oracle is one key that can confiscate anybody's money. Shipping any of them by default is shipping the wrong one. The bond is still worth having: an illiquid deposit is a cost a hundred throwaway addresses cannot all pay at once. Ordering is a total order on the address everywhere it matters, so two identical `Render` calls produce identical bytes: the sets are `p/moul/addrset`, the leaderboard breaks ties on the address, and nothing is built by ranging a map. A reason is free text, bounded to one line of `MaxReasonLen` bytes with control characters refused, and still has to be escaped where it is shown. **Live realm:** [`r/moul/x/social/vouch`](https://github.com/moul/gno-contracts/tree/main/r/moul/x/social/vouch), which also carries the reasoning for why this one issues no token. <!-- 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/social/vouch/v0 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/p/moul/x/social/vouch/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/social/vouch/v0" gno = "0.9"
  6. #6vouch.gno
  7. #7// Package vouch is the engine behind a web of trust: one address says it // stands behind another, in writing, optionally with its own money locked // against the claim. // // # What it is for // // It is a sybil gate, and it exists because the apps around it do not have // one. A realm that mints a point per distinct replier is farmed by two // addresses replying to each other; a realm that counts one vote per address // is farmed by holding a hundred addresses. Neither can fix that alone, // because neither knows anything about the people behind the addresses. // // This package knows one thing: who was willing to say, on chain and under // their own name, that an address is a person they stand behind. A realm asks // [Graph.IsTrusted] and gates on the answer. That is the whole product, and // every other read here exists to make that one legible. // // # The model // // Graph every vouch, in both directions, plus the refund ledger // Vouch a directed statement: from, for, reason, bond, heights // // A vouch is directed and at most one exists per ordered pair. A second // [Graph.Record] from the same address to the same target UPDATES the reason // and ADDS to the bond rather than counting twice, which is what makes the // score a count of people instead of a count of transactions. An address // cannot vouch for itself. // // # The bond // // A vouch may lock coins. The engine only counts them: it takes the amount as // an argument, tracks who posted how much on whom, and on [Graph.Revoke] moves // that amount into a withdrawal ledger the voucher pulls from. It moves // nothing itself. The realm holding the coins performs the transfer AFTER // [Graph.Withdraw] has zeroed the credit, which is the ordering the // pull-payment pattern demands. // // # No slashing, and why that is the hard part // // A bond here is value at risk only in the sense that it is illiquid: it can // be withdrawn by revoking, and nothing can take it away. That is deliberate // for a v0, and the missing half is not the accounting. // // Slashing needs an arbiter: somebody has to decide that a vouch was a lie. // Every candidate is a design question with teeth. A DAO vote is a popularity // contest against whoever is unpopular this month. A challenge market pays // whoever is loudest and turns the graph into a griefing surface. An oracle is // one key that can confiscate anyone's money. Shipping any of them by default // would be shipping the wrong one, so this version ships the part that is // uncontroversial: who said what, who put money behind it, and the gate that // reads it. // // # Reasons are attacker-controlled markdown // // A reason is free text. [ValidReason] bounds it to [MaxReasonLen] bytes on // one line and refuses control characters, but everything inside that is // allowed and must be escaped where it is shown: ui.Inline in prose, ui.Cell // in a table cell. package vouch import ( "errors" "strconv" "strings" "gno.land/p/moul/addrset/v1" "gno.land/p/moul/kit/num/v0" "gno.land/p/moul/kit/tally/v0" "gno.land/p/moul/md/v0" "gno.land/p/moul/x/daily/pullpayment/v0" ) // MaxReasonLen is the longest reason accepted, in bytes. Long enough to say // how you know somebody, short enough that one call cannot lock an unbounded // storage deposit the realm's deployer is paying for. const MaxReasonLen = 200 const maxInt64 = int64(9223372036854775807) // The errors a caller can get back. A p/ returns them; the realm decides to // abort. var ( ErrSelfVouch = errors.New("vouch: an address cannot vouch for itself") ErrBadReason = errors.New("vouch: reason is empty, too long, multi-line, or has control characters") ErrBadBond = errors.New("vouch: a bond cannot be negative") ErrNoVouch = errors.New("vouch: no such vouch") ErrNothingOwed = errors.New("vouch: nothing to withdraw") ErrOverflow = errors.New("vouch: bond would overflow") ) // Vouch is one directed statement of trust. type Vouch struct { From address For address Reason string // Bond is the amount locked on this vouch, in the realm's denom. It is // the sum of every bond sent with this pair, since a repeated vouch // adds to it rather than replacing it. Bond int64 // At is the height the vouch was first made, UpdatedAt the height it // last changed. They are equal until the voucher restates it. At int64 UpdatedAt int64 } // Graph is the whole web of trust: every vouch, both directions of every // edge, what is bonded on whom, and what revoking owes back to whom. type Graph struct { edges map[string]*Vouch // "from|for" -> the vouch inbound map[string]*addrset.Set // target -> who vouches for them outbound map[string]*addrset.Set // voucher -> who they vouch for bonded map[string]int64 // target -> total bonded on them // people is every address with at least one inbound vouch, sorted, so // a listing never has to iterate a map to build rendered output. people addrset.Set refunds *pullpayment.Ledger count int totalBonded int64 } // NewGraph returns an empty graph. func NewGraph() *Graph { return &Graph{ edges: map[string]*Vouch{}, inbound: map[string]*addrset.Set{}, outbound: map[string]*addrset.Set{}, bonded: map[string]int64{}, refunds: pullpayment.New(), } } // Record writes from's vouch for target and reports whether it replaced one // that already existed. // // A repeated vouch is an update and not a second voice: the reason is // replaced, the bond is added to the one already posted, and the score does // not move. bond may be zero, which is the ordinary case. func (g *Graph) Record(from, target address, reason string, bond, at int64) (updated bool, err error) { if from == target { return false, ErrSelfVouch } if !ValidReason(reason) { return false, ErrBadReason } if bond < 0 { return false, ErrBadBond } tk := target.String() if g.bonded[tk] > maxInt64-bond || g.totalBonded > maxInt64-bond { return false, ErrOverflow } if v, ok := g.edges[key(from, target)]; ok { if v.Bond > maxInt64-bond { return false, ErrOverflow } v.Reason = reason v.Bond += bond v.UpdatedAt = at g.bonded[tk] += bond g.totalBonded += bond return true, nil } g.edges[key(from, target)] = &Vouch{ From: from, For: target, Reason: reason, Bond: bond, At: at, UpdatedAt: at, } g.set(g.inbound, tk).Add(from) g.set(g.outbound, from.String()).Add(target) g.people.Add(target) g.bonded[tk] += bond g.totalBonded += bond g.count++ return false, nil } // Revoke removes from's vouch for target and credits the bond back to from, // returning the amount credited. // // The coins are not sent here and this package never holds any: the credit // waits in the refund ledger until from calls [Graph.Withdraw]. func (g *Graph) Revoke(from, target address) (refund int64, err error) { v, ok := g.edges[key(from, target)] if !ok { return 0, ErrNoVouch } // Credit first, because it is the only step that can fail. Nothing is // mutated until the refund is certain, so a full ledger leaves the // graph exactly as it was rather than half revoked. if v.Bond > 0 { if err := g.refunds.Credit(from.String(), v.Bond); err != nil { return 0, err } } delete(g.edges, key(from, target)) tk, fk := target.String(), from.String() if in, ok := g.inbound[tk]; ok { in.Remove(from) if in.Size() == 0 { delete(g.inbound, tk) g.people.Remove(target) } } if out, ok := g.outbound[fk]; ok { out.Remove(target) if out.Size() == 0 { delete(g.outbound, fk) } } g.bonded[tk] -= v.Bond if g.bonded[tk] == 0 { delete(g.bonded, tk) } g.totalBonded -= v.Bond g.count-- return v.Bond, nil } // Withdraw zeroes what the graph owes who and returns it, so the realm can // send exactly that much. // // The credit is gone from the ledger before this returns, which is what makes // a reentrant call find nothing: the realm transfers after, never before. func (g *Graph) Withdraw(who address) (int64, error) { amount, err := g.refunds.Withdraw(who.String()) if err != nil { return 0, ErrNothingOwed } return amount, nil } // Get returns one vouch. func (g *Graph) Get(from, target address) (*Vouch, bool) { v, ok := g.edges[key(from, target)] return v, ok } // ScoreOf is how many distinct addresses vouch for addr. // // It counts people and not statements: restating a vouch does not raise it, // and revoking lowers it. func (g *Graph) ScoreOf(addr address) int { if in, ok := g.inbound[addr.String()]; ok { return in.Size() } return 0 } // BondedFor is the total locked on addr by everyone vouching for them. func (g *Graph) BondedFor(addr address) int64 { return g.bonded[addr.String()] } // IsTrusted reports whether addr is vouched for by at least min distinct // addresses. It is the gate another realm calls, and the reason this package // exists. // // A min below one is raised to one. A gate that lets everybody through is a // bug at the call site rather than an answer worth returning, and silently // agreeing with it is how a sybil check ships disabled. // // What it cannot tell you is whether those vouchers are distinct PEOPLE. Two // addresses vouching for each other both reach a score of one for the price of // two transactions, which is why [Graph.Mutual] is exported and why a gate // that matters should ask for more than one. func (g *Graph) IsTrusted(addr address, min int) bool { if min < 1 { min = 1 } return g.ScoreOf(addr) >= min } // VouchedBy is every address that vouches for addr, sorted. // // Sorted and not chronological: the set is the storage, the order is a total // order on the address, and two identical calls therefore render identically. func (g *Graph) VouchedBy(addr address) []address { return collect(g.inbound[addr.String()]) } // VouchesOf is every address addr vouches for, sorted. It is the other // direction of [Graph.VouchedBy]. func (g *Graph) VouchesOf(addr address) []address { return collect(g.outbound[addr.String()]) } // Mutual reports whether a and b vouch for each other. // // A mutual pair is the cheapest sybil shape there is, so this is here to be // discounted by a caller that cares, not as a badge. func (g *Graph) Mutual(a, b address) bool { if a == b { return false } _, there := g.edges[key(a, b)] _, back := g.edges[key(b, a)] return there && back } // ReasonFrom is what from wrote about target, or the empty string when there // is no such vouch. It is raw caller text: escape it where it is shown. func (g *Graph) ReasonFrom(from, target address) string { if v, ok := g.edges[key(from, target)]; ok { return v.Reason } return "" } // BondFrom is what from locked on target, or zero. func (g *Graph) BondFrom(from, target address) int64 { if v, ok := g.edges[key(from, target)]; ok { return v.Bond } return 0 } // Count is how many vouches exist, across everybody. func (g *Graph) Count() int { return g.count } // People is how many addresses have at least one vouch for them. func (g *Graph) People() int { return g.people.Size() } // Owed is what revoking has credited to addr and nobody has withdrawn yet. func (g *Graph) Owed(addr address) int64 { return g.refunds.Balance(addr.String()) } // TotalOwed is every unwithdrawn refund. The realm must hold at least // TotalOwed plus [Graph.TotalBonded] to be solvent. func (g *Graph) TotalOwed() int64 { return g.refunds.TotalOwed() } // TotalBonded is everything locked on every vouch that still stands. func (g *Graph) TotalBonded() int64 { return g.totalBonded } // Ranked is one row of [Graph.Leaderboard]. type Ranked struct { Addr address Score int Bonded int64 } // Leaderboard is the most vouched for addresses, highest score first, ties // broken by address so the order is total and a Render never reshuffles. // // limit at or below zero returns nothing. func (g *Graph) Leaderboard(limit int) []Ranked { if limit <= 0 || g.people.Size() == 0 { return nil } entries := make([]tally.Entry, 0, g.people.Size()) g.people.IterateByOffset(0, g.people.Size(), func(a address) bool { entries = append(entries, tally.Entry{Key: a.String(), Score: int64(g.ScoreOf(a))}) return false }) top := tally.Top(entries, limit) out := make([]Ranked, 0, len(top)) for _, e := range top { a := address(e.Key) out = append(out, Ranked{Addr: a, Score: int(e.Score), Bonded: g.bonded[e.Key]}) } return out } // ValidReason reports whether reason can be stored: non-empty after trimming, // within [MaxReasonLen], on one line, and free of control characters. // // One line is a deliberate bound rather than a rendering workaround. A reason // is shown in a table cell beside the address it is about, and a writer who // needs a second paragraph is writing something other than a reason. // // Control characters are refused rather than stripped because the text is // shown back to whoever wrote it, and silently rewriting what somebody said is // worse than telling them it was refused. Everything else is allowed and // escaped at render time, since a validator and an escaper protect against // different mistakes. func ValidReason(reason string) bool { if len(reason) > MaxReasonLen || strings.TrimSpace(reason) == "" { return false } for i := 0; i < len(reason); i++ { if c := reason[i]; c < 0x20 || c == 0x7f { return false } } return true } // Badge is the one-line trust mark, for a realm that wants to show what the // gate it just called was reading. // // It takes the numbers rather than the graph because the realm holding the // graph is the only one that can read it: everybody else has the two integers // from a cross-realm call and needs nothing more to render them. func Badge(realmPath string, addr address, score int, bonded int64) string { if score == 0 { return md.Link("not vouched for", AddrURL(realmPath, addr)) } word := " vouchers" if score == 1 { word = " voucher" } out := "\U0001F91D " + md.Link(strconv.Itoa(score)+word, AddrURL(realmPath, addr)) if bonded > 0 { out += " · " + num.GNOTf(bonded) + " bonded" } return out } // AddrURL is the gnoweb path of one address's page on the hosting realm. func AddrURL(realmPath string, addr address) string { return RealmURL(realmPath) + ":addr/" + addr.String() } // RealmURL is the gnoweb path of a realm given as a package path. // // The chain domain is the first element of a package path and a gnoweb path is // the rest of it, so this is a prefix strip and not a hostname this package // has to know. func RealmURL(realmPath string) string { if i := strings.Index(realmPath, "/"); i >= 0 { return realmPath[i:] } return "/" + realmPath } // key is the storage key of one directed edge. Addresses are fixed-length // bech32, so the separator is belt and braces rather than load-bearing. func key(from, target address) string { return from.String() + "|" + target.String() } // set returns the address set stored under k, creating it on first use. func (g *Graph) set(m map[string]*addrset.Set, k string) *addrset.Set { if s, ok := m[k]; ok { return s } s := &addrset.Set{} m[k] = s return s } // collect reads a set out in sorted order. func collect(s *addrset.Set) []address { if s == nil || s.Size() == 0 { return nil } out := make([]address, 0, s.Size()) s.IterateByOffset(0, s.Size(), func(a address) bool { out = append(out, a) return false }) return out }
  8. #8vouch_test.gno
  9. #9package vouch import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" "gno.land/p/nt/urequire/v0" ) var ( alice = testutils.TestAddress("alice") bob = testutils.TestAddress("bob") carol = testutils.TestAddress("carol") dave = testutils.TestAddress("dave") ) const realmPath = "gno.land/r/moul/x/social/vouch/v0" func TestValidReason(t *testing.T) { tests := []struct { name string reason string want bool }{ {"plain", "worked with them for a year", true}, {"markdown is allowed and escaped later", "[x](y) | z", true}, {"at the limit", strings.Repeat("x", MaxReasonLen), true}, {"empty", "", false}, {"blank", " ", false}, {"too long", strings.Repeat("x", MaxReasonLen+1), false}, {"a reason is one line", "met them\nat a conference", false}, {"no tabs either", "met them\there", false}, {"control character", "a\x01b", false}, {"delete character", "a\x7fb", false}, } for _, tt := range tests { uassert.Equal(t, tt.want, ValidReason(tt.reason), tt.name) } } func TestRecordRefusesWhatItCannotStore(t *testing.T) { g := NewGraph() _, err := g.Record(alice, alice, "myself", 0, 100) uassert.ErrorIs(t, err, ErrSelfVouch) _, err = g.Record(alice, bob, "", 0, 100) uassert.ErrorIs(t, err, ErrBadReason) _, err = g.Record(alice, bob, "fine", -1, 100) uassert.ErrorIs(t, err, ErrBadBond) uassert.Equal(t, 0, g.Count()) uassert.Equal(t, 0, g.People()) uassert.Equal(t, 0, g.ScoreOf(alice)) } // A score counts people. This is the property every caller of IsTrusted is // relying on, so it is the one worth pinning hardest. func TestScoreCountsPeopleAndNotVouches(t *testing.T) { g := NewGraph() updated, err := g.Record(bob, alice, "known them for years", 0, 100) urequire.NoError(t, err) uassert.False(t, updated, "the first vouch is not an update") uassert.Equal(t, 1, g.ScoreOf(alice)) // Saying it again is an edit, not a second voice. updated, err = g.Record(bob, alice, "still true", 0, 101) urequire.NoError(t, err) uassert.True(t, updated, "the second vouch replaced the first") uassert.Equal(t, 1, g.ScoreOf(alice), "one person is one point, however often they say it") uassert.Equal(t, "still true", g.ReasonFrom(bob, alice), "the reason was updated") uassert.Equal(t, 1, g.Count()) // A different person does move it. _, err = g.Record(carol, alice, "we shipped together", 0, 102) urequire.NoError(t, err) uassert.Equal(t, 2, g.ScoreOf(alice)) uassert.Equal(t, 2, g.Count()) uassert.Equal(t, 1, g.People(), "one address has been vouched for") // And revoking lowers it. _, err = g.Revoke(carol, alice) urequire.NoError(t, err) uassert.Equal(t, 1, g.ScoreOf(alice)) uassert.Equal(t, 1, g.Count()) } func TestIsTrusted(t *testing.T) { g := NewGraph() g.Record(bob, alice, "one", 0, 100) g.Record(carol, alice, "two", 0, 100) tests := []struct { name string addr address min int want bool }{ {"below the score", alice, 1, true}, {"at the score", alice, 2, true}, {"above the score", alice, 3, false}, {"zero is read as one", alice, 0, true}, {"zero does not open the gate for nobody", dave, 0, false}, {"a negative min is read as one too", dave, -5, false}, {"nobody vouches for dave", dave, 1, false}, } for _, tt := range tests { uassert.Equal(t, tt.want, g.IsTrusted(tt.addr, tt.min), tt.name) } } func TestTheBondFollowsTheVouchInBothDirections(t *testing.T) { g := NewGraph() _, err := g.Record(bob, alice, "staking 1 GNOT on this", 1_000_000, 100) urequire.NoError(t, err) uassert.Equal(t, int64(1_000_000), g.BondedFor(alice)) uassert.Equal(t, int64(1_000_000), g.TotalBonded()) uassert.Equal(t, int64(0), g.Owed(bob), "nothing is owed while the vouch stands") // A repeated vouch adds to the bond rather than replacing it. _, err = g.Record(bob, alice, "doubling down", 500_000, 101) urequire.NoError(t, err) uassert.Equal(t, int64(1_500_000), g.BondedFor(alice)) uassert.Equal(t, int64(1_500_000), g.BondFrom(bob, alice)) // A second voucher's bond piles on top. g.Record(carol, alice, "and me", 250_000, 102) uassert.Equal(t, int64(1_750_000), g.BondedFor(alice)) // Revoking returns exactly what that voucher put in, and nothing else. refund, err := g.Revoke(bob, alice) urequire.NoError(t, err) uassert.Equal(t, int64(1_500_000), refund) uassert.Equal(t, int64(250_000), g.BondedFor(alice), "carol's bond stays") uassert.Equal(t, int64(250_000), g.TotalBonded()) uassert.Equal(t, int64(1_500_000), g.Owed(bob)) uassert.Equal(t, int64(1_500_000), g.TotalOwed()) // Withdrawing zeroes the credit before the realm sends anything. got, err := g.Withdraw(bob) urequire.NoError(t, err) uassert.Equal(t, int64(1_500_000), got) uassert.Equal(t, int64(0), g.Owed(bob)) uassert.Equal(t, int64(0), g.TotalOwed()) // A reentrant withdrawal finds nothing. _, err = g.Withdraw(bob) uassert.ErrorIs(t, err, ErrNothingOwed) } func TestRevokeRefusesWhatWasNeverThere(t *testing.T) { g := NewGraph() _, err := g.Revoke(alice, bob) uassert.ErrorIs(t, err, ErrNoVouch) g.Record(alice, bob, "fine", 0, 100) _, err = g.Revoke(bob, alice) uassert.ErrorIs(t, err, ErrNoVouch, "a vouch is directed, so the other way round is not one") } func TestTheTwoDirections(t *testing.T) { g := NewGraph() g.Record(alice, bob, "a to b", 0, 100) g.Record(alice, carol, "a to c", 0, 100) g.Record(bob, carol, "b to c", 0, 100) uassert.Equal(t, 0, len(g.VouchedBy(alice)), "nobody vouches for alice") uassert.Equal(t, 2, len(g.VouchesOf(alice))) uassert.Equal(t, 2, len(g.VouchedBy(carol))) uassert.Equal(t, 0, len(g.VouchesOf(carol))) // Sorted by address, so two identical calls render identically. in := g.VouchedBy(carol) uassert.True(t, in[0].String() < in[1].String(), "inbound is sorted") uassert.Equal(t, "a to c", g.ReasonFrom(alice, carol)) uassert.Equal(t, "", g.ReasonFrom(carol, alice), "no vouch, no reason") } func TestMutual(t *testing.T) { g := NewGraph() g.Record(alice, bob, "a to b", 0, 100) uassert.False(t, g.Mutual(alice, bob), "one direction is not mutual") uassert.False(t, g.Mutual(alice, alice), "an address is never mutual with itself") g.Record(bob, alice, "b to a", 0, 101) uassert.True(t, g.Mutual(alice, bob)) uassert.True(t, g.Mutual(bob, alice), "mutual is symmetric") uassert.False(t, g.Mutual(alice, carol)) // The cheapest sybil shape: two addresses, two transactions, both pass a // gate set at one. That is the whole reason Mutual is exported. uassert.True(t, g.IsTrusted(alice, 1)) uassert.True(t, g.IsTrusted(bob, 1)) uassert.False(t, g.IsTrusted(alice, 2)) } func TestLeaderboardIsATotalOrder(t *testing.T) { g := NewGraph() g.Record(alice, carol, "one", 0, 100) g.Record(bob, carol, "two", 0, 100) g.Record(alice, bob, "three", 100, 100) rows := g.Leaderboard(10) urequire.Equal(t, 2, len(rows)) uassert.Equal(t, carol.String(), rows[0].Addr.String(), "highest score first") uassert.Equal(t, 2, rows[0].Score) uassert.Equal(t, int64(100), rows[1].Bonded) uassert.Equal(t, 1, len(g.Leaderboard(1)), "limit truncates") uassert.Equal(t, 0, len(g.Leaderboard(0))) uassert.Equal(t, 0, len(NewGraph().Leaderboard(10)), "an empty graph ranks nobody") // Revoking the last vouch for an address drops it off the board. g.Revoke(alice, bob) uassert.Equal(t, 1, len(g.Leaderboard(10))) uassert.Equal(t, 0, g.ScoreOf(bob)) } func TestBadge(t *testing.T) { g := NewGraph() uassert.True(t, strings.Contains(Badge(realmPath, alice, g.ScoreOf(alice), 0), "not vouched for")) g.Record(bob, alice, "one", 2_000_000, 100) got := Badge(realmPath, alice, g.ScoreOf(alice), g.BondedFor(alice)) uassert.True(t, strings.Contains(got, "1 voucher]"), "singular: "+got) uassert.True(t, strings.Contains(got, "2 GNOT bonded"), got) uassert.True(t, strings.Contains(got, AddrURL(realmPath, alice)), got) g.Record(carol, alice, "two", 0, 101) uassert.True(t, strings.Contains(Badge(realmPath, alice, g.ScoreOf(alice), 0), "2 vouchers]")) } func TestURLsStripTheChainDomain(t *testing.T) { uassert.Equal(t, "/r/moul/x/social/vouch/v0", RealmURL(realmPath)) uassert.Equal(t, "/r/a/b:addr/"+alice.String(), AddrURL("gno.land/r/a/b", alice)) }
#9AddPackagegno.land/r/moul/x/daily/collatz/v115 arguments
Attached funds
6000000ugnot

Arguments · 15

  1. #1collatz
  2. #2README.md
  3. #3# Collatz Explorer > ⚠️ **Experimental — generated with no human supervision.** This realm was > produced automatically by an MCP-driven agent to exercise the gno MCP server > and tooling, and to generate test content for gno compilers, linters and > formatters. **Not audited. Not for production.** Full context & folder README: > [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md) --- A hailstone-sequence explorer realm. Call `Compute(n)` to walk the Collatz sequence for a positive integer `n` — the realm records its stopping time (number of steps to reach 1) and peak value, attributed to your address. The root page shows a leaderboard of the longest sequences submitted, and `/<n>` renders the full hailstone sequence for any `n` on the fly. Realm path: `gno.land/r/moul/x/daily/collatz/v1` ## Example calls ``` # record a run for n = 27 (111 steps, peak 9232) gnokey maketx call -pkgpath "gno.land/r/moul/x/daily/collatz/v1" \ -func Compute -args 27 ... # view the leaderboard gnokey query vm/qrender --data "gno.land/r/moul/x/daily/collatz/v1:" # view the full hailstone sequence for 27 gnokey query vm/qrender --data "gno.land/r/moul/x/daily/collatz/v1:/27" ``` <!-- 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. **On mainnet:** [![deployment status](https://gnoscope.com/_badges/shield/status/r/moul/x/daily/collatz/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/daily/collatz/v1) [![transactions](https://gnoscope.com/_badges/shield/txs/r/moul/x/daily/collatz/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/daily/collatz/v1) [![unique callers](https://gnoscope.com/_badges/shield/users/r/moul/x/daily/collatz/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/daily/collatz/v1) [![deployed revision](https://gnoscope.com/_badges/shield/version/r/moul/x/daily/collatz/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/daily/collatz/v1) **Dependency graph:** ![gno.land/r/moul/x/daily/collatz/v1 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/daily/collatz/v1/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. #4collatz.gno
  5. #5package collatz import ( "chain" "sort" "strconv" "strings" "gno.land/p/moul/md/v0" "gno.land/p/nt/avl/v0" ) // selfPath is this realm's own page. A root-relative link resolves against // the domain, not the realm, so every link back into it names it in full. const selfPath = "/r/moul/x/daily/collatz/v1" // maxIters caps the Collatz walk so a hostile / huge input cannot loop // forever on-chain. No known n stays below this bound and fails to reach 1. const maxIters = 1000000 // MaxResults bounds the leaderboard's state: anyone may Compute, and each new // n is a new entry the root page sorts. const MaxResults = 1000 // maxSeqLen caps how many terms Render("/<n>") will print. const maxSeqLen = 500 // Result is one recorded submission. type Result struct { N int64 Steps int Peak int64 Who address } // results maps a decimal-string key of N -> Result. avl.Tree keeps a // deterministic order for iteration inside Render. var results avl.Tree // maxOdd is the largest odd term whose successor 3n+1 still fits in an // int64. Past it the step wraps: 6148914691236517205 maps to exactly 2^64, // which is 0, and the walk then spins on 0 until maxIters. const maxOdd = (1<<63 - 1 - 1) / 3 // stopping returns the number of steps to reach 1 and the peak value seen, // walking the Collatz (hailstone) map. ok is false when a term would overflow // int64, in which case steps and peak describe the walk up to that term. It // never mutates state. func stopping(n int64) (steps int, peak int64, ok bool) { peak = n cur := n for cur != 1 { if steps >= maxIters { break } if cur%2 == 0 { cur = cur / 2 } else { if cur > maxOdd { return steps, peak, false } cur = 3*cur + 1 } if cur > peak { peak = cur } steps++ } return steps, peak, true } // Compute walks the hailstone sequence for n and records the result // attributed to the caller. It is a crossing (state-mutating) func. func Compute(cur realm, n int) { if !cur.IsCurrent() { panic("collatz: spoofed realm") } caller := cur.Previous().Address() if n <= 0 { panic("n must be > 0") } nn := int64(n) steps, peak, ok := stopping(nn) if !ok { // Recording it would put a wrapped walk on the leaderboard, forever. panic("collatz: the walk from " + strconv.FormatInt(nn, 10) + " overflows int64") } res := Result{N: nn, Steps: steps, Peak: peak, Who: caller} key := strconv.FormatInt(nn, 10) if !results.Has(key) && results.Size() >= MaxResults { panic("collatz: the leaderboard is full at " + strconv.Itoa(MaxResults) + " entries") } results.Set(key, res) chain.Emit( "Computed", "n", key, "steps", strconv.Itoa(steps), "peak", strconv.FormatInt(peak, 10), "who", caller.String(), ) } // byLongest implements sort.Interface, ordering results by steps desc, // then peak desc, then n asc for stability. type byLongest []Result func (b byLongest) Len() int { return len(b) } func (b byLongest) Swap(i, j int) { b[i], b[j] = b[j], b[i] } func (b byLongest) Less(i, j int) bool { if b[i].Steps != b[j].Steps { return b[i].Steps > b[j].Steps } if b[i].Peak != b[j].Peak { return b[i].Peak > b[j].Peak } return b[i].N < b[j].N } // Render shows the leaderboard at root, or the full hailstone sequence for a // given n at path "/<n>". func Render(path string) string { p := strings.TrimPrefix(path, "/") if p == "" { return renderLeaderboard() } return renderSequence(p) } func renderLeaderboard() string { all := make([]Result, 0, results.Size()) results.Iterate("", "", func(_ string, v interface{}) bool { all = append(all, v.(Result)) return false }) var sb strings.Builder sb.WriteString("# Collatz Explorer\n\n") sb.WriteString("Hailstone-sequence explorer. Call `Compute(n)` to record a run, ") sb.WriteString("or view a full sequence at `/<n>`.\n\n") if len(all) == 0 { sb.WriteString("_No sequences submitted yet._\n") return sb.String() } sort.Stable(byLongest(all)) sb.WriteString("## Leaderboard — longest sequences\n\n") sb.WriteString("| # | n | steps | peak | who |\n") sb.WriteString("|---|---|-------|------|-----|\n") limit := len(all) if limit > 20 { limit = 20 } for i := 0; i < limit; i++ { r := all[i] sb.WriteString("| " + strconv.Itoa(i+1) + " | ") sb.WriteString("[" + strconv.FormatInt(r.N, 10) + "](" + selfPath + ":" + strconv.FormatInt(r.N, 10) + ") | ") sb.WriteString(strconv.Itoa(r.Steps) + " | ") sb.WriteString(strconv.FormatInt(r.Peak, 10) + " | ") sb.WriteString(r.Who.String() + " |\n") } sb.WriteString("\n_" + strconv.Itoa(len(all)) + " sequence(s) recorded._\n") return sb.String() } func renderSequence(p string) string { n, err := strconv.ParseInt(p, 10, 64) var sb strings.Builder if err != nil || n <= 0 { sb.WriteString("# Invalid n\n\n" + md.InlineCode(p) + " is not a positive integer.\n") return sb.String() } sb.WriteString("# Hailstone sequence for " + strconv.FormatInt(n, 10) + "\n\n") seq := make([]int64, 0, 64) cur := n var peak int64 = n truncated := false overflow := false for { seq = append(seq, cur) if cur == 1 { break } if len(seq) >= maxSeqLen { truncated = true break } if cur%2 == 0 { cur = cur / 2 } else { if cur > maxOdd { overflow = true break } cur = 3*cur + 1 } if cur > peak { peak = cur } } parts := make([]string, len(seq)) for i, v := range seq { parts[i] = strconv.FormatInt(v, 10) } sb.WriteString(strings.Join(parts, " → ")) sb.WriteString("\n\n") sb.WriteString("- steps: " + strconv.Itoa(len(seq)-1) + "\n") sb.WriteString("- peak: " + strconv.FormatInt(peak, 10) + "\n") if overflow { sb.WriteString("\n_Stopped: the next term would overflow int64._\n") } if truncated { sb.WriteString("\n_Sequence truncated at " + strconv.Itoa(maxSeqLen) + " terms._\n") } return sb.String() }
  6. #6collatz_test.gno
  7. #7package collatz import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) func TestStopping(t *testing.T) { cases := []struct { n int64 steps int peak int64 }{ {1, 0, 1}, {2, 1, 2}, {6, 8, 16}, {7, 16, 52}, {27, 111, 9232}, } for _, c := range cases { steps, peak, _ := stopping(c.n) uassert.Equal(t, c.steps, steps) uassert.Equal(t, c.peak, peak) } } func TestComputeAndLeaderboard(cur realm, t *testing.T) { addr := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(addr)) Compute(cross(cur), 27) Compute(cross(cur), 6) out := Render("") uassert.True(t, strings.Contains(out, "Leaderboard"), "has leaderboard") uassert.True(t, strings.Contains(out, "9232"), "shows peak of 27") // 27 has more steps than 6, so it must appear first (rank 1). i27 := strings.Index(out, "[27]") i6 := strings.Index(out, "[6]") uassert.True(t, i27 >= 0 && i6 >= 0, "both rows present") uassert.True(t, i27 < i6, "27 ranked above 6") } func TestComputeRejectsNonPositive(cur realm, t *testing.T) { addr := testutils.TestAddress("bob") testing.SetRealm(testing.NewUserRealm(addr)) uassert.AbortsWithMessage(t, cur, "n must be > 0", func() { Compute(cross(cur), 0) }) } func TestRenderSequence(t *testing.T) { out := Render("/6") uassert.True(t, strings.Contains(out, "6 → 3 → 10 → 5 → 16 → 8 → 4 → 2 → 1"), "full seq for 6") uassert.True(t, strings.Contains(out, "steps: 8"), "reports 8 steps") bad := Render("/abc") uassert.True(t, strings.Contains(bad, "Invalid"), "rejects non-numeric path") } // 3n+1 for this n is exactly 2^64, which wraps to 0. Recording it would put a // million-step walk on 0 at the top of the leaderboard, forever. func TestComputeRefusesAnOverflowingWalk(cur realm, t *testing.T) { uassert.AbortsContains(t, cur, "overflows int64", func() { Compute(cross(cur), 6148914691236517205) }) _, _, ok := stopping(27) uassert.True(t, ok, "an ordinary walk is unaffected") out := Render("/6148914691236517205") uassert.True(t, strings.Contains(out, "would overflow int64"), out) uassert.False(t, strings.Contains(out, "→ 0"), "no wrapped term rendered") }
  8. #8gnomod.toml
  9. #9module = "gno.land/r/moul/x/daily/collatz/v1" gno = "0.9" private = true
  10. #10zz_bounds_test.gno
  11. #11package collatz import ( "strconv" "testing" "gno.land/p/nt/uassert/v0" ) // Anyone may Compute, and each new n is an entry the root page sorts: the // leaderboard refuses a new n once it is full, and still updates an old one. func TestLeaderboardIsBounded(cur realm, t *testing.T) { var added []string for i := 1; results.Size() < MaxResults; i++ { k := strconv.Itoa(1_000_000 + i) results.Set(k, Result{N: int64(1_000_000 + i)}) added = append(added, k) } // One realm state per package run: leave the board as it was found. defer func() { for _, k := range added { results.Remove(k) } }() uassert.AbortsContains(t, cur, "leaderboard is full", func() { Compute(cross(cur), 999_999_937) }) Compute(cross(cur), 1_000_001) // an n already recorded is refreshed, not refused }
  12. #12zz_escape_test.gno
  13. #13package collatz import ( "strings" "testing" "gno.land/p/moul/md/v0" "gno.land/p/nt/uassert/v0" ) // A path that is not a valid input is echoed back. It is the visitor's text, // so a backtick in it must not close a code span and let a link through. func TestInvalidPathIsEscaped(t *testing.T) { out := Render("/x` [claim](https://evil.example) `") // Still there, but inside a span md.InlineCode widened past its backticks. uassert.True(t, strings.Contains(out, md.InlineCode("x` [claim](https://evil.example) `")), out) }
  14. #14zz_links_test.gno
  15. #15package collatz import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) // The leaderboard links each n to this realm's own sequence page. It once // linked to the malformed gno.land/r:<n>. func TestLeaderboardLinksNameThisRealm(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(testutils.TestAddress("linker"))) Compute(cross(cur), 7) out := Render("") uassert.True(t, strings.Contains(out, "](/r/moul/x/daily/collatz/v1:7)"), out) }
#10AddPackagegno.land/r/moul/x/daily/linktree/v115 arguments
Attached funds
6000000ugnot

Arguments · 15

  1. #1linktree
  2. #2README.md
  3. #3# Linktree > ⚠️ **Experimental — generated with no human supervision.** This realm was > produced automatically by an MCP-driven agent to exercise the gno MCP server > and tooling, and to generate test content for gno compilers, linters and > formatters. **Not audited. Not for production.** Full context & folder README: > [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md) --- A personal links page, one profile per address. Each caller owns their own profile: they set a bio and add or remove labeled links. Anyone can browse a profile at `/<address>`, and the root page lists every profile that exists with a link to each. Profiles are stored in an ordered AVL tree so rendering is deterministic. Realm path: `gno.land/r/moul/x/daily/linktree/v1` ## Example calls ``` # set your bio SetBio("Gnome builder, coffee enthusiast") # add links AddLink("Home", "https://example.com") AddLink("GitHub", "https://github.com/me") # remove the first link (0-indexed) RemoveLink(0) ``` ## Render - `Render("")` — index of all profiles, each linking to its page. - `Render("/g1abc...")` — that address's bio + links as a Markdown bullet list. <!-- 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. **On mainnet:** [![deployment status](https://gnoscope.com/_badges/shield/status/r/moul/x/daily/linktree/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/daily/linktree/v1) [![transactions](https://gnoscope.com/_badges/shield/txs/r/moul/x/daily/linktree/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/daily/linktree/v1) [![unique callers](https://gnoscope.com/_badges/shield/users/r/moul/x/daily/linktree/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/daily/linktree/v1) [![deployed revision](https://gnoscope.com/_badges/shield/version/r/moul/x/daily/linktree/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/daily/linktree/v1) **Dependency graph:** ![gno.land/r/moul/x/daily/linktree/v1 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/daily/linktree/v1/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/r/moul/x/daily/linktree/v1" gno = "0.9" private = true
  6. #6linktree.gno
  7. #7package linktree import ( "strconv" "strings" "chain" "gno.land/p/moul/kit/ui/v0" "gno.land/p/nt/avl/v0" ) // selfPath is this realm's own page. A root-relative link resolves against // the domain, not the realm, so every link back into it names it in full. const selfPath = "/r/moul/x/daily/linktree/v1" // link is a single labeled URL on a profile. type link struct { Label string URL string } // profile is one address's personal links page. type profile struct { Bio string Links []link } // profiles maps an address (string key) to its *profile. // avl.Tree keeps iteration deterministic for Render. var profiles avl.Tree // The bounds on what a caller may store. Anyone may create a profile, and the // root page lists every profile while a profile page lists every link. const ( MaxProfiles = 1000 MaxLinks = 20 MaxBioLen = 280 MaxLabelLen = 64 MaxURLLen = 512 ) // SetBio sets (or replaces) the calling address's bio. func SetBio(cur realm, bio string) { addr := caller(cur) if len(bio) > MaxBioLen { panic("bio too long") } p := getOrCreate(addr.String()) p.Bio = bio chain.Emit("BioSet", "addr", addr.String()) } // AddLink appends a labeled URL to the calling address's profile. func AddLink(cur realm, label string, url string) { addr := caller(cur) if label == "" { panic("label must not be empty") } if url == "" { panic("url must not be empty") } // The URL becomes a markdown link destination as written, so it is // checked here rather than escaped there: http(s) only, and nothing that // could close the destination or start another. if !safeURL(url) { panic("url must be http:// or https:// with no spaces, parentheses or angle brackets") } if len(label) > MaxLabelLen || len(url) > MaxURLLen { panic("label or url too long") } p := getOrCreate(addr.String()) if len(p.Links) >= MaxLinks { panic("a profile holds at most " + strconv.Itoa(MaxLinks) + " links") } p.Links = append(p.Links, link{Label: label, URL: url}) chain.Emit("LinkAdded", "addr", addr.String(), "label", label) } // RemoveLink deletes the link at index from the calling address's profile. func RemoveLink(cur realm, index int) { addr := caller(cur) key := addr.String() v := profiles.Get(key) if v == nil { panic("no profile for caller") } p := v.(*profile) if index < 0 || index >= len(p.Links) { panic("index out of range") } // A fresh slice, not append(s[:i], s[i+1:]...): the in-place form keeps the // old backing array, so the deposit for a removed link never comes back. kept := make([]link, 0, len(p.Links)-1) kept = append(kept, p.Links[:index]...) kept = append(kept, p.Links[index+1:]...) p.Links = kept chain.Emit("LinkRemoved", "addr", key, "index", strconv.Itoa(index)) } // getOrCreate returns the profile for key, creating an empty one if absent. func getOrCreate(key string) *profile { if v := profiles.Get(key); v != nil { return v.(*profile) } if profiles.Size() >= MaxProfiles { panic("linktree is full at " + strconv.Itoa(MaxProfiles) + " profiles") } p := &profile{} profiles.Set(key, p) return p } // Render shows all profiles at root, or a single profile at "/<address>". func Render(path string) string { addr := strings.Trim(path, "/") if addr == "" { return renderIndex() } return renderProfile(addr) } func renderIndex() string { var b strings.Builder b.WriteString("# Linktree\n\n") if profiles.Size() == 0 { b.WriteString("_No profiles yet. Call `SetBio` or `AddLink` to create one._\n") return b.String() } b.WriteString("Profiles:\n\n") profiles.Iterate("", "", func(key string, _ interface{}) bool { b.WriteString("- [") b.WriteString(key) b.WriteString("](" + selfPath + ":") b.WriteString(key) b.WriteString(")\n") return false }) return b.String() } func renderProfile(addr string) string { var b strings.Builder b.WriteString("# ") b.WriteString(ui.Inline(addr)) b.WriteString("\n\n") v := profiles.Get(addr) if v == nil { b.WriteString("_No profile for this address._\n") return b.String() } p := v.(*profile) if p.Bio != "" { b.WriteString(ui.Inline(p.Bio)) b.WriteString("\n\n") } if len(p.Links) == 0 { b.WriteString("_No links yet._\n") return b.String() } b.WriteString("## Links\n\n") for _, l := range p.Links { b.WriteString("- [") b.WriteString(ui.Inline(l.Label)) b.WriteString("](") b.WriteString(l.URL) b.WriteString(")\n") } return b.String() } // caller is the address that crossed into this realm, after checking that the // realm token is this frame's own. func caller(cur realm) address { if !cur.IsCurrent() { panic("linktree: spoofed realm") } return cur.Previous().Address() } // safeURL reports whether u can be written into a markdown link destination // unescaped. func safeURL(u string) bool { if !strings.HasPrefix(u, "https://") && !strings.HasPrefix(u, "http://") { return false } return !strings.ContainsAny(u, " \t\r\n()<>[]`\\") }
  8. #8linktree_test.gno
  9. #9package linktree import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) func TestProfileLifecycle(cur realm, t *testing.T) { alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) SetBio(cross(cur), "gnome builder") AddLink(cross(cur), "Home", "https://alice.example") AddLink(cross(cur), "GitHub", "https://github.com/alice") out := Render("/" + alice.String()) uassert.True(t, strings.Contains(out, "gnome builder"), "bio present") uassert.True(t, strings.Contains(out, "[Home](https://alice.example)"), "first link present") uassert.True(t, strings.Contains(out, "[GitHub](https://github.com/alice)"), "second link present") // Remove the first link; second must survive. RemoveLink(cross(cur), 0) out = Render("/" + alice.String()) uassert.False(t, strings.Contains(out, "[Home]"), "removed link gone") uassert.True(t, strings.Contains(out, "[GitHub](https://github.com/alice)"), "kept link present") } func TestIndexAndOwnership(cur realm, t *testing.T) { alice := testutils.TestAddress("alice2") bob := testutils.TestAddress("bob2") testing.SetRealm(testing.NewUserRealm(alice)) AddLink(cross(cur), "A", "https://a.example") testing.SetRealm(testing.NewUserRealm(bob)) AddLink(cross(cur), "B", "https://b.example") // Root index lists both addresses. idx := Render("") uassert.True(t, strings.Contains(idx, alice.String()), "alice listed") uassert.True(t, strings.Contains(idx, bob.String()), "bob listed") // bob is current caller; his profile has only his link, not alice's. bobOut := Render("/" + bob.String()) uassert.True(t, strings.Contains(bobOut, "[B](https://b.example)"), "bob has own link") uassert.False(t, strings.Contains(bobOut, "[A]"), "bob does not have alice's link") // Out-of-range removal on bob aborts. uassert.AbortsWithMessage(t, cur, "index out of range", func() { RemoveLink(cross(cur), 99) }) } func TestUnknownProfile(t *testing.T) { out := Render("/g1doesnotexist") uassert.True(t, strings.Contains(out, "No profile for this address"), "unknown profile message") }
  10. #10zz_bounds_test.gno
  11. #11package linktree import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) // A profile holds a bounded number of links of bounded size, and removing one // keeps the others in order. func TestLinksAreBounded(cur realm, t *testing.T) { who := testutils.TestAddress("bounded") testing.SetRealm(testing.NewUserRealm(who)) uassert.AbortsContains(t, cur, "bio too long", func() { SetBio(cross(cur), strings.Repeat("b", MaxBioLen+1)) }) for i := 0; i < MaxLinks; i++ { AddLink(cross(cur), "l", "https://e.example/"+strings.Repeat("x", i)) } uassert.AbortsContains(t, cur, "at most", func() { AddLink(cross(cur), "l", "https://e.example/") }) RemoveLink(cross(cur), 0) p := profiles.Get(who.String()).(*profile) uassert.Equal(t, MaxLinks-1, len(p.Links)) uassert.Equal(t, "https://e.example/x", p.Links[0].URL) } // The profile count is bounded globally, not just per address. func TestProfileCountIsBounded(cur realm, t *testing.T) { var added []string for i := 0; profiles.Size() < MaxProfiles; i++ { k := testutils.TestAddress("p" + strings.Repeat("x", i%7) + string(rune('a'+i%26)) + string(rune('a'+i/26%26)) + string(rune('a'+i/676%26))).String() if profiles.Get(k) == nil { profiles.Set(k, &profile{}) added = append(added, k) } } defer func() { for _, k := range added { profiles.Remove(k) } }() testing.SetRealm(testing.NewUserRealm(testutils.TestAddress("latecomer"))) uassert.AbortsContains(t, cur, "linktree is full", func() { SetBio(cross(cur), "hi") }) }
  12. #12zz_escape_test.gno
  13. #13package linktree import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) // The bio and a label are escaped, and a URL that could break out of its // link destination, or is not http(s), is refused when it is written. func TestProfileFieldsAreEscapedAndURLsChecked(cur realm, t *testing.T) { who := testutils.TestAddress("escaper") testing.SetRealm(testing.NewUserRealm(who)) SetBio(cross(cur), "[claim](https://evil.example)") AddLink(cross(cur), "[claim](https://evil.example)", "https://ok.example/path") page := Render(who.String()) uassert.False(t, strings.Contains(page, "[claim](https://evil.example)"), page) uassert.AbortsContains(t, cur, "url must be http", func() { AddLink(cross(cur), "x", "javascript:alert(1)") }) uassert.AbortsContains(t, cur, "url must be http", func() { AddLink(cross(cur), "x", "https://a.example) [evil](https://b.example") }) } // A path that names nothing is echoed back on the not-found page. It is the // visitor's text, so a backtick in it must not close a code span and let a // link through. func TestNotFoundPathIsEscaped(t *testing.T) { out := Render("x` [claim](https://evil.example) `") uassert.False(t, strings.Contains(out, "[claim](https://evil.example)"), out) }
  14. #14zz_links_test.gno
  15. #15package linktree import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) // The index links each profile to this realm's own page. It once linked to // /r/REPLACE_ADDR/linktree. func TestIndexLinksNameThisRealm(cur realm, t *testing.T) { who := testutils.TestAddress("linker") testing.SetRealm(testing.NewUserRealm(who)) SetBio(cross(cur), "links") out := Render("") uassert.True(t, strings.Contains(out, "](/r/moul/x/daily/linktree/v1:"+who.String()+")"), out) }
#11AddPackagegno.land/r/moul/x/daily/polls/v115 arguments
Attached funds
8000000ugnot

Arguments · 15

  1. #1polls
  2. #2README.md
  3. #3# Polls & Voting > ⚠️ **Experimental — generated with no human supervision.** This realm was > produced automatically by an MCP-driven agent to exercise the gno MCP server > and tooling, and to generate test content for gno compilers, linters and > formatters. **Not audited. Not for production.** Full context & folder README: > [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md) --- An on-chain poll and voting realm for gno.land (gno 0.9). Anyone can create a poll with a question and a comma-separated set of options; every caller address may cast exactly one vote per poll. The realm's `Render` page lists each poll with its options, live tallies drawn as `▓░` bar charts, per-option percentages, and the total number of votes. ## Realm path `gno.land/r/moul/x/daily/polls/v1` ## Exported transactions - `CreatePoll(question string, options string) int` — create a poll. `options` is a comma-separated list (blank entries are dropped; at least 2 required). Returns the new poll's id. - `Vote(pollID int, optionIndex int)` — cast one vote for `optionIndex` on poll `pollID`. One vote per caller address per poll; a second vote aborts. Both are gno 0.9 crossing functions (first parameter `cur realm`); the caller address is read via `runtime.PreviousRealm().Address()`. ## Read path - `Render("")` — index of all polls with bar-chart tallies. - `Render("<id>")` — a single poll by id. ## Example calls ```sh # create a poll (returns its id, e.g. 0) gnokey maketx call -pkgpath "gno.land/r/moul/x/daily/polls/v1" \ -func CreatePoll -args "Best L1 for smart contracts?" -args "gno,eth,sol" \ -gas-fee 20000ugnot -gas-wanted 2000000 -broadcast -chainid onyx-1 -remote https://rpc.onyx.testnets.gno.land:443 mykey # vote for option index 0 on poll 0 gnokey maketx call -pkgpath "gno.land/r/moul/x/daily/polls/v1" \ -func Vote -args 0 -args 0 \ -gas-fee 20000ugnot -gas-wanted 2000000 -broadcast -chainid onyx-1 -remote https://rpc.onyx.testnets.gno.land:443 mykey # view results in gnoweb # https://gno.land/r/moul/x/daily/polls/v1 # https://gno.land/r/moul/x/daily/polls/v1:0 ``` ## Testing ```sh gno test . ``` <!-- 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. **On mainnet:** [![deployment status](https://gnoscope.com/_badges/shield/status/r/moul/x/daily/polls/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/daily/polls/v1) [![transactions](https://gnoscope.com/_badges/shield/txs/r/moul/x/daily/polls/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/daily/polls/v1) [![unique callers](https://gnoscope.com/_badges/shield/users/r/moul/x/daily/polls/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/daily/polls/v1) [![deployed revision](https://gnoscope.com/_badges/shield/version/r/moul/x/daily/polls/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/daily/polls/v1) **Dependency graph:** ![gno.land/r/moul/x/daily/polls/v1 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/daily/polls/v1/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/r/moul/x/daily/polls/v1" gno = "0.9" private = true
  6. #6polls.gno
  7. #7// Package polls is an on-chain poll / voting realm for gno.land. // // Anyone can create a poll with a question and a comma-separated list of // options. Each caller address may cast exactly one vote per poll. Render // draws every poll with its options, tallies as ▓░ bar charts, percentages // and the total vote count. package polls import ( "chain" "strconv" "strings" "gno.land/p/moul/kit/ui/v0" "gno.land/p/nt/avl/v0" ) // selfPath is this realm's own page. A root-relative link resolves against // the domain, not the realm, so every link back into it names it in full. const selfPath = "/r/moul/x/daily/polls/v1" // Poll is a single question with its options and per-address vote tracking. type Poll struct { ID int Question string Options []string Tallies []int Creator string // bech32 address of the poll creator voted *avl.Tree // address string -> struct{}{}, one vote per address } var ( polls []*Poll // index i holds the poll with ID i nextID int ) // CreatePoll registers a new poll. options is a comma-separated list; blank // entries are dropped. Returns the new poll's id. Crossing function: an EOA // invokes it via MsgCall; another realm via polls.CreatePoll(cross, q, opts). func CreatePoll(cur realm, question string, options string) int { creator := caller(cur).String() q := strings.TrimSpace(question) if q == "" { panic("question must not be empty") } if len(q) > MaxQuestionLen { panic("question too long") } opts := parseOptions(options) if len(opts) < 2 { panic("a poll needs at least 2 options") } if len(opts) > MaxOptions { panic("a poll takes at most " + strconv.Itoa(MaxOptions) + " options") } for _, o := range opts { if len(o) > MaxOptionLen { panic("option too long") } } if len(polls) >= MaxPolls { panic("polls is full at " + strconv.Itoa(MaxPolls) + " polls") } id := nextID nextID++ p := &Poll{ ID: id, Question: q, Options: opts, Tallies: make([]int, len(opts)), Creator: creator, voted: avl.NewTree(), } polls = append(polls, p) chain.Emit("PollCreated", "id", strconv.Itoa(id), "creator", creator, "options", strconv.Itoa(len(opts)), ) return id } // Vote casts one vote for optionIndex on poll pollID. Each caller address may // vote at most once per poll. Crossing function. func Vote(cur realm, pollID int, optionIndex int) { voter := caller(cur).String() p := getPoll(pollID) if optionIndex < 0 || optionIndex >= len(p.Options) { panic("option index out of range") } if p.voted.Has(voter) { panic("address already voted on this poll") } p.voted.Set(voter, struct{}{}) p.Tallies[optionIndex]++ chain.Emit("Voted", "id", strconv.Itoa(pollID), "voter", voter, "option", strconv.Itoa(optionIndex), ) } // parseOptions splits a comma-separated option string, trimming whitespace and // dropping empties. Pure logic — no realm state. func parseOptions(options string) []string { var out []string for _, raw := range strings.Split(options, ",") { o := strings.TrimSpace(raw) if o != "" { out = append(out, o) } } return out } // getPoll returns the poll with the given id or panics if it does not exist. func getPoll(id int) *Poll { if id < 0 || id >= len(polls) { panic("poll not found") } return polls[id] } // PollCount returns the number of polls created so far (read-only helper). func PollCount() int { return len(polls) } const barWidth = 20 // The bounds on what a creator may store. Anyone may create a poll, and the // root page renders the newest ones. const ( MaxPolls = 500 MaxQuestionLen = 200 MaxOptions = 10 MaxOptionLen = 64 IndexRecent = 20 // polls the root page shows, newest first ) // bar renders a proportional ▓░ bar of fixed width for count out of total. func bar(count, total int) string { filled := 0 if total > 0 { filled = count * barWidth / total } if filled > barWidth { filled = barWidth } return strings.Repeat("▓", filled) + strings.Repeat("░", barWidth-filled) } // percent returns the integer percentage of count out of total. func percent(count, total int) int { if total == 0 { return 0 } return count * 100 / total } // total sums a tally slice. func total(tallies []int) int { sum := 0 for _, t := range tallies { sum += t } return sum } // renderPoll writes one poll's Markdown block into b. func renderPoll(b *strings.Builder, p *Poll) { sum := total(p.Tallies) b.WriteString("## Poll #") b.WriteString(strconv.Itoa(p.ID)) b.WriteString(": ") b.WriteString(ui.Inline(p.Question)) b.WriteString("\n\n") for i, opt := range p.Options { c := p.Tallies[i] b.WriteString("- **") b.WriteString(ui.Inline(opt)) b.WriteString("** `") b.WriteString(bar(c, sum)) b.WriteString("` ") b.WriteString(strconv.Itoa(percent(c, sum))) b.WriteString("% (") b.WriteString(strconv.Itoa(c)) if c == 1 { b.WriteString(" vote)") } else { b.WriteString(" votes)") } b.WriteString("\n") } b.WriteString("\n_Total votes: ") b.WriteString(strconv.Itoa(sum)) b.WriteString("_\n\n") } // Render produces the Markdown page for gnoweb. It is NOT a crossing function. // // - "" -> the IndexRecent newest polls // - "page/<n>" -> the n-th page of IndexRecent, newest first (page/1 is "") // - "<id>" -> a single poll by id func Render(path string) string { var b strings.Builder path = strings.TrimSpace(path) page := 1 if rest, ok := strings.CutPrefix(path, "page/"); ok { n, err := strconv.Atoi(rest) // Bounded before any multiply: the page comes from the URL. if err != nil || n < 1 || n > 1+(len(polls)-1)/IndexRecent { return "# Polls\n\nNo such page.\n\n[← all polls](" + selfPath + ")\n" } page, path = n, "" } if path != "" { id, err := strconv.Atoi(path) if err != nil || id < 0 || id >= len(polls) { return "# Polls\n\nPoll not found.\n" } b.WriteString("# Polls\n\n") renderPoll(&b, polls[id]) b.WriteString("[← all polls](" + selfPath + ")\n") return b.String() } b.WriteString("# Polls & Voting\n\n") if len(polls) == 0 { b.WriteString("_No polls yet. Create one with `CreatePoll(question, \"a,b,c\")`._\n") return b.String() } pages := 1 + (len(polls)-1)/IndexRecent b.WriteString("Total polls: ") b.WriteString(strconv.Itoa(len(polls))) if pages > 1 { b.WriteString(", page " + strconv.Itoa(page) + " of " + strconv.Itoa(pages)) } b.WriteString("\n\n") //gnovet:ignore page-offset-overflow page is bounded by pages above top := len(polls) - 1 - (page-1)*IndexRecent for i := top; i >= 0 && i > top-IndexRecent; i-- { renderPoll(&b, polls[i]) } if page < pages { b.WriteString("[older polls →](" + selfPath + ":page/" + strconv.Itoa(page+1) + ")\n") } return b.String() } // caller is the address that crossed into this realm, after checking that the // realm token is this frame's own. func caller(cur realm) address { if !cur.IsCurrent() { panic("polls: spoofed realm") } return cur.Previous().Address() }
  8. #8polls_test.gno
  9. #9package polls 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") ) // resetState clears package-level state between tests so cases are independent. func resetState() { polls = nil nextID = 0 } func TestParseOptions(t *testing.T) { tests := []struct { name string input string want []string }{ {"simple", "a,b,c", []string{"a", "b", "c"}}, {"trims spaces", " yes , no ", []string{"yes", "no"}}, {"drops empties", "a,,b, ,c", []string{"a", "b", "c"}}, {"single", "only", []string{"only"}}, {"all blank", " , ,", nil}, } for _, tt := range tests { got := parseOptions(tt.input) if len(got) != len(tt.want) { t.Errorf("%s: len = %d, want %d (%v)", tt.name, len(got), len(tt.want), got) continue } for i := range got { if got[i] != tt.want[i] { t.Errorf("%s: [%d] = %q, want %q", tt.name, i, got[i], tt.want[i]) } } } } func TestCreateAndVote(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(alice)) id := CreatePoll(cross(cur), "Best chain?", "gno,eth,btc") if id != 0 { t.Fatalf("first poll id = %d, want 0", id) } if PollCount() != 1 { t.Fatalf("PollCount = %d, want 1", PollCount()) } // alice votes gno. Vote(cross(cur), id, 0) // bob votes eth. testing.SetRealm(testing.NewUserRealm(bob)) Vote(cross(cur), id, 1) // carol votes gno. testing.SetRealm(testing.NewUserRealm(carol)) Vote(cross(cur), id, 0) p := getPoll(id) if p.Tallies[0] != 2 { t.Errorf("gno tally = %d, want 2", p.Tallies[0]) } if p.Tallies[1] != 1 { t.Errorf("eth tally = %d, want 1", p.Tallies[1]) } if total(p.Tallies) != 3 { t.Errorf("total = %d, want 3", total(p.Tallies)) } } func TestDoubleVotePanics(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(alice)) id := CreatePoll(cross(cur), "Coffee or tea?", "coffee,tea") Vote(cross(cur), id, 0) // A second vote by the same address aborts (cross-realm panic). uassert.AbortsWithMessage(t, cur, "address already voted on this poll", func() { Vote(cross(cur), id, 1) }) } func TestVoteBounds(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(alice)) id := CreatePoll(cross(cur), "A or B?", "a,b") uassert.AbortsWithMessage(t, cur, "option index out of range", func() { Vote(cross(cur), id, 5) }) } func TestCreatePollNeedsTwoOptions(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsWithMessage(t, cur, "a poll needs at least 2 options", func() { CreatePoll(cross(cur), "One?", "only") }) } func TestRender(cur realm, t *testing.T) { resetState() testing.SetRealm(testing.NewUserRealm(alice)) // Empty index. if got := Render(""); !strings.Contains(got, "No polls yet") { t.Errorf("empty Render should mention no polls, got:\n%s", got) } id := CreatePoll(cross(cur), "Pick one", "x,y") Vote(cross(cur), id, 0) // Index lists the poll and bar chart glyphs. idx := Render("") if !strings.Contains(idx, "Pick one") { t.Errorf("index missing question:\n%s", idx) } if !strings.Contains(idx, "▓") || !strings.Contains(idx, "░") { t.Errorf("index missing bar glyphs:\n%s", idx) } if !strings.Contains(idx, "Total votes: 1") { t.Errorf("index missing total votes:\n%s", idx) } if !strings.Contains(idx, "100%") { t.Errorf("index missing 100%% for the sole vote:\n%s", idx) } // Single-poll path. single := Render("0") if !strings.Contains(single, "Poll #0") { t.Errorf("single Render missing poll header:\n%s", single) } // Unknown path. if got := Render("99"); !strings.Contains(got, "not found") { t.Errorf("unknown poll should say not found, got:\n%s", got) } }
  10. #10zz_bounds_test.gno
  11. #11package polls import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) // What a creator may store is bounded, and the root page renders a fixed // window of the newest polls whatever the count. func TestPollsAreBounded(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(testutils.TestAddress("bounded"))) uassert.AbortsContains(t, cur, "question too long", func() { CreatePoll(cross(cur), strings.Repeat("q", MaxQuestionLen+1), "a,b") }) uassert.AbortsContains(t, cur, "at most", func() { CreatePoll(cross(cur), "many", strings.Repeat("o,", MaxOptions)+"o") }) for len(polls) < IndexRecent+3 { CreatePoll(cross(cur), "filler", "a,b") } uassert.Equal(t, IndexRecent, strings.Count(Render(""), "## Poll #")) } // The poll count is bounded globally. func TestPollCountIsBounded(cur realm, t *testing.T) { before := len(polls) for len(polls) < MaxPolls { polls = append(polls, &Poll{ID: len(polls), Question: "q", Options: []string{"a", "b"}, Tallies: []int{0, 0}}) } defer func() { polls = polls[:before] }() testing.SetRealm(testing.NewUserRealm(testutils.TestAddress("latecomer"))) uassert.AbortsContains(t, cur, "polls is full", func() { CreatePoll(cross(cur), "one more", "a,b") }) } // Polls past the newest page stay reachable: the index links to the next // page, and a page past the end says so instead of wrapping. func TestOlderPollsArePaged(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(testutils.TestAddress("pager"))) for len(polls) < IndexRecent+1 { CreatePoll(cross(cur), "filler", "a,b") } idx := Render("") uassert.True(t, strings.Contains(idx, "(/r/moul/x/daily/polls/v1:page/2)"), idx) p2 := Render("page/2") uassert.True(t, strings.Contains(p2, "## Poll #0:"), p2) uassert.True(t, strings.Contains(Render("page/9223372036854775807"), "No such page")) }
  12. #12zz_escape_test.gno
  13. #13package polls import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) // A question and its options are the creator's text: escaped. func TestQuestionAndOptionsAreEscaped(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(testutils.TestAddress("escaper"))) id := CreatePoll(cross(cur), "[claim](https://evil.example)", "[claim](https://evil.example)"+",no") page := Render(itoaTest(id)) uassert.False(t, strings.Contains(page, "[claim](https://evil.example)"), page) uassert.False(t, strings.Contains(Render(""), "[claim](https://evil.example)"), Render("")) }
  14. #14zz_links_test.gno
  15. #15package polls import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) // A poll page links back to this realm. It once linked to // /r/REPLACE_ADDR/polls. func TestPollPageLinksBackToThisRealm(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(testutils.TestAddress("linker"))) id := CreatePoll(cross(cur), "Linked?", "yes,no") page := Render(itoaTest(id)) uassert.True(t, strings.Contains(page, "[← all polls](/r/moul/x/daily/polls/v1)"), page) } func itoaTest(n int) string { if n == 0 { return "0" } s := "" for n > 0 { s = string(rune(byte(n%10)+byte(48))) + s n /= 10 } return s }
#12AddPackagegno.land/r/moul/x/daily/vault/v115 arguments
Attached funds
5000000ugnot

Arguments · 15

  1. #1vault
  2. #2README.md
  3. #3# Key-Value Vault > ⚠️ **Experimental — generated with no human supervision.** This realm was > produced automatically by an MCP-driven agent to exercise the gno MCP server > and tooling, and to generate test content for gno compilers, linters and > formatters. **Not audited. Not for production.** Full context & folder README: > [r/moul/x/daily](https://github.com/moul/gno-contracts/blob/main/r/moul/x/daily/README.md) --- A per-address key-value store on gno.land. Each caller gets their own private namespace; only the owner can set or delete their own entries. The root render lists every address that owns a vault with its entry count, and the `/<address>` path renders that address's keys and values in a table. **Realm path:** `gno.land/r/moul/x/daily/vault/v1` ## Example calls ``` # Store a value in your namespace Set("greeting", "hello") # Overwrite it Set("greeting", "hi there") # Remove it (aborts if the key doesn't exist) Delete("greeting") ``` ## Render - `Render("")` — table of all addresses with a vault and their entry counts. - `Render("/g1abc...")` — table of that address's keys and values. <!-- 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. **On mainnet:** [![deployment status](https://gnoscope.com/_badges/shield/status/r/moul/x/daily/vault/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/daily/vault/v1) [![transactions](https://gnoscope.com/_badges/shield/txs/r/moul/x/daily/vault/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/daily/vault/v1) [![unique callers](https://gnoscope.com/_badges/shield/users/r/moul/x/daily/vault/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/daily/vault/v1) [![deployed revision](https://gnoscope.com/_badges/shield/version/r/moul/x/daily/vault/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/daily/vault/v1) **Dependency graph:** ![gno.land/r/moul/x/daily/vault/v1 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/daily/vault/v1/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/r/moul/x/daily/vault/v1" gno = "0.9" private = true
  6. #6vault.gno
  7. #7package vault import ( "chain" "strconv" "strings" "gno.land/p/moul/kit/ui/v0" "gno.land/p/nt/avl/v0" ) // selfPath is this realm's own page. A root-relative link resolves against // the domain, not the realm, so every link back into it names it in full. const selfPath = "/r/moul/x/daily/vault/v1" // vaults maps address string -> *avl.Tree (key string -> value string). var vaults avl.Tree // The bounds on what a caller may store. Anyone may open a vault, the root // page lists every vault, and a vault's page lists every entry. const ( MaxVaults = 1000 MaxEntries = 100 // per vault MaxKeyLen = 64 MaxValueLen = 512 ) // vaultFor returns the caller's namespace tree, creating it if needed. func vaultFor(owner address) *avl.Tree { k := owner.String() if v := vaults.Get(k); v != nil { return v.(*avl.Tree) } if vaults.Size() >= MaxVaults { panic("vault is full at " + strconv.Itoa(MaxVaults) + " vaults") } t := &avl.Tree{} vaults.Set(k, t) return t } // Set stores value under key in the caller's own namespace. func Set(cur realm, key string, value string) { who := caller(cur) if key == "" { panic("key must not be empty") } if len(key) > MaxKeyLen || len(value) > MaxValueLen { panic("key or value too long") } t := vaultFor(who) if !t.Has(key) && t.Size() >= MaxEntries { panic("a vault holds at most " + strconv.Itoa(MaxEntries) + " entries") } t.Set(key, value) chain.Emit("VaultSet", "owner", who.String(), "key", key) } // Delete removes key from the caller's own namespace. func Delete(cur realm, key string) { who := caller(cur) k := who.String() v := vaults.Get(k) if v == nil { panic("no vault for caller") } t := v.(*avl.Tree) if _, removed := t.Remove(key); !removed { panic("key not found") } if t.Size() == 0 { vaults.Remove(k) } chain.Emit("VaultDelete", "owner", who.String(), "key", key) } // Render shows all vaults (root) or a single address's entries ("/<address>"). func Render(path string) string { p := strings.TrimPrefix(path, "/") if p == "" { return renderRoot() } return renderAddress(p) } func renderRoot() string { var b strings.Builder b.WriteString("# Key-Value Vault\n\n") b.WriteString("A per-address key-value store. Each caller owns their own namespace; only the owner can mutate their entries.\n\n") if vaults.Size() == 0 { b.WriteString("_No vaults yet. Call `Set(cur, key, value)` to create one._\n") return b.String() } b.WriteString("## Vaults\n\n") b.WriteString("| Address | Entries |\n") b.WriteString("|---|---|\n") vaults.Iterate("", "", func(k string, v interface{}) bool { t := v.(*avl.Tree) b.WriteString("| [") b.WriteString(k) b.WriteString("](" + selfPath + ":") // link back into this realm's render for the address subpage b.WriteString(k) b.WriteString(") | ") b.WriteString(strconv.Itoa(t.Size())) b.WriteString(" |\n") return false }) b.WriteString("\nView an address's entries at path `/<address>`.\n") return b.String() } func renderAddress(addr string) string { var b strings.Builder b.WriteString("# Vault: ") b.WriteString(ui.Inline(addr)) b.WriteString("\n\n") v := vaults.Get(addr) if v == nil { b.WriteString("_No vault for this address._\n") return b.String() } t := v.(*avl.Tree) b.WriteString("| Key | Value |\n") b.WriteString("|---|---|\n") t.Iterate("", "", func(k string, val interface{}) bool { b.WriteString("| ") b.WriteString(ui.Cell(k)) b.WriteString(" | ") b.WriteString(ui.Cell(val.(string))) b.WriteString(" |\n") return false }) b.WriteString("\nTotal entries: ") b.WriteString(strconv.Itoa(t.Size())) b.WriteString("\n") return b.String() } // caller is the address that crossed into this realm, after checking that the // realm token is this frame's own. func caller(cur realm) address { if !cur.IsCurrent() { panic("vault: spoofed realm") } return cur.Previous().Address() }
  8. #8vault_test.gno
  9. #9package vault import ( "strings" "testing" "gno.land/p/nt/avl/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) func TestSetAndRender(cur realm, t *testing.T) { alice := testutils.TestAddress("alice") testing.SetRealm(testing.NewUserRealm(alice)) Set(cross(cur), "greeting", "hello") Set(cross(cur), "lang", "gno") out := renderAddress(alice.String()) uassert.True(t, strings.Contains(out, "greeting"), "should contain key greeting") uassert.True(t, strings.Contains(out, "hello"), "should contain value hello") uassert.True(t, strings.Contains(out, "Total entries: 2"), "should count 2 entries") root := Render("") uassert.True(t, strings.Contains(root, alice.String()), "root should list alice") } func TestDeleteAndIsolation(cur realm, t *testing.T) { alice := testutils.TestAddress("alice2") bob := testutils.TestAddress("bob2") testing.SetRealm(testing.NewUserRealm(alice)) Set(cross(cur), "shared", "alice-value") testing.SetRealm(testing.NewUserRealm(bob)) Set(cross(cur), "shared", "bob-value") // Namespaces are isolated: same key, different owners. av := vaults.Get(alice.String()) bv := vaults.Get(bob.String()) avVal := av.(*avl.Tree).Get("shared") bvVal := bv.(*avl.Tree).Get("shared") uassert.Equal(t, "alice-value", avVal.(string)) uassert.Equal(t, "bob-value", bvVal.(string)) // Bob deletes his own key; alice's remains. Delete(cross(cur), "shared") bobStill := vaults.Has(bob.String()) uassert.False(t, bobStill, "bob's vault should be gone after last key removed") aliceStill := vaults.Has(alice.String()) uassert.True(t, aliceStill, "alice's vault should remain") // Deleting a missing key aborts. testing.SetRealm(testing.NewUserRealm(alice)) uassert.AbortsWithMessage(t, cur, "key not found", func() { Delete(cross(cur), "nope") }) }
  10. #10zz_bounds_test.gno
  11. #11package vault import ( "strconv" "strings" "testing" "gno.land/p/nt/avl/v0" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) // A vault holds a bounded number of entries of bounded size; overwriting an // existing key is always allowed. func TestVaultIsBounded(cur realm, t *testing.T) { testing.SetRealm(testing.NewUserRealm(testutils.TestAddress("bounded"))) uassert.AbortsContains(t, cur, "too long", func() { Set(cross(cur), strings.Repeat("k", MaxKeyLen+1), "v") }) for i := 0; i < MaxEntries; i++ { Set(cross(cur), "k"+strconv.Itoa(i), "v") } uassert.AbortsContains(t, cur, "at most", func() { Set(cross(cur), "one-more", "v") }) Set(cross(cur), "k0", "overwritten") } // The vault count is bounded globally, not just entries per vault. func TestVaultCountIsBounded(cur realm, t *testing.T) { var added []string for i := 0; vaults.Size() < MaxVaults; i++ { k := testutils.TestAddress("v" + strconv.Itoa(i)).String() if vaults.Get(k) == nil { vaults.Set(k, &avl.Tree{}) added = append(added, k) } } defer func() { for _, k := range added { vaults.Remove(k) } }() testing.SetRealm(testing.NewUserRealm(testutils.TestAddress("latecomer"))) uassert.AbortsContains(t, cur, "vault is full", func() { Set(cross(cur), "k", "v") }) }
  12. #12zz_escape_test.gno
  13. #13package vault import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) // A key and a value are the owner's text, written into table cells: a pipe // must not open a column and a link must not go live. func TestKeysAndValuesAreTableSafe(cur realm, t *testing.T) { who := testutils.TestAddress("escaper") testing.SetRealm(testing.NewUserRealm(who)) Set(cross(cur), "k | [claim](https://evil.example)", "[claim](https://evil.example)") page := Render(who.String()) uassert.False(t, strings.Contains(page, "[claim](https://evil.example)"), page) uassert.False(t, strings.Contains(page, "| k | "), page) } // A path that names nothing is echoed back on the not-found page. It is the // visitor's text, so a backtick in it must not close a code span and let a // link through. func TestNotFoundPathIsEscaped(t *testing.T) { out := Render("x` [claim](https://evil.example) `") uassert.False(t, strings.Contains(out, "[claim](https://evil.example)"), out) }
  14. #14zz_links_test.gno
  15. #15package vault import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) // The index links each vault to this realm's own page. It once linked to // the malformed gno.land/r:<address>. func TestIndexLinksNameThisRealm(cur realm, t *testing.T) { who := testutils.TestAddress("linker") testing.SetRealm(testing.NewUserRealm(who)) Set(cross(cur), "k", "v") out := Render("") uassert.True(t, strings.Contains(out, "](/r/moul/x/daily/vault/v1:"+who.String()+")"), out) }
#13AddPackagegno.land/r/moul/x/plan9/ns/v111 arguments
Attached funds
14000000ugnot

Arguments · 11

  1. #1ns
  2. #2README.md
  3. #3# `gno.land/r/moul/x/plan9/ns/v1` **A Plan 9 namespace server for gno.land.** Every account gets a private, persistent namespace: its own RAM root plus a mount table it alone controls. Realms publish file trees into `/srv`, accounts `bind` those trees wherever they like, and a read-only `rc` shell renders the whole thing in gnoweb. ```sh gnokey maketx call -pkgpath gno.land/r/moul/x/plan9/ns/v1 \ -func Exec -args 'bind -ac /srv/dev /dev; echo hello > /tmp/greeting' ``` Then browse it at `/r/moul/x/plan9/ns/v1:ns?u=<your address>`. This is the part of Plan 9 that gno does not otherwise have. The chain has a single global tree of realm paths that looks the same to everybody; here a name means what *you* bound it to. Composing two realms that were never written to work together stops being a redeploy and becomes a transaction. ## Surface | call | what it does | |---|---| | `Post(cur, name, f)` | publish a `ninep.File` tree under `/srv/<name>` | | `Unpost(cur, name)` | withdraw it; only the posting realm may | | `Exec(cur, line)` | run an `rc` command line against the caller's namespace | | `Reset(cur)` | throw the caller's namespace away | | `Run(key, line)` | the read-only query side, used by `Render` | | `Namespace(key)` | the mount table, in `ns(1)` format | `Render` routes: `ns`, `ls/<path>`, `cat/<path>`, `stat/<path>`, `walk/<path>`, and `rc?c=<command>` for any read-only command line. `?u=` picks whose namespace; it defaults to a seeded demo one, so gnoweb shows something live with no transaction. ## The default namespace This chain's `/lib/namespace`: a private ram root, the mount points Plan 9 requires to exist before anything can be bound onto them, `/srv` mounted, and `/dev` bound from it when a device server has been posted. ``` mount #s /srv bind /srv/dev /dev ``` ## Security **Mounted trees are read-only by construction.** `ninep.File` has no mutating method, so grafting a foreign realm's tree into your namespace cannot be turned into a write against that realm; writes only ever reach a memfs tree this realm created for you. A crossing write method would mint *this* realm's frame for the callee, which is the confused-deputy shape `r/gov/dao`'s `Executor` relies on deliberately and `p/nt/grc20`'s `Teller` refuses deliberately. It is out of scope for v0, and three abort tests pin the boundary: a second realm cannot take over a `/srv` name, a write to a mounted tree fails with `read-only file server`, and a command line that fails part way aborts the whole call. `/srv` names are first come, first served, with the posting realm recorded and the only one allowed to unpost. Squatting is possible and accepted for an experiment. Built on [`ninep`](../../../../../p/moul/x/plan9/ninep/v0), [`memfs`](../../../../../p/moul/x/plan9/memfs/v0), [`ns`](../../../../../p/moul/x/plan9/ns/v0) and [`rc`](../../../../../p/moul/x/plan9/rc/v0). 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 realm 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. **On mainnet:** [![deployment status](https://gnoscope.com/_badges/shield/status/r/moul/x/plan9/ns/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/plan9/ns/v1) [![transactions](https://gnoscope.com/_badges/shield/txs/r/moul/x/plan9/ns/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/plan9/ns/v1) [![unique callers](https://gnoscope.com/_badges/shield/users/r/moul/x/plan9/ns/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/plan9/ns/v1) [![deployed revision](https://gnoscope.com/_badges/shield/version/r/moul/x/plan9/ns/v1?network=mainnet)](https://gnoscope.com/realm/r/moul/x/plan9/ns/v1) **Dependency graph:** ![gno.land/r/moul/x/plan9/ns/v1 dependency graph](https://raw.githubusercontent.com/moul/gno-contracts/main/_assets/gno.land/r/moul/x/plan9/ns/v1/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/r/moul/x/plan9/ns/v1" gno = "0.9" # public: imported by r/moul/x/plan9/dev, which is what the pair is for
  6. #6ns.gno
  7. #7// Package ns is a Plan 9 namespace server for gno.land. // // Every account gets a private, persistent namespace: its own RAM root plus a // mount table it alone controls. Realms publish file trees into /srv, accounts // bind those trees wherever they like, and a read-only rc shell renders the // whole thing in gnoweb. // // This is the part of Plan 9 that gno does not otherwise have. The chain has a // single global tree of realm paths that looks the same to everybody; here a // name means what YOU bound it to. Composing two realms that were never // written to work together stops being a redeploy and becomes a transaction: // // gnokey maketx call -pkgpath gno.land/r/moul/x/plan9/ns/v1 -func Exec \ // -args 'bind -ac /srv/dev /dev; echo hello > /tmp/greeting' // // SECURITY. Mounted trees are READ-ONLY by construction: ninep.File has no // mutating method, so grafting a foreign realm's tree into your namespace // cannot be turned into a write against that realm. Writes only ever reach a // memfs tree this realm created for you. A crossing write method would mint // THIS realm's 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; it is out of scope for v0. See moul/gno-contracts#136. // // 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 realm 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 ns import ( "chain/runtime" "errors" "strconv" "strings" "gno.land/p/moul/kit/ui/v0" "gno.land/p/moul/md/v0" "gno.land/p/nt/avl/v0" "gno.land/p/moul/realmpath/v0" memfs "gno.land/p/moul/x/plan9/memfs/v0" ninep "gno.land/p/moul/x/plan9/ninep/v0" nspkg "gno.land/p/moul/x/plan9/ns/v0" rc "gno.land/p/moul/x/plan9/rc/v0" ) // DemoKey names the namespace gnoweb browses when no ?u= is given. It is a // plain string rather than an address so it can never collide with one. const DemoKey = "demo" // The bounds on what callers can create. Any realm may Post a service and any // account gets a namespace on its first Exec; the home page lists both. const ( MaxServices = 64 MaxNamespaces = 1000 HomeRecent = 50 // namespaces the home page lists ) type service struct { file ninep.File owner string // pkgpath of the realm that posted it addr address // that realm's address since int64 // block height of the posting } type space struct { fs *memfs.FS ns *nspkg.Ns } var ( services = avl.NewTree() // name -> *service spaces = avl.NewTree() // address string (or DemoKey) -> *space ) func init() { seedDemo() } // ---------------------------------------------------------------- /srv // srvDir is the synthetic directory that makes posted services reachable by // name. It is written out by hand rather than built with synfs because its // contents change as realms post, and because implementing ninep.File // directly is meant to look easy: four methods, no state of its own. type srvDir struct{} func (d *srvDir) Stat() ninep.Stat { return ninep.Stat{ Qid: ninep.Qid{Type: ninep.QTDIR, Path: 1}, Mode: ninep.DMDIR | 0555, Mtime: runtime.ChainHeight(), Name: "srv", Uid: "sys", Gid: "sys", Muid: "sys", } } func (d *srvDir) Walk(name string) (ninep.File, error) { if !ninep.ValidName(name) { return nil, ninep.ErrBadName } v := services.Get(name) if v == nil { return nil, ninep.ErrNotExist } return v.(*service).file, nil } func (d *srvDir) Read(off, count int64) (string, error) { return "", ninep.ErrIsDir } func (d *srvDir) ReadDir() ([]ninep.Stat, error) { out := []ninep.Stat{} services.Iterate("", "", func(k string, v any) bool { st := v.(*service).file.Stat() st.Name = k // the posted name, not whatever the server calls its root out = append(out, st) return false }) return out, nil } var srvRoot = &srvDir{} // ---------------------------------------------------------------- posting // Post publishes a file tree under name in /srv, where any account can bind // it. The posting realm is recorded and is the only one that may Unpost. // // Names are first come, first served, which is fine for an experiment and // would not be for anything else. func Post(cur realm, name string, f ninep.File) { if !cur.IsCurrent() { panic("post: cur is not the caller's live realm") } if !ninep.ValidName(name) { panic("post: " + ninep.ErrBadName.Error()) } if f == nil { panic("post: nil file server") } prev := cur.Previous() if services.Get(name) == nil && services.Size() >= MaxServices { panic("post: /srv is full at " + strconv.Itoa(MaxServices) + " services") } if existing := services.Get(name); existing != nil { if existing.(*service).owner != prev.PkgPath() { panic("post: " + name + " is already posted by " + existing.(*service).owner) } } services.Set(name, &service{ file: f, owner: prev.PkgPath(), addr: prev.Address(), since: runtime.ChainHeight(), }) } // Unpost withdraws a service. Only the realm that posted it may do so. func Unpost(cur realm, name string) { if !cur.IsCurrent() { panic("unpost: cur is not the caller's live realm") } v := services.Get(name) if v == nil { panic("unpost: " + ninep.ErrNotExist.Error()) } if v.(*service).owner != cur.Previous().PkgPath() { panic("unpost: " + name + " belongs to " + v.(*service).owner) } services.Remove(name) } // Services lists the posted service names, in order. func Services() []string { out := []string{} services.Iterate("", "", func(k string, _ any) bool { out = append(out, k) return false }) return out } // ---------------------------------------------------------------- namespaces // newSpace builds the default namespace, which is this chain's /lib/namespace: // a private ram root, the mount points that Plan 9 requires to exist before // anything can be bound onto them, /srv mounted, and /dev bound from it when a // device server has been posted. func newSpace(owner string) *space { now := runtime.ChainHeight() fs := memfs.New(owner, now) fs.MkdirAll("/srv", now) fs.MkdirAll("/dev", now) fs.MkdirAll("/tmp", now) n := nspkg.New(fs.Root()) n.Mount(srvRoot, "#s", "/srv", nspkg.MREPL) if services.Has("dev") { n.Bind("/srv/dev", "/dev", nspkg.MREPL) } return &space{fs: fs, ns: n} } func spaceFor(key string) *space { if v := spaces.Get(key); v != nil { return v.(*space) } if spaces.Size() >= MaxNamespaces { panic("ns: full at " + strconv.Itoa(MaxNamespaces) + " namespaces") } sp := newSpace(key) spaces.Set(key, sp) return sp } func peek(key string) *space { if v := spaces.Get(key); v != nil { return v.(*space) } return nil } // seedDemo builds the namespace gnoweb shows by default. It is deliberately // reproducible: an example test resets it before rendering. func seedDemo() { spaces.Remove(DemoKey) sp := spaceFor(DemoKey) now := runtime.ChainHeight() sp.fs.WriteFile("/tmp/greeting", "hello from a namespace\n", now) sp.fs.MkdirAll("/usr/glenda/bin", now) sp.fs.WriteFile("/usr/glenda/bin/rc", "#!/bin/rc\n", now) sp.fs.MkdirAll("/bin", now) sp.fs.WriteFile("/bin/ls", "system ls\n", now) sp.ns.Bind("/usr/glenda/bin", "/bin", nspkg.MAFTER|nspkg.MCREATE) sp.ns.Cd("/usr/glenda") } // ResetDemo rebuilds the demo namespace. Anyone may call it: it is a demo, and // the alternative is a demo that the first visitor ruins for everybody. func ResetDemo(cur realm) { if !cur.IsCurrent() { panic("resetdemo: cur is not the caller's live realm") } seedDemo() } // ---------------------------------------------------------------- shell // Exec runs a command line against the CALLER's namespace and returns its // output. The namespace belongs to cur.Previous().Address(), so a user gets // theirs and a realm gets its own. // // An error panics, so a half-applied command line reverts with the // transaction rather than leaving a namespace nobody asked for. func Exec(cur realm, line string) string { if !cur.IsCurrent() { panic("exec: cur is not the caller's live realm") } key := cur.Previous().Address().String() sp := spaceFor(key) sh := rc.New(sp.ns, rc.ReadWrite, runtime.ChainHeight) out, err := sh.Run(line) if err != nil { panic(err.Error()) } return out } // Reset discards the caller's namespace, so the next use rebuilds the default. func Reset(cur realm) { if !cur.IsCurrent() { panic("reset: cur is not the caller's live realm") } spaces.Remove(cur.Previous().Address().String()) } // Run executes a READ-ONLY command line against key's namespace. It is the // query side of Exec: no transaction, no writes, safe from Render. func Run(key, line string) (string, error) { sp := peek(key) if sp == nil { return "", errors.New("no namespace for " + key) } sh := rc.New(sp.ns, rc.ReadOnly, runtime.ChainHeight) return sh.Run(line) } // Namespace returns key's mount table, in ns(1) format. func Namespace(key string) string { sp := peek(key) if sp == nil { return "" } return sp.ns.String() } // Keys lists the namespaces that exist, in order. func Keys() []string { out := []string{} spaces.Iterate("", "", func(k string, _ any) bool { out = append(out, k) return false }) return out } // ---------------------------------------------------------------- render // Render browses a namespace. // // Render("") overview, posted services, how to drive it // Render("ns?u=<key>") the mount table // Render("ls/bin?u=<key>") a directory listing (ls -l) // Render("cat/tmp/greeting") a file // Render("stat/bin") the 9P stat, with the union width // Render("walk/bin/rc") how each element of a path resolves // Render("rc?c=<command>") any read-only rc command line // // ?u= selects the namespace; it defaults to the demo one. func Render(path string) string { req := realmpath.Parse(path) key := req.Query.Get("u") if key == "" { key = DemoKey } // A namespace is keyed by the address that created it, or is the demo one. // Anything else names no namespace, and refusing it here is also what keeps // a crafted ?u= out of the links and code spans every page builds from it. if key != DemoKey && !address(key).IsValid() { return "# 404\n\nno namespace by that name. `?u=` takes an address.\n" } parts := req.PathParts() if len(parts) == 0 || parts[0] == "" { return renderHome(key) } cmd := parts[0] rest := "/" + strings.Join(parts[1:], "/") var line string switch cmd { case "ns": return renderCmd(key, "ns", "ns") case "ls": line = "ls -l " + quote(rest) case "cat": line = "cat " + quote(rest) case "stat": line = "stat " + quote(rest) case "walk": line = "walk " + quote(rest) case "rc": line = req.Query.Get("c") if line == "" { line = "help" } default: return "# 404\n\nunknown command " + md.InlineCode(cmd) + ". Try `ls`, `cat`, `stat`, `walk`, `ns` or `rc?c=...`.\n" } return renderCmd(key, line, cmd+" "+rest) } func renderCmd(key, line, title string) string { var b strings.Builder b.WriteString("# " + ui.Inline(title) + "\n\n") b.WriteString("namespace: `" + key + "`\n\n") out, err := Run(key, line) if err != nil { b.WriteString(md.CodeBlock(err.Error())) } else if out == "" { b.WriteString("_(no output)_\n") } else { // The output is a user's file contents: a line of backticks in it // would close a hand-written fence, and md.CodeBlock picks one it cannot. b.WriteString(md.CodeBlock(strings.TrimSuffix(out, "\n"))) } b.WriteString("\n[namespace](/r/moul/x/plan9/ns/v1:ns?u=" + key + ") · [root](/r/moul/x/plan9/ns/v1:ls?u=" + key + ") · [home](/r/moul/x/plan9/ns/v1)\n") return b.String() } func renderHome(key string) string { var b strings.Builder b.WriteString("# plan9: namespaces for gno\n\n") b.WriteString("A Plan 9 namespace server. Every account owns a private mount table over ") b.WriteString("[9P-shaped](https://9p.io/sys/doc/9.html) file trees: realms post trees ") b.WriteString("into `/srv`, you `bind` them where you want them, and a name means what ") b.WriteString("*you* bound it to.\n\n") b.WriteString("## /srv\n\n") if services.Size() == 0 { b.WriteString("_No service is posted yet._\n\n") } else { b.WriteString("| name | posted by | since |\n|---|---|---|\n") services.Iterate("", "", func(k string, v any) bool { s := v.(*service) b.WriteString("| " + ui.Cell(k) + " | [`" + s.owner + "`](" + strings.TrimPrefix(s.owner, "gno.land") + ") | " + strconv.FormatInt(s.since, 10) + " |\n") return false }) b.WriteString("\n") } b.WriteString("## The demo namespace\n\n") b.WriteString(md.CodeBlock(strings.TrimSuffix(Namespace(DemoKey), "\n")) + "\n") b.WriteString("`/bin` is a union: the system `/bin` first, then `/usr/glenda/bin`, ") b.WriteString("with `-c` so new files land in the second. Listing it shows both members ") b.WriteString("because a Plan 9 union is a concatenation, so shadowing stays visible.\n\n") b.WriteString("- [ns](/r/moul/x/plan9/ns/v1:ns) · [ls /](/r/moul/x/plan9/ns/v1:ls) · [ls /bin](/r/moul/x/plan9/ns/v1:ls/bin) · ") b.WriteString("[cat /tmp/greeting](/r/moul/x/plan9/ns/v1:cat/tmp/greeting) · [walk /bin/rc](/r/moul/x/plan9/ns/v1:walk/bin/rc)\n") b.WriteString("- any read-only command: [`rc?c=ls -l /srv`](/r/moul/x/plan9/ns/v1:rc?c=ls%20-l%20/srv)\n\n") b.WriteString("## Your own namespace\n\n") b.WriteString("```\n") b.WriteString("gnokey maketx call -pkgpath gno.land/r/moul/x/plan9/ns/v1 \\\n") b.WriteString(" -func Exec -args 'bind -ac /srv/dev /dev; echo hi > /tmp/f'\n") b.WriteString("```\n\n") b.WriteString("Then browse it with `?u=<your address>`. `Reset` throws it away.\n\n") b.WriteString("## Namespaces\n\n") keys := Keys() if len(keys) > HomeRecent { b.WriteString(strconv.Itoa(len(keys)) + " namespaces, the first " + strconv.Itoa(HomeRecent) + " listed.\n\n") keys = keys[:HomeRecent] } for _, k := range keys { b.WriteString("- [`" + k + "`](/r/moul/x/plan9/ns/v1:ns?u=" + k + ")\n") } b.WriteString("\nDesign and analysis: ") b.WriteString("[moul/gno-contracts#136](https://github.com/moul/gno-contracts/issues/136).\n") b.WriteString("\n_Not affiliated with Plan 9. Plan 9 from Bell Labs is the ") b.WriteString("work of the Computing Science Research Center at Bell Labs; the name ") b.WriteString("and the marks are theirs, and the copyright is held by the ") b.WriteString("[Plan 9 Foundation](https://p9f.org). This realm borrows the ") b.WriteString("vocabulary and none of the code: it is an homage, asking what that ") b.WriteString("ecosystem's spirit looks like on a chain._\n") return b.String() } // quote wraps a path for rc if it needs it. func quote(s string) string { // Always quoted: the path comes from the URL, and a route like // cat/tmp/f;echo must read one file, not run a second command. The shell // is read-only either way, but a path is data. return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
  8. #8ns_test.gno
  9. #9package ns import ( "strconv" "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ninep "gno.land/p/moul/x/plan9/ninep/v0" synfs "gno.land/p/moul/x/plan9/synfs/v0" ) // probe is a tiny read-only server, standing in for another realm's tree. func probe(name, contents string) ninep.File { t := synfs.New(name, "sys", func() int64 { return 1 }) t.Root().Add("value", func() string { return contents }) return t.Root() } func TestDemoNamespaceIsSeeded(t *testing.T) { seedDemo() out, err := Run(DemoKey, "ls -u /bin") if err != nil { t.Fatalf("ls: %v", err) } if out != "ls\nrc\n" { t.Errorf("the demo /bin should be a union of two directories: %q", out) } if got := Namespace(DemoKey); !strings.Contains(got, "bind -ac /usr/glenda/bin /bin") { t.Errorf("mount table: %q", got) } if got, _ := Run(DemoKey, "cat /tmp/greeting"); got != "hello from a namespace\n" { t.Errorf("greeting: %q", got) } } func TestRunIsReadOnly(t *testing.T) { seedDemo() if _, err := Run(DemoKey, "echo x > /tmp/f"); err == nil { t.Fatal("Render's shell must refuse a write") } if _, err := Run(DemoKey, "rm /tmp/greeting"); err == nil { t.Fatal("Render's shell must refuse a remove") } if _, err := Run("nobody", "ls /"); err == nil { t.Fatal("an unknown namespace should not be created by a read") } } func TestPostAndBind(cur realm, t *testing.T) { services.Remove("probe") Post(cross(cur), "probe", probe("probe", "42")) found := false for _, s := range Services() { if s == "probe" { found = true } } if !found { t.Fatalf("probe is not in /srv: %v", Services()) } // A fresh namespace sees it through /srv without importing anything. spaces.Remove("tester") spaceFor("tester") defer spaces.Remove("tester") out, err := Run("tester", "cat /srv/probe/value") if err != nil { t.Fatalf("cat through /srv: %v", err) } if out != "42" { t.Errorf("got %q, want 42", out) } services.Remove("probe") } func TestExecWritesToTheCallersOwnNamespace(cur realm, t *testing.T) { alice := testutils.TestAddress("alice") bob := testutils.TestAddress("bob") spaces.Remove(alice.String()) spaces.Remove(bob.String()) testing.SetRealm(testing.NewUserRealm(alice)) Exec(cross(cur), "echo 'alice was here' > /tmp/note") testing.SetRealm(testing.NewUserRealm(bob)) Exec(cross(cur), "echo 'bob was here' > /tmp/note") got, err := Run(alice.String(), "cat /tmp/note") if err != nil { t.Fatalf("alice: %v", err) } if got != "alice was here\n" { t.Errorf("alice's namespace: %q", got) } if got, _ = Run(bob.String(), "cat /tmp/note"); got != "bob was here\n" { t.Errorf("bob's namespace: %q", got) } // Reset throws a namespace away; the next use rebuilds the default. testing.SetRealm(testing.NewUserRealm(alice)) Reset(cross(cur)) if _, err := Run(alice.String(), "cat /tmp/note"); err == nil { t.Error("the namespace should be gone after Reset") } spaces.Remove(alice.String()) spaces.Remove(bob.String()) } func TestExecBindsIntoTheCallersNamespace(cur realm, t *testing.T) { services.Remove("probe") Post(cross(cur), "probe", probe("probe", "42")) defer services.Remove("probe") erin := testutils.TestAddress("erin") spaces.Remove(erin.String()) testing.SetRealm(testing.NewUserRealm(erin)) Exec(cross(cur), "bind /srv/probe /dev") if got := mustRun(t, erin.String(), "cat /dev/value"); got != "42" { t.Errorf("read through the mount: %q", got) } if ns := Namespace(erin.String()); !strings.Contains(ns, "bind /srv/probe /dev") { t.Errorf("mount table: %q", ns) } spaces.Remove(erin.String()) } func TestSrvListingUsesThePostedName(cur realm, t *testing.T) { services.Remove("aliased") // The server calls its own root "probe"; /srv must show it as "aliased". Post(cross(cur), "aliased", probe("probe", "x")) defer services.Remove("aliased") spaces.Remove("lister") spaceFor("lister") out := mustRun(t, "lister", "ls /srv") if !strings.Contains(out, "aliased") { t.Errorf("ls /srv: %q", out) } if strings.Contains(out, "probe") { t.Errorf("/srv leaked the server's own root name: %q", out) } spaces.Remove("lister") } func mustRun(t *testing.T, key, line string) string { t.Helper() out, err := Run(key, line) if err != nil { t.Fatalf("%s: %v", line, err) } return out } // --- the boundary. Three properties the suite would be unsafe without. ------- // A /srv name belongs to the realm that posted it: first come, first served, // and then locked. func TestPostRejectsASecondOwner(cur realm, t *testing.T) { services.Remove("taken") Post(cross(cur), "taken", probe("taken", "first")) defer services.Remove("taken") testing.SetRealm(testing.NewCodeRealm("gno.land/r/other/thing/v0")) uassert.AbortsWithMessage(t, cur, "post: taken is already posted by gno.land/r/moul/x/plan9/ns/v1", func() { Post(cross(cur), "taken", probe("taken", "second")) }) } // Grafting another realm's tree into your namespace gives you reads and // nothing else. ninep.File has no mutating method, so there is no route from a // mount to a write against the realm that posted it. func TestMountedTreesAreReadOnly(cur realm, t *testing.T) { services.Remove("probe") Post(cross(cur), "probe", probe("probe", "read me")) defer services.Remove("probe") frank := testutils.TestAddress("frank") spaces.Remove(frank.String()) defer spaces.Remove(frank.String()) testing.SetRealm(testing.NewUserRealm(frank)) Exec(cross(cur), "bind /srv/probe /dev") uassert.Equal(t, "read me", mustRun(t, frank.String(), "cat /dev/value")) uassert.AbortsWithMessage(t, cur, "echo: read-only file server", func() { Exec(cross(cur), "echo nope > /dev/value") }) } // A command line stops at the first error and the error leaves the realm as an // abort, so a half-applied Exec reverts with its transaction instead of // leaving a namespace nobody asked for. func TestExecAbortsAtTheFirstError(cur realm, t *testing.T) { grace := testutils.TestAddress("grace") spaces.Remove(grace.String()) defer spaces.Remove(grace.String()) testing.SetRealm(testing.NewUserRealm(grace)) uassert.AbortsWithMessage(t, cur, "cat: file does not exist", func() { Exec(cross(cur), "echo ok > /tmp/a; cat /absent; echo never > /tmp/b") }) } func TestRenderUnknownCommand(t *testing.T) { if got := Render("nope/x"); !strings.Contains(got, "404") { t.Errorf("got %q", got) } } // ?u= is the only caller-controlled part of every link the pages build, so a // value that is neither an address nor the demo key is refused outright. func TestRenderRefusesANonAddressNamespace(t *testing.T) { out := Render("ns?u=x%29%20[evil](https://example)") uassert.True(t, strings.Contains(out, "# 404"), out) uassert.False(t, strings.Contains(out, "[evil](https://example)"), out) } // A file's contents are its owner's text. A line of backticks in one used to // close the hand-written fence around `cat`'s output, and everything after it // rendered as live markdown on the realm's own page. func TestCatOutputCannotCloseItsFence(cur realm, t *testing.T) { who := testutils.TestAddress("fencer") testing.SetRealm(testing.NewUserRealm(who)) Exec(cross(cur), "echo '```' > /tmp/f; echo '[claim](https://evil.example)' >> /tmp/f") out := Render("cat/tmp/f?u=" + who.String()) // md.CodeBlock picks a fence longer than any run of backticks inside, so // the user's three backticks and the link after them stay inside the block. uassert.True(t, strings.Contains(out, "````\n```\n[claim](https://evil.example)\n````\n"), out) } // The command name and the rest of the path come from the URL: escaped in the // heading and in the unknown-command page. func TestPathDerivedTextIsEscaped(t *testing.T) { for _, path := range []string{"x` [claim](https://evil.example) `", "cat/x` [claim](https://evil.example) `"} { out := Render(path) uassert.False(t, strings.Contains(out, "[claim](https://evil.example)"), out) } } // A service name is chosen by whichever realm posts it, and ValidName refuses // only "", ".", ".." and "/": the /srv table escapes it as a cell. func TestServiceNameIsTableSafe(cur realm, t *testing.T) { name := "x` | [claim](https://evil.example) |" services.Set(name, &service{owner: "gno.land/r/someone/poster"}) defer services.Remove(name) out := Render("") uassert.False(t, strings.Contains(out, "[claim](https://evil.example)"), out) } // A path route reads what it names: the rest of the path is one quoted // argument, so a ";" in it is part of the file name and runs nothing. func TestPathRouteIsOneArgument(t *testing.T) { // Unquoted, rc would cat /tmp/greeting and then run the second command; // quoted, it looks for one file with a ";" in its name and finds none. out := Render("cat/tmp/greeting;echo") uassert.True(t, strings.Contains(out, "cat: file does not exist"), out) uassert.Equal(t, "'/a;b'", quote("/a;b")) } // Namespaces and services are bounded, and the home page lists a fixed number. func TestNamespacesAndServicesAreBounded(cur realm, t *testing.T) { var added []string for i := 0; spaces.Size() < MaxNamespaces; i++ { k := testutils.TestAddress("ns" + strconv.Itoa(i)).String() if spaces.Get(k) == nil { spaces.Set(k, newSpace(k)) added = append(added, k) } } defer func() { for _, k := range added { spaces.Remove(k) } }() uassert.PanicsContains(t, cur, "full at", func() { spaceFor(testutils.TestAddress("one-more").String()) }) uassert.Equal(t, HomeRecent, strings.Count(Render(""), "](/r/moul/x/plan9/ns/v1:ns?u=")) } // /srv is bounded: once MaxServices names are posted, a new one is refused, // and re-posting is not how a realm gets around it. func TestServicesAreBounded(cur realm, t *testing.T) { var added []string for i := 0; services.Size() < MaxServices; i++ { name := "svc" + strconv.Itoa(i) if services.Get(name) == nil { services.Set(name, &service{owner: "gno.land/r/someone/poster"}) added = append(added, name) } } defer func() { for _, n := range added { services.Remove(n) } }() testing.SetRealm(testing.NewCodeRealm("gno.land/r/someone/late")) uassert.AbortsContains(t, cur, "/srv is full", func() { Post(cross(cur), "late", probe("late", "x")) }) }
  10. #10render_example_test.gno
  11. #11package ns import "gno.land/p/nt/avl/v0" // resetForExample rebuilds the whole realm state. Realm globals persist for // the entire test binary and examples run after every Test, so without this an // example would pin whatever the last test happened to leave behind. func resetForExample() { services = avl.NewTree() spaces = avl.NewTree() seedDemo() } // ExampleRender pins the realm's front page. func ExampleRender() { resetForExample() print(Render("")) // Output: // # plan9: namespaces for gno // // A Plan 9 namespace server. Every account owns a private mount table over [9P-shaped](https://9p.io/sys/doc/9.html) file trees: realms post trees into `/srv`, you `bind` them where you want them, and a name means what *you* bound it to. // // ## /srv // // _No service is posted yet._ // // ## The demo namespace // // ``` // mount #s /srv // bind -ac /usr/glenda/bin /bin // cd /usr/glenda // ``` // // `/bin` is a union: the system `/bin` first, then `/usr/glenda/bin`, with `-c` so new files land in the second. Listing it shows both members because a Plan 9 union is a concatenation, so shadowing stays visible. // // - [ns](/r/moul/x/plan9/ns/v1:ns) · [ls /](/r/moul/x/plan9/ns/v1:ls) · [ls /bin](/r/moul/x/plan9/ns/v1:ls/bin) · [cat /tmp/greeting](/r/moul/x/plan9/ns/v1:cat/tmp/greeting) · [walk /bin/rc](/r/moul/x/plan9/ns/v1:walk/bin/rc) // - any read-only command: [`rc?c=ls -l /srv`](/r/moul/x/plan9/ns/v1:rc?c=ls%20-l%20/srv) // // ## Your own namespace // // ``` // gnokey maketx call -pkgpath gno.land/r/moul/x/plan9/ns/v1 \ // -func Exec -args 'bind -ac /srv/dev /dev; echo hi > /tmp/f' // ``` // // Then browse it with `?u=<your address>`. `Reset` throws it away. // // ## Namespaces // // - [`demo`](/r/moul/x/plan9/ns/v1:ns?u=demo) // // 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 realm borrows the vocabulary and none of the code: it is an homage, asking what that ecosystem's spirit looks like on a chain._ } // ExampleRender_ls pins a union directory listing. func ExampleRender_ls() { resetForExample() print(Render("ls/bin")) // Output: // # ls /bin // // namespace: `demo` // // ``` // -rw-r--r-- demo demo 10 ls // -rw-r--r-- demo demo 10 rc // ``` // // [namespace](/r/moul/x/plan9/ns/v1:ns?u=demo) · [root](/r/moul/x/plan9/ns/v1:ls?u=demo) · [home](/r/moul/x/plan9/ns/v1) } // ExampleRender_walk pins a resolution trace, which is where a bind becomes // visible: the union column widens exactly at the bound name. func ExampleRender_walk() { resetForExample() print(Render("walk/bin/rc")) // Output: // # walk /bin/rc // // namespace: `demo` // // ``` // / (1 5 d) drwxr-xr-x union=1 // /bin (a 1 d) drwxr-xr-x union=2 // /bin/rc (9 1 f) -rw-r--r-- union=1 // ``` // // [namespace](/r/moul/x/plan9/ns/v1:ns?u=demo) · [root](/r/moul/x/plan9/ns/v1:ls?u=demo) · [home](/r/moul/x/plan9/ns/v1) }

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:[]

← Back to block 592,215